CssToInlineStyles.php 22 KB

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