CssToInlineStyles.php 22 KB

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