PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 1.10.2
Fluent Support – Helpdesk & Customer Support Ticket System v1.10.2
2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 All 68 releases
fluent-support / app / Services / Emogrifier.php

Emogrifier.php in Fluent Support – Helpdesk & Customer Support Ticket System 1.10.2, at app/Services/Emogrifier.php

1,849 lines 58.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Services;
4
5 /**
6 * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
7 *
8 * For more information, please see the README.md file.
9 *
10 * @version 2.0.0
11 *
12 * @author Cameron Brooks
13 * @author Jaime Prado
14 * @author Oliver Klee <github@oliverklee.de>
15 * @author Roman Ožana <ozana@omdesign.cz>
16 * @author Sander Kruger <s.kruger@invessel.com>
17 * @author Zoli Szabó <zoli.szabo+github@gmail.com>
18 */
19 class 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 = [];
94
95 /**
96 * @var string[]
97 */
98 private $unprocessableHtmlTags = ['wbr'];
99
100 /**
101 * @var bool[]
102 */
103 private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
104
105 /**
106 * @var mixed[]
107 */
108 private $caches = [
109 self::CACHE_KEY_CSS => [],
110 self::CACHE_KEY_SELECTOR => [],
111 self::CACHE_KEY_XPATH => [],
112 self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
113 self::CACHE_KEY_COMBINED_STYLES => [],
114 ];
115
116 /**
117 * the visited nodes with the XPath paths as array keys
118 *
119 * @var \DOMElement[]
120 */
121 private $visitedNodes = [];
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 string[][]
128 */
129 private $styleAttributesForNodes = [];
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 /**
160 * @var string[]
161 */
162 private $xPathRules = [
163 // attribute presence
164 '/^\\[(\\w+|\\w+\\=[\'"]?\\w+[\'"]?)\\]/' => '*[@\\1]',
165 // type and attribute exact value
166 '/(\\w)\\[(\\w+)\\=[\'"]?([\\w\\s]+)[\'"]?\\]/' => '\\1[@\\2="\\3"]',
167 // type and attribute value with ~ (one word within a whitespace-separated list of words)
168 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w\-_\\/]+)[\'"]?\\]/'
169 => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
170 // type and attribute value with | (either exact value match or prefix followed by a hyphen)
171 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w\-_\\s\\/]+)[\'"]?\\]/'
172 => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
173 // type and attribute value with ^ (prefix match)
174 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w\-_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
175 // type and attribute value with * (substring match)
176 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w\-_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
177 // adjacent sibling
178 '/\\s+\\+\\s+/' => '/following-sibling::*[1]/self::',
179 // child
180 '/\\s*>\\s*/' => '/',
181 // descendant
182 '/\\s+(?=.*[^\\]]{1}$)/' => '//',
183 // type and :first-child
184 '/([^\\/]+):first-child/i' => '*[1]/self::\\1',
185 // type and :last-child
186 '/([^\\/]+):last-child/i' => '*[last()]/self::\\1',
187
188 // The following matcher will break things if it is placed before the adjacent matcher.
189 // So one of the matchers matches either too much or not enough.
190 // type and attribute value with $ (suffix match)
191 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w\-_\\s\\/]+)[\'"]?\\]/'
192 => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
193 ];
194
195 /**
196 * Determines whether CSS styles that have an equivalent HTML attribute
197 * should be mapped and attached to those elements.
198 *
199 * @var bool
200 */
201 private $shouldMapCssToHtml = false;
202
203 /**
204 * This multi-level array contains simple mappings of CSS properties to
205 * HTML attributes. If a mapping only applies to certain HTML nodes or
206 * only for certain values, the mapping is an object with a whitelist
207 * of nodes and values.
208 *
209 * @var mixed[][]
210 */
211 private $cssToHtmlMap = [
212 'background-color' => [
213 'attribute' => 'bgcolor',
214 ],
215 'text-align' => [
216 'attribute' => 'align',
217 'nodes' => ['p', 'div', 'td'],
218 'values' => ['left', 'right', 'center', 'justify'],
219 ],
220 'float' => [
221 'attribute' => 'align',
222 'nodes' => ['table', 'img'],
223 'values' => ['left', 'right'],
224 ],
225 'border-spacing' => [
226 'attribute' => 'cellspacing',
227 'nodes' => ['table'],
228 ],
229 ];
230
231 /**
232 * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
233 *
234 * @var bool
235 */
236 private $debug = false;
237
238 /**
239 * The constructor.
240 *
241 * @param string $html the HTML to emogrify, must be UTF-8-encoded
242 * @param string $css the CSS to merge, must be UTF-8-encoded
243 */
244 public function __construct($html = '', $css = '')
245 {
246 $this->setHtml($html);
247 $this->setCss($css);
248 }
249
250 /**
251 * The destructor.
252 */
253 public function __destruct()
254 {
255 $this->purgeVisitedNodes();
256 }
257
258 /**
259 * Sets the HTML to emogrify.
260 *
261 * @param string $html the HTML to emogrify, must be UTF-8-encoded
262 *
263 * @return void
264 */
265 public function setHtml($html)
266 {
267 $this->html = $html;
268 }
269
270 /**
271 * Sets the CSS to merge with the HTML.
272 *
273 * @param string $css the CSS to merge, must be UTF-8-encoded
274 *
275 * @return void
276 */
277 public function setCss($css)
278 {
279 $this->css = $css;
280 }
281
282 /**
283 * Applies $this->css to $this->html and returns the HTML with the CSS
284 * applied.
285 *
286 * This method places the CSS inline.
287 *
288 * @return string
289 *
290 * @throws \BadMethodCallException
291 */
292 public function emogrify()
293 {
294 return $this->createAndProcessXmlDocument()->saveHTML();
295 }
296
297 /**
298 * Applies $this->css to $this->html and returns only the HTML content
299 * within the <body> tag.
300 *
301 * This method places the CSS inline.
302 *
303 * @return string
304 *
305 * @throws \BadMethodCallException
306 */
307 public function emogrifyBodyContent()
308 {
309 $xmlDocument = $this->createAndProcessXmlDocument();
310 $bodyNodeHtml = $xmlDocument->saveHTML($this->getBodyElement($xmlDocument));
311
312 return str_replace(['<body>', '</body>'], '', $bodyNodeHtml);
313 }
314
315 /**
316 * Creates an XML document from $this->html and emogrifies ist.
317 *
318 * @return \DOMDocument
319 *
320 * @throws \BadMethodCallException
321 */
322 private function createAndProcessXmlDocument()
323 {
324 if ($this->html === '') {
325 throw new \BadMethodCallException('Please set some HTML first.', 1390393096);
326 }
327
328 $xmlDocument = $this->createRawXmlDocument();
329 $this->ensureExistenceOfBodyElement($xmlDocument);
330 $this->process($xmlDocument);
331
332 return $xmlDocument;
333 }
334
335 /**
336 * Applies $this->css to $xmlDocument.
337 *
338 * This method places the CSS inline.
339 *
340 * @param \DOMDocument $xmlDocument
341 *
342 * @return void
343 *
344 * @throws \InvalidArgumentException
345 */
346 protected function process(\DOMDocument $xmlDocument)
347 {
348 $xPath = new \DOMXPath($xmlDocument);
349 $this->clearAllCaches();
350 $this->purgeVisitedNodes();
351 \set_error_handler([$this, 'handleXpathQueryWarnings'], E_WARNING);
352
353 $this->normalizeStyleAttributesOfAllNodes($xPath);
354
355 // grab any existing style blocks from the html and append them to the existing CSS
356 // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
357 $allCss = $this->css;
358 if ($this->isStyleBlocksParsingEnabled) {
359 $allCss .= $this->getCssFromAllStyleNodes($xPath);
360 }
361
362 $cssParts = $this->splitCssAndMediaQuery($allCss);
363 $excludedNodes = $this->getNodesToExclude($xPath);
364 $cssRules = $this->parseCssRules($cssParts['css']);
365 foreach ($cssRules as $cssRule) {
366 // There's no real way to test "PHP Warning" output generated by the following XPath query unless PHPUnit
367 // converts it to an exception. Unfortunately, this would only apply to tests and not work for production
368 // executions, which can still flood logs/output unnecessarily. Instead, Emogrifier's error handler should
369 // always throw an exception and it must be caught here and only rethrown if in debug mode.
370 try {
371 // \DOMXPath::query will always return a DOMNodeList or an exception when errors are caught.
372 $nodesMatchingCssSelectors = $xPath->query($this->translateCssToXpath($cssRule['selector']));
373 } catch (\InvalidArgumentException $e) {
374 if ($this->debug) {
375 throw $e;
376 }
377 continue;
378 }
379
380 /** @var \DOMElement $node */
381 foreach ($nodesMatchingCssSelectors as $node) {
382 if (in_array($node, $excludedNodes, true)) {
383 continue;
384 }
385 // if it has a style attribute, get it, process it, and append (overwrite) new stuff
386 if ($node->hasAttribute('style')) {
387 // break it up into an associative array
388 $oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
389 } else {
390 $oldStyleDeclarations = [];
391 }
392 $newStyleDeclarations = $this->parseCssDeclarationsBlock($cssRule['declarationsBlock']);
393 $node->setAttribute(
394 'style',
395 $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
396 );
397 }
398 }
399
400 if ($this->isInlineStyleAttributesParsingEnabled) {
401 $this->fillStyleAttributesWithMergedStyles();
402 }
403
404 if ($this->shouldMapCssToHtml) {
405 $this->mapAllInlineStylesToHtmlAttributes($xPath);
406 }
407
408 if ($this->shouldKeepInvisibleNodes) {
409 $this->removeInvisibleNodes($xPath);
410 }
411
412 $this->removeImportantAnnotationFromAllInlineStyles($xPath);
413
414 $this->copyCssWithMediaToStyleNode($xmlDocument, $xPath, $cssParts['media']);
415
416 \restore_error_handler();
417 }
418
419 /**
420 * Searches for all nodes with a style attribute, transforms the CSS found
421 * to HTML attributes and adds those attributes to each node.
422 *
423 * @param \DOMXPath $xPath
424 *
425 * @return void
426 */
427 private function mapAllInlineStylesToHtmlAttributes(\DOMXPath $xPath)
428 {
429 /** @var \DOMElement $node */
430 foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
431 $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
432 $this->mapCssToHtmlAttributes($inlineStyleDeclarations, $node);
433 }
434 }
435
436 /**
437 * Searches for all nodes with a style attribute and removes the "!important" annotations out of
438 * the inline style declarations, eventually by rearranging declarations.
439 *
440 * @param \DOMXPath $xPath
441 *
442 * @return void
443 */
444 private function removeImportantAnnotationFromAllInlineStyles(\DOMXPath $xPath)
445 {
446 foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
447 $this->removeImportantAnnotationFromNodeInlineStyle($node);
448 }
449 }
450
451 /**
452 * Removes the "!important" annotations out of the inline style declarations,
453 * eventually by rearranging declarations.
454 * Rearranging needed when !important shorthand properties are followed by some of their
455 * not !important expanded-version properties.
456 * For example "font: 12px serif !important; font-size: 13px;" must be reordered
457 * to "font-size: 13px; font: 12px serif;" in order to remain correct.
458 *
459 * @param \DOMElement $node
460 *
461 * @return void
462 */
463 private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node)
464 {
465 $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
466 $regularStyleDeclarations = [];
467 $importantStyleDeclarations = [];
468 foreach ($inlineStyleDeclarations as $property => $value) {
469 if ($this->attributeValueIsImportant($value)) {
470 $importantStyleDeclarations[$property] = trim(str_replace('!important', '', $value));
471 } else {
472 $regularStyleDeclarations[$property] = $value;
473 }
474 }
475 $inlineStyleDeclarationsInNewOrder = array_merge(
476 $regularStyleDeclarations,
477 $importantStyleDeclarations
478 );
479 $node->setAttribute(
480 'style',
481 $this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder)
482 );
483 }
484
485 /**
486 * Returns a list with all DOM nodes that have a style attribute.
487 *
488 * @param \DOMXPath $xPath
489 *
490 * @return \DOMNodeList
491 */
492 private function getAllNodesWithStyleAttribute(\DOMXPath $xPath)
493 {
494 return $xPath->query('//*[@style]');
495 }
496
497 /**
498 * Applies $styles to $node.
499 *
500 * This method maps CSS styles to HTML attributes and adds those to the
501 * node.
502 *
503 * @param string[] $styles the new CSS styles taken from the global styles to be applied to this node
504 * @param \DOMElement $node node to apply styles to
505 *
506 * @return void
507 */
508 private function mapCssToHtmlAttributes(array $styles, \DOMElement $node)
509 {
510 foreach ($styles as $property => $value) {
511 // Strip !important indicator
512 $value = trim(str_replace('!important', '', $value));
513 $this->mapCssToHtmlAttribute($property, $value, $node);
514 }
515 }
516
517 /**
518 * Tries to apply the CSS style to $node as an attribute.
519 *
520 * This method maps a CSS rule to HTML attributes and adds those to the node.
521 *
522 * @param string $property the name of the CSS property to map
523 * @param string $value the value of the style rule to map
524 * @param \DOMElement $node node to apply styles to
525 *
526 * @return void
527 */
528 private function mapCssToHtmlAttribute($property, $value, \DOMElement $node)
529 {
530 if (!$this->mapSimpleCssProperty($property, $value, $node)) {
531 $this->mapComplexCssProperty($property, $value, $node);
532 }
533 }
534
535 /**
536 * Looks up the CSS property in the mapping table and maps it if it matches the conditions.
537 *
538 * @param string $property the name of the CSS property to map
539 * @param string $value the value of the style rule to map
540 * @param \DOMElement $node node to apply styles to
541 *
542 * @return bool true if the property cab be mapped using the simple mapping table
543 */
544 private function mapSimpleCssProperty($property, $value, \DOMElement $node)
545 {
546 if (!isset($this->cssToHtmlMap[$property])) {
547 return false;
548 }
549
550 $mapping = $this->cssToHtmlMap[$property];
551 $nodesMatch = !isset($mapping['nodes']) || in_array($node->nodeName, $mapping['nodes'], true);
552 $valuesMatch = !isset($mapping['values']) || in_array($value, $mapping['values'], true);
553 if (!$nodesMatch || !$valuesMatch) {
554 return false;
555 }
556
557 $node->setAttribute($mapping['attribute'], $value);
558
559 return true;
560 }
561
562 /**
563 * Maps CSS properties that need special transformation to an HTML attribute.
564 *
565 * @param string $property the name of the CSS property to map
566 * @param string $value the value of the style rule to map
567 * @param \DOMElement $node node to apply styles to
568 *
569 * @return void
570 */
571 private function mapComplexCssProperty($property, $value, \DOMElement $node)
572 {
573 $nodeName = $node->nodeName;
574 $isTable = $nodeName === 'table';
575 $isImage = $nodeName === 'img';
576 $isTableOrImage = $isTable || $isImage;
577
578 switch ($property) {
579 case 'background':
580 // Parse out the color, if any
581 $styles = explode(' ', $value);
582 $first = $styles[0];
583 if (!is_numeric($first[0]) && strpos($first, 'url') !== 0) {
584 // This is not a position or image, assume it's a color
585 $node->setAttribute('bgcolor', $first);
586 }
587 break;
588 case 'width':
589 // intentional fall-through
590 case 'height':
591 // Only parse values in px and %, but not values like "auto".
592 if (preg_match('/^\d+(px|%)$/', $value)) {
593 // Remove 'px'. This regex only conserves numbers and %
594 $number = preg_replace('/[^0-9.%]/', '', $value);
595 $node->setAttribute($property, $number);
596 }
597 break;
598 case 'margin':
599 if ($isTableOrImage) {
600 $margins = $this->parseCssShorthandValue($value);
601 if ($margins['left'] === 'auto' && $margins['right'] === 'auto') {
602 $node->setAttribute('align', 'center');
603 }
604 }
605 break;
606 case 'border':
607 if ($isTableOrImage) {
608 if ($value === 'none' || $value === '0') {
609 $node->setAttribute('border', '0');
610 }
611 }
612 break;
613 default:
614 }
615 }
616
617 /**
618 * Parses a shorthand CSS value and splits it into individual values
619 *
620 * @param string $value a string of CSS value with 1, 2, 3 or 4 sizes
621 * For example: padding: 0 auto;
622 * '0 auto' is split into top: 0, left: auto, bottom: 0,
623 * right: auto.
624 *
625 * @return string[] an array of values for top, right, bottom and left (using these as associative array keys)
626 */
627 private function parseCssShorthandValue($value)
628 {
629 $values = preg_split('/\\s+/', $value);
630
631 $css = [];
632 $css['top'] = $values[0];
633 $css['right'] = (count($values) > 1) ? $values[1] : $css['top'];
634 $css['bottom'] = (count($values) > 2) ? $values[2] : $css['top'];
635 $css['left'] = (count($values) > 3) ? $values[3] : $css['right'];
636
637 return $css;
638 }
639
640 /**
641 * Extracts and parses the individual rules from a CSS string.
642 *
643 * @param string $css a string of raw CSS code
644 *
645 * @return string[][] an array of string sub-arrays with the keys
646 * "selector" (the CSS selector(s), e.g., "*" or "h1"),
647 * "declarationsBLock" (the semicolon-separated CSS declarations for that selector(s),
648 * e.g., "color: red; height: 4px;"),
649 * and "line" (the line number e.g. 42)
650 */
651 private function parseCssRules($css)
652 {
653 $cssKey = md5($css);
654 if (!isset($this->caches[self::CACHE_KEY_CSS][$cssKey])) {
655 // process the CSS file for selectors and definitions
656 preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mi', $css, $matches, PREG_SET_ORDER);
657
658 $cssRules = [];
659 /** @var string[][] $matches */
660 /** @var string[] $cssRule */
661 foreach ($matches as $key => $cssRule) {
662 $cssDeclaration = trim($cssRule[2]);
663 if ($cssDeclaration === '') {
664 continue;
665 }
666
667 $selectors = explode(',', $cssRule[1]);
668 foreach ($selectors as $selector) {
669 // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
670 // only allow structural pseudo-classes
671 $hasPseudoElement = strpos($selector, '::') !== false;
672 $hasAnyPseudoClass = (bool)preg_match('/:[a-zA-Z]/', $selector);
673 $hasSupportedPseudoClass = (bool)preg_match(
674 '/:(\\S+\\-(child|type\\()|not\\([[:ascii:]]*\\))/i',
675 $selector
676 );
677 if ($hasPseudoElement || ($hasAnyPseudoClass && !$hasSupportedPseudoClass)) {
678 continue;
679 }
680
681 $cssRules[] = [
682 'selector' => trim($selector),
683 'declarationsBlock' => $cssDeclaration,
684 // keep track of where it appears in the file, since order is important
685 'line' => $key,
686 ];
687 }
688 }
689
690 usort($cssRules, [$this, 'sortBySelectorPrecedence']);
691
692 $this->caches[self::CACHE_KEY_CSS][$cssKey] = $cssRules;
693 }
694
695 return $this->caches[self::CACHE_KEY_CSS][$cssKey];
696 }
697
698 /**
699 * Disables the parsing of inline styles.
700 *
701 * @return void
702 */
703 public function disableInlineStyleAttributesParsing()
704 {
705 $this->isInlineStyleAttributesParsingEnabled = false;
706 }
707
708 /**
709 * Disables the parsing of <style> blocks.
710 *
711 * @return void
712 */
713 public function disableStyleBlocksParsing()
714 {
715 $this->isStyleBlocksParsingEnabled = false;
716 }
717
718 /**
719 * Disables the removal of elements with `display: none` properties.
720 *
721 * @return void
722 */
723 public function disableInvisibleNodeRemoval()
724 {
725 $this->shouldKeepInvisibleNodes = false;
726 }
727
728 /**
729 * Enables the attachment/override of HTML attributes for which a
730 * corresponding CSS property has been set.
731 *
732 * @return void
733 */
734 public function enableCssToHtmlMapping()
735 {
736 $this->shouldMapCssToHtml = true;
737 }
738
739 /**
740 * Clears all caches.
741 *
742 * @return void
743 */
744 private function clearAllCaches()
745 {
746 $this->clearCache(self::CACHE_KEY_CSS);
747 $this->clearCache(self::CACHE_KEY_SELECTOR);
748 $this->clearCache(self::CACHE_KEY_XPATH);
749 $this->clearCache(self::CACHE_KEY_CSS_DECLARATIONS_BLOCK);
750 $this->clearCache(self::CACHE_KEY_COMBINED_STYLES);
751 }
752
753 /**
754 * Clears a single cache by key.
755 *
756 * @param int $key the cache key, must be CACHE_KEY_CSS, CACHE_KEY_SELECTOR, CACHE_KEY_XPATH
757 * or CACHE_KEY_CSS_DECLARATION_BLOCK
758 *
759 * @return void
760 *
761 * @throws \InvalidArgumentException
762 */
763 private function clearCache($key)
764 {
765 $allowedCacheKeys = [
766 self::CACHE_KEY_CSS,
767 self::CACHE_KEY_SELECTOR,
768 self::CACHE_KEY_XPATH,
769 self::CACHE_KEY_CSS_DECLARATIONS_BLOCK,
770 self::CACHE_KEY_COMBINED_STYLES,
771 ];
772 if (!in_array($key, $allowedCacheKeys, true)) {
773 throw new \InvalidArgumentException('Invalid cache key: ' . esc_html($key), 1391822035);
774 }
775
776 $this->caches[$key] = [];
777 }
778
779 /**
780 * Purges the visited nodes.
781 *
782 * @return void
783 */
784 private function purgeVisitedNodes()
785 {
786 $this->visitedNodes = [];
787 $this->styleAttributesForNodes = [];
788 }
789
790 /**
791 * Marks a tag for removal.
792 *
793 * There are some HTML tags that DOMDocument cannot process, and it will throw an error if it encounters them.
794 * In particular, DOMDocument will complain if you try to use HTML5 tags in an XHTML document.
795 *
796 * Note: The tags will not be removed if they have any content.
797 *
798 * @param string $tagName the tag name, e.g., "p"
799 *
800 * @return void
801 */
802 public function addUnprocessableHtmlTag($tagName)
803 {
804 $this->unprocessableHtmlTags[] = $tagName;
805 }
806
807 /**
808 * Drops a tag from the removal list.
809 *
810 * @param string $tagName the tag name, e.g., "p"
811 *
812 * @return void
813 */
814 public function removeUnprocessableHtmlTag($tagName)
815 {
816 $key = array_search($tagName, $this->unprocessableHtmlTags, true);
817 if ($key !== false) {
818 unset($this->unprocessableHtmlTags[$key]);
819 }
820 }
821
822 /**
823 * Marks a media query type to keep.
824 *
825 * @param string $mediaName the media type name, e.g., "braille"
826 *
827 * @return void
828 */
829 public function addAllowedMediaType($mediaName)
830 {
831 $this->allowedMediaTypes[$mediaName] = true;
832 }
833
834 /**
835 * Drops a media query type from the allowed list.
836 *
837 * @param string $mediaName the tag name, e.g., "braille"
838 *
839 * @return void
840 */
841 public function removeAllowedMediaType($mediaName)
842 {
843 if (isset($this->allowedMediaTypes[$mediaName])) {
844 unset($this->allowedMediaTypes[$mediaName]);
845 }
846 }
847
848 /**
849 * Adds a selector to exclude nodes from emogrification.
850 *
851 * Any nodes that match the selector will not have their style altered.
852 *
853 * @param string $selector the selector to exclude, e.g., ".editor"
854 *
855 * @return void
856 */
857 public function addExcludedSelector($selector)
858 {
859 $this->excludedSelectors[$selector] = true;
860 }
861
862 /**
863 * No longer excludes the nodes matching this selector from emogrification.
864 *
865 * @param string $selector the selector to no longer exclude, e.g., ".editor"
866 *
867 * @return void
868 */
869 public function removeExcludedSelector($selector)
870 {
871 if (isset($this->excludedSelectors[$selector])) {
872 unset($this->excludedSelectors[$selector]);
873 }
874 }
875
876 /**
877 * This removes styles from your email that contain display:none.
878 * We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only
879 * supports XPath 1.0, lower-case() isn't available to us. We've thus far only set attributes to lowercase,
880 * not attribute values. Consequently, we need to translate() the letters that would be in 'NONE' ("NOE")
881 * to lowercase.
882 *
883 * @param \DOMXPath $xPath
884 *
885 * @return void
886 */
887 private function removeInvisibleNodes(\DOMXPath $xPath)
888 {
889 $nodesWithStyleDisplayNone = $xPath->query(
890 '//*[contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")]'
891 );
892 if ($nodesWithStyleDisplayNone->length === 0) {
893 return;
894 }
895
896 // The checks on parentNode and is_callable below ensure that if we've deleted the parent node,
897 // we don't try to call removeChild on a nonexistent child node
898 /** @var \DOMNode $node */
899 foreach ($nodesWithStyleDisplayNone as $node) {
900 if ($node->parentNode && is_callable([$node->parentNode, 'removeChild'])) {
901 $node->parentNode->removeChild($node);
902 }
903 }
904 }
905
906 /**
907 * Parses the document and normalizes all existing CSS attributes.
908 * This changes 'DISPLAY: none' to 'display: none'.
909 * We wouldn't have to do this if DOMXPath supported XPath 2.0.
910 * Also stores a reference of nodes with existing inline styles so we don't overwrite them.
911 *
912 * @param \DOMXPath $xPath
913 *
914 * @return void
915 */
916 private function normalizeStyleAttributesOfAllNodes(\DOMXPath $xPath)
917 {
918 /** @var \DOMElement $node */
919 foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
920 if ($this->isInlineStyleAttributesParsingEnabled) {
921 $this->normalizeStyleAttributes($node);
922 }
923 // Remove style attribute in every case, so we can add them back (if inline style attributes
924 // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
925 // else original inline style rules may remain at the beginning of the final inline style definition
926 // of a node, which may give not the desired results
927 $node->removeAttribute('style');
928 }
929 }
930
931 /**
932 * Normalizes the value of the "style" attribute and saves it.
933 *
934 * @param \DOMElement $node
935 *
936 * @return void
937 */
938 private function normalizeStyleAttributes(\DOMElement $node)
939 {
940 $normalizedOriginalStyle = preg_replace_callback(
941 '/[A-z\\-]+(?=\\:)/S',
942 function (array $m) {
943 return strtolower($m[0]);
944 },
945 $node->getAttribute('style')
946 );
947
948 // in order to not overwrite existing style attributes in the HTML, we
949 // have to save the original HTML styles
950 $nodePath = $node->getNodePath();
951 if (!isset($this->styleAttributesForNodes[$nodePath])) {
952 $this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationsBlock($normalizedOriginalStyle);
953 $this->visitedNodes[$nodePath] = $node;
954 }
955
956 $node->setAttribute('style', $normalizedOriginalStyle);
957 }
958
959 /**
960 * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
961 *
962 * @return void
963 */
964 private function fillStyleAttributesWithMergedStyles()
965 {
966 foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
967 $node = $this->visitedNodes[$nodePath];
968 $currentStyleAttributes = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
969 $node->setAttribute(
970 'style',
971 $this->generateStyleStringFromDeclarationsArrays(
972 $currentStyleAttributes,
973 $styleAttributesForNode
974 )
975 );
976 }
977 }
978
979 /**
980 * This method merges old or existing name/value array with new name/value array
981 * and then generates a string of the combined style suitable for placing inline.
982 * This becomes the single point for CSS string generation allowing for consistent
983 * CSS output no matter where the CSS originally came from.
984 *
985 * @param string[] $oldStyles
986 * @param string[] $newStyles
987 *
988 * @return string
989 */
990 private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles)
991 {
992 $combinedStyles = array_merge($oldStyles, $newStyles);
993 $cacheKey = serialize($combinedStyles);
994 if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
995 return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
996 }
997
998 foreach ($oldStyles as $attributeName => $attributeValue) {
999 if (!isset($newStyles[$attributeName])) {
1000 continue;
1001 }
1002
1003 $newAttributeValue = $newStyles[$attributeName];
1004 if ($this->attributeValueIsImportant($attributeValue)
1005 && !$this->attributeValueIsImportant($newAttributeValue)
1006 ) {
1007 $combinedStyles[$attributeName] = $attributeValue;
1008 }
1009 }
1010
1011 $style = '';
1012 foreach ($combinedStyles as $attributeName => $attributeValue) {
1013 $style .= strtolower(trim($attributeName)) . ': ' . trim($attributeValue) . '; ';
1014 }
1015 $trimmedStyle = rtrim($style);
1016
1017 $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
1018
1019 return $trimmedStyle;
1020 }
1021
1022 /**
1023 * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
1024 *
1025 * @param string[] $styleDeclarations
1026 *
1027 * @return string
1028 */
1029 private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations)
1030 {
1031 return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
1032 }
1033
1034 /**
1035 * Checks whether $attributeValue is marked as !important.
1036 *
1037 * @param string $attributeValue
1038 *
1039 * @return bool
1040 */
1041 private function attributeValueIsImportant($attributeValue)
1042 {
1043 return strtolower(substr(trim($attributeValue), -10)) === '!important';
1044 }
1045
1046 /**
1047 * Applies $css to $xmlDocument, limited to the media queries that actually apply to the document.
1048 *
1049 * @param \DOMDocument $xmlDocument the document to match against
1050 * @param \DOMXPath $xPath
1051 * @param string $css a string of CSS
1052 *
1053 * @return void
1054 */
1055 private function copyCssWithMediaToStyleNode(\DOMDocument $xmlDocument, \DOMXPath $xPath, $css)
1056 {
1057 if ($css === '') {
1058 return;
1059 }
1060
1061 $mediaQueriesRelevantForDocument = [];
1062
1063 foreach ($this->extractMediaQueriesFromCss($css) as $mediaQuery) {
1064 foreach ($this->parseCssRules($mediaQuery['css']) as $selector) {
1065 if ($this->existsMatchForCssSelector($xPath, $selector['selector'])) {
1066 $mediaQueriesRelevantForDocument[] = $mediaQuery['query'];
1067 break;
1068 }
1069 }
1070 }
1071
1072 $this->addStyleElementToDocument($xmlDocument, implode($mediaQueriesRelevantForDocument));
1073 }
1074
1075 /**
1076 * Extracts the media queries from $css while skipping empty media queries.
1077 *
1078 * @param string $css
1079 *
1080 * @return string[][] numeric array with string sub-arrays with the keys "css" and "query"
1081 */
1082 private function extractMediaQueriesFromCss($css)
1083 {
1084 preg_match_all('/@media\\b[^{]*({((?:[^{}]+|(?1))*)})/', $css, $rawMediaQueries, PREG_SET_ORDER);
1085 $parsedQueries = [];
1086
1087 /** @var string[][] $rawMediaQueries */
1088 foreach ($rawMediaQueries as $mediaQuery) {
1089 if ($mediaQuery[2] !== '') {
1090 $parsedQueries[] = [
1091 'css' => $mediaQuery[2],
1092 'query' => $mediaQuery[0],
1093 ];
1094 }
1095 }
1096
1097 return $parsedQueries;
1098 }
1099
1100 /**
1101 * Checks whether there is at least one matching element for $cssSelector.
1102 * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
1103 * just not implemented/recognized yet by Emogrifier).
1104 *
1105 * @param \DOMXPath $xPath
1106 * @param string $cssSelector
1107 *
1108 * @return bool
1109 *
1110 * @throws \InvalidArgumentException
1111 */
1112 private function existsMatchForCssSelector(\DOMXPath $xPath, $cssSelector)
1113 {
1114 try {
1115 $nodesMatchingSelector = $xPath->query($this->translateCssToXpath($cssSelector));
1116 } catch (\InvalidArgumentException $e) {
1117 if ($this->debug) {
1118 throw $e;
1119 }
1120 return true;
1121 }
1122
1123 return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
1124 }
1125
1126 /**
1127 * Returns CSS content.
1128 *
1129 * @param \DOMXPath $xPath
1130 *
1131 * @return string
1132 */
1133 private function getCssFromAllStyleNodes(\DOMXPath $xPath)
1134 {
1135 $styleNodes = $xPath->query('//style');
1136
1137 if ($styleNodes === false) {
1138 return '';
1139 }
1140
1141 $css = '';
1142 /** @var \DOMNode $styleNode */
1143 foreach ($styleNodes as $styleNode) {
1144 $css .= "\n\n" . $styleNode->nodeValue;
1145 $styleNode->parentNode->removeChild($styleNode);
1146 }
1147
1148 return $css;
1149 }
1150
1151 /**
1152 * Adds a style element with $css to $document head.
1153 *
1154 * This method is protected to allow overriding.
1155 *
1156 *
1157 * @param \DOMDocument $document
1158 * @param string $css
1159 *
1160 * @return void
1161 */
1162 protected function addStyleElementToDocument(\DOMDocument $document, $css)
1163 {
1164 $styleElement = $document->createElement('style', $css);
1165 $styleAttribute = $document->createAttribute('type');
1166 $styleAttribute->value = 'text/css';
1167 $styleElement->appendChild($styleAttribute);
1168
1169 $headElement = $this->getHeadElement($document);
1170 $headElement->appendChild($styleElement);
1171 }
1172
1173 /**
1174 * Checks that $document has a BODY element and adds it if it is missing.
1175 *
1176 * @param \DOMDocument $document
1177 */
1178 private function ensureExistenceOfBodyElement(\DOMDocument $document)
1179 {
1180 if ($document->getElementsByTagName('body')->item(0) !== null) {
1181 return;
1182 }
1183
1184 $htmlElement = $document->getElementsByTagName('html')->item(0);
1185
1186 $htmlElement->appendChild($document->createElement('body'));
1187 }
1188
1189 /**
1190 * Returns the BODY element.
1191 *
1192 * This method assumes that there always is a BODY element.
1193 *
1194 * @param \DOMDocument $document
1195 *
1196 * @return \DOMElement
1197 *
1198 * @throws \BadMethodCallException
1199 */
1200 private function getBodyElement(\DOMDocument $document)
1201 {
1202 $bodyElement = $document->getElementsByTagName('body')->item(0);
1203 if ($bodyElement === null) {
1204 throw new \BadMethodCallException(
1205 'getBodyElement method may only be called after ensureExistenceOfBodyElement has been called.',
1206 1508173775427
1207 );
1208 }
1209
1210 return $bodyElement;
1211 }
1212
1213 /**
1214 * Returns the BODY element.
1215 *
1216 * This method assumes that there always is a BODY element.
1217 *
1218 * @param \DOMDocument $document
1219 *
1220 * @return \DOMElement
1221 *
1222 * @throws \BadMethodCallException
1223 */
1224 private function getHeadElement(\DOMDocument $document)
1225 {
1226 $headElement = $document->getElementsByTagName('head')->item(0);
1227 if ($headElement === null) {
1228 throw new \BadMethodCallException(
1229 'getHeadElement method may only be called after ensureExistenceOfBodyElement has been called.',
1230 1508173775427
1231 );
1232 }
1233
1234 return $headElement;
1235 }
1236
1237 /**
1238 * Splits input CSS code to an array where:
1239 *
1240 * - key "css" will be contains clean CSS code
1241 * - key "media" will be contains all valuable media queries
1242 *
1243 * Example:
1244 *
1245 * The CSS code
1246 *
1247 * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
1248 *
1249 * will be parsed into the following array:
1250 *
1251 * "css" => "h1 { color:red; }"
1252 * "media" => "@media { h1 {}}"
1253 *
1254 * @param string $css
1255 *
1256 * @return string[]
1257 */
1258 private function splitCssAndMediaQuery($css)
1259 {
1260 $cssWithoutComments = preg_replace('/\\/\\*.*\\*\\//sU', '', $css);
1261
1262 $mediaTypesExpression = '';
1263 if (!empty($this->allowedMediaTypes)) {
1264 $mediaTypesExpression = '|' . implode('|', array_keys($this->allowedMediaTypes));
1265 }
1266
1267 $media = '';
1268 $cssForAllowedMediaTypes = preg_replace_callback(
1269 '#@media\\s+(?:only\\s)?(?:[\\s{\\(]\\s*' . $mediaTypesExpression . ')\\s*[^{]*+{.*}\\s*}\\s*#misU',
1270 function ($matches) use (&$media) {
1271 $media .= $matches[0];
1272 },
1273 $cssWithoutComments
1274 );
1275
1276 // filter the CSS
1277 $search = [
1278 'import directives' => '/^\\s*@import\\s[^;]+;/misU',
1279 'remaining media enclosures' => '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
1280 ];
1281
1282 $cleanedCss = preg_replace($search, '', $cssForAllowedMediaTypes);
1283
1284 return ['css' => $cleanedCss, 'media' => $media];
1285 }
1286
1287 /**
1288 * Creates a DOMDocument instance with the current HTML.
1289 *
1290 * @return \DOMDocument
1291 */
1292 private function createRawXmlDocument()
1293 {
1294 $xmlDocument = new \DOMDocument;
1295 $xmlDocument->encoding = 'UTF-8';
1296 $xmlDocument->strictErrorChecking = false;
1297 $xmlDocument->formatOutput = true;
1298 $libXmlState = libxml_use_internal_errors(true);
1299 $xmlDocument->loadHTML($this->getUnifiedHtml());
1300 libxml_clear_errors();
1301 libxml_use_internal_errors($libXmlState);
1302 $xmlDocument->normalizeDocument();
1303
1304 return $xmlDocument;
1305 }
1306
1307 /**
1308 * Returns the HTML with the unprocessable HTML tags removed and
1309 * with added document type and Content-Type meta tag if needed.
1310 *
1311 * @return string the unified HTML
1312 *
1313 * @throws \BadMethodCallException
1314 */
1315 private function getUnifiedHtml()
1316 {
1317 $htmlWithoutUnprocessableTags = $this->removeUnprocessableTags($this->html);
1318 $htmlWithDocumentType = $this->ensureDocumentType($htmlWithoutUnprocessableTags);
1319
1320 return $this->addContentTypeMetaTag($htmlWithDocumentType);
1321 }
1322
1323 /**
1324 * Removes the unprocessable tags from $html (if this feature is enabled).
1325 *
1326 * @param string $html
1327 *
1328 * @return string the reworked HTML with the unprocessable tags removed
1329 */
1330 private function removeUnprocessableTags($html)
1331 {
1332 if (empty($this->unprocessableHtmlTags)) {
1333 return $html;
1334 }
1335
1336 $unprocessableHtmlTags = implode('|', $this->unprocessableHtmlTags);
1337
1338 return preg_replace(
1339 '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i',
1340 '',
1341 $html
1342 );
1343 }
1344
1345 /**
1346 * Makes sure that the passed HTML has a document type.
1347 *
1348 * @param string $html
1349 *
1350 * @return string HTML with document type
1351 */
1352 private function ensureDocumentType($html)
1353 {
1354 $hasDocumentType = stripos($html, '<!DOCTYPE') !== false;
1355 if ($hasDocumentType) {
1356 return $html;
1357 }
1358
1359 return self::DEFAULT_DOCUMENT_TYPE . $html;
1360 }
1361
1362 /**
1363 * Adds a Content-Type meta tag for the charset.
1364 *
1365 * @param string $html
1366 *
1367 * @return string the HTML with the meta tag added
1368 */
1369 private function addContentTypeMetaTag($html)
1370 {
1371 $hasContentTypeMetaTag = stripos($html, 'Content-Type') !== false;
1372 if ($hasContentTypeMetaTag) {
1373 return $html;
1374 }
1375
1376 // We are trying to insert the meta tag to the right spot in the DOM.
1377 // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
1378 $hasHeadTag = stripos($html, '<head') !== false;
1379 $hasHtmlTag = stripos($html, '<html') !== false;
1380
1381 if ($hasHeadTag) {
1382 $reworkedHtml = preg_replace('/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html);
1383 } elseif ($hasHtmlTag) {
1384 $reworkedHtml = preg_replace(
1385 '/<html(.*?)>/i',
1386 '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
1387 $html
1388 );
1389 } else {
1390 $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
1391 }
1392
1393 return $reworkedHtml;
1394 }
1395
1396 /**
1397 * @param string[] $a
1398 * @param string[] $b
1399 *
1400 * @return int
1401 */
1402 private function sortBySelectorPrecedence(array $a, array $b)
1403 {
1404 $precedenceA = $this->getCssSelectorPrecedence($a['selector']);
1405 $precedenceB = $this->getCssSelectorPrecedence($b['selector']);
1406
1407 // We want these sorted in ascending order so selectors with lesser precedence get processed first and
1408 // selectors with greater precedence get sorted last.
1409 $precedenceForEquals = ($a['line'] < $b['line'] ? -1 : 1);
1410 $precedenceForNotEquals = ($precedenceA < $precedenceB ? -1 : 1);
1411 return ($precedenceA === $precedenceB) ? $precedenceForEquals : $precedenceForNotEquals;
1412 }
1413
1414 /**
1415 * @param string $selector
1416 *
1417 * @return int
1418 */
1419 private function getCssSelectorPrecedence($selector)
1420 {
1421 $selectorKey = md5($selector);
1422 if (!isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
1423 $precedence = 0;
1424 $value = 100;
1425 // ids: worth 100, classes: worth 10, elements: worth 1
1426 $search = ['\\#', '\\.', ''];
1427
1428 foreach ($search as $s) {
1429 if (trim($selector) === '') {
1430 break;
1431 }
1432 $number = 0;
1433 $selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $number);
1434 $precedence += ($value * $number);
1435 $value /= 10;
1436 }
1437 $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
1438 }
1439
1440 return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
1441 }
1442
1443 /**
1444 * Maps a CSS selector to an XPath query string.
1445 *
1446 * @see http://plasmasturm.org/log/444/
1447 *
1448 * @param string $cssSelector a CSS selector
1449 *
1450 * @return string the corresponding XPath selector
1451 */
1452 private function translateCssToXpath($cssSelector)
1453 {
1454 $paddedSelector = ' ' . $cssSelector . ' ';
1455 $lowercasePaddedSelector = preg_replace_callback(
1456 '/\\s+\\w+\\s+/',
1457 function (array $matches) {
1458 return strtolower($matches[0]);
1459 },
1460 $paddedSelector
1461 );
1462 $trimmedLowercaseSelector = trim($lowercasePaddedSelector);
1463 $xPathKey = md5($trimmedLowercaseSelector);
1464 if (isset($this->caches[self::CACHE_KEY_XPATH][$xPathKey])) {
1465 return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1466 }
1467
1468 $hasNotSelector = (bool)preg_match(
1469 '/^([^:]+):not\\(\\s*([[:ascii:]]+)\\s*\\)$/',
1470 $trimmedLowercaseSelector,
1471 $matches
1472 );
1473 if (!$hasNotSelector) {
1474 $xPath = '//' . $this->translateCssToXpathPass($trimmedLowercaseSelector);
1475 } else {
1476 /** @var string[] $matches */
1477 $partBeforeNot = $matches[1];
1478 $notContents = $matches[2];
1479 $xPath = '//' . $this->translateCssToXpathPass($partBeforeNot) .
1480 '[not(' . $this->translateCssToXpathPassInline($notContents) . ')]';
1481 }
1482 $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey] = $xPath;
1483
1484 return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1485 }
1486
1487 /**
1488 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector.
1489 *
1490 * @param string $trimmedLowercaseSelector
1491 *
1492 * @return string
1493 */
1494 private function translateCssToXpathPass($trimmedLowercaseSelector)
1495 {
1496 return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1497 $trimmedLowercaseSelector,
1498 [$this, 'matchClassAttributes']
1499 );
1500 }
1501
1502 /**
1503 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector for inline usage.
1504 *
1505 * @param string $trimmedLowercaseSelector
1506 *
1507 * @return string
1508 */
1509 private function translateCssToXpathPassInline($trimmedLowercaseSelector)
1510 {
1511 return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1512 $trimmedLowercaseSelector,
1513 [$this, 'matchClassAttributesInline']
1514 );
1515 }
1516
1517 /**
1518 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector while using
1519 * $matchClassAttributesCallback as to match the class attributes.
1520 *
1521 * @param string $trimmedLowercaseSelector
1522 * @param callable $matchClassAttributesCallback
1523 *
1524 * @return string
1525 */
1526 private function translateCssToXpathPassWithMatchClassAttributesCallback(
1527 $trimmedLowercaseSelector,
1528 callable $matchClassAttributesCallback
1529 ) {
1530 $roughXpath = preg_replace(array_keys($this->xPathRules), $this->xPathRules, $trimmedLowercaseSelector);
1531 $xPathWithIdAttributeMatchers = preg_replace_callback(
1532 self::ID_ATTRIBUTE_MATCHER,
1533 [$this, 'matchIdAttributes'],
1534 $roughXpath
1535 );
1536 $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1537 self::CLASS_ATTRIBUTE_MATCHER,
1538 $matchClassAttributesCallback,
1539 $xPathWithIdAttributeMatchers
1540 );
1541
1542 // Advanced selectors are going to require a bit more advanced emogrification.
1543 $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1544 '/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1545 [$this, 'translateNthChild'],
1546 $xPathWithIdAttributeAndClassMatchers
1547 );
1548 $finalXpath = preg_replace_callback(
1549 '/([^\\/]+):nth-of-type\\(\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1550 [$this, 'translateNthOfType'],
1551 $xPathWithIdAttributeAndClassMatchers
1552 );
1553
1554 return $finalXpath;
1555 }
1556
1557 /**
1558 * @param string[] $match
1559 *
1560 * @return string
1561 */
1562 private function matchIdAttributes(array $match)
1563 {
1564 return ($match[1] !== '' ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
1565 }
1566
1567 /**
1568 * @param string[] $match
1569 *
1570 * @return string xPath class attribute query wrapped in element selector
1571 */
1572 private function matchClassAttributes(array $match)
1573 {
1574 return ($match[1] !== '' ? $match[1] : '*') . '[' . $this->matchClassAttributesInline($match) . ']';
1575 }
1576
1577 /**
1578 * @param string[] $match
1579 *
1580 * @return string xPath class attribute query
1581 */
1582 private function matchClassAttributesInline(array $match)
1583 {
1584 return 'contains(concat(" ",@class," "),concat(" ","' .
1585 implode(
1586 '"," "))][contains(concat(" ",@class," "),concat(" ","',
1587 explode('.', substr($match[2], 1))
1588 ) . '"," "))';
1589 }
1590
1591 /**
1592 * @param string[] $match
1593 *
1594 * @return string
1595 */
1596 private function translateNthChild(array $match)
1597 {
1598 $parseResult = $this->parseNth($match);
1599
1600 if (isset($parseResult[self::MULTIPLIER])) {
1601 if ($parseResult[self::MULTIPLIER] < 0) {
1602 $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1603 $xPathExpression = sprintf(
1604 '*[(last() - position()) mod %1%u = %2$u]/self::%3$s',
1605 $parseResult[self::MULTIPLIER],
1606 $parseResult[self::INDEX],
1607 $match[1]
1608 );
1609 } else {
1610 $xPathExpression = sprintf(
1611 '*[position() mod %1$u = %2$u]/self::%3$s',
1612 $parseResult[self::MULTIPLIER],
1613 $parseResult[self::INDEX],
1614 $match[1]
1615 );
1616 }
1617 } else {
1618 $xPathExpression = sprintf('*[%1$u]/self::%2$s', $parseResult[self::INDEX], $match[1]);
1619 }
1620
1621 return $xPathExpression;
1622 }
1623
1624 /**
1625 * @param string[] $match
1626 *
1627 * @return string
1628 */
1629 private function translateNthOfType(array $match)
1630 {
1631 $parseResult = $this->parseNth($match);
1632
1633 if (isset($parseResult[self::MULTIPLIER])) {
1634 if ($parseResult[self::MULTIPLIER] < 0) {
1635 $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1636 $xPathExpression = sprintf(
1637 '%1$s[(last() - position()) mod %2$u = %3$u]',
1638 $match[1],
1639 $parseResult[self::MULTIPLIER],
1640 $parseResult[self::INDEX]
1641 );
1642 } else {
1643 $xPathExpression = sprintf(
1644 '%1$s[position() mod %2$u = %3$u]',
1645 $match[1],
1646 $parseResult[self::MULTIPLIER],
1647 $parseResult[self::INDEX]
1648 );
1649 }
1650 } else {
1651 $xPathExpression = sprintf('%1$s[%2$u]', $match[1], $parseResult[self::INDEX]);
1652 }
1653
1654 return $xPathExpression;
1655 }
1656
1657 /**
1658 * @param string[] $match
1659 *
1660 * @return int[]
1661 */
1662 private function parseNth(array $match)
1663 {
1664 if (in_array(strtolower($match[2]), ['even', 'odd'], true)) {
1665 // we have "even" or "odd"
1666 $index = strtolower($match[2]) === 'even' ? 0 : 1;
1667 return [self::MULTIPLIER => 2, self::INDEX => $index];
1668 }
1669 if (stripos($match[2], 'n') === false) {
1670 // if there is a multiplier
1671 $index = (int)str_replace(' ', '', $match[2]);
1672 return [self::INDEX => $index];
1673 }
1674
1675 if (isset($match[3])) {
1676 $multipleTerm = str_replace($match[3], '', $match[2]);
1677 $index = (int)str_replace(' ', '', $match[3]);
1678 } else {
1679 $multipleTerm = $match[2];
1680 $index = 0;
1681 }
1682
1683 $multiplier = str_ireplace('n', '', $multipleTerm);
1684
1685 if ($multiplier === '') {
1686 $multiplier = 1;
1687 } elseif ($multiplier === '0') {
1688 return [self::INDEX => $index];
1689 } else {
1690 $multiplier = (int)$multiplier;
1691 }
1692
1693 while ($index < 0) {
1694 $index += abs($multiplier);
1695 }
1696
1697 return [self::MULTIPLIER => $multiplier, self::INDEX => $index];
1698 }
1699
1700 /**
1701 * Parses a CSS declaration block into property name/value pairs.
1702 *
1703 * Example:
1704 *
1705 * The declaration block
1706 *
1707 * "color: #000; font-weight: bold;"
1708 *
1709 * will be parsed into the following array:
1710 *
1711 * "color" => "#000"
1712 * "font-weight" => "bold"
1713 *
1714 * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
1715 *
1716 * @return string[]
1717 * the CSS declarations with the property names as array keys and the property values as array values
1718 */
1719 private function parseCssDeclarationsBlock($cssDeclarationsBlock)
1720 {
1721 if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
1722 return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
1723 }
1724
1725 $properties = [];
1726 $declarations = preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
1727
1728 foreach ($declarations as $declaration) {
1729 $matches = [];
1730 if (!preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/', trim($declaration), $matches)) {
1731 continue;
1732 }
1733
1734 $propertyName = strtolower($matches[1]);
1735 $propertyValue = $matches[2];
1736 $properties[$propertyName] = $propertyValue;
1737 }
1738 $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
1739
1740 return $properties;
1741 }
1742
1743 /**
1744 * Find the nodes that are not to be emogrified.
1745 *
1746 * @param \DOMXPath $xPath
1747 *
1748 * @return \DOMElement[]
1749 *
1750 * @throws \InvalidArgumentException
1751 */
1752 private function getNodesToExclude(\DOMXPath $xPath)
1753 {
1754 $excludedNodes = [];
1755 foreach (array_keys($this->excludedSelectors) as $selectorToExclude) {
1756 try {
1757 $matchingNodes = $xPath->query($this->translateCssToXpath($selectorToExclude));
1758 } catch (\InvalidArgumentException $e) {
1759 if ($this->debug) {
1760 throw $e;
1761 }
1762 continue;
1763 }
1764 foreach ($matchingNodes as $node) {
1765 $excludedNodes[] = $node;
1766 }
1767 }
1768
1769 return $excludedNodes;
1770 }
1771
1772 /**
1773 * Handles invalid xPath expression warnings, generated during the process() method,
1774 * during querying \DOMDocument and trigger \InvalidArgumentException with invalid selector
1775 * or \RuntimeException, depending on the source of the warning.
1776 *
1777 * @param int $type
1778 * @param string $message
1779 * @param string $file
1780 * @param int $line
1781 * @param array $context
1782 *
1783 * @return bool always false
1784 *
1785 * @throws \InvalidArgumentException
1786 * @throws \RuntimeException
1787 */
1788 public function handleXpathQueryWarnings( // @codingStandardsIgnoreLine
1789 $type,
1790 $message,
1791 $file,
1792 $line,
1793 array $context
1794 ) {
1795 $selector = '';
1796 if (isset($context['cssRule']['selector'])) {
1797 // warnings generated by invalid/unrecognized selectors in method process()
1798 $selector = $context['cssRule']['selector'];
1799 } elseif (isset($context['selectorToExclude'])) {
1800 // warnings generated by invalid/unrecognized selectors in method getNodesToExclude()
1801 $selector = $context['selectorToExclude'];
1802 } elseif (isset($context['cssSelector'])) {
1803 // warnings generated by invalid/unrecognized selectors in method existsMatchForCssSelector()
1804 $selector = $context['cssSelector'];
1805 }
1806
1807 if ($selector !== '') {
1808 throw new \InvalidArgumentException(
1809 sprintf(
1810 '%1$s in selector >> %2$s << in %3$s on line %4$u',
1811 esc_html($message),
1812 esc_html($selector),
1813 esc_html($file),
1814 (int) $line
1815 ),
1816 1509279985
1817 );
1818 }
1819
1820 // Catches eventual warnings generated by method getAllNodesWithStyleAttribute()
1821 if (isset($context['xPath'])) {
1822 throw new \RuntimeException(
1823 sprintf(
1824 '%1$s in %2$s on line %3$u',
1825 esc_html($message),
1826 esc_html($file),
1827 (int) $line
1828 ),
1829 1509280067
1830 );
1831 }
1832
1833 // the normal error handling continues when handler return false
1834 return false;
1835 }
1836
1837 /**
1838 * Sets the debug mode.
1839 *
1840 * @param bool $debug set to true to enable debug mode
1841 *
1842 * @return void
1843 */
1844 public function setDebug($debug)
1845 {
1846 $this->debug = $debug;
1847 }
1848 }
1849