PluginProbe
Property Hive / 2.3.0
Property Hive v2.3.0
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 2.3.0, at includes/libraries/class-emogrifier.php

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