PluginProbe
weForms – Easy Drag & Drop Contact Form Builder For WordPress / 1.3.2
weForms – Easy Drag & Drop Contact Form Builder For WordPress v1.3.2
1.6.7 1.6.8 1.6.9 1.6.12 1.6.13 1.6.14 1.6.15 1.6.16 1.6.17 1.6.18 1.6.19 1.6.2 1.6.20 1.6.21 1.6.22 1.6.23 1.6.24 1.6.25 1.6.26 1.6.27 1.6.28 1.6.3 1.6.4 1.6.5 1.6.6 All 74 releases
weforms / includes / library / Emogrifier.php

Emogrifier.php in weForms – Easy Drag & Drop Contact Form Builder For WordPress 1.3.2, at includes/library/Emogrifier.php

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