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

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

1,839 lines 58.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Services\Libs\Emogrifier;
4 /**
5 * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
6 *
7 * For more information, please see the README.md file.
8 *
9 * @version 2.0.0
10 *
11 * @author Cameron Brooks
12 * @author Jaime Prado
13 * @author Oliver Klee <[email protected]>
14 * @author Roman Ožana <[email protected]>
15 * @author Sander Kruger <[email protected]>
16 * @author Zoli Szabó <[email protected]>
17 */
18 class 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 \set_error_handler([$this, 'handleXpathQueryWarnings'], E_WARNING);
351
352 $this->normalizeStyleAttributesOfAllNodes($xPath);
353
354 // grab any existing style blocks from the html and append them to the existing CSS
355 // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
356 $allCss = $this->css;
357 if ($this->isStyleBlocksParsingEnabled) {
358 $allCss .= $this->getCssFromAllStyleNodes($xPath);
359 }
360
361 $cssParts = $this->splitCssAndMediaQuery($allCss);
362 $excludedNodes = $this->getNodesToExclude($xPath);
363 $cssRules = $this->parseCssRules($cssParts['css']);
364 foreach ($cssRules as $cssRule) {
365 // There's no real way to test "PHP Warning" output generated by the following XPath query unless PHPUnit
366 // converts it to an exception. Unfortunately, this would only apply to tests and not work for production
367 // executions, which can still flood logs/output unnecessarily. Instead, Emogrifier's error handler should
368 // always throw an exception and it must be caught here and only rethrown if in debug mode.
369 try {
370 // \DOMXPath::query will always return a DOMNodeList or an exception when errors are caught.
371 $nodesMatchingCssSelectors = @$xPath->query($this->translateCssToXpath($cssRule['selector']));
372 } catch (\InvalidArgumentException $e) {
373 if ($this->debug) {
374 throw $e;
375 }
376 continue;
377 }
378
379 if($nodesMatchingCssSelectors) {
380 /** @var \DOMElement $node */
381 foreach ($nodesMatchingCssSelectors as $node) {
382 if (in_array($node, $excludedNodes, true)) {
383 continue;
384 }
385 // if it has a style attribute, get it, process it, and append (overwrite) new stuff
386 if ($node->hasAttribute('style')) {
387 // break it up into an associative array
388 $oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
389 } else {
390 $oldStyleDeclarations = [];
391 }
392 $newStyleDeclarations = $this->parseCssDeclarationsBlock($cssRule['declarationsBlock']);
393 $node->setAttribute(
394 'style',
395 $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
396 );
397 }
398 }
399 }
400
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 head.
1154 *
1155 * This method is protected to allow overriding.
1156 *
1157 *
1158 * @param \DOMDocument $document
1159 * @param string $css
1160 *
1161 * @return void
1162 */
1163 protected function addStyleElementToDocument(\DOMDocument $document, $css)
1164 {
1165 $styleElement = $document->createElement('style', $css);
1166 $styleAttribute = $document->createAttribute('type');
1167 $styleAttribute->value = 'text/css';
1168 $styleElement->appendChild($styleAttribute);
1169
1170 $headElement = $this->getHeadElement($document);
1171 $headElement->appendChild($styleElement);
1172 }
1173
1174 /**
1175 * Checks that $document has a BODY element and adds it if it is missing.
1176 *
1177 * @param \DOMDocument $document
1178 */
1179 private function ensureExistenceOfBodyElement(\DOMDocument $document)
1180 {
1181 if ($document->getElementsByTagName('body')->item(0) !== null) {
1182 return;
1183 }
1184
1185 $htmlElement = $document->getElementsByTagName('html')->item(0);
1186
1187 $htmlElement->appendChild($document->createElement('body'));
1188 }
1189
1190 /**
1191 * Returns the BODY element.
1192 *
1193 * This method assumes that there always is a BODY element.
1194 *
1195 * @param \DOMDocument $document
1196 *
1197 * @return \DOMElement
1198 *
1199 * @throws \BadMethodCallException
1200 */
1201 private function getBodyElement(\DOMDocument $document)
1202 {
1203 $bodyElement = $document->getElementsByTagName('body')->item(0);
1204 if ($bodyElement === null) {
1205 throw new \BadMethodCallException(
1206 'getBodyElement method may only be called after ensureExistenceOfBodyElement has been called.',
1207 1508173775427
1208 );
1209 }
1210
1211 return $bodyElement;
1212 }
1213
1214 /**
1215 * Returns the BODY element.
1216 *
1217 * This method assumes that there always is a BODY element.
1218 *
1219 * @param \DOMDocument $document
1220 *
1221 * @return \DOMElement
1222 *
1223 * @throws \BadMethodCallException
1224 */
1225 private function getHeadElement(\DOMDocument $document)
1226 {
1227 $headElement = $document->getElementsByTagName('head')->item(0);
1228 if ($headElement === null) {
1229 throw new \BadMethodCallException(
1230 'getHeadElement method may only be called after ensureExistenceOfBodyElement has been called.',
1231 1508173775427
1232 );
1233 }
1234
1235 return $headElement;
1236 }
1237
1238 /**
1239 * Splits input CSS code to an array where:
1240 *
1241 * - key "css" will be contains clean CSS code
1242 * - key "media" will be contains all valuable media queries
1243 *
1244 * Example:
1245 *
1246 * The CSS code
1247 *
1248 * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
1249 *
1250 * will be parsed into the following array:
1251 *
1252 * "css" => "h1 { color:red; }"
1253 * "media" => "@media { h1 {}}"
1254 *
1255 * @param string $css
1256 *
1257 * @return string[]
1258 */
1259 private function splitCssAndMediaQuery($css)
1260 {
1261 $cssWithoutComments = preg_replace('/\\/\\*.*\\*\\//sU', '', $css);
1262
1263 $mediaTypesExpression = '';
1264 if (!empty($this->allowedMediaTypes)) {
1265 $mediaTypesExpression = '|' . implode('|', array_keys($this->allowedMediaTypes));
1266 }
1267
1268 $media = '';
1269 $cssForAllowedMediaTypes = preg_replace_callback(
1270 '#@media\\s+(?:only\\s)?(?:[\\s{\\(]\\s*' . $mediaTypesExpression . ')\\s*[^{]*+{.*}\\s*}\\s*#misU',
1271 function ($matches) use (&$media) {
1272 $media .= $matches[0];
1273 },
1274 $cssWithoutComments
1275 );
1276
1277 // filter the CSS
1278 $search = [
1279 'import directives' => '/^\\s*@import\\s[^;]+;/misU',
1280 'remaining media enclosures' => '/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
1281 ];
1282
1283 $cleanedCss = preg_replace($search, '', $cssForAllowedMediaTypes);
1284
1285 return ['css' => $cleanedCss, 'media' => $media];
1286 }
1287
1288 /**
1289 * Creates a DOMDocument instance with the current HTML.
1290 *
1291 * @return \DOMDocument
1292 */
1293 private function createRawXmlDocument()
1294 {
1295 $xmlDocument = new \DOMDocument;
1296 $xmlDocument->encoding = 'UTF-8';
1297 $xmlDocument->strictErrorChecking = false;
1298 $xmlDocument->formatOutput = true;
1299 $libXmlState = libxml_use_internal_errors(true);
1300 $xmlDocument->loadHTML($this->getUnifiedHtml());
1301 libxml_clear_errors();
1302 libxml_use_internal_errors($libXmlState);
1303 $xmlDocument->normalizeDocument();
1304
1305 return $xmlDocument;
1306 }
1307
1308 /**
1309 * Returns the HTML with the unprocessable HTML tags removed and
1310 * with added document type and Content-Type meta tag if needed.
1311 *
1312 * @return string the unified HTML
1313 *
1314 * @throws \BadMethodCallException
1315 */
1316 private function getUnifiedHtml()
1317 {
1318 $htmlWithoutUnprocessableTags = $this->removeUnprocessableTags($this->html);
1319 $htmlWithDocumentType = $this->ensureDocumentType($htmlWithoutUnprocessableTags);
1320
1321 return $this->addContentTypeMetaTag($htmlWithDocumentType);
1322 }
1323
1324 /**
1325 * Removes the unprocessable tags from $html (if this feature is enabled).
1326 *
1327 * @param string $html
1328 *
1329 * @return string the reworked HTML with the unprocessable tags removed
1330 */
1331 private function removeUnprocessableTags($html)
1332 {
1333 if (empty($this->unprocessableHtmlTags)) {
1334 return $html;
1335 }
1336
1337 $unprocessableHtmlTags = implode('|', $this->unprocessableHtmlTags);
1338
1339 return preg_replace(
1340 '/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i',
1341 '',
1342 $html
1343 );
1344 }
1345
1346 /**
1347 * Makes sure that the passed HTML has a document type.
1348 *
1349 * @param string $html
1350 *
1351 * @return string HTML with document type
1352 */
1353 private function ensureDocumentType($html)
1354 {
1355 $hasDocumentType = stripos($html, '<!DOCTYPE') !== false;
1356 if ($hasDocumentType) {
1357 return $html;
1358 }
1359
1360 return self::DEFAULT_DOCUMENT_TYPE . $html;
1361 }
1362
1363 /**
1364 * Adds a Content-Type meta tag for the charset.
1365 *
1366 * @param string $html
1367 *
1368 * @return string the HTML with the meta tag added
1369 */
1370 private function addContentTypeMetaTag($html)
1371 {
1372 $hasContentTypeMetaTag = stripos($html, 'Content-Type') !== false;
1373 if ($hasContentTypeMetaTag) {
1374 return $html;
1375 }
1376
1377 // We are trying to insert the meta tag to the right spot in the DOM.
1378 // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
1379 $hasHeadTag = stripos($html, '<head') !== false;
1380 $hasHtmlTag = stripos($html, '<html') !== false;
1381
1382 if ($hasHeadTag) {
1383 $reworkedHtml = preg_replace('/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html);
1384 } elseif ($hasHtmlTag) {
1385 $reworkedHtml = preg_replace(
1386 '/<html(.*?)>/i',
1387 '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
1388 $html
1389 );
1390 } else {
1391 $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
1392 }
1393
1394 return $reworkedHtml;
1395 }
1396
1397 /**
1398 * @param string[] $a
1399 * @param string[] $b
1400 *
1401 * @return int
1402 */
1403 private function sortBySelectorPrecedence(array $a, array $b)
1404 {
1405 $precedenceA = $this->getCssSelectorPrecedence($a['selector']);
1406 $precedenceB = $this->getCssSelectorPrecedence($b['selector']);
1407
1408 // We want these sorted in ascending order so selectors with lesser precedence get processed first and
1409 // selectors with greater precedence get sorted last.
1410 $precedenceForEquals = ($a['line'] < $b['line'] ? -1 : 1);
1411 $precedenceForNotEquals = ($precedenceA < $precedenceB ? -1 : 1);
1412 return ($precedenceA === $precedenceB) ? $precedenceForEquals : $precedenceForNotEquals;
1413 }
1414
1415 /**
1416 * @param string $selector
1417 *
1418 * @return int
1419 */
1420 private function getCssSelectorPrecedence($selector)
1421 {
1422 $selectorKey = md5($selector);
1423 if (!isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
1424 $precedence = 0;
1425 $value = 100;
1426 // ids: worth 100, classes: worth 10, elements: worth 1
1427 $search = ['\\#', '\\.', ''];
1428
1429 foreach ($search as $s) {
1430 if (trim($selector) === '') {
1431 break;
1432 }
1433 $number = 0;
1434 $selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $number);
1435 $precedence += ($value * $number);
1436 $value /= 10;
1437 }
1438 $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
1439 }
1440
1441 return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
1442 }
1443
1444 /**
1445 * Maps a CSS selector to an XPath query string.
1446 *
1447 * @see http://plasmasturm.org/log/444/
1448 *
1449 * @param string $cssSelector a CSS selector
1450 *
1451 * @return string the corresponding XPath selector
1452 */
1453 private function translateCssToXpath($cssSelector)
1454 {
1455 $paddedSelector = ' ' . $cssSelector . ' ';
1456 $lowercasePaddedSelector = preg_replace_callback(
1457 '/\\s+\\w+\\s+/',
1458 function (array $matches) {
1459 return strtolower($matches[0]);
1460 },
1461 $paddedSelector
1462 );
1463 $trimmedLowercaseSelector = trim($lowercasePaddedSelector);
1464 $xPathKey = md5($trimmedLowercaseSelector);
1465 if (isset($this->caches[self::CACHE_KEY_XPATH][$xPathKey])) {
1466 return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1467 }
1468
1469 $hasNotSelector = (bool)preg_match(
1470 '/^([^:]+):not\\(\\s*([[:ascii:]]+)\\s*\\)$/',
1471 $trimmedLowercaseSelector,
1472 $matches
1473 );
1474 if (!$hasNotSelector) {
1475 $xPath = '//' . $this->translateCssToXpathPass($trimmedLowercaseSelector);
1476 } else {
1477 /** @var string[] $matches */
1478 $partBeforeNot = $matches[1];
1479 $notContents = $matches[2];
1480 $xPath = '//' . $this->translateCssToXpathPass($partBeforeNot) .
1481 '[not(' . $this->translateCssToXpathPassInline($notContents) . ')]';
1482 }
1483 $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey] = $xPath;
1484
1485 return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1486 }
1487
1488 /**
1489 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector.
1490 *
1491 * @param string $trimmedLowercaseSelector
1492 *
1493 * @return string
1494 */
1495 private function translateCssToXpathPass($trimmedLowercaseSelector)
1496 {
1497 return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1498 $trimmedLowercaseSelector,
1499 [$this, 'matchClassAttributes']
1500 );
1501 }
1502
1503 /**
1504 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector for inline usage.
1505 *
1506 * @param string $trimmedLowercaseSelector
1507 *
1508 * @return string
1509 */
1510 private function translateCssToXpathPassInline($trimmedLowercaseSelector)
1511 {
1512 return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1513 $trimmedLowercaseSelector,
1514 [$this, 'matchClassAttributesInline']
1515 );
1516 }
1517
1518 /**
1519 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector while using
1520 * $matchClassAttributesCallback as to match the class attributes.
1521 *
1522 * @param string $trimmedLowercaseSelector
1523 * @param callable $matchClassAttributesCallback
1524 *
1525 * @return string
1526 */
1527 private function translateCssToXpathPassWithMatchClassAttributesCallback(
1528 $trimmedLowercaseSelector,
1529 callable $matchClassAttributesCallback
1530 ) {
1531 $roughXpath = preg_replace(array_keys($this->xPathRules), $this->xPathRules, $trimmedLowercaseSelector);
1532 $xPathWithIdAttributeMatchers = preg_replace_callback(
1533 self::ID_ATTRIBUTE_MATCHER,
1534 [$this, 'matchIdAttributes'],
1535 $roughXpath
1536 );
1537 $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1538 self::CLASS_ATTRIBUTE_MATCHER,
1539 $matchClassAttributesCallback,
1540 $xPathWithIdAttributeMatchers
1541 );
1542
1543 // Advanced selectors are going to require a bit more advanced emogrification.
1544 $xPathWithIdAttributeAndClassMatchers = preg_replace_callback(
1545 '/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1546 [$this, 'translateNthChild'],
1547 $xPathWithIdAttributeAndClassMatchers
1548 );
1549 $finalXpath = preg_replace_callback(
1550 '/([^\\/]+):nth-of-type\\(\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1551 [$this, 'translateNthOfType'],
1552 $xPathWithIdAttributeAndClassMatchers
1553 );
1554
1555 return $finalXpath;
1556 }
1557
1558 /**
1559 * @param string[] $match
1560 *
1561 * @return string
1562 */
1563 private function matchIdAttributes(array $match)
1564 {
1565 return ($match[1] !== '' ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
1566 }
1567
1568 /**
1569 * @param string[] $match
1570 *
1571 * @return string xPath class attribute query wrapped in element selector
1572 */
1573 private function matchClassAttributes(array $match)
1574 {
1575 return ($match[1] !== '' ? $match[1] : '*') . '[' . $this->matchClassAttributesInline($match) . ']';
1576 }
1577
1578 /**
1579 * @param string[] $match
1580 *
1581 * @return string xPath class attribute query
1582 */
1583 private function matchClassAttributesInline(array $match)
1584 {
1585 return 'contains(concat(" ",@class," "),concat(" ","' .
1586 implode(
1587 '"," "))][contains(concat(" ",@class," "),concat(" ","',
1588 explode('.', substr($match[2], 1))
1589 ) . '"," "))';
1590 }
1591
1592 /**
1593 * @param string[] $match
1594 *
1595 * @return string
1596 */
1597 private function translateNthChild(array $match)
1598 {
1599 $parseResult = $this->parseNth($match);
1600
1601 if (isset($parseResult[self::MULTIPLIER])) {
1602 if ($parseResult[self::MULTIPLIER] < 0) {
1603 $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1604 $xPathExpression = sprintf(
1605 '*[(last() - position()) mod %1%u = %2$u]/self::%3$s',
1606 $parseResult[self::MULTIPLIER],
1607 $parseResult[self::INDEX],
1608 $match[1]
1609 );
1610 } else {
1611 $xPathExpression = sprintf(
1612 '*[position() mod %1$u = %2$u]/self::%3$s',
1613 $parseResult[self::MULTIPLIER],
1614 $parseResult[self::INDEX],
1615 $match[1]
1616 );
1617 }
1618 } else {
1619 $xPathExpression = sprintf('*[%1$u]/self::%2$s', $parseResult[self::INDEX], $match[1]);
1620 }
1621
1622 return $xPathExpression;
1623 }
1624
1625 /**
1626 * @param string[] $match
1627 *
1628 * @return string
1629 */
1630 private function translateNthOfType(array $match)
1631 {
1632 $parseResult = $this->parseNth($match);
1633
1634 if (isset($parseResult[self::MULTIPLIER])) {
1635 if ($parseResult[self::MULTIPLIER] < 0) {
1636 $parseResult[self::MULTIPLIER] = abs($parseResult[self::MULTIPLIER]);
1637 $xPathExpression = sprintf(
1638 '%1$s[(last() - position()) mod %2$u = %3$u]',
1639 $match[1],
1640 $parseResult[self::MULTIPLIER],
1641 $parseResult[self::INDEX]
1642 );
1643 } else {
1644 $xPathExpression = sprintf(
1645 '%1$s[position() mod %2$u = %3$u]',
1646 $match[1],
1647 $parseResult[self::MULTIPLIER],
1648 $parseResult[self::INDEX]
1649 );
1650 }
1651 } else {
1652 $xPathExpression = sprintf('%1$s[%2$u]', $match[1], $parseResult[self::INDEX]);
1653 }
1654
1655 return $xPathExpression;
1656 }
1657
1658 /**
1659 * @param string[] $match
1660 *
1661 * @return int[]
1662 */
1663 private function parseNth(array $match)
1664 {
1665 if (in_array(strtolower($match[2]), ['even', 'odd'], true)) {
1666 // we have "even" or "odd"
1667 $index = strtolower($match[2]) === 'even' ? 0 : 1;
1668 return [self::MULTIPLIER => 2, self::INDEX => $index];
1669 }
1670 if (stripos($match[2], 'n') === false) {
1671 // if there is a multiplier
1672 $index = (int)str_replace(' ', '', $match[2]);
1673 return [self::INDEX => $index];
1674 }
1675
1676 if (isset($match[3])) {
1677 $multipleTerm = str_replace($match[3], '', $match[2]);
1678 $index = (int)str_replace(' ', '', $match[3]);
1679 } else {
1680 $multipleTerm = $match[2];
1681 $index = 0;
1682 }
1683
1684 $multiplier = str_ireplace('n', '', $multipleTerm);
1685
1686 if ($multiplier === '') {
1687 $multiplier = 1;
1688 } elseif ($multiplier === '0') {
1689 return [self::INDEX => $index];
1690 } else {
1691 $multiplier = (int)$multiplier;
1692 }
1693
1694 while ($index < 0) {
1695 $index += abs($multiplier);
1696 }
1697
1698 return [self::MULTIPLIER => $multiplier, self::INDEX => $index];
1699 }
1700
1701 /**
1702 * Parses a CSS declaration block into property name/value pairs.
1703 *
1704 * Example:
1705 *
1706 * The declaration block
1707 *
1708 * "color: #000; font-weight: bold;"
1709 *
1710 * will be parsed into the following array:
1711 *
1712 * "color" => "#000"
1713 * "font-weight" => "bold"
1714 *
1715 * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
1716 *
1717 * @return string[]
1718 * the CSS declarations with the property names as array keys and the property values as array values
1719 */
1720 private function parseCssDeclarationsBlock($cssDeclarationsBlock)
1721 {
1722 if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
1723 return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
1724 }
1725
1726 $properties = [];
1727 $declarations = preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
1728
1729 foreach ($declarations as $declaration) {
1730 $matches = [];
1731 if (!preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/', trim($declaration), $matches)) {
1732 continue;
1733 }
1734
1735 $propertyName = strtolower($matches[1]);
1736 $propertyValue = $matches[2];
1737 $properties[$propertyName] = $propertyValue;
1738 }
1739 $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
1740
1741 return $properties;
1742 }
1743
1744 /**
1745 * Find the nodes that are not to be emogrified.
1746 *
1747 * @param \DOMXPath $xPath
1748 *
1749 * @return \DOMElement[]
1750 *
1751 * @throws \InvalidArgumentException
1752 */
1753 private function getNodesToExclude(\DOMXPath $xPath)
1754 {
1755 $excludedNodes = [];
1756 foreach (array_keys($this->excludedSelectors) as $selectorToExclude) {
1757 try {
1758 $matchingNodes = $xPath->query($this->translateCssToXpath($selectorToExclude));
1759 } catch (\InvalidArgumentException $e) {
1760 if ($this->debug) {
1761 throw $e;
1762 }
1763 continue;
1764 }
1765 foreach ($matchingNodes as $node) {
1766 $excludedNodes[] = $node;
1767 }
1768 }
1769
1770 return $excludedNodes;
1771 }
1772
1773 /**
1774 * Handles invalid xPath expression warnings, generated during the process() method,
1775 * during querying \DOMDocument and trigger \InvalidArgumentException with invalid selector
1776 * or \RuntimeException, depending on the source of the warning.
1777 *
1778 * @param int $type
1779 * @param string $message
1780 * @param string $file
1781 * @param int $line
1782 * @param array $context
1783 *
1784 * @return bool always false
1785 *
1786 * @throws \InvalidArgumentException
1787 * @throws \RuntimeException
1788 */
1789 public function handleXpathQueryWarnings( // @codingStandardsIgnoreLine
1790 $type,
1791 $message,
1792 $file,
1793 $line,
1794 array $context = []
1795 ) {
1796 $selector = '';
1797 if (isset($context['cssRule']['selector'])) {
1798 // warnings generated by invalid/unrecognized selectors in method process()
1799 $selector = $context['cssRule']['selector'];
1800 } elseif (isset($context['selectorToExclude'])) {
1801 // warnings generated by invalid/unrecognized selectors in method getNodesToExclude()
1802 $selector = $context['selectorToExclude'];
1803 } elseif (isset($context['cssSelector'])) {
1804 // warnings generated by invalid/unrecognized selectors in method existsMatchForCssSelector()
1805 $selector = $context['cssSelector'];
1806 }
1807
1808 if ($selector !== '') {
1809 throw new \InvalidArgumentException(
1810 sprintf('%1$s in selector >> %2$s << in %3$s on line %4$u', $message, $selector, $file, $line),
1811 1509279985
1812 );
1813 }
1814
1815 // Catches eventual warnings generated by method getAllNodesWithStyleAttribute()
1816 if (isset($context['xPath'])) {
1817 throw new \RuntimeException(
1818 sprintf('%1$s in %2$s on line %3$u', $message, $file, $line),
1819 1509280067
1820 );
1821 }
1822
1823 // the normal error handling continues when handler return false
1824 return false;
1825 }
1826
1827 /**
1828 * Sets the debug mode.
1829 *
1830 * @param bool $debug set to true to enable debug mode
1831 *
1832 * @return void
1833 */
1834 public function setDebug($debug)
1835 {
1836 $this->debug = $debug;
1837 }
1838 }
1839