PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.2
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.2
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Services / Emogrifier / Emogrifier.php

Emogrifier.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.2, at app/Services/Emogrifier/Emogrifier.php

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