PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.21
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.21
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
fluent-cart / app / Services / Libs / Emogrifier / EmogrifierPhp7.php

EmogrifierPhp7.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.3.21, at app/Services/Libs/Emogrifier/EmogrifierPhp7.php

1,860 lines 59.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\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 EmogrifierPhp7
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 //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
351 \set_error_handler([$this, 'handleXpathQueryWarnings'], E_WARNING);
352
353 $this->normalizeStyleAttributesOfAllNodes($xPath);
354
355 // grab any existing style blocks from the html and append them to the existing CSS
356 // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
357 $allCss = $this->css;
358 if ($this->isStyleBlocksParsingEnabled) {
359 $allCss .= $this->getCssFromAllStyleNodes($xPath);
360 }
361
362 $cssParts = $this->splitCssAndMediaQuery($allCss);
363 $excludedNodes = $this->getNodesToExclude($xPath);
364 $cssRules = $this->parseCssRules($cssParts['css']);
365 foreach ($cssRules as $cssRule) {
366 // There's no real way to test "PHP Warning" output generated by the following XPath query unless PHPUnit
367 // converts it to an exception. Unfortunately, this would only apply to tests and not work for production
368 // executions, which can still flood logs/output unnecessarily. Instead, Emogrifier's error handler should
369 // always throw an exception and it must be caught here and only rethrown if in debug mode.
370 try {
371 // \DOMXPath::query will always return a DOMNodeList or an exception when errors are caught.
372 $nodesMatchingCssSelectors = @$xPath->query($this->translateCssToXpath($cssRule['selector']));
373 } catch (\InvalidArgumentException $e) {
374 if ($this->debug) {
375 throw $e;
376 }
377 continue;
378 }
379
380 if($nodesMatchingCssSelectors) {
381 /** @var \DOMElement $node */
382 foreach ($nodesMatchingCssSelectors as $node) {
383 if (in_array($node, $excludedNodes, true)) {
384 continue;
385 }
386 // if it has a style attribute, get it, process it, and append (overwrite) new stuff
387 if ($node->hasAttribute('style')) {
388 // break it up into an associative array
389 $oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
390 } else {
391 $oldStyleDeclarations = [];
392 }
393 $newStyleDeclarations = $this->parseCssDeclarationsBlock($cssRule['declarationsBlock']);
394 $node->setAttribute(
395 'style',
396 $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
397 );
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(
776 sprintf(
777 /* translators: %s is the cache key */
778 esc_html__('Invalid cache key: %s', 'fluent-cart'),
779 esc_html($key)
780 ),
781 1391822035
782 );
783 }
784
785 $this->caches[$key] = [];
786 }
787
788 /**
789 * Purges the visited nodes.
790 *
791 * @return void
792 */
793 private function purgeVisitedNodes()
794 {
795 $this->visitedNodes = [];
796 $this->styleAttributesForNodes = [];
797 }
798
799 /**
800 * Marks a tag for removal.
801 *
802 * There are some HTML tags that DOMDocument cannot process, and it will throw an error if it encounters them.
803 * In particular, DOMDocument will complain if you try to use HTML5 tags in an XHTML document.
804 *
805 * Note: The tags will not be removed if they have any content.
806 *
807 * @param string $tagName the tag name, e.g., "p"
808 *
809 * @return void
810 */
811 public function addUnprocessableHtmlTag($tagName)
812 {
813 $this->unprocessableHtmlTags[] = $tagName;
814 }
815
816 /**
817 * Drops a tag from the removal list.
818 *
819 * @param string $tagName the tag name, e.g., "p"
820 *
821 * @return void
822 */
823 public function removeUnprocessableHtmlTag($tagName)
824 {
825 $key = array_search($tagName, $this->unprocessableHtmlTags, true);
826 if ($key !== false) {
827 unset($this->unprocessableHtmlTags[$key]);
828 }
829 }
830
831 /**
832 * Marks a media query type to keep.
833 *
834 * @param string $mediaName the media type name, e.g., "braille"
835 *
836 * @return void
837 */
838 public function addAllowedMediaType($mediaName)
839 {
840 $this->allowedMediaTypes[$mediaName] = true;
841 }
842
843 /**
844 * Drops a media query type from the allowed list.
845 *
846 * @param string $mediaName the tag name, e.g., "braille"
847 *
848 * @return void
849 */
850 public function removeAllowedMediaType($mediaName)
851 {
852 if (isset($this->allowedMediaTypes[$mediaName])) {
853 unset($this->allowedMediaTypes[$mediaName]);
854 }
855 }
856
857 /**
858 * Adds a selector to exclude nodes from emogrification.
859 *
860 * Any nodes that match the selector will not have their style altered.
861 *
862 * @param string $selector the selector to exclude, e.g., ".editor"
863 *
864 * @return void
865 */
866 public function addExcludedSelector($selector)
867 {
868 $this->excludedSelectors[$selector] = true;
869 }
870
871 /**
872 * No longer excludes the nodes matching this selector from emogrification.
873 *
874 * @param string $selector the selector to no longer exclude, e.g., ".editor"
875 *
876 * @return void
877 */
878 public function removeExcludedSelector($selector)
879 {
880 if (isset($this->excludedSelectors[$selector])) {
881 unset($this->excludedSelectors[$selector]);
882 }
883 }
884
885 /**
886 * This removes styles from your email that contain display:none.
887 * We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only
888 * supports XPath 1.0, lower-case() isn't available to us. We've thus far only set attributes to lowercase,
889 * not attribute values. Consequently, we need to translate() the letters that would be in 'NONE' ("NOE")
890 * to lowercase.
891 *
892 * @param \DOMXPath $xPath
893 *
894 * @return void
895 */
896 private function removeInvisibleNodes(\DOMXPath $xPath)
897 {
898 $nodesWithStyleDisplayNone = $xPath->query(
899 '//*[contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")]'
900 );
901 if ($nodesWithStyleDisplayNone->length === 0) {
902 return;
903 }
904
905 // The checks on parentNode and is_callable below ensure that if we've deleted the parent node,
906 // we don't try to call removeChild on a nonexistent child node
907 /** @var \DOMNode $node */
908 foreach ($nodesWithStyleDisplayNone as $node) {
909 if ($node->parentNode && is_callable([$node->parentNode, 'removeChild'])) {
910 $node->parentNode->removeChild($node);
911 }
912 }
913 }
914
915 /**
916 * Parses the document and normalizes all existing CSS attributes.
917 * This changes 'DISPLAY: none' to 'display: none'.
918 * We wouldn't have to do this if DOMXPath supported XPath 2.0.
919 * Also stores a reference of nodes with existing inline styles so we don't overwrite them.
920 *
921 * @param \DOMXPath $xPath
922 *
923 * @return void
924 */
925 private function normalizeStyleAttributesOfAllNodes(\DOMXPath $xPath)
926 {
927 /** @var \DOMElement $node */
928 foreach ($this->getAllNodesWithStyleAttribute($xPath) as $node) {
929 if ($this->isInlineStyleAttributesParsingEnabled) {
930 $this->normalizeStyleAttributes($node);
931 }
932 // Remove style attribute in every case, so we can add them back (if inline style attributes
933 // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
934 // else original inline style rules may remain at the beginning of the final inline style definition
935 // of a node, which may give not the desired results
936 $node->removeAttribute('style');
937 }
938 }
939
940 /**
941 * Normalizes the value of the "style" attribute and saves it.
942 *
943 * @param \DOMElement $node
944 *
945 * @return void
946 */
947 private function normalizeStyleAttributes(\DOMElement $node)
948 {
949 $normalizedOriginalStyle = preg_replace_callback(
950 '/[A-z\\-]+(?=\\:)/S',
951 function (array $m) {
952 return strtolower($m[0]);
953 },
954 $node->getAttribute('style')
955 );
956
957 // in order to not overwrite existing style attributes in the HTML, we
958 // have to save the original HTML styles
959 $nodePath = $node->getNodePath();
960 if (!isset($this->styleAttributesForNodes[$nodePath])) {
961 $this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationsBlock($normalizedOriginalStyle);
962 $this->visitedNodes[$nodePath] = $node;
963 }
964
965 $node->setAttribute('style', $normalizedOriginalStyle);
966 }
967
968 /**
969 * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
970 *
971 * @return void
972 */
973 private function fillStyleAttributesWithMergedStyles()
974 {
975 foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
976 $node = $this->visitedNodes[$nodePath];
977 $currentStyleAttributes = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
978 $node->setAttribute(
979 'style',
980 $this->generateStyleStringFromDeclarationsArrays(
981 $currentStyleAttributes,
982 $styleAttributesForNode
983 )
984 );
985 }
986 }
987
988 /**
989 * This method merges old or existing name/value array with new name/value array
990 * and then generates a string of the combined style suitable for placing inline.
991 * This becomes the single point for CSS string generation allowing for consistent
992 * CSS output no matter where the CSS originally came from.
993 *
994 * @param string[] $oldStyles
995 * @param string[] $newStyles
996 *
997 * @return string
998 */
999 private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles)
1000 {
1001 $combinedStyles = array_merge($oldStyles, $newStyles);
1002 $cacheKey = serialize($combinedStyles);
1003 if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
1004 return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
1005 }
1006
1007 foreach ($oldStyles as $attributeName => $attributeValue) {
1008 if (!isset($newStyles[$attributeName])) {
1009 continue;
1010 }
1011
1012 $newAttributeValue = $newStyles[$attributeName];
1013 if ($this->attributeValueIsImportant($attributeValue)
1014 && !$this->attributeValueIsImportant($newAttributeValue)
1015 ) {
1016 $combinedStyles[$attributeName] = $attributeValue;
1017 }
1018 }
1019
1020 $style = '';
1021 foreach ($combinedStyles as $attributeName => $attributeValue) {
1022 $style .= strtolower(trim($attributeName)) . ': ' . trim($attributeValue) . '; ';
1023 }
1024 $trimmedStyle = rtrim($style);
1025
1026 $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
1027
1028 return $trimmedStyle;
1029 }
1030
1031 /**
1032 * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
1033 *
1034 * @param string[] $styleDeclarations
1035 *
1036 * @return string
1037 */
1038 private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations)
1039 {
1040 return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
1041 }
1042
1043 /**
1044 * Checks whether $attributeValue is marked as !important.
1045 *
1046 * @param string $attributeValue
1047 *
1048 * @return bool
1049 */
1050 private function attributeValueIsImportant($attributeValue)
1051 {
1052 return strtolower(substr(trim($attributeValue), -10)) === '!important';
1053 }
1054
1055 /**
1056 * Applies $css to $xmlDocument, limited to the media queries that actually apply to the document.
1057 *
1058 * @param \DOMDocument $xmlDocument the document to match against
1059 * @param \DOMXPath $xPath
1060 * @param string $css a string of CSS
1061 *
1062 * @return void
1063 */
1064 private function copyCssWithMediaToStyleNode(\DOMDocument $xmlDocument, \DOMXPath $xPath, $css)
1065 {
1066 if ($css === '') {
1067 return;
1068 }
1069
1070 $mediaQueriesRelevantForDocument = [];
1071
1072 foreach ($this->extractMediaQueriesFromCss($css) as $mediaQuery) {
1073 foreach ($this->parseCssRules($mediaQuery['css']) as $selector) {
1074 if ($this->existsMatchForCssSelector($xPath, $selector['selector'])) {
1075 $mediaQueriesRelevantForDocument[] = $mediaQuery['query'];
1076 break;
1077 }
1078 }
1079 }
1080
1081 $this->addStyleElementToDocument($xmlDocument, implode($mediaQueriesRelevantForDocument));
1082 }
1083
1084 /**
1085 * Extracts the media queries from $css while skipping empty media queries.
1086 *
1087 * @param string $css
1088 *
1089 * @return string[][] numeric array with string sub-arrays with the keys "css" and "query"
1090 */
1091 private function extractMediaQueriesFromCss($css)
1092 {
1093 preg_match_all('/@media\\b[^{]*({((?:[^{}]+|(?1))*)})/', $css, $rawMediaQueries, PREG_SET_ORDER);
1094 $parsedQueries = [];
1095
1096 /** @var string[][] $rawMediaQueries */
1097 foreach ($rawMediaQueries as $mediaQuery) {
1098 if ($mediaQuery[2] !== '') {
1099 $parsedQueries[] = [
1100 'css' => $mediaQuery[2],
1101 'query' => $mediaQuery[0],
1102 ];
1103 }
1104 }
1105
1106 return $parsedQueries;
1107 }
1108
1109 /**
1110 * Checks whether there is at least one matching element for $cssSelector.
1111 * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
1112 * just not implemented/recognized yet by Emogrifier).
1113 *
1114 * @param \DOMXPath $xPath
1115 * @param string $cssSelector
1116 *
1117 * @return bool
1118 *
1119 * @throws \InvalidArgumentException
1120 */
1121 private function existsMatchForCssSelector(\DOMXPath $xPath, $cssSelector)
1122 {
1123 try {
1124 $nodesMatchingSelector = $xPath->query($this->translateCssToXpath($cssSelector));
1125 } catch (\InvalidArgumentException $e) {
1126 if ($this->debug) {
1127 throw $e;
1128 }
1129 return true;
1130 }
1131
1132 return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
1133 }
1134
1135 /**
1136 * Returns CSS content.
1137 *
1138 * @param \DOMXPath $xPath
1139 *
1140 * @return string
1141 */
1142 private function getCssFromAllStyleNodes(\DOMXPath $xPath)
1143 {
1144 $styleNodes = $xPath->query('//style');
1145
1146 if ($styleNodes === false) {
1147 return '';
1148 }
1149
1150 $css = '';
1151 /** @var \DOMNode $styleNode */
1152 foreach ($styleNodes as $styleNode) {
1153 $css .= "\n\n" . $styleNode->nodeValue;
1154 $styleNode->parentNode->removeChild($styleNode);
1155 }
1156
1157 return $css;
1158 }
1159
1160 /**
1161 * Adds a style element with $css to $document head.
1162 *
1163 * This method is protected to allow overriding.
1164 *
1165 *
1166 * @param \DOMDocument $document
1167 * @param string $css
1168 *
1169 * @return void
1170 */
1171 protected function addStyleElementToDocument(\DOMDocument $document, $css)
1172 {
1173 $styleElement = $document->createElement('style', $css);
1174 $styleAttribute = $document->createAttribute('type');
1175 $styleAttribute->value = 'text/css';
1176 $styleElement->appendChild($styleAttribute);
1177
1178 $headElement = $this->getHeadElement($document);
1179 $headElement->appendChild($styleElement);
1180 }
1181
1182 /**
1183 * Checks that $document has a BODY element and adds it if it is missing.
1184 *
1185 * @param \DOMDocument $document
1186 */
1187 private function ensureExistenceOfBodyElement(\DOMDocument $document)
1188 {
1189 if ($document->getElementsByTagName('body')->item(0) !== null) {
1190 return;
1191 }
1192
1193 $htmlElement = $document->getElementsByTagName('html')->item(0);
1194
1195 $htmlElement->appendChild($document->createElement('body'));
1196 }
1197
1198 /**
1199 * Returns the BODY element.
1200 *
1201 * This method assumes that there always is a BODY element.
1202 *
1203 * @param \DOMDocument $document
1204 *
1205 * @return \DOMElement
1206 *
1207 * @throws \BadMethodCallException
1208 */
1209 private function getBodyElement(\DOMDocument $document)
1210 {
1211 $bodyElement = $document->getElementsByTagName('body')->item(0);
1212 if ($bodyElement === null) {
1213 throw new \BadMethodCallException(
1214 'getBodyElement method may only be called after ensureExistenceOfBodyElement has been called.',
1215 1508173775427
1216 );
1217 }
1218
1219 return $bodyElement;
1220 }
1221
1222 /**
1223 * Returns the BODY element.
1224 *
1225 * This method assumes that there always is a BODY element.
1226 *
1227 * @param \DOMDocument $document
1228 *
1229 * @return \DOMElement
1230 *
1231 * @throws \BadMethodCallException
1232 */
1233 private function getHeadElement(\DOMDocument $document)
1234 {
1235 $headElement = $document->getElementsByTagName('head')->item(0);
1236 if ($headElement === null) {
1237 throw new \BadMethodCallException(
1238 'getHeadElement method may only be called after ensureExistenceOfBodyElement has been called.',
1239 1508173775427
1240 );
1241 }
1242
1243 return $headElement;
1244 }
1245
1246 /**
1247 * Splits input CSS code to an array where:
1248 *
1249 * - key "css" will be contains clean CSS code
1250 * - key "media" will be contains all valuable media queries
1251 *
1252 * Example:
1253 *
1254 * The CSS code
1255 *
1256 * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
1257 *
1258 * will be parsed into the following array:
1259 *
1260 * "css" => "h1 { color:red; }"
1261 * "media" => "@media { h1 {}}"
1262 *
1263 * @param string $css
1264 *
1265 * @return string[]
1266 */
1267 private function splitCssAndMediaQuery($css)
1268 {
1269 $cssWithoutComments = preg_replace('/\\/\\*.*\\*\\//sU', '', $css);
1270
1271 $mediaTypesExpression = '';
1272 if (!empty($this->allowedMediaTypes)) {
1273 $mediaTypesExpression = '|' . implode('|', array_keys($this->allowedMediaTypes));
1274 }
1275
1276 $media = '';
1277 $cssForAllowedMediaTypes = preg_replace_callback(
1278 '#@media\\s+(?:only\\s)?(?:[\\s{\\(]\\s*' . $mediaTypesExpression . ')\\s*[^{]*+{.*}\\s*}\\s*#misU',
1279 function ($matches) use (&$media) {
1280 $media .= $matches[0];
1281 },
1282 $cssWithoutComments
1283 );
1284
1285 // filter the CSS
1286 $search = [
1287 'import directives' => '/^\\s*@import\\s[^;]+;/misU',
1288 'remaining media enclosures' => '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
1289 ];
1290
1291 $cleanedCss = preg_replace($search, '', $cssForAllowedMediaTypes);
1292
1293 return ['css' => $cleanedCss, 'media' => $media];
1294 }
1295
1296 /**
1297 * Creates a DOMDocument instance with the current HTML.
1298 *
1299 * @return \DOMDocument
1300 */
1301 private function createRawXmlDocument()
1302 {
1303 $xmlDocument = new \DOMDocument;
1304 $xmlDocument->encoding = 'UTF-8';
1305 $xmlDocument->strictErrorChecking = false;
1306 $xmlDocument->formatOutput = true;
1307 $libXmlState = libxml_use_internal_errors(true);
1308 $xmlDocument->loadHTML($this->getUnifiedHtml());
1309 libxml_clear_errors();
1310 libxml_use_internal_errors($libXmlState);
1311 $xmlDocument->normalizeDocument();
1312
1313 return $xmlDocument;
1314 }
1315
1316 /**
1317 * Returns the HTML with the unprocessable HTML tags removed and
1318 * with added document type and Content-Type meta tag if needed.
1319 *
1320 * @return string the unified HTML
1321 *
1322 * @throws \BadMethodCallException
1323 */
1324 private function getUnifiedHtml()
1325 {
1326 $htmlWithoutUnprocessableTags = $this->removeUnprocessableTags($this->html);
1327 $htmlWithDocumentType = $this->ensureDocumentType($htmlWithoutUnprocessableTags);
1328
1329 return $this->addContentTypeMetaTag($htmlWithDocumentType);
1330 }
1331
1332 /**
1333 * Removes the unprocessable tags from $html (if this feature is enabled).
1334 *
1335 * @param string $html
1336 *
1337 * @return string the reworked HTML with the unprocessable tags removed
1338 */
1339 private function removeUnprocessableTags($html)
1340 {
1341 if (empty($this->unprocessableHtmlTags)) {
1342 return $html;
1343 }
1344
1345 $unprocessableHtmlTags = implode('|', $this->unprocessableHtmlTags);
1346
1347 return preg_replace(
1348 '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i',
1349 '',
1350 $html
1351 );
1352 }
1353
1354 /**
1355 * Makes sure that the passed HTML has a document type.
1356 *
1357 * @param string $html
1358 *
1359 * @return string HTML with document type
1360 */
1361 private function ensureDocumentType($html)
1362 {
1363 $hasDocumentType = stripos($html, '<!DOCTYPE') !== false;
1364 if ($hasDocumentType) {
1365 return $html;
1366 }
1367
1368 return self::DEFAULT_DOCUMENT_TYPE . $html;
1369 }
1370
1371 /**
1372 * Adds a Content-Type meta tag for the charset.
1373 *
1374 * @param string $html
1375 *
1376 * @return string the HTML with the meta tag added
1377 */
1378 private function addContentTypeMetaTag($html)
1379 {
1380 $hasContentTypeMetaTag = stripos($html, 'Content-Type') !== false;
1381 if ($hasContentTypeMetaTag) {
1382 return $html;
1383 }
1384
1385 // We are trying to insert the meta tag to the right spot in the DOM.
1386 // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
1387 $hasHeadTag = stripos($html, '<head') !== false;
1388 $hasHtmlTag = stripos($html, '<html') !== false;
1389
1390 if ($hasHeadTag) {
1391 $reworkedHtml = preg_replace('/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html);
1392 } elseif ($hasHtmlTag) {
1393 $reworkedHtml = preg_replace(
1394 '/<html(.*?)>/i',
1395 '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
1396 $html
1397 );
1398 } else {
1399 $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
1400 }
1401
1402 return $reworkedHtml;
1403 }
1404
1405 /**
1406 * @param string[] $a
1407 * @param string[] $b
1408 *
1409 * @return int
1410 */
1411 private function sortBySelectorPrecedence(array $a, array $b)
1412 {
1413 $precedenceA = $this->getCssSelectorPrecedence($a['selector']);
1414 $precedenceB = $this->getCssSelectorPrecedence($b['selector']);
1415
1416 // We want these sorted in ascending order so selectors with lesser precedence get processed first and
1417 // selectors with greater precedence get sorted last.
1418 $precedenceForEquals = ($a['line'] < $b['line'] ? -1 : 1);
1419 $precedenceForNotEquals = ($precedenceA < $precedenceB ? -1 : 1);
1420 return ($precedenceA === $precedenceB) ? $precedenceForEquals : $precedenceForNotEquals;
1421 }
1422
1423 /**
1424 * @param string $selector
1425 *
1426 * @return int
1427 */
1428 private function getCssSelectorPrecedence($selector)
1429 {
1430 $selectorKey = md5($selector);
1431 if (!isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
1432 $precedence = 0;
1433 $value = 100;
1434 // ids: worth 100, classes: worth 10, elements: worth 1
1435 $search = ['\\#', '\\.', ''];
1436
1437 foreach ($search as $s) {
1438 if (trim($selector) === '') {
1439 break;
1440 }
1441 $number = 0;
1442 $selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $number);
1443 $precedence += ($value * $number);
1444 $value /= 10;
1445 }
1446 $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
1447 }
1448
1449 return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
1450 }
1451
1452 /**
1453 * Maps a CSS selector to an XPath query string.
1454 *
1455 * @see http://plasmasturm.org/log/444/
1456 *
1457 * @param string $cssSelector a CSS selector
1458 *
1459 * @return string the corresponding XPath selector
1460 */
1461 private function translateCssToXpath($cssSelector)
1462 {
1463 $paddedSelector = ' ' . $cssSelector . ' ';
1464 $lowercasePaddedSelector = preg_replace_callback(
1465 '/\\s+\\w+\\s+/',
1466 function (array $matches) {
1467 return strtolower($matches[0]);
1468 },
1469 $paddedSelector
1470 );
1471 $trimmedLowercaseSelector = trim($lowercasePaddedSelector);
1472 $xPathKey = md5($trimmedLowercaseSelector);
1473 if (isset($this->caches[self::CACHE_KEY_XPATH][$xPathKey])) {
1474 return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1475 }
1476
1477 $hasNotSelector = (bool)preg_match(
1478 '/^([^:]+):not\\(\\s*([[:ascii:]]+)\\s*\\)$/',
1479 $trimmedLowercaseSelector,
1480 $matches
1481 );
1482 if (!$hasNotSelector) {
1483 $xPath = '//' . $this->translateCssToXpathPass($trimmedLowercaseSelector);
1484 } else {
1485 /** @var string[] $matches */
1486 $partBeforeNot = $matches[1];
1487 $notContents = $matches[2];
1488 $xPath = '//' . $this->translateCssToXpathPass($partBeforeNot) .
1489 '[not(' . $this->translateCssToXpathPassInline($notContents) . ')]';
1490 }
1491 $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey] = $xPath;
1492
1493 return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1494 }
1495
1496 /**
1497 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector.
1498 *
1499 * @param string $trimmedLowercaseSelector
1500 *
1501 * @return string
1502 */
1503 private function translateCssToXpathPass($trimmedLowercaseSelector)
1504 {
1505 return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1506 $trimmedLowercaseSelector,
1507 [$this, 'matchClassAttributes']
1508 );
1509 }
1510
1511 /**
1512 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector for inline usage.
1513 *
1514 * @param string $trimmedLowercaseSelector
1515 *
1516 * @return string
1517 */
1518 private function translateCssToXpathPassInline($trimmedLowercaseSelector)
1519 {
1520 return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1521 $trimmedLowercaseSelector,
1522 [$this, 'matchClassAttributesInline']
1523 );
1524 }
1525
1526 /**
1527 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector while using
1528 * $matchClassAttributesCallback as to match the class attributes.
1529 *
1530 * @param string $trimmedLowercaseSelector
1531 * @param callable $matchClassAttributesCallback
1532 *
1533 * @return string
1534 */
1535 private function translateCssToXpathPassWithMatchClassAttributesCallback(
1536 $trimmedLowercaseSelector,
1537 callable $matchClassAttributesCallback
1538 ) {
1539 $roughXpath = preg_replace(array_keys($this->xPathRules), $this->xPathRules, $trimmedLowercaseSelector);
1540 $xPathWithIdAttributeMatchers = preg_replace_callback(
1541 self::ID_ATTRIBUTE_MATCHER,
1542 [$this, 'matchIdAttributes'],
1543 $roughXpath
1544 );
1545 $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1546 self::CLASS_ATTRIBUTE_MATCHER,
1547 $matchClassAttributesCallback,
1548 $xPathWithIdAttributeMatchers
1549 );
1550
1551 // Advanced selectors are going to require a bit more advanced emogrification.
1552 $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1553 '/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1554 [$this, 'translateNthChild'],
1555 $xPathWithIdAttributeAndClassMatchers
1556 );
1557 $finalXpath = preg_replace_callback(
1558 '/([^\\/]+):nth-of-type\\(\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1559 [$this, 'translateNthOfType'],
1560 $xPathWithIdAttributeAndClassMatchers
1561 );
1562
1563 return $finalXpath;
1564 }
1565
1566 /**
1567 * @param string[] $match
1568 *
1569 * @return string
1570 */
1571 private function matchIdAttributes(array $match)
1572 {
1573 return ($match[1] !== '' ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
1574 }
1575
1576 /**
1577 * @param string[] $match
1578 *
1579 * @return string xPath class attribute query wrapped in element selector
1580 */
1581 private function matchClassAttributes(array $match)
1582 {
1583 return ($match[1] !== '' ? $match[1] : '*') . '[' . $this->matchClassAttributesInline($match) . ']';
1584 }
1585
1586 /**
1587 * @param string[] $match
1588 *
1589 * @return string xPath class attribute query
1590 */
1591 private function matchClassAttributesInline(array $match)
1592 {
1593 return 'contains(concat(" ",@class," "),concat(" ","' .
1594 implode(
1595 '"," "))][contains(concat(" ",@class," "),concat(" ","',
1596 explode('.', substr($match[2], 1))
1597 ) . '"," "))';
1598 }
1599
1600 /**
1601 * @param string[] $match
1602 *
1603 * @return string
1604 */
1605 private function translateNthChild(array $match)
1606 {
1607 $parseResult = $this->parseNth($match);
1608
1609 if (isset($parseResult[self::MULTIPLIER])) {
1610 if ($parseResult[self::MULTIPLIER] < 0) {
1611 $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1612 $xPathExpression = sprintf(
1613 '*[(last() - position()) mod %1%u = %2$u]/self::%3$s',
1614 $parseResult[self::MULTIPLIER],
1615 $parseResult[self::INDEX],
1616 $match[1]
1617 );
1618 } else {
1619 $xPathExpression = sprintf(
1620 '*[position() mod %1$u = %2$u]/self::%3$s',
1621 $parseResult[self::MULTIPLIER],
1622 $parseResult[self::INDEX],
1623 $match[1]
1624 );
1625 }
1626 } else {
1627 $xPathExpression = sprintf('*[%1$u]/self::%2$s', $parseResult[self::INDEX], $match[1]);
1628 }
1629
1630 return $xPathExpression;
1631 }
1632
1633 /**
1634 * @param string[] $match
1635 *
1636 * @return string
1637 */
1638 private function translateNthOfType(array $match)
1639 {
1640 $parseResult = $this->parseNth($match);
1641
1642 if (isset($parseResult[self::MULTIPLIER])) {
1643 if ($parseResult[self::MULTIPLIER] < 0) {
1644 $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1645 $xPathExpression = sprintf(
1646 '%1$s[(last() - position()) mod %2$u = %3$u]',
1647 $match[1],
1648 $parseResult[self::MULTIPLIER],
1649 $parseResult[self::INDEX]
1650 );
1651 } else {
1652 $xPathExpression = sprintf(
1653 '%1$s[position() mod %2$u = %3$u]',
1654 $match[1],
1655 $parseResult[self::MULTIPLIER],
1656 $parseResult[self::INDEX]
1657 );
1658 }
1659 } else {
1660 $xPathExpression = sprintf('%1$s[%2$u]', $match[1], $parseResult[self::INDEX]);
1661 }
1662
1663 return $xPathExpression;
1664 }
1665
1666 /**
1667 * @param string[] $match
1668 *
1669 * @return int[]
1670 */
1671 private function parseNth(array $match)
1672 {
1673 if (in_array(strtolower($match[2]), ['even', 'odd'], true)) {
1674 // we have "even" or "odd"
1675 $index = strtolower($match[2]) === 'even' ? 0 : 1;
1676 return [self::MULTIPLIER => 2, self::INDEX => $index];
1677 }
1678 if (stripos($match[2], 'n') === false) {
1679 // if there is a multiplier
1680 $index = (int)str_replace(' ', '', $match[2]);
1681 return [self::INDEX => $index];
1682 }
1683
1684 if (isset($match[3])) {
1685 $multipleTerm = str_replace($match[3], '', $match[2]);
1686 $index = (int)str_replace(' ', '', $match[3]);
1687 } else {
1688 $multipleTerm = $match[2];
1689 $index = 0;
1690 }
1691
1692 $multiplier = str_ireplace('n', '', $multipleTerm);
1693
1694 if ($multiplier === '') {
1695 $multiplier = 1;
1696 } elseif ($multiplier === '0') {
1697 return [self::INDEX => $index];
1698 } else {
1699 $multiplier = (int)$multiplier;
1700 }
1701
1702 while ($index < 0) {
1703 $index += abs($multiplier);
1704 }
1705
1706 return [self::MULTIPLIER => $multiplier, self::INDEX => $index];
1707 }
1708
1709 /**
1710 * Parses a CSS declaration block into property name/value pairs.
1711 *
1712 * Example:
1713 *
1714 * The declaration block
1715 *
1716 * "color: #000; font-weight: bold;"
1717 *
1718 * will be parsed into the following array:
1719 *
1720 * "color" => "#000"
1721 * "font-weight" => "bold"
1722 *
1723 * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
1724 *
1725 * @return string[]
1726 * the CSS declarations with the property names as array keys and the property values as array values
1727 */
1728 private function parseCssDeclarationsBlock($cssDeclarationsBlock)
1729 {
1730 if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
1731 return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
1732 }
1733
1734 $properties = [];
1735 $declarations = preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
1736
1737 foreach ($declarations as $declaration) {
1738 $matches = [];
1739 if (!preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/', trim($declaration), $matches)) {
1740 continue;
1741 }
1742
1743 $propertyName = strtolower($matches[1]);
1744 $propertyValue = $matches[2];
1745 $properties[$propertyName] = $propertyValue;
1746 }
1747 $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
1748
1749 return $properties;
1750 }
1751
1752 /**
1753 * Find the nodes that are not to be emogrified.
1754 *
1755 * @param \DOMXPath $xPath
1756 *
1757 * @return \DOMElement[]
1758 *
1759 * @throws \InvalidArgumentException
1760 */
1761 private function getNodesToExclude(\DOMXPath $xPath)
1762 {
1763 $excludedNodes = [];
1764 foreach (array_keys($this->excludedSelectors) as $selectorToExclude) {
1765 try {
1766 $matchingNodes = $xPath->query($this->translateCssToXpath($selectorToExclude));
1767 } catch (\InvalidArgumentException $e) {
1768 if ($this->debug) {
1769 throw $e;
1770 }
1771 continue;
1772 }
1773 foreach ($matchingNodes as $node) {
1774 $excludedNodes[] = $node;
1775 }
1776 }
1777
1778 return $excludedNodes;
1779 }
1780
1781 /**
1782 * Handles invalid xPath expression warnings, generated during the process() method,
1783 * during querying \DOMDocument and trigger \InvalidArgumentException with invalid selector
1784 * or \RuntimeException, depending on the source of the warning.
1785 *
1786 * @param int $type
1787 * @param string $message
1788 * @param string $file
1789 * @param int $line
1790 * @param array $context
1791 *
1792 * @return bool always false
1793 *
1794 * @throws \InvalidArgumentException
1795 * @throws \RuntimeException
1796 */
1797 public function handleXpathQueryWarnings( // @codingStandardsIgnoreLine
1798 $type,
1799 $message,
1800 $file,
1801 $line,
1802 array $context = []
1803 ) {
1804 $selector = '';
1805 if (isset($context['cssRule']['selector'])) {
1806 // warnings generated by invalid/unrecognized selectors in method process()
1807 $selector = $context['cssRule']['selector'];
1808 } elseif (isset($context['selectorToExclude'])) {
1809 // warnings generated by invalid/unrecognized selectors in method getNodesToExclude()
1810 $selector = $context['selectorToExclude'];
1811 } elseif (isset($context['cssSelector'])) {
1812 // warnings generated by invalid/unrecognized selectors in method existsMatchForCssSelector()
1813 $selector = $context['cssSelector'];
1814 }
1815
1816 if ($selector !== '') {
1817 throw new \InvalidArgumentException(
1818 sprintf(
1819 /* translators: %1$s is the error message, %2$s is the selector, %3$s is the file name, %4$u is the line number */
1820 esc_html__('%1$s in selector >> %2$s << in %3$s on line %4$u', 'fluent-cart'),
1821 esc_html($message),
1822 esc_html($selector),
1823 esc_html($file),
1824 absint($line)
1825 ),
1826 1509279985
1827 );
1828 }
1829
1830 // Catches eventual warnings generated by method getAllNodesWithStyleAttribute()
1831 if (isset($context['xPath'])) {
1832 throw new \RuntimeException(
1833 sprintf(
1834 /* translators: %1$s is the error message, %2$s is the file name, %3$u is the line number */
1835 esc_html__('%1$s in %2$s on line %3$u', 'fluent-cart'),
1836 esc_html($message),
1837 esc_html($file),
1838 absint($line)
1839 ),
1840 1509280067
1841 );
1842 }
1843
1844 // the normal error handling continues when handler return false
1845 return false;
1846 }
1847
1848 /**
1849 * Sets the debug mode.
1850 *
1851 * @param bool $debug set to true to enable debug mode
1852 *
1853 * @return void
1854 */
1855 public function setDebug($debug)
1856 {
1857 $this->debug = $debug;
1858 }
1859 }
1860