PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 3.6.41
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v3.6.41
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 3.6.41, at app/Services/Emogrifier/Emogrifier.php

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