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