PluginProbe
weForms – Easy Drag & Drop Contact Form Builder For WordPress / 1.6.13
weForms – Easy Drag & Drop Contact Form Builder For WordPress v1.6.13
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.6.13, at includes/library/Emogrifier.php

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