PluginProbe
Property Hive / 1.4.53
Property Hive v1.4.53
2.3.0 2.2.6 2.2.5 2.2.4 2.2.3 2.2.2 1.4.46 1.4.47 1.4.48 1.4.49 1.4.5 1.4.50 1.4.51 1.4.52 1.4.53 1.4.54 1.4.55 1.4.56 1.4.57 1.4.58 1.4.59 1.4.6 1.4.60 1.4.61 1.4.62 All 260 releases
propertyhive / includes / libraries / class-emogrifier.php

class-emogrifier.php in Property Hive 1.4.53, at includes/libraries/class-emogrifier.php

1,230 lines 33.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
4 *
5 * For more information, please see the README.md file.
6 *
7 * @version 1.0.0
8 *
9 * @author Cameron Brooks
10 * @author Jaime Prado
11 * @author Oliver Klee <typo3-coding@oliverklee.de>
12 * @author Roman Ožana <ozana@omdesign.cz>
13 */
14 class Emogrifier
15 {
16 /**
17 * @var int
18 */
19 const CACHE_KEY_CSS = 0;
20
21 /**
22 * @var int
23 */
24 const CACHE_KEY_SELECTOR = 1;
25
26 /**
27 * @var int
28 */
29 const CACHE_KEY_XPATH = 2;
30
31 /**
32 * @var int
33 */
34 const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 3;
35
36 /**
37 * @var int
38 */
39 const CACHE_KEY_COMBINED_STYLES = 4;
40
41 /**
42 * for calculating nth-of-type and nth-child selectors
43 *
44 * @var int
45 */
46 const INDEX = 0;
47
48 /**
49 * for calculating nth-of-type and nth-child selectors
50 *
51 * @var int
52 */
53 const MULTIPLIER = 1;
54
55 /**
56 * @var string
57 */
58 const ID_ATTRIBUTE_MATCHER = '/(\\w+)?\\#([\\w\\-]+)/';
59
60 /**
61 * @var string
62 */
63 const CLASS_ATTRIBUTE_MATCHER = '/(\\w+|[\\*\\]])?((\\.[\\w\\-]+)+)/';
64
65 /**
66 * @var string
67 */
68 const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
69
70 /**
71 * @var string
72 */
73 const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
74
75 /**
76 * @var string
77 */
78 private $html = '';
79
80 /**
81 * @var string
82 */
83 private $css = '';
84
85 /**
86 * @var bool[]
87 */
88 private $excludedSelectors = array();
89
90 /**
91 * @var string[]
92 */
93 private $unprocessableHtmlTags = array( 'wbr' );
94
95 /**
96 * @var bool[]
97 */
98 private $allowedMediaTypes = array( 'all' => true, 'screen' => true, 'print' => true );
99
100 /**
101 * @var array[]
102 */
103 private $caches = array(
104 self::CACHE_KEY_CSS => array(),
105 self::CACHE_KEY_SELECTOR => array(),
106 self::CACHE_KEY_XPATH => array(),
107 self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => array(),
108 self::CACHE_KEY_COMBINED_STYLES => array(),
109 );
110
111 /**
112 * the visited nodes with the XPath paths as array keys
113 *
114 * @var DoMElement[]
115 */
116 private $visitedNodes = array();
117
118 /**
119 * the styles to apply to the nodes with the XPath paths as array keys for the outer array
120 * and the attribute names/values as key/value pairs for the inner array
121 *
122 * @var array[]
123 */
124 private $styleAttributesForNodes = array();
125
126 /**
127 * Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
128 * If set to false, the value of the style attributes will be discarded.
129 *
130 * @var bool
131 */
132 private $isInlineStyleAttributesParsingEnabled = true;
133
134 /**
135 * Determines whether the <style> blocks in the HTML passed to this class should be parsed.
136 *
137 * If set to true, the <style> blocks will be removed from the HTML and their contents will be applied to the HTML
138 * via inline styles.
139 *
140 * If set to false, the <style> blocks will be left as they are in the HTML.
141 *
142 * @var bool
143 */
144 private $isStyleBlocksParsingEnabled = true;
145
146 /**
147 * Determines whether elements with the `display: none` property are
148 * removed from the DOM.
149 *
150 * @var bool
151 */
152 private $shouldKeepInvisibleNodes = true;
153
154 public static $_media = '';
155
156 /**
157 * The constructor.
158 *
159 * @param string $html the HTML to emogrify, must be UTF-8-encoded
160 * @param string $css the CSS to merge, must be UTF-8-encoded
161 */
162 public function __construct( $html = '', $css = '' ) {
163 $this->setHtml($html);
164 $this->setCss($css);
165 }
166
167 /**
168 * The destructor.
169 */
170 public function __destruct() {
171 $this->purgeVisitedNodes();
172 }
173
174 /**
175 * Sets the HTML to emogrify.
176 *
177 * @param string $html the HTML to emogrify, must be UTF-8-encoded
178 *
179 * @return void
180 */
181 public function setHtml( $html ) {
182 $this->html = $html;
183 }
184
185 /**
186 * Sets the CSS to merge with the HTML.
187 *
188 * @param string $css the CSS to merge, must be UTF-8-encoded
189 *
190 * @return void
191 */
192 public function setCss( $css ) {
193 $this->css = $css;
194 }
195
196 /**
197 * Applies $this->css to $this->html and returns the HTML with the CSS
198 * applied.
199 *
200 * This method places the CSS inline.
201 *
202 * @return string
203 *
204 * @throws BadMethodCallException
205 */
206 public function emogrify() {
207 if ( $this->html === '' ) {
208 throw new BadMethodCallException('Please set some HTML first before calling emogrify.', 1390393096);
209 }
210
211 self::$_media = ''; // reset
212 $xmlDocument = $this->createXmlDocument();
213 $this->process($xmlDocument);
214
215 return $xmlDocument->saveHTML();
216 }
217
218 /**
219 * Applies $this->css to $this->html and returns only the HTML content
220 * within the <body> tag.
221 *
222 * This method places the CSS inline.
223 *
224 * @return string
225 *
226 * @throws BadMethodCallException
227 */
228 public function emogrifyBodyContent() {
229 if ( $this->html === '' ) {
230 throw new BadMethodCallException('Please set some HTML first before calling emogrify.', 1390393096);
231 }
232
233 $xmlDocument = $this->createXmlDocument();
234 $this->process($xmlDocument);
235
236 $innerDocument = new DoMDocument();
237 foreach ( $xmlDocument->documentElement->getElementsByTagName('body')->item(0)->childNodes as $childNode ) {
238 $innerDocument->appendChild($innerDocument->importNode($childNode, true));
239 }
240
241 return $innerDocument->saveHTML();
242 }
243
244 /**
245 * Applies $this->css to $xmlDocument.
246 *
247 * This method places the CSS inline.
248 *
249 * @param DoMDocument $xmlDocument
250 *
251 * @return void
252 */
253 protected function process( DoMDocument $xmlDocument ) {
254 $xpath = new DoMXPath($xmlDocument);
255 $this->clearAllCaches();
256
257 // Before be begin processing the CSS file, parse the document and normalize all existing CSS attributes.
258 // This changes 'DISPLAY: none' to 'display: none'.
259 // We wouldn't have to do this if DOMXPath supported XPath 2.0.
260 // Also store a reference of nodes with existing inline styles so we don't overwrite them.
261 $this->purgeVisitedNodes();
262
263 $nodesWithStyleAttributes = $xpath->query('//*[@style]');
264 if ( $nodesWithStyleAttributes !== false ) {
265 /** @var DoMElement $node */
266 foreach ( $nodesWithStyleAttributes as $node ) {
267 if ( $this->isInlineStyleAttributesParsingEnabled ) {
268 $this->normalizeStyleAttributes($node);
269 } else {
270 $node->removeAttribute('style');
271 }
272 }
273 }
274
275 // grab any existing style blocks from the html and append them to the existing CSS
276 // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
277 $allCss = $this->css;
278
279 if ( $this->isStyleBlocksParsingEnabled ) {
280 $allCss .= $this->getCssFromAllStyleNodes($xpath);
281 }
282
283 $cssParts = $this->splitCssAndMediaQuery($allCss);
284 $excludedNodes = $this->getNodesToExclude($xpath);
285 $cssRules = $this->parseCssRules($cssParts['css']);
286 foreach ( $cssRules as $cssRule ) {
287 // query the body for the xpath selector
288 $nodesMatchingCssSelectors = $xpath->query($this->translateCssToXpath($cssRule['selector']));
289 // ignore invalid selectors
290 if ( $nodesMatchingCssSelectors === false ) {
291 continue;
292 }
293
294 /** @var DoMElement $node */
295 foreach ( $nodesMatchingCssSelectors as $node ) {
296 if ( in_array($node, $excludedNodes, true) ) {
297 continue;
298 }
299
300 // if it has a style attribute, get it, process it, and append (overwrite) new stuff
301 if ( $node->hasAttribute('style') ) {
302 // break it up into an associative array
303 $oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
304 } else {
305 $oldStyleDeclarations = array();
306 }
307 $newStyleDeclarations = $this->parseCssDeclarationsBlock($cssRule['declarationsBlock']);
308 $node->setAttribute(
309 'style',
310 $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
311 );
312 }
313 }
314
315 if ( $this->isInlineStyleAttributesParsingEnabled ) {
316 $this->fillStyleAttributesWithMergedStyles();
317 }
318
319 if ( $this->shouldKeepInvisibleNodes ) {
320 $this->removeInvisibleNodes($xpath);
321 }
322
323 $this->copyCssWithMediaToStyleNode($xmlDocument, $xpath, $cssParts['media']);
324 }
325
326 /**
327 * Extracts and parses the individual rules from a CSS string.
328 *
329 * @param string $css a string of raw CSS code
330 *
331 * @return string[][] an array of string sub-arrays with the keys
332 * "selector" (the CSS selector(s), e.g., "*" or "h1"),
333 * "declarationsBLock" (the semicolon-separated CSS declarations for that selector(s),
334 * e.g., "color: red; height: 4px;"),
335 * and "line" (the line number e.g. 42)
336 */
337 private function parseCssRules( $css ) {
338 $cssKey = md5($css);
339 if ( ! isset($this->caches[ self::CACHE_KEY_CSS ][ $cssKey ]) ) {
340 // process the CSS file for selectors and definitions
341 preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mis', $css, $matches, PREG_SET_ORDER);
342
343 $cssRules = array();
344 /** @var string[] $cssRule */
345 foreach ( $matches as $key => $cssRule ) {
346 $cssDeclaration = trim($cssRule[2]);
347 if ( $cssDeclaration === '' ) {
348 continue;
349 }
350
351 $selectors = explode(',', $cssRule[1]);
352 foreach ( $selectors as $selector ) {
353 // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
354 // only allow structural pseudo-classes
355 if ( strpos($selector, ':') !== false && ! preg_match('/:\\S+\\-(child|type\\()/i', $selector) ) {
356 continue;
357 }
358
359 $cssRules[] = array(
360 'selector' => trim($selector),
361 'declarationsBlock' => $cssDeclaration,
362 // keep track of where it appears in the file, since order is important
363 'line' => $key,
364 );
365 }
366 }
367
368 usort($cssRules, array( $this, 'sortBySelectorPrecedence' ) );
369
370 $this->caches[ self::CACHE_KEY_CSS ][ $cssKey ] = $cssRules;
371 }
372
373 return $this->caches[ self::CACHE_KEY_CSS ][ $cssKey ];
374 }
375
376 /**
377 * Disables the parsing of inline styles.
378 *
379 * @return void
380 */
381 public function disableInlineStyleAttributesParsing() {
382 $this->isInlineStyleAttributesParsingEnabled = false;
383 }
384
385 /**
386 * Disables the parsing of <style> blocks.
387 *
388 * @return void
389 */
390 public function disableStyleBlocksParsing() {
391 $this->isStyleBlocksParsingEnabled = false;
392 }
393
394 /**
395 * Disables the removal of elements with `display: none` properties.
396 *
397 * @return void
398 */
399 public function disableInvisibleNodeRemoval() {
400 $this->shouldKeepInvisibleNodes = false;
401 }
402
403 /**
404 * Clears all caches.
405 *
406 * @return void
407 */
408 private function clearAllCaches() {
409 $this->clearCache(self::CACHE_KEY_CSS);
410 $this->clearCache(self::CACHE_KEY_SELECTOR);
411 $this->clearCache(self::CACHE_KEY_XPATH);
412 $this->clearCache(self::CACHE_KEY_CSS_DECLARATIONS_BLOCK);
413 $this->clearCache(self::CACHE_KEY_COMBINED_STYLES);
414 }
415
416 /**
417 * Clears a single cache by key.
418 *
419 * @param int $key the cache key, must be CACHE_KEY_CSS, CACHE_KEY_SELECTOR, CACHE_KEY_XPATH
420 * or CACHE_KEY_CSS_DECLARATION_BLOCK
421 *
422 * @return void
423 *
424 * @throws \InvalidArgumentException
425 */
426 private function clearCache( $key ) {
427 $allowedCacheKeys = array(
428 self::CACHE_KEY_CSS,
429 self::CACHE_KEY_SELECTOR,
430 self::CACHE_KEY_XPATH,
431 self::CACHE_KEY_CSS_DECLARATIONS_BLOCK,
432 self::CACHE_KEY_COMBINED_STYLES,
433 );
434 if ( ! in_array($key, $allowedCacheKeys, true) ) {
435 throw new InvalidArgumentException('Invalid cache key: ' . $key, 1391822035);
436 }
437
438 $this->caches[ $key ] = array();
439 }
440
441 /**
442 * Purges the visited nodes.
443 *
444 * @return void
445 */
446 private function purgeVisitedNodes() {
447 $this->visitedNodes = array();
448 $this->styleAttributesForNodes = array();
449 }
450
451 /**
452 * Marks a tag for removal.
453 *
454 * There are some HTML tags that DOMDocument cannot process, and it will throw an error if it encounters them.
455 * In particular, DOMDocument will complain if you try to use HTML5 tags in an XHTML document.
456 *
457 * Note: The tags will not be removed if they have any content.
458 *
459 * @param string $tagName the tag name, e.g., "p"
460 *
461 * @return void
462 */
463 public function addUnprocessableHtmlTag( $tagName ) {
464 $this->unprocessableHtmlTags[] = $tagName;
465 }
466
467 /**
468 * Drops a tag from the removal list.
469 *
470 * @param string $tagName the tag name, e.g., "p"
471 *
472 * @return void
473 */
474 public function removeUnprocessableHtmlTag( $tagName ) {
475 $key = array_search($tagName, $this->unprocessableHtmlTags, true);
476 if ( $key !== false ) {
477 unset($this->unprocessableHtmlTags[ $key ]);
478 }
479 }
480
481 /**
482 * Marks a media query type to keep.
483 *
484 * @param string $mediaName the media type name, e.g., "braille"
485 *
486 * @return void
487 */
488 public function addAllowedMediaType( $mediaName ) {
489 $this->allowedMediaTypes[ $mediaName ] = true;
490 }
491
492 /**
493 * Drops a media query type from the allowed list.
494 *
495 * @param string $mediaName the tag name, e.g., "braille"
496 *
497 * @return void
498 */
499 public function removeAllowedMediaType( $mediaName ) {
500 if ( isset($this->allowedMediaTypes[ $mediaName ]) ) {
501 unset($this->allowedMediaTypes[ $mediaName ]);
502 }
503 }
504
505 /**
506 * Adds a selector to exclude nodes from emogrification.
507 *
508 * Any nodes that match the selector will not have their style altered.
509 *
510 * @param string $selector the selector to exclude, e.g., ".editor"
511 *
512 * @return void
513 */
514 public function addExcludedSelector( $selector ) {
515 $this->excludedSelectors[ $selector ] = true;
516 }
517
518 /**
519 * No longer excludes the nodes matching this selector from emogrification.
520 *
521 * @param string $selector the selector to no longer exclude, e.g., ".editor"
522 *
523 * @return void
524 */
525 public function removeExcludedSelector( $selector ) {
526 if ( isset($this->excludedSelectors[ $selector ]) ) {
527 unset($this->excludedSelectors[ $selector ]);
528 }
529 }
530
531 /**
532 * This removes styles from your email that contain display:none.
533 * We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only
534 * supports XPath 1.0, lower-case() isn't available to us. We've thus far only set attributes to lowercase,
535 * not attribute values. Consequently, we need to translate() the letters that would be in 'NONE' ("NOE")
536 * to lowercase.
537 *
538 * @param DoMXPath $xpath
539 *
540 * @return void
541 */
542 private function removeInvisibleNodes( DoMXPath $xpath ) {
543 $nodesWithStyleDisplayNone = $xpath->query(
544 '//*[contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")]'
545 );
546 if ( $nodesWithStyleDisplayNone->length === 0 ) {
547 return;
548 }
549
550 // The checks on parentNode and is_callable below ensure that if we've deleted the parent node,
551 // we don't try to call removeChild on a nonexistent child node
552 /** @var DoMNode $node */
553 foreach ( $nodesWithStyleDisplayNone as $node ) {
554 if ( $node->parentNode && is_callable( array( $node->parentNode, 'removeChild' ) ) ) {
555 $node->parentNode->removeChild($node);
556 }
557 }
558 }
559
560 private function normalizeStyleAttributes_callback( $m ) {
561 return strtolower( $m[0] );
562 }
563
564 /**
565 * Normalizes the value of the "style" attribute and saves it.
566 *
567 * @param DoMElement $node
568 *
569 * @return void
570 */
571 private function normalizeStyleAttributes( DoMElement $node ) {
572 $normalizedOriginalStyle = preg_replace_callback(
573 '/[A-z\\-]+(?=\\:)/S',
574 array( $this, 'normalizeStyleAttributes_callback' ),
575 $node->getAttribute('style')
576 );
577
578 // in order to not overwrite existing style attributes in the HTML, we
579 // have to save the original HTML styles
580 $nodePath = $node->getNodePath();
581 if ( ! isset($this->styleAttributesForNodes[ $nodePath ]) ) {
582 $this->styleAttributesForNodes[ $nodePath ] = $this->parseCssDeclarationsBlock($normalizedOriginalStyle);
583 $this->visitedNodes[ $nodePath ] = $node;
584 }
585
586 $node->setAttribute('style', $normalizedOriginalStyle);
587 }
588
589 /**
590 * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
591 *
592 * @return void
593 */
594 private function fillStyleAttributesWithMergedStyles() {
595 foreach ( $this->styleAttributesForNodes as $nodePath => $styleAttributesForNode ) {
596 $node = $this->visitedNodes[ $nodePath ];
597 $currentStyleAttributes = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
598 $node->setAttribute(
599 'style',
600 $this->generateStyleStringFromDeclarationsArrays(
601 $currentStyleAttributes,
602 $styleAttributesForNode
603 )
604 );
605 }
606 }
607
608 /**
609 * This method merges old or existing name/value array with new name/value array
610 * and then generates a string of the combined style suitable for placing inline.
611 * This becomes the single point for CSS string generation allowing for consistent
612 * CSS output no matter where the CSS originally came from.
613 *
614 * @param string[] $oldStyles
615 * @param string[] $newStyles
616 *
617 * @return string
618 */
619 private function generateStyleStringFromDeclarationsArrays( array $oldStyles, array $newStyles ) {
620 $combinedStyles = array_merge($oldStyles, $newStyles);
621 $cacheKey = serialize($combinedStyles);
622 if ( isset($this->caches[ self::CACHE_KEY_COMBINED_STYLES ][ $cacheKey ]) ) {
623 return $this->caches[ self::CACHE_KEY_COMBINED_STYLES ][ $cacheKey ];
624 }
625
626 foreach ( $oldStyles as $attributeName => $attributeValue ) {
627 if ( isset($newStyles[ $attributeName ]) && strtolower(substr($attributeValue, -10)) === '!important' ) {
628 $combinedStyles[ $attributeName ] = $attributeValue;
629 }
630 }
631
632 $style = '';
633 foreach ( $combinedStyles as $attributeName => $attributeValue ) {
634 $style .= strtolower(trim($attributeName)) . ': ' . trim($attributeValue) . '; ';
635 }
636 $trimmedStyle = rtrim($style);
637
638 $this->caches[ self::CACHE_KEY_COMBINED_STYLES ][ $cacheKey ] = $trimmedStyle;
639
640 return $trimmedStyle;
641 }
642
643 /**
644 * Applies $css to $xmlDocument, limited to the media queries that actually apply to the document.
645 *
646 * @param DoMDocument $xmlDocument the document to match against
647 * @param DoMXPath $xpath
648 * @param string $css a string of CSS
649 *
650 * @return void
651 */
652 private function copyCssWithMediaToStyleNode( DoMDocument $xmlDocument, DoMXPath $xpath, $css ) {
653 if ( $css === '' ) {
654 return;
655 }
656
657 $mediaQueriesRelevantForDocument = array();
658
659 foreach ( $this->extractMediaQueriesFromCss($css) as $mediaQuery ) {
660 foreach ( $this->parseCssRules($mediaQuery['css']) as $selector ) {
661 if ( $this->existsMatchForCssSelector($xpath, $selector['selector']) ) {
662 $mediaQueriesRelevantForDocument[] = $mediaQuery['query'];
663 break;
664 }
665 }
666 }
667
668 $this->addStyleElementToDocument($xmlDocument, implode($mediaQueriesRelevantForDocument));
669 }
670
671 /**
672 * Extracts the media queries from $css.
673 *
674 * @param string $css
675 *
676 * @return string[][] numeric array with string sub-arrays with the keys "css" and "query"
677 */
678 private function extractMediaQueriesFromCss( $css ) {
679 preg_match_all('#(?<query>@media[^{]*\\{(?<css>(.*?)\\})(\\s*)\\})#s', $css, $mediaQueries);
680 $result = array();
681 foreach ( array_keys($mediaQueries['css']) as $key ) {
682 $result[] = array(
683 'css' => $mediaQueries['css'][ $key ],
684 'query' => $mediaQueries['query'][ $key ],
685 );
686 }
687 return $result;
688 }
689
690 /**
691 * Checks whether there is at least one matching element for $cssSelector.
692 *
693 * @param DoMXPath $xpath
694 * @param string $cssSelector
695 *
696 * @return bool
697 */
698 private function existsMatchForCssSelector( DoMXPath $xpath, $cssSelector ) {
699 $nodesMatchingSelector = $xpath->query($this->translateCssToXpath($cssSelector));
700
701 return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
702 }
703
704 /**
705 * Returns CSS content.
706 *
707 * @param DoMXPath $xpath
708 *
709 * @return string
710 */
711 private function getCssFromAllStyleNodes( DoMXPath $xpath ) {
712 $styleNodes = $xpath->query('//style');
713
714 if ( $styleNodes === false ) {
715 return '';
716 }
717
718 $css = '';
719 /** @var DoMNode $styleNode */
720 foreach ( $styleNodes as $styleNode ) {
721 $css .= "\n\n" . $styleNode->nodeValue;
722 $styleNode->parentNode->removeChild($styleNode);
723 }
724
725 return $css;
726 }
727
728 /**
729 * Adds a style element with $css to $document.
730 *
731 * This method is protected to allow overriding.
732 *
733 * @see https://github.com/jjriv/emogrifier/issues/103
734 *
735 * @param DoMDocument $document
736 * @param string $css
737 *
738 * @return void
739 */
740 protected function addStyleElementToDocument( DoMDocument $document, $css ) {
741 $styleElement = $document->createElement('style', $css);
742 $styleAttribute = $document->createAttribute('type');
743 $styleAttribute->value = 'text/css';
744 $styleElement->appendChild($styleAttribute);
745
746 $head = $this->getOrCreateHeadElement($document);
747 $head->appendChild($styleElement);
748 }
749
750 /**
751 * Returns the existing or creates a new head element in $document.
752 *
753 * @param DoMDocument $document
754 *
755 * @return DoMNode the head element
756 */
757 private function getOrCreateHeadElement( DoMDocument $document ) {
758 $head = $document->getElementsByTagName('head')->item(0);
759
760 if ( $head === null ) {
761 $head = $document->createElement('head');
762 $html = $document->getElementsByTagName('html')->item(0);
763 $html->insertBefore($head, $document->getElementsByTagName('body')->item(0));
764 }
765
766 return $head;
767 }
768
769 private function splitCssAndMediaQuery_callback() {
770
771 }
772
773 /**
774 * Splits input CSS code to an array where:
775 *
776 * - key "css" will be contains clean CSS code.
777 * - key "media" will be contains all valuable media queries.
778 *
779 * Example:
780 *
781 * The CSS code.
782 *
783 * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
784 *
785 * will be parsed into the following array:
786 *
787 * "css" => "h1 { color:red; }"
788 * "media" => "@media { h1 {}}"
789 *
790 * @param string $css
791 * @return array
792 */
793 private function splitCssAndMediaQuery( $css ) {
794 $css = preg_replace_callback( '#@media\\s+(?:only\\s)?(?:[\\s{\(]|screen|all)\\s?[^{]+{.*}\\s*}\\s*#misU', array( $this, '_media_concat' ), $css );
795 // filter the CSS
796 $search = array(
797 // get rid of css comment code
798 '/\\/\\*.*\\*\\//sU',
799 // strip out any import directives
800 '/^\\s*@import\\s[^;]+;/misU',
801 // strip remains media enclosures
802 '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
803 );
804 $replace = array(
805 '',
806 '',
807 '',
808 );
809 // clean CSS before output
810 $css = preg_replace($search, $replace, $css);
811 return array( 'css' => $css, 'media' => self::$_media );
812 }
813
814 private function _media_concat( $matches ) {
815 self::$_media .= $matches[0];
816 }
817
818 /**
819 * Creates a DOMDocument instance with the current HTML.
820 *
821 * @return DoMDocument
822 */
823 private function createXmlDocument() {
824 $xmlDocument = new DoMDocument;
825 $xmlDocument->encoding = 'UTF-8';
826 $xmlDocument->strictErrorChecking = false;
827 $xmlDocument->formatOutput = true;
828 $libXmlState = libxml_use_internal_errors(true);
829 $xmlDocument->loadHTML($this->getUnifiedHtml());
830 libxml_clear_errors();
831 libxml_use_internal_errors($libXmlState);
832 $xmlDocument->normalizeDocument();
833
834 return $xmlDocument;
835 }
836
837 /**
838 * Returns the HTML with the unprocessable HTML tags removed and
839 * with added document type and Content-Type meta tag if needed.
840 *
841 * @return string the unified HTML
842 *
843 * @throws BadMethodCallException
844 */
845 private function getUnifiedHtml() {
846 $htmlWithoutUnprocessableTags = $this->removeUnprocessableTags($this->html);
847 $htmlWithDocumentType = $this->ensureDocumentType($htmlWithoutUnprocessableTags);
848
849 return $this->addContentTypeMetaTag($htmlWithDocumentType);
850 }
851
852 /**
853 * Removes the unprocessable tags from $html (if this feature is enabled).
854 *
855 * @param string $html
856 *
857 * @return string the reworked HTML with the unprocessable tags removed
858 */
859 private function removeUnprocessableTags( $html ) {
860 if ( empty($this->unprocessableHtmlTags) ) {
861 return $html;
862 }
863
864 $unprocessableHtmlTags = implode('|', $this->unprocessableHtmlTags);
865
866 return preg_replace(
867 '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i',
868 '',
869 $html
870 );
871 }
872
873 /**
874 * Makes sure that the passed HTML has a document type.
875 *
876 * @param string $html
877 *
878 * @return string HTML with document type
879 */
880 private function ensureDocumentType( $html ) {
881 $hasDocumentType = stripos($html, '<!DOCTYPE') !== false;
882 if ( $hasDocumentType ) {
883 return $html;
884 }
885
886 return self::DEFAULT_DOCUMENT_TYPE . $html;
887 }
888
889 /**
890 * Adds a Content-Type meta tag for the charset.
891 *
892 * @param string $html
893 *
894 * @return string the HTML with the meta tag added
895 */
896 private function addContentTypeMetaTag( $html ) {
897 $hasContentTypeMetaTag = stristr($html, 'Content-Type') !== false;
898 if ( $hasContentTypeMetaTag ) {
899 return $html;
900
901 }
902
903 // We are trying to insert the meta tag to the right spot in the DOM.
904 // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
905 $hasHeadTag = stripos($html, '<head') !== false;
906 $hasHtmlTag = stripos($html, '<html') !== false;
907
908 if ( $hasHeadTag ) {
909 $reworkedHtml = preg_replace('/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html);
910 } elseif ( $hasHtmlTag ) {
911 $reworkedHtml = preg_replace(
912 '/<html(.*?)>/i',
913 '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
914 $html
915 );
916 } else {
917 $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
918 }
919
920 return $reworkedHtml;
921 }
922
923 /**
924 * @param string[] $a
925 * @param string[] $b
926 *
927 * @return int
928 */
929 private function sortBySelectorPrecedence( array $a, array $b ) {
930 $precedenceA = $this->getCssSelectorPrecedence($a['selector']);
931 $precedenceB = $this->getCssSelectorPrecedence($b['selector']);
932
933 // We want these sorted in ascending order so selectors with lesser precedence get processed first and
934 // selectors with greater precedence get sorted last.
935 $precedenceForEquals = ($a['line'] < $b['line'] ? -1 : 1);
936 $precedenceForNotEquals = ($precedenceA < $precedenceB ? -1 : 1);
937 return ($precedenceA === $precedenceB) ? $precedenceForEquals : $precedenceForNotEquals;
938 }
939
940 /**
941 * @param string $selector
942 *
943 * @return int
944 */
945 private function getCssSelectorPrecedence( $selector ) {
946 $selectorKey = md5($selector);
947 if ( ! isset($this->caches[ self::CACHE_KEY_SELECTOR ][ $selectorKey ]) ) {
948 $precedence = 0;
949 $value = 100;
950 // ids: worth 100, classes: worth 10, elements: worth 1
951 $search = array( '\\#','\\.','' );
952
953 foreach ( $search as $s ) {
954 if ( trim($selector) === '' ) {
955 break;
956 }
957 $number = 0;
958 $selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $number);
959 $precedence += ($value * $number);
960 $value /= 10;
961 }
962 $this->caches[ self::CACHE_KEY_SELECTOR ][ $selectorKey ] = $precedence;
963 }
964
965 return $this->caches[ self::CACHE_KEY_SELECTOR ][ $selectorKey ];
966 }
967
968 private function translateCssToXpath_callback( $matches ) {
969 return strtolower($matches[0]);
970 }
971
972 /**
973 * Maps a CSS selector to an XPath query string.
974 *
975 * @see http://plasmasturm.org/log/444/
976 *
977 * @param string $cssSelector a CSS selector
978 *
979 * @return string the corresponding XPath selector
980 */
981 private function translateCssToXpath( $cssSelector ) {
982 $paddedSelector = ' ' . $cssSelector . ' ';
983 $lowercasePaddedSelector = preg_replace_callback(
984 '/\\s+\\w+\\s+/',
985 array( $this, 'translateCssToXpath_callback' ),
986 $paddedSelector
987 );
988 $trimmedLowercaseSelector = trim($lowercasePaddedSelector);
989 $xpathKey = md5($trimmedLowercaseSelector);
990 if ( ! isset($this->caches[ self::CACHE_KEY_XPATH ][ $xpathKey ]) ) {
991 $cssSelectorMatches = array(
992 'child' => '/\\s+>\\s+/',
993 'adjacent sibling' => '/\\s+\\+\\s+/',
994 'descendant' => '/\\s+/',
995 ':first-child' => '/([^\\/]+):first-child/i',
996 ':last-child' => '/([^\\/]+):last-child/i',
997 'attribute only' => '/^\\[(\\w+|\\w+\\=[\'"]?\\w+[\'"]?)\\]/',
998 'attribute' => '/(\\w)\\[(\\w+)\\]/',
999 'exact attribute' => '/(\\w)\\[(\\w+)\\=[\'"]?(\\w+)[\'"]?\\]/',
1000 );
1001 $xPathReplacements = array(
1002 'child' => '/',
1003 'adjacent sibling' => '/following-sibling::*[1]/self::',
1004 'descendant' => '//',
1005 ':first-child' => '\\1/*[1]',
1006 ':last-child' => '\\1/*[last()]',
1007 'attribute only' => '*[@\\1]',
1008 'attribute' => '\\1[@\\2]',
1009 'exact attribute' => '\\1[@\\2="\\3"]',
1010 );
1011
1012 $roughXpath = '//' . preg_replace($cssSelectorMatches, $xPathReplacements, $trimmedLowercaseSelector);
1013
1014 $xpathWithIdAttributeMatchers = preg_replace_callback(
1015 self::ID_ATTRIBUTE_MATCHER,
1016 array( $this, 'matchIdAttributes' ),
1017 $roughXpath
1018 );
1019 $xpathWithIdAttributeAndClassMatchers = preg_replace_callback(
1020 self::CLASS_ATTRIBUTE_MATCHER,
1021 array( $this, 'matchClassAttributes' ),
1022 $xpathWithIdAttributeMatchers
1023 );
1024
1025 // Advanced selectors are going to require a bit more advanced emogrification.
1026 // When we required PHP 5.3, we could do this with closures.
1027 $xpathWithIdAttributeAndClassMatchers = preg_replace_callback(
1028 '/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1029 array( $this, 'translateNthChild' ),
1030 $xpathWithIdAttributeAndClassMatchers
1031 );
1032 $finalXpath = preg_replace_callback(
1033 '/([^\\/]+):nth-of-type\\(\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1034 array( $this, 'translateNthOfType' ),
1035 $xpathWithIdAttributeAndClassMatchers
1036 );
1037
1038 $this->caches[ self::CACHE_KEY_SELECTOR ][ $xpathKey ] = $finalXpath;
1039 }
1040 return $this->caches[ self::CACHE_KEY_SELECTOR ][ $xpathKey ];
1041 }
1042
1043 /**
1044 * @param string[] $match
1045 *
1046 * @return string
1047 */
1048 private function matchIdAttributes( array $match ) {
1049 return ($match[1] !== '' ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
1050 }
1051
1052 /**
1053 * @param string[] $match
1054 *
1055 * @return string
1056 */
1057 private function matchClassAttributes( array $match ) {
1058 return ($match[1] !== '' ? $match[1] : '*') . '[contains(concat(" ",@class," "),concat(" ","' .
1059 implode(
1060 '"," "))][contains(concat(" ",@class," "),concat(" ","',
1061 explode('.', substr($match[2], 1))
1062 ) . '"," "))]';
1063 }
1064
1065 /**
1066 * @param string[] $match
1067 *
1068 * @return string
1069 */
1070 private function translateNthChild( array $match ) {
1071 $parseResult = $this->parseNth($match);
1072
1073 if ( isset($parseResult[ self::MULTIPLIER ]) ) {
1074 if ( $parseResult[ self::MULTIPLIER ] < 0 ) {
1075 $parseResult[ self::MULTIPLIER ] = abs($parseResult[ self::MULTIPLIER ]);
1076 $xPathExpression = sprintf(
1077 '*[(last() - position()) mod %u = %u]/self::%s',
1078 $parseResult[ self::MULTIPLIER ],
1079 $parseResult[ self::INDEX ],
1080 $match[1]
1081 );
1082 } else {
1083 $xPathExpression = sprintf(
1084 '*[position() mod %u = %u]/self::%s',
1085 $parseResult[ self::MULTIPLIER ],
1086 $parseResult[ self::INDEX ],
1087 $match[1]
1088 );
1089 }
1090 } else {
1091 $xPathExpression = sprintf('*[%u]/self::%s', $parseResult[ self::INDEX ], $match[1]);
1092 }
1093
1094 return $xPathExpression;
1095 }
1096
1097 /**
1098 * @param string[] $match
1099 *
1100 * @return string
1101 */
1102 private function translateNthOfType( array $match ) {
1103 $parseResult = $this->parseNth($match);
1104
1105 if ( isset($parseResult[ self::MULTIPLIER ]) ) {
1106 if ( $parseResult[ self::MULTIPLIER ] < 0 ) {
1107 $parseResult[ self::MULTIPLIER ] = abs($parseResult[ self::MULTIPLIER ]);
1108 $xPathExpression = sprintf(
1109 '%s[(last() - position()) mod %u = %u]',
1110 $match[1],
1111 $parseResult[ self::MULTIPLIER ],
1112 $parseResult[ self::INDEX ]
1113 );
1114 } else {
1115 $xPathExpression = sprintf(
1116 '%s[position() mod %u = %u]',
1117 $match[1],
1118 $parseResult[ self::MULTIPLIER ],
1119 $parseResult[ self::INDEX ]
1120 );
1121 }
1122 } else {
1123 $xPathExpression = sprintf('%s[%u]', $match[1], $parseResult[ self::INDEX ]);
1124 }
1125
1126 return $xPathExpression;
1127 }
1128
1129 /**
1130 * @param string[] $match
1131 *
1132 * @return int[]
1133 */
1134 private function parseNth( array $match ) {
1135 if ( in_array(strtolower($match[2]), array( 'even', 'odd' ), true) ) {
1136 // we have "even" or "odd"
1137 $index = strtolower($match[2]) === 'even' ? 0 : 1;
1138 return array( self::MULTIPLIER => 2, self::INDEX => $index );
1139 }
1140 if ( stripos($match[2], 'n') === false ) {
1141 // if there is a multiplier
1142 $index = (int) str_replace(' ', '', $match[2]);
1143 return array( self::INDEX => $index );
1144 }
1145
1146 if ( isset($match[3]) ) {
1147 $multipleTerm = str_replace($match[3], '', $match[2]);
1148 $index = (int) str_replace(' ', '', $match[3]);
1149 } else {
1150 $multipleTerm = $match[2];
1151 $index = 0;
1152 }
1153
1154 $multiplier = str_ireplace('n', '', $multipleTerm);
1155
1156 if ( $multiplier === '' ) {
1157 $multiplier = 1;
1158 } elseif ( $multiplier === '0' ) {
1159 return array( self::INDEX => $index );
1160 } else {
1161 $multiplier = (int) $multiplier;
1162 }
1163
1164 while ( $index < 0 ) {
1165 $index += abs($multiplier);
1166 }
1167
1168 return array( self::MULTIPLIER => $multiplier, self::INDEX => $index );
1169 }
1170
1171 /**
1172 * Parses a CSS declaration block into property name/value pairs.
1173 *
1174 * Example:
1175 *
1176 * The declaration block
1177 *
1178 * "color: #000; font-weight: bold;"
1179 *
1180 * will be parsed into the following array:
1181 *
1182 * "color" => "#000"
1183 * "font-weight" => "bold"
1184 *
1185 * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
1186 *
1187 * @return string[]
1188 * the CSS declarations with the property names as array keys and the property values as array values
1189 */
1190 private function parseCssDeclarationsBlock( $cssDeclarationsBlock ) {
1191 if ( isset($this->caches[ self::CACHE_KEY_CSS_DECLARATIONS_BLOCK ][ $cssDeclarationsBlock ]) ) {
1192 return $this->caches[ self::CACHE_KEY_CSS_DECLARATIONS_BLOCK ][ $cssDeclarationsBlock ];
1193 }
1194
1195 $properties = array();
1196 $declarations = preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
1197
1198 foreach ( $declarations as $declaration ) {
1199 $matches = array();
1200 if ( ! preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/', trim($declaration), $matches) ) {
1201 continue;
1202 }
1203
1204 $propertyName = strtolower($matches[1]);
1205 $propertyValue = $matches[2];
1206 $properties[ $propertyName ] = $propertyValue;
1207 }
1208 $this->caches[ self::CACHE_KEY_CSS_DECLARATIONS_BLOCK ][ $cssDeclarationsBlock ] = $properties;
1209
1210 return $properties;
1211 }
1212
1213 /**
1214 * Find the nodes that are not to be emogrified.
1215 *
1216 * @param DoMXPath $xpath
1217 *
1218 * @return DoMElement[]
1219 */
1220 private function getNodesToExclude( DoMXPath $xpath ) {
1221 $excludedNodes = array();
1222 foreach ( array_keys($this->excludedSelectors) as $selectorToExclude ) {
1223 foreach ( $xpath->query($this->translateCssToXpath($selectorToExclude)) as $node ) {
1224 $excludedNodes[] = $node;
1225 }
1226 }
1227
1228 return $excludedNodes;
1229 }
1230 }