PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 1.5.1
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v1.5.1
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
fluent-booking / app / Services / Libs / Emogrifier / Emogrifier.php

Emogrifier.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 1.5.1, at app/Services/Libs/Emogrifier/Emogrifier.php

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