CssToInlineStyles.php 22 KB

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