CssToInlineStyles.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
  1. <?php
  2. //namespace TijsVerkoyen\CssToInlineStyles;
  3. /**
  4. * CSS to Inline Styles class
  5. *
  6. * @author Tijs Verkoyen <php-css-to-inline-styles@verkoyen.eu>
  7. * @version 1.2.1
  8. * @copyright Copyright (c), Tijs Verkoyen. All rights reserved.
  9. * @license BSD License
  10. *
  11. * @deprecated Use composer and Emogrifier instead.
  12. */
  13. class CssToInlineStyles
  14. {
  15. /**
  16. * The CSS to use
  17. *
  18. * @var string
  19. */
  20. private $css;
  21. /**
  22. * The processed CSS rules
  23. *
  24. * @var array
  25. */
  26. private $cssRules;
  27. /**
  28. * Should the generated HTML be cleaned
  29. *
  30. * @var bool
  31. */
  32. private $cleanup = false;
  33. /**
  34. * The encoding to use.
  35. *
  36. * @var string
  37. */
  38. private $encoding = 'UTF-8';
  39. /**
  40. * The HTML to process
  41. *
  42. * @var string
  43. */
  44. private $html;
  45. /**
  46. * Use inline-styles block as CSS
  47. *
  48. * @var bool
  49. */
  50. private $useInlineStylesBlock = false;
  51. /**
  52. * Strip original style tags
  53. *
  54. * @var bool
  55. */
  56. private $stripOriginalStyleTags = false;
  57. /**
  58. * Exclude the media queries from the inlined styles
  59. *
  60. * @var bool
  61. */
  62. private $excludeMediaQueries = false;
  63. /**
  64. * Only necessary and applicable for PHP5.4 and above.
  65. *
  66. * @var bool
  67. */
  68. private $correctUtf8 = false;
  69. /**
  70. * Creates an instance, you could set the HTML and CSS here, or load it
  71. * later.
  72. *
  73. * @return void
  74. * @param string[optional] $html The HTML to process.
  75. * @param string[optional] $css The CSS to use.
  76. */
  77. public function __construct($html = null, $css = null)
  78. {
  79. if($html !== null) $this->setHTML($html);
  80. if($css !== null) $this->setCSS($css);
  81. $this->correctUtf8 = version_compare(PHP_VERSION, '5.4.0') >= 0;
  82. }
  83. /**
  84. * Convert a CSS-selector into an xPath-query
  85. *
  86. * @return string
  87. * @param string $selector The CSS-selector.
  88. */
  89. private function buildXPathQuery($selector)
  90. {
  91. // redefine
  92. $selector = (string) $selector;
  93. // the CSS selector
  94. $cssSelector = [
  95. // E F, Matches any F element that is a descendant of an E element
  96. '/(\w)\s+([\w\*])/',
  97. // E > F, Matches any F element that is a child of an element E
  98. '/(\w)\s*>\s*([\w\*])/',
  99. // E:first-child, Matches element E when E is the first child of its parent
  100. '/(\w):first-child/',
  101. // E + F, Matches any F element immediately preceded by an element
  102. '/(\w)\s*\+\s*(\w)/',
  103. // E[foo], Matches any E element with the "foo" attribute set (whatever the value)
  104. '/(\w)\[([\w\-_]+)]/',
  105. // E[foo="warning"], Matches any E element whose "foo" attribute value is exactly equal to "warning"
  106. '/(\w)\[([\w\-_]+)\=\"(.*)\"]/',
  107. // div.warning, HTML only. The same as DIV[class~="warning"]
  108. '/(\w+|\*)+\.([\w\-_]+)+/',
  109. // .warning, HTML only. The same as *[class~="warning"]
  110. '/\.([\w\-_]+)/',
  111. // E#myid, Matches any E element with id-attribute equal to "myid"
  112. '/(\w+)+\#([\w\-_]+)/',
  113. // #myid, Matches any element with id-attribute equal to "myid"
  114. '/\#([\w\-_]+)/'
  115. ];
  116. // the xPath-equivalent
  117. $xPathQuery = [
  118. // E F, Matches any F element that is a descendant of an E element
  119. '\1//\2',
  120. // E > F, Matches any F element that is a child of an element E
  121. '\1/\2',
  122. // E:first-child, Matches element E when E is the first child of its parent
  123. '*[1]/self::\1',
  124. // E + F, Matches any F element immediately preceded by an element
  125. '\1/following-sibling::*[1]/self::\2',
  126. // E[foo], Matches any E element with the "foo" attribute set (whatever the value)
  127. '\1 [ @\2 ]',
  128. // E[foo="warning"], Matches any E element whose "foo" attribute value is exactly equal to "warning"
  129. '\1[ contains( concat( " ", @\2, " " ), concat( " ", "\3", " " ) ) ]',
  130. // div.warning, HTML only. The same as DIV[class~="warning"]
  131. '\1[ contains( concat( " ", @class, " " ), concat( " ", "\2", " " ) ) ]',
  132. // .warning, HTML only. The same as *[class~="warning"]
  133. '*[ contains( concat( " ", @class, " " ), concat( " ", "\1", " " ) ) ]',
  134. // E#myid, Matches any E element with id-attribute equal to "myid"
  135. '\1[ @id = "\2" ]',
  136. // #myid, Matches any element with id-attribute equal to "myid"
  137. '*[ @id = "\1" ]'
  138. ];
  139. // return
  140. $xPath = (string) '//' . preg_replace($cssSelector, $xPathQuery, $selector);
  141. return str_replace('] *', ']//*', $xPath);
  142. }
  143. /**
  144. * Calculate the specifity for the CSS-selector
  145. *
  146. * @return integer
  147. * @param string $selector The selector to calculate the specifity for.
  148. */
  149. private function calculateCSSSpecifity($selector)
  150. {
  151. // cleanup selector
  152. $selector = preg_replace('/\s?(\>|\+)\s?/', '$1', $selector);
  153. // init var
  154. $specifity = 0;
  155. // split the selector into chunks based on spaces
  156. $chunks = preg_split( '/\>|\+|\s/', $selector);
  157. // loop chunks
  158. foreach ($chunks as $chunk) {
  159. // an ID is important, so give it a high specifity
  160. if(strstr($chunk, '#') !== false) $specifity += 100;
  161. // classes are more important than a tag, but less important then an ID
  162. elseif(strstr($chunk, '.')) $specifity += 10;
  163. // anything else isn't that important
  164. else $specifity += 1;
  165. }
  166. // return
  167. return $specifity;
  168. }
  169. /**
  170. * Cleanup the generated HTML
  171. *
  172. * @return string
  173. * @param string $html The HTML to cleanup.
  174. */
  175. private function cleanupHTML($html)
  176. {
  177. // remove classes
  178. $html = preg_replace('/(\s)+class="(.*)"(\s)*/U', ' ', $html);
  179. // remove IDs
  180. $html = preg_replace('/(\s)+id="(.*)"(\s)*/U', ' ', $html);
  181. // return
  182. return $html;
  183. }
  184. /**
  185. * Converts the loaded HTML into an HTML-string with inline styles based on the loaded CSS
  186. *
  187. * @return string
  188. * @param bool[optional] $outputXHTML Should we output valid XHTML?
  189. */
  190. public function convert($outputXHTML = false)
  191. {
  192. // redefine
  193. $outputXHTML = (bool) $outputXHTML;
  194. // validate
  195. if($this->html == null) throw new Exception('No HTML provided.');
  196. // should we use inline style-block
  197. if ($this->useInlineStylesBlock) {
  198. // init var
  199. $matches = [];
  200. // match the style blocks
  201. preg_match_all('|<style(.*)>(.*)</style>|isU', $this->html, $matches);
  202. // any style-blocks found?
  203. if (!empty($matches[2])) {
  204. // add
  205. foreach($matches[2] as $match) $this->css .= "\n" . trim($match) ."\n";
  206. }
  207. }
  208. // process css
  209. $this->processCSS();
  210. // create new DOMDocument
  211. $document = new \DOMDocument('1.0', $this->getEncoding());
  212. // set error level
  213. libxml_use_internal_errors(true);
  214. //Check if html has ISO encoding and convert to UTF-8
  215. $encoding = mb_detect_encoding($this->html, ['UTF-8', 'ISO-8859-1']);
  216. if ($encoding === 'ISO-8859-1') {
  217. $this->html = utf8_encode($this->html);
  218. }
  219. // load HTML
  220. $document->loadHTML($this->html);
  221. // create new XPath
  222. $xPath = new \DOMXPath($document);
  223. // any rules?
  224. if (!empty($this->cssRules)) {
  225. // loop rules
  226. foreach ($this->cssRules as $rule) {
  227. // init var
  228. $query = $this->buildXPathQuery($rule['selector']);
  229. // validate query
  230. if($query === false) continue;
  231. // search elements
  232. $elements = $xPath->query($query);
  233. // validate elements
  234. if($elements === false) continue;
  235. // loop found elements
  236. foreach ($elements as $element) {
  237. // no styles stored?
  238. if ($element->attributes->getNamedItem(
  239. 'data-css-to-inline-styles-original-styles'
  240. ) == null) {
  241. // init var
  242. $originalStyle = '';
  243. if ($element->attributes->getNamedItem('style') !== null) {
  244. $originalStyle = $element->attributes->getNamedItem('style')->value;
  245. }
  246. // store original styles
  247. $element->setAttribute(
  248. 'data-css-to-inline-styles-original-styles',
  249. $originalStyle
  250. );
  251. // clear the styles
  252. $element->setAttribute('style', '');
  253. }
  254. // init var
  255. $properties = [];
  256. // get current styles
  257. $stylesAttribute = $element->attributes->getNamedItem('style');
  258. // any styles defined before?
  259. if ($stylesAttribute !== null) {
  260. // get value for the styles attribute
  261. $definedStyles = (string) $stylesAttribute->value;
  262. // split into properties
  263. $definedProperties = (array) explode(';', $definedStyles);
  264. // loop properties
  265. foreach ($definedProperties as $property) {
  266. // validate property
  267. if($property == '') continue;
  268. // split into chunks
  269. $chunks = (array) explode(':', trim($property), 2);
  270. // validate
  271. if(!isset($chunks[1])) continue;
  272. // loop chunks
  273. $properties[$chunks[0]] = trim($chunks[1]);
  274. }
  275. }
  276. // add new properties into the list
  277. foreach ($rule['properties'] as $key => $value) {
  278. $properties[$key] = $value;
  279. }
  280. // build string
  281. $propertyChunks = [];
  282. // build chunks
  283. foreach ($properties as $key => $values) {
  284. foreach ((array) $values as $value) {
  285. $propertyChunks[] = $key . ': ' . $value . ';';
  286. }
  287. }
  288. // build properties string
  289. $propertiesString = implode(' ', $propertyChunks);
  290. // set attribute
  291. if ($propertiesString != '') {
  292. $element->setAttribute('style', $propertiesString);
  293. }
  294. }
  295. }
  296. // reapply original styles
  297. $query = $this->buildXPathQuery(
  298. '*[@data-css-to-inline-styles-original-styles]'
  299. );
  300. // validate query
  301. if($query === false) return;
  302. // search elements
  303. $elements = $xPath->query($query);
  304. // loop found elements
  305. foreach ($elements as $element) {
  306. // get the original styles
  307. $originalStyle = $element->attributes->getNamedItem(
  308. 'data-css-to-inline-styles-original-styles'
  309. )->value;
  310. if ($originalStyle != '') {
  311. $originalProperties = [];
  312. $originalStyles = (array) explode(';', $originalStyle);
  313. foreach ($originalStyles as $property) {
  314. // validate property
  315. if($property == '') continue;
  316. // split into chunks
  317. $chunks = (array) explode(':', trim($property), 2);
  318. // validate
  319. if(!isset($chunks[1])) continue;
  320. // loop chunks
  321. $originalProperties[$chunks[0]] = trim($chunks[1]);
  322. }
  323. // get current styles
  324. $stylesAttribute = $element->attributes->getNamedItem('style');
  325. $properties = [];
  326. // any styles defined before?
  327. if ($stylesAttribute !== null) {
  328. // get value for the styles attribute
  329. $definedStyles = (string) $stylesAttribute->value;
  330. // split into properties
  331. $definedProperties = (array) explode(';', $definedStyles);
  332. // loop properties
  333. foreach ($definedProperties as $property) {
  334. // validate property
  335. if($property == '') continue;
  336. // split into chunks
  337. $chunks = (array) explode(':', trim($property), 2);
  338. // validate
  339. if(!isset($chunks[1])) continue;
  340. // loop chunks
  341. $properties[$chunks[0]] = trim($chunks[1]);
  342. }
  343. }
  344. // add new properties into the list
  345. foreach ($originalProperties as $key => $value) {
  346. $properties[$key] = $value;
  347. }
  348. // build string
  349. $propertyChunks = [];
  350. // build chunks
  351. foreach ($properties as $key => $values) {
  352. foreach ((array) $values as $value) {
  353. $propertyChunks[] = $key . ': ' . $value . ';';
  354. }
  355. }
  356. // build properties string
  357. $propertiesString = implode(' ', $propertyChunks);
  358. // set attribute
  359. if($propertiesString != '') $element->setAttribute(
  360. 'style', $propertiesString
  361. );
  362. }
  363. // remove placeholder
  364. $element->removeAttribute(
  365. 'data-css-to-inline-styles-original-styles'
  366. );
  367. }
  368. }
  369. // should we output XHTML?
  370. if ($outputXHTML) {
  371. $document->formatOutput = true;
  372. // get the HTML as XML
  373. $html = $document->saveXML(null, LIBXML_NOEMPTYTAG);
  374. // get start of the XML-declaration
  375. $startPosition = strpos($html, '<?xml');
  376. // valid start position?
  377. if ($startPosition !== false) {
  378. // get end of the xml-declaration
  379. $endPosition = strpos($html, '?>', $startPosition);
  380. // remove the XML-header
  381. $html = ltrim(substr($html, $endPosition + 2));
  382. }
  383. }
  384. // just regular HTML 4.01 as it should be used in newsletters
  385. else {
  386. // get the HTML
  387. $document->formatOutput = true;
  388. $html = $document->saveHTML();
  389. }
  390. if ($this->correctUtf8) {
  391. // Only for >PHP5.4
  392. $chars = [
  393. '&nbsp;', '&laquo;', '&raquo;', '&lt;', '&gt;',
  394. '&copy;', '&reg;', '&trade;', '&apos;', '&amp;', '&quot;',
  395. ];
  396. // Make sure chars dont annihilate the result
  397. foreach ($chars as $char) {
  398. $html = str_replace($char, '[[' . substr($char, 1, -1) . ']]', $html);
  399. }
  400. // Correct scrambled UTF8 chars (&atilde;&#131;...) back to their correct representation.
  401. $html = html_entity_decode($html, ENT_XHTML);
  402. $html = utf8_decode($html);
  403. foreach ($chars as $char) {
  404. $html = str_replace('[[' . substr($char, 1, -1) . ']]', $char, $html);
  405. }
  406. }
  407. // cleanup the HTML if we need to
  408. if($this->cleanup) $html = $this->cleanupHTML($html);
  409. // strip original style tags if we need to
  410. if ($this->stripOriginalStyleTags) {
  411. $html = $this->stripOriginalStyleTags($html);
  412. }
  413. return $html;
  414. }
  415. /**
  416. * Get the encoding to use
  417. *
  418. * @return string
  419. */
  420. private function getEncoding()
  421. {
  422. return $this->encoding;
  423. }
  424. /**
  425. * Process the loaded CSS
  426. *
  427. * @return void
  428. */
  429. private function processCSS()
  430. {
  431. // init vars
  432. $css = (string) $this->css;
  433. // remove newlines
  434. $css = str_replace(["\r", "\n"], '', $css);
  435. // replace double quotes by single quotes
  436. $css = str_replace('"', '\'', $css);
  437. // remove comments
  438. $css = preg_replace('|/\*.*?\*/|', '', $css);
  439. // remove double spaces
  440. $css = preg_replace('/\s\s+/', ' ', $css);
  441. if ($this->excludeMediaQueries) {
  442. $css = preg_replace('/@media [^{]*{([^{}]|{[^{}]*})*}/', '', $css);
  443. }
  444. // rules are splitted by }
  445. $rules = (array) explode('}', $css);
  446. // init var
  447. $i = 1;
  448. // loop rules
  449. foreach ($rules as $rule) {
  450. // split into chunks
  451. $chunks = explode('{', $rule);
  452. // invalid rule?
  453. if(!isset($chunks[1])) continue;
  454. // set the selectors
  455. $selectors = trim($chunks[0]);
  456. // get cssProperties
  457. $cssProperties = trim($chunks[1]);
  458. // split multiple selectors
  459. $selectors = (array) explode(',', $selectors);
  460. // loop selectors
  461. foreach ($selectors as $selector) {
  462. // cleanup
  463. $selector = trim($selector);
  464. // build an array for each selector
  465. $ruleSet = [];
  466. // store selector
  467. $ruleSet['selector'] = $selector;
  468. // process the properties
  469. $ruleSet['properties'] = $this->processCSSProperties(
  470. $cssProperties
  471. );
  472. // calculate specifity
  473. $ruleSet['specifity'] = $this->calculateCSSSpecifity(
  474. $selector
  475. ) + $i;
  476. // add into global rules
  477. $this->cssRules[] = $ruleSet;
  478. }
  479. // increment
  480. $i++;
  481. }
  482. // sort based on specifity
  483. if (!empty($this->cssRules)) {
  484. usort($this->cssRules, [__CLASS__, 'sortOnSpecifity']);
  485. }
  486. }
  487. /**
  488. * Process the CSS-properties
  489. *
  490. * @return array
  491. * @param string $propertyString The CSS-properties.
  492. */
  493. private function processCSSProperties($propertyString)
  494. {
  495. // split into chunks
  496. $properties = (array) explode(';', $propertyString);
  497. // init var
  498. $pairs = [];
  499. // loop properties
  500. foreach ($properties as $property) {
  501. // split into chunks
  502. $chunks = (array) explode(':', $property, 2);
  503. // validate
  504. if(!isset($chunks[1])) continue;
  505. // cleanup
  506. $chunks[0] = trim($chunks[0]);
  507. $chunks[1] = trim($chunks[1]);
  508. // add to pairs array
  509. if(!isset($pairs[$chunks[0]]) ||
  510. !in_array($chunks[1], $pairs[$chunks[0]])) {
  511. $pairs[$chunks[0]][] = $chunks[1];
  512. }
  513. }
  514. // sort the pairs
  515. ksort($pairs);
  516. // return
  517. return $pairs;
  518. }
  519. /**
  520. * Should the IDs and classes be removed?
  521. *
  522. * @return void
  523. * @param bool[optional] $on Should we enable cleanup?
  524. */
  525. public function setCleanup($on = true)
  526. {
  527. $this->cleanup = (bool) $on;
  528. }
  529. /**
  530. * Set CSS to use
  531. *
  532. * @return void
  533. * @param string $css The CSS to use.
  534. */
  535. public function setCSS($css)
  536. {
  537. $this->css = (string) $css;
  538. }
  539. /**
  540. * Set the encoding to use with the DOMDocument
  541. *
  542. * @return void
  543. * @param string $encoding The encoding to use.
  544. */
  545. public function setEncoding($encoding)
  546. {
  547. $this->encoding = (string) $encoding;
  548. }
  549. /**
  550. * Set HTML to process
  551. *
  552. * @return void
  553. * @param string $html The HTML to process.
  554. */
  555. public function setHTML($html)
  556. {
  557. $this->html = (string) $html;
  558. }
  559. /**
  560. * Set utf8 correction
  561. *
  562. * @return void
  563. * @param bool $on.
  564. */
  565. public function setCorrectUtf8($on = true)
  566. {
  567. $this->correctUtf8 = (bool) $on;
  568. }
  569. /**
  570. * Set use of inline styles block
  571. * If this is enabled the class will use the style-block in the HTML.
  572. *
  573. * @return void
  574. * @param bool[optional] $on Should we process inline styles?
  575. */
  576. public function setUseInlineStylesBlock($on = true)
  577. {
  578. $this->useInlineStylesBlock = (bool) $on;
  579. }
  580. /**
  581. * Set strip original style tags
  582. * If this is enabled the class will remove all style tags in the HTML.
  583. *
  584. * @return void
  585. * @param bool[optional] $on Should we process inline styles?
  586. */
  587. public function setStripOriginalStyleTags($on = true)
  588. {
  589. $this->stripOriginalStyleTags = (bool) $on;
  590. }
  591. /**
  592. * Set exclude media queries
  593. *
  594. * If this is enabled the media queries will be removed before inlining the rules
  595. *
  596. * @return void
  597. * @param bool[optional] $on
  598. */
  599. public function setExcludeMediaQueries($on = true)
  600. {
  601. $this->excludeMediaQueries = (bool) $on;
  602. }
  603. /**
  604. * Strip style tags into the generated HTML
  605. *
  606. * @return string
  607. * @param string $html The HTML to strip style tags.
  608. */
  609. private function stripOriginalStyleTags($html)
  610. {
  611. return preg_replace('|<style(.*)>(.*)</style>|isU', '', $html);
  612. }
  613. /**
  614. * Sort an array on the specifity element
  615. *
  616. * @return integer
  617. * @param array $e1 The first element.
  618. * @param array $e2 The second element.
  619. */
  620. private static function sortOnSpecifity($e1, $e2)
  621. {
  622. // validate
  623. if(!isset($e1['specifity']) || !isset($e2['specifity'])) return 0;
  624. // lower
  625. if($e1['specifity'] < $e2['specifity']) return -1;
  626. // higher
  627. if($e1['specifity'] > $e2['specifity']) return 1;
  628. // fallback
  629. return 0;
  630. }
  631. }