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

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

1,875 lines 62.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
5 *
6 * For more information, please see the README.md file.
7 *
8 * @deprecated Will be removed for version 4.0.0. Please use the CssInliner class instead.
9 *
10 * @author Cameron Brooks
11 * @author Jaime Prado
12 * @author Oliver Klee <github@oliverklee.de>
13 * @author Roman Ožana <ozana@omdesign.cz>
14 * @author Sander Kruger <s.kruger@invessel.com>
15 * @author Zoli Szabó <zoli.szabo+github@gmail.com>
16 */
17 class Emogrifier
18 {
19 /**
20 * @var int
21 */
22 const CACHE_KEY_CSS = 0;
23
24 /**
25 * @var int
26 */
27 const CACHE_KEY_SELECTOR = 1;
28
29 /**
30 * @var int
31 */
32 const CACHE_KEY_XPATH = 2;
33
34 /**
35 * @var int
36 */
37 const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 3;
38
39 /**
40 * @var int
41 */
42 const CACHE_KEY_COMBINED_STYLES = 4;
43
44 /**
45 * for calculating nth-of-type and nth-child selectors
46 *
47 * @var int
48 */
49 const INDEX = 0;
50
51 /**
52 * for calculating nth-of-type and nth-child selectors
53 *
54 * @var int
55 */
56 const MULTIPLIER = 1;
57
58 /**
59 * @var string
60 */
61 const ID_ATTRIBUTE_MATCHER = '/(\\w+)?\\#([\\w\\-]+)/';
62
63 /**
64 * @var string
65 */
66 const CLASS_ATTRIBUTE_MATCHER = '/(\\w+|[\\*\\]])?((\\.[\\w\\-]+)+)/';
67
68 /**
69 * Regular expression component matching a static pseudo class in a selector, without the preceding ":",
70 * for which the applicable elements can be determined (by converting the selector to an XPath expression).
71 * (Contains alternation without a group and is intended to be placed within a capturing, non-capturing or lookahead
72 * group, as appropriate for the usage context.)
73 *
74 * @var string
75 */
76 const PSEUDO_CLASS_MATCHER = '(?:first|last|nth)-child|nth-of-type|not\\([[:ascii:]]*\\)';
77
78 /**
79 * @var string
80 */
81 const CONTENT_TYPE_META_TAG = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
82
83 /**
84 * @var string
85 */
86 const DEFAULT_DOCUMENT_TYPE = '<!DOCTYPE html>';
87
88 /**
89 * @var string Regular expression part to match tag names that PHP's DOMDocument implementation is not aware are
90 * self-closing. These are mostly HTML5 elements, but for completeness <command> (obsolete) and <keygen>
91 * (deprecated) are also included.
92 *
93 * @see https://bugs.php.net/bug.php?id=73175
94 */
95 const PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER = '(?:command|embed|keygen|source|track|wbr)';
96
97 /**
98 * @var \DOMDocument
99 */
100 protected $domDocument = null;
101
102 /**
103 * @var \DOMXPath
104 */
105 protected $xPath = null;
106
107 /**
108 * @var string
109 */
110 private $css = '';
111
112 /**
113 * @var bool[]
114 */
115 private $excludedSelectors = [];
116
117 /**
118 * @var string[]
119 */
120 private $unprocessableHtmlTags = ['wbr'];
121
122 /**
123 * @var bool[]
124 */
125 private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
126
127 /**
128 * @var mixed[]
129 */
130 private $caches = [
131 self::CACHE_KEY_CSS => [],
132 self::CACHE_KEY_SELECTOR => [],
133 self::CACHE_KEY_XPATH => [],
134 self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
135 self::CACHE_KEY_COMBINED_STYLES => [],
136 ];
137
138 /**
139 * the visited nodes with the XPath paths as array keys
140 *
141 * @var \DOMElement[]
142 */
143 private $visitedNodes = [];
144
145 /**
146 * the styles to apply to the nodes with the XPath paths as array keys for the outer array
147 * and the attribute names/values as key/value pairs for the inner array
148 *
149 * @var string[][]
150 */
151 private $styleAttributesForNodes = [];
152
153 /**
154 * Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
155 * If set to false, the value of the style attributes will be discarded.
156 *
157 * @var bool
158 */
159 private $isInlineStyleAttributesParsingEnabled = true;
160
161 /**
162 * Determines whether the <style> blocks in the HTML passed to this class should be parsed.
163 *
164 * If set to true, the <style> blocks will be removed from the HTML and their contents will be applied to the HTML
165 * via inline styles.
166 *
167 * If set to false, the <style> blocks will be left as they are in the HTML.
168 *
169 * @var bool
170 */
171 private $isStyleBlocksParsingEnabled = true;
172
173 /**
174 * For calculating selector precedence order.
175 * Keys are a regular expression part to match before a CSS name.
176 * Values are a multiplier factor per match to weight specificity.
177 *
178 * @var int[]
179 */
180 private $selectorPrecedenceMatchers = [
181 // IDs: worth 10000
182 '\\#' => 10000,
183 // classes, attributes, pseudo-classes (not pseudo-elements) except `:not`: worth 100
184 '(?:\\.|\\[|(?<!:):(?!not\\())' => 100,
185 // elements (not attribute values or `:not`), pseudo-elements: worth 1
186 '(?:(?<![="\':\\w\\-])|::)' => 1,
187 ];
188
189 /**
190 * @var string[]
191 */
192 private $xPathRules = [
193 // attribute presence
194 '/^\\[(\\w+|\\w+\\=[\'"]?\\w+[\'"]?)\\]/' => '*[@\\1]',
195 // type and attribute exact value
196 '/(\\w)\\[(\\w+)\\=[\'"]?([\\w\\s]+)[\'"]?\\]/' => '\\1[@\\2="\\3"]',
197 // element attribute~=
198 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w\-_\\/]+)[\'"]?\\]/' => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
199 // element attribute^=
200 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w\-_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
201 // element attribute*=
202 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w\-_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
203 // element attribute$=
204 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w\-_\\s\\/]+)[\'"]?\\]/' => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
205 // element attribute|=
206 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w\-_\\s\\/]+)[\'"]?\\]/' => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
207 ];
208
209 /**
210 * Determines whether CSS styles that have an equivalent HTML attribute
211 * should be mapped and attached to those elements.
212 *
213 * @var bool
214 */
215 private $shouldMapCssToHtml = false;
216
217 /**
218 * This multi-level array contains simple mappings of CSS properties to
219 * HTML attributes. If a mapping only applies to certain HTML nodes or
220 * only for certain values, the mapping is an object with a whitelist
221 * of nodes and values.
222 *
223 * @var mixed[][]
224 */
225 private $cssToHtmlMap = [
226 'background-color' => [
227 'attribute' => 'bgcolor',
228 ],
229 'text-align' => [
230 'attribute' => 'align',
231 'nodes' => ['p', 'div', 'td'],
232 'values' => ['left', 'right', 'center', 'justify'],
233 ],
234 'float' => [
235 'attribute' => 'align',
236 'nodes' => ['table', 'img'],
237 'values' => ['left', 'right'],
238 ],
239 'border-spacing' => [
240 'attribute' => 'cellspacing',
241 'nodes' => ['table'],
242 ],
243 // type and attribute value with ~ (one word within a whitespace-separated list of words)
244 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\~\\=[\\s]*[\'"]?([\\w\\-\_\\/]+)[\'"]?\\]/'
245 => '\\1[contains(concat(" ", @\\2, " "), concat(" ", "\\3", " "))]',
246 // type and attribute value with | (either exact value match or prefix followed by a hyphen)
247 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\|\\=[\\s]*[\'"]?([\\w\\-\_\\s\\/]+)[\'"]?\\]/'
248 => '\\1[@\\2="\\3" or starts-with(@\\2, concat("\\3", "-"))]',
249 // type and attribute value with ^ (prefix match)
250 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\^\\=[\\s]*[\'"]?([\\w\\-\_\\/]+)[\'"]?\\]/' => '\\1[starts-with(@\\2, "\\3")]',
251 // type and attribute value with * (substring match)
252 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\*\\=[\\s]*[\'"]?([\\w\\-\_\\s\\/:;]+)[\'"]?\\]/' => '\\1[contains(@\\2, "\\3")]',
253 // adjacent sibling
254 '/\\s*\\+\\s*/' => '/following-sibling::*[1]/self::',
255 // child
256 '/\\s*>\\s*/' => '/',
257 // descendant (don't match spaces within already translated XPath predicates)
258 '/\\s+(?![^\\[\\]]*+\\])/' => '//',
259 // type and :first-child
260 '/([^\\/]+):first-child/i' => '*[1]/self::\\1',
261 // type and :last-child
262 '/([^\\/]+):last-child/i' => '*[last()]/self::\\1',
263
264 // The following matcher will break things if it is placed before the adjacent matcher.
265 // So one of the matchers matches either too much or not enough.
266 // type and attribute value with $ (suffix match)
267 '/([\\w\\*]+)\\[(\\w+)[\\s]*\\$\\=[\\s]*[\'"]?([\\w\\-\_\\s\\/]+)[\'"]?\\]/'
268 => '\\1[substring(@\\2, string-length(@\\2) - string-length("\\3") + 1) = "\\3"]',
269 ];
270
271 /**
272 * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
273 *
274 * @var bool
275 */
276 private $debug = false;
277
278 /**
279 * @param string $unprocessedHtml the HTML to process, must be UTF-8-encoded
280 * @param string $css the CSS to merge, must be UTF-8-encoded
281 */
282 public function __construct($unprocessedHtml = '', $css = '')
283 {
284 if ($unprocessedHtml !== '') {
285 $this->setHtml($unprocessedHtml);
286 }
287 $this->setCss($css);
288 }
289
290 /**
291 * Sets the HTML to process.
292 *
293 * @param string $html the HTML to process, must be UTF-encoded, must not be empty
294 *
295 * @return void
296 *
297 * @throws \InvalidArgumentException if $unprocessedHtml is anything other than a non-empty string
298 */
299 public function setHtml($html)
300 {
301 if (!\is_string($html)) {
302 throw new \InvalidArgumentException('The provided HTML must be a string.', 1540403913);
303 }
304 if ($html === '') {
305 throw new \InvalidArgumentException('The provided HTML must not be empty.', 1540403910);
306 }
307
308 $this->createUnifiedDomDocument($html);
309 }
310
311 /**
312 * Provides access to the internal DOMDocument representation of the HTML in its current state.
313 *
314 * @return \DOMDocument
315 */
316 public function getDomDocument()
317 {
318 return $this->domDocument;
319 }
320
321 /**
322 * Sets the CSS to merge with the HTML.
323 *
324 * @param string $css the CSS to merge, must be UTF-8-encoded
325 *
326 * @return void
327 */
328 public function setCss($css)
329 {
330 $this->css = $css;
331 }
332
333 /**
334 * Renders the normalized and processed HTML.
335 *
336 * @return string
337 */
338 protected function render()
339 {
340 $htmlWithPossibleErroneousClosingTags = $this->domDocument->saveHTML();
341
342 return $this->removeSelfClosingTagsClosingTags($htmlWithPossibleErroneousClosingTags);
343 }
344
345 /**
346 * Renders the content of the BODY element of the normalized and processed HTML.
347 *
348 * @return string
349 */
350 protected function renderBodyContent()
351 {
352 $htmlWithPossibleErroneousClosingTags = $this->domDocument->saveHTML($this->getBodyElement());
353 $bodyNodeHtml = $this->removeSelfClosingTagsClosingTags($htmlWithPossibleErroneousClosingTags);
354
355 return \preg_replace('%</?+body(?:\\s[^>]*+)?+>%', '', $bodyNodeHtml);
356 }
357
358 /**
359 * Eliminates any invalid closing tags for void elements from the given HTML.
360 *
361 * @param string $html
362 *
363 * @return string
364 */
365 private function removeSelfClosingTagsClosingTags($html)
366 {
367 return \preg_replace('%</' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '>%', '', $html);
368 }
369
370 /**
371 * Returns the BODY element.
372 *
373 * This method assumes that there always is a BODY element.
374 *
375 * @return \DOMElement
376 */
377 private function getBodyElement()
378 {
379 return $this->domDocument->getElementsByTagName('body')->item(0);
380 }
381
382 /**
383 * Returns the HEAD element.
384 *
385 * This method assumes that there always is a HEAD element.
386 *
387 * @return \DOMElement
388 */
389 private function getHeadElement()
390 {
391 return $this->domDocument->getElementsByTagName('head')->item(0);
392 }
393
394 /**
395 * Applies $this->css to the given HTML and returns the HTML with the CSS
396 * applied.
397 *
398 * This method places the CSS inline.
399 *
400 * @return string
401 *
402 * @throws \BadMethodCallException
403 */
404 public function emogrify()
405 {
406 $this->assertExistenceOfHtml();
407
408 $this->process();
409
410 return $this->render();
411 }
412
413 /**
414 * Applies $this->css to the given HTML and returns only the HTML content
415 * within the <body> tag.
416 *
417 * This method places the CSS inline.
418 *
419 * @return string
420 *
421 * @throws \BadMethodCallException
422 */
423 public function emogrifyBodyContent()
424 {
425 $this->assertExistenceOfHtml();
426
427 $this->process();
428
429 return $this->renderBodyContent();
430 }
431
432 /**
433 * Checks that some HTML has been set, and throws an exception otherwise.
434 *
435 * @return void
436 *
437 * @throws \BadMethodCallException
438 */
439 private function assertExistenceOfHtml()
440 {
441 if ($this->domDocument === null) {
442 throw new \BadMethodCallException('Please set some HTML first.', 1390393096);
443 }
444 }
445
446 /**
447 * Creates a DOM document from the given HTML and stores it in $this->domDocument.
448 *
449 * The DOM document will always have a BODY element.
450 *
451 * @param string $html
452 *
453 * @return void
454 */
455 private function createUnifiedDomDocument($html)
456 {
457 $this->createRawDomDocument($html);
458 $this->ensureExistenceOfBodyElement();
459 }
460
461 /**
462 * Creates a DOMDocument instance from the given HTML and stores it in $this->domDocument.
463 *
464 * @param string $html
465 *
466 * @return void
467 */
468 private function createRawDomDocument($html)
469 {
470 $domDocument = new \DOMDocument();
471 $domDocument->strictErrorChecking = false;
472 $domDocument->formatOutput = true;
473 $libXmlState = \libxml_use_internal_errors(true);
474 $domDocument->loadHTML($this->prepareHtmlForDomConversion($html));
475 \libxml_clear_errors();
476 \libxml_use_internal_errors($libXmlState);
477
478 $this->domDocument = $domDocument;
479 $this->xPath = new \DOMXPath($this->domDocument);
480 }
481
482 /**
483 * Returns the HTML with added document type, Content-Type meta tag, and self-closing slashes, if needed,
484 * ensuring that the HTML will be good for creating a DOM document from it.
485 *
486 * @param string $html
487 *
488 * @return string the unified HTML
489 */
490 private function prepareHtmlForDomConversion($html)
491 {
492 $htmlWithSelfClosingSlashes = $this->ensurePhpUnrecognizedSelfClosingTagsAreXml($html);
493 $htmlWithDocumentType = $this->ensureDocumentType($htmlWithSelfClosingSlashes);
494
495 return $this->addContentTypeMetaTag($htmlWithDocumentType);
496 }
497
498 /**
499 * Applies $this->css to $this->domDocument.
500 *
501 * This method places the CSS inline.
502 *
503 * @return void
504 *
505 * @throws \InvalidArgumentException
506 */
507 protected function process()
508 {
509 $this->clearAllCaches();
510 $this->purgeVisitedNodes();
511
512 \set_error_handler([$this, 'handleXpathQueryWarnings'], E_WARNING);
513 $this->removeUnprocessableTags();
514 $this->normalizeStyleAttributesOfAllNodes();
515
516 // grab any existing style blocks from the html and append them to the existing CSS
517 // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
518 $allCss = $this->css;
519 if ($this->isStyleBlocksParsingEnabled) {
520 $allCss .= $this->getCssFromAllStyleNodes();
521 }
522
523 $cssWithoutComments = $this->removeCssComments($allCss);
524 list($cssWithoutCommentsCharsetOrImport, $cssImportRules)
525 = $this->extractImportAndCharsetRules($cssWithoutComments);
526
527 $excludedNodes = $this->getNodesToExclude();
528 $cssRules = $this->parseCssRules($cssWithoutCommentsCharsetOrImport);
529 foreach ($cssRules['inlinable'] as $cssRule) {
530 // There's no real way to test "PHP Warning" output generated by the following XPath query unless PHPUnit
531 // converts it to an exception. Unfortunately, this would only apply to tests and not work for production
532 // executions, which can still flood logs/output unnecessarily. Instead, Emogrifier's error handler should
533 // always throw an exception and it must be caught here and only rethrown if in debug mode.
534 try {
535 // \DOMXPath::query will always return a DOMNodeList or throw an exception when errors are caught.
536 $nodesMatchingCssSelectors = $this->xPath->query($this->translateCssToXpath($cssRule['selector']));
537 } catch (\InvalidArgumentException $e) {
538 if ($this->debug) {
539 throw $e;
540 }
541 continue;
542 }
543
544 /** @var \DOMElement $node */
545 foreach ($nodesMatchingCssSelectors as $node) {
546 if (\in_array($node, $excludedNodes, true)) {
547 continue;
548 }
549 $this->copyInlinableCssToStyleAttribute($node, $cssRule);
550 }
551 }
552
553 if ($this->isInlineStyleAttributesParsingEnabled) {
554 $this->fillStyleAttributesWithMergedStyles();
555 }
556
557 $this->removeImportantAnnotationFromAllInlineStyles();
558
559 $this->copyUninlinableCssToStyleNode($cssRules['uninlinable'], $cssImportRules);
560
561 \restore_error_handler();
562 }
563
564 /**
565 * Searches for all nodes with a style attribute and removes the "!important" annotations out of
566 * the inline style declarations, eventually by rearranging declarations.
567 *
568 * @return void
569 */
570 private function removeImportantAnnotationFromAllInlineStyles()
571 {
572 foreach ($this->getAllNodesWithStyleAttribute() as $node) {
573 $this->removeImportantAnnotationFromNodeInlineStyle($node);
574 }
575 }
576
577 /**
578 * Removes the "!important" annotations out of the inline style declarations,
579 * eventually by rearranging declarations.
580 * Rearranging needed when !important shorthand properties are followed by some of their
581 * not !important expanded-version properties.
582 * For example "font: 12px serif !important; font-size: 13px;" must be reordered
583 * to "font-size: 13px; font: 12px serif;" in order to remain correct.
584 *
585 * @param \DOMElement $node
586 *
587 * @return void
588 */
589 private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node)
590 {
591 $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
592 $regularStyleDeclarations = [];
593 $importantStyleDeclarations = [];
594 foreach ($inlineStyleDeclarations as $property => $value) {
595 if ($this->attributeValueIsImportant($value)) {
596 $importantStyleDeclarations[$property] = \trim(\str_replace('!important', '', $value));
597 } else {
598 $regularStyleDeclarations[$property] = $value;
599 }
600 }
601 $inlineStyleDeclarationsInNewOrder = \array_merge(
602 $regularStyleDeclarations,
603 $importantStyleDeclarations
604 );
605 $node->setAttribute(
606 'style',
607 $this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder)
608 );
609 }
610
611 /**
612 * Returns a list with all DOM nodes that have a style attribute.
613 *
614 * @return \DOMNodeList
615 */
616 private function getAllNodesWithStyleAttribute()
617 {
618 return $this->xPath->query('//*[@style]');
619 }
620
621 /**
622 * Extracts and parses the individual rules from a CSS string.
623 *
624 * @param string $css a string of raw CSS code with comments removed
625 *
626 * @return string[][][] A 2-entry array with the key "inlinable" containing rules which can be inlined as `style`
627 * attributes and the key "uninlinable" containing rules which cannot. Each value is an array of string
628 * sub-arrays with the keys
629 * "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
630 * or an empty string if not from a `@media` rule),
631 * "selector" (the CSS selector, e.g., "*" or "header h1"),
632 * "hasUnmatchablePseudo" (true if that selector contains pseudo-elements or dynamic pseudo-classes
633 * such that the declarations cannot be applied inline),
634 * "declarationsBlock" (the semicolon-separated CSS declarations for that selector,
635 * e.g., "color: red; height: 4px;"),
636 * and "line" (the line number e.g. 42)
637 */
638 private function parseCssRules($css)
639 {
640 $cssKey = \md5($css);
641 if (!isset($this->caches[self::CACHE_KEY_CSS][$cssKey])) {
642 $matches = $this->getCssRuleMatches($css);
643
644 $cssRules = [
645 'inlinable' => [],
646 'uninlinable' => [],
647 ];
648 /** @var string[][] $matches */
649 /** @var string[] $cssRule */
650 foreach ($matches as $key => $cssRule) {
651 $cssDeclaration = \trim($cssRule['declarations']);
652 if ($cssDeclaration === '') {
653 continue;
654 }
655
656 foreach (\explode(',', $cssRule['selectors']) as $selector) {
657 // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
658 // only allow structural pseudo-classes
659 $hasPseudoElement = \strpos($selector, '::') !== false;
660 $hasUnsupportedPseudoClass = (bool)\preg_match(
661 '/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i',
662 $selector
663 );
664 $hasUnmatchablePseudo = $hasPseudoElement || $hasUnsupportedPseudoClass;
665
666 $parsedCssRule = [
667 'media' => $cssRule['media'],
668 'selector' => \trim($selector),
669 'hasUnmatchablePseudo' => $hasUnmatchablePseudo,
670 'declarationsBlock' => $cssDeclaration,
671 // keep track of where it appears in the file, since order is important
672 'line' => $key,
673 ];
674 $ruleType = ($cssRule['media'] === '' && !$hasUnmatchablePseudo) ? 'inlinable' : 'uninlinable';
675 $cssRules[$ruleType][] = $parsedCssRule;
676 }
677 }
678
679 \usort($cssRules['inlinable'], [$this, 'sortBySelectorPrecedence']);
680
681 $this->caches[self::CACHE_KEY_CSS][$cssKey] = $cssRules;
682 }
683
684 return $this->caches[self::CACHE_KEY_CSS][$cssKey];
685 }
686
687 /**
688 * Parses a string of CSS into the media query, selectors and declarations for each ruleset in order.
689 *
690 * @param string $css CSS with comments removed
691 *
692 * @return string[][] Array of string sub-arrays with the keys
693 * "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
694 * or an empty string if not from an `@media` rule),
695 * "selectors" (the CSS selector(s), e.g., "*" or "h1, h2"),
696 * "declarations" (the semicolon-separated CSS declarations for that/those selector(s),
697 * e.g., "color: red; height: 4px;"),
698 */
699 private function getCssRuleMatches($css)
700 {
701 $splitCss = $this->splitCssAndMediaQuery($css);
702
703 $ruleMatches = [];
704 foreach ($splitCss as $cssPart) {
705 // process each part for selectors and definitions
706 \preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mi', $cssPart['css'], $matches, PREG_SET_ORDER);
707
708 /** @var string[][] $matches */
709 foreach ($matches as $cssRule) {
710 $ruleMatches[] = [
711 'media' => $cssPart['media'],
712 'selectors' => $cssRule[1],
713 'declarations' => $cssRule[2],
714 ];
715 }
716 }
717
718 return $ruleMatches;
719 }
720
721 /**
722 * Disables the parsing of inline styles.
723 *
724 * @return void
725 */
726 public function disableInlineStyleAttributesParsing()
727 {
728 $this->isInlineStyleAttributesParsingEnabled = false;
729 }
730
731 /**
732 * Disables the parsing of <style> blocks.
733 *
734 * @return void
735 */
736 public function disableStyleBlocksParsing()
737 {
738 $this->isStyleBlocksParsingEnabled = false;
739 }
740
741 /**
742 * Clears all caches.
743 *
744 * @return void
745 */
746 private function clearAllCaches()
747 {
748 $this->caches = [
749 self::CACHE_KEY_CSS => [],
750 self::CACHE_KEY_SELECTOR => [],
751 self::CACHE_KEY_XPATH => [],
752 self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [],
753 self::CACHE_KEY_COMBINED_STYLES => [],
754 ];
755 }
756
757 /**
758 * Purges the visited nodes.
759 *
760 * @return void
761 */
762 private function purgeVisitedNodes()
763 {
764 $this->visitedNodes = [];
765 $this->styleAttributesForNodes = [];
766 }
767
768 /**
769 * Marks a tag for removal.
770 *
771 * There are some HTML tags that DOMDocument cannot process, and it will throw an error if it encounters them.
772 * In particular, DOMDocument will complain if you try to use HTML5 tags in an XHTML document.
773 *
774 * Note: The tags will not be removed if they have any content.
775 *
776 * @param string $tagName the tag name, e.g., "p"
777 *
778 * @return void
779 */
780 public function addUnprocessableHtmlTag($tagName)
781 {
782 $this->unprocessableHtmlTags[] = $tagName;
783 }
784
785 /**
786 * Drops a tag from the removal list.
787 *
788 * @param string $tagName the tag name, e.g., "p"
789 *
790 * @return void
791 */
792 public function removeUnprocessableHtmlTag($tagName)
793 {
794 $key = \array_search($tagName, $this->unprocessableHtmlTags, true);
795 if ($key !== false) {
796 /** @var int|string $key */
797 unset($this->unprocessableHtmlTags[$key]);
798 }
799 }
800
801 /**
802 * Marks a media query type to keep.
803 *
804 * @param string $mediaName the media type name, e.g., "braille"
805 *
806 * @return void
807 */
808 public function addAllowedMediaType($mediaName)
809 {
810 $this->allowedMediaTypes[$mediaName] = true;
811 }
812
813 /**
814 * Drops a media query type from the allowed list.
815 *
816 * @param string $mediaName the tag name, e.g., "braille"
817 *
818 * @return void
819 */
820 public function removeAllowedMediaType($mediaName)
821 {
822 if (isset($this->allowedMediaTypes[$mediaName])) {
823 unset($this->allowedMediaTypes[$mediaName]);
824 }
825 }
826
827 /**
828 * Adds a selector to exclude nodes from emogrification.
829 *
830 * Any nodes that match the selector will not have their style altered.
831 *
832 * @param string $selector the selector to exclude, e.g., ".editor"
833 *
834 * @return void
835 */
836 public function addExcludedSelector($selector)
837 {
838 $this->excludedSelectors[$selector] = true;
839 }
840
841 /**
842 * No longer excludes the nodes matching this selector from emogrification.
843 *
844 * @param string $selector the selector to no longer exclude, e.g., ".editor"
845 *
846 * @return void
847 */
848 public function removeExcludedSelector($selector)
849 {
850 if (isset($this->excludedSelectors[$selector])) {
851 unset($this->excludedSelectors[$selector]);
852 }
853 }
854
855 /**
856 * Parses the document and normalizes all existing CSS attributes.
857 * This changes 'DISPLAY: none' to 'display: none'.
858 * We wouldn't have to do this if DOMXPath supported XPath 2.0.
859 * Also stores a reference of nodes with existing inline styles so we don't overwrite them.
860 *
861 * @return void
862 */
863 private function normalizeStyleAttributesOfAllNodes()
864 {
865 /** @var \DOMElement $node */
866 foreach ($this->getAllNodesWithStyleAttribute() as $node) {
867 if ($this->isInlineStyleAttributesParsingEnabled) {
868 $this->normalizeStyleAttributes($node);
869 }
870 // Remove style attribute in every case, so we can add them back (if inline style attributes
871 // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
872 // else original inline style rules may remain at the beginning of the final inline style definition
873 // of a node, which may give not the desired results
874 $node->removeAttribute('style');
875 }
876 }
877
878 /**
879 * Normalizes the value of the "style" attribute and saves it.
880 *
881 * @param \DOMElement $node
882 *
883 * @return void
884 */
885 private function normalizeStyleAttributes(\DOMElement $node)
886 {
887 $normalizedOriginalStyle = \preg_replace_callback(
888 '/-?+[_a-zA-Z][\\w\\-]*+(?=:)/S',
889 static function (array $m) {
890 return \strtolower($m[0]);
891 },
892 $node->getAttribute('style')
893 );
894
895 // in order to not overwrite existing style attributes in the HTML, we
896 // have to save the original HTML styles
897 $nodePath = $node->getNodePath();
898 if (!isset($this->styleAttributesForNodes[$nodePath])) {
899 $this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationsBlock($normalizedOriginalStyle);
900 $this->visitedNodes[$nodePath] = $node;
901 }
902
903 $node->setAttribute('style', $normalizedOriginalStyle);
904 }
905
906 /**
907 * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
908 *
909 * @return void
910 */
911 private function fillStyleAttributesWithMergedStyles()
912 {
913 foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
914 $node = $this->visitedNodes[$nodePath];
915 $currentStyleAttributes = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
916 $node->setAttribute(
917 'style',
918 $this->generateStyleStringFromDeclarationsArrays(
919 $currentStyleAttributes,
920 $styleAttributesForNode
921 )
922 );
923 }
924 }
925
926 /**
927 * This method merges old or existing name/value array with new name/value array
928 * and then generates a string of the combined style suitable for placing inline.
929 * This becomes the single point for CSS string generation allowing for consistent
930 * CSS output no matter where the CSS originally came from.
931 *
932 * @param string[] $oldStyles
933 * @param string[] $newStyles
934 *
935 * @return string
936 */
937 private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles)
938 {
939 $cacheKey = \serialize([$oldStyles, $newStyles]);
940 if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
941 return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
942 }
943
944 // Unset the overridden styles to preserve order, important if shorthand and individual properties are mixed
945 foreach ($oldStyles as $attributeName => $attributeValue) {
946 if (!isset($newStyles[$attributeName])) {
947 continue;
948 }
949
950 $newAttributeValue = $newStyles[$attributeName];
951 if (
952 $this->attributeValueIsImportant($attributeValue)
953 && !$this->attributeValueIsImportant($newAttributeValue)
954 ) {
955 unset($newStyles[$attributeName]);
956 } else {
957 unset($oldStyles[$attributeName]);
958 }
959 }
960
961 $combinedStyles = \array_merge($oldStyles, $newStyles);
962
963 $style = '';
964 foreach ($combinedStyles as $attributeName => $attributeValue) {
965 $style .= \strtolower(\trim($attributeName)) . ': ' . \trim($attributeValue) . '; ';
966 }
967 $trimmedStyle = \rtrim($style);
968
969 $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
970
971 return $trimmedStyle;
972 }
973
974 /**
975 * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
976 *
977 * @param string[] $styleDeclarations
978 *
979 * @return string
980 */
981 private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations)
982 {
983 return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
984 }
985
986 /**
987 * Checks whether $attributeValue is marked as !important.
988 *
989 * @param string $attributeValue
990 *
991 * @return bool
992 */
993 private function attributeValueIsImportant($attributeValue)
994 {
995 return \strtolower(\substr(\trim($attributeValue), -10)) === '!important';
996 }
997
998 /**
999 * Copies $cssRule into the style attribute of $node.
1000 *
1001 * Note: This method does not check whether $cssRule matches $node.
1002 *
1003 * @param \DOMElement $node
1004 * @param string[][] $cssRule
1005 *
1006 * @return void
1007 */
1008 private function copyInlinableCssToStyleAttribute(\DOMElement $node, array $cssRule)
1009 {
1010 $newStyleDeclarations = $this->parseCssDeclarationsBlock($cssRule['declarationsBlock']);
1011 if ($newStyleDeclarations === []) {
1012 return;
1013 }
1014
1015 // if it has a style attribute, get it, process it, and append (overwrite) new stuff
1016 if ($node->hasAttribute('style')) {
1017 // break it up into an associative array
1018 $oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
1019 } else {
1020 $oldStyleDeclarations = [];
1021 }
1022 $node->setAttribute(
1023 'style',
1024 $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
1025 );
1026 }
1027
1028 /**
1029 * Applies $cssRules to $this->domDocument, limited to the rules that actually apply to the document, by placing
1030 * them as CSS in a `<style>` element.
1031 *
1032 * @param string[][] $cssRules the "uninlinable" array of CSS rules returned by `parseCssRules`
1033 * @param string $cssImportRules This may contain any `@import` rules that should precede the CSS placed in the
1034 * `<style>` element. If there are no unlinlinable CSS rules to copy there, a `<style>` element will be
1035 * created containing just `$cssImportRules`. `$cssImportRules` may be an empty string; if it is, and there
1036 * are no unlinlinable CSS rules, an empty `<style>` element will not be created.
1037 *
1038 * @return void
1039 */
1040 private function copyUninlinableCssToStyleNode(array $cssRules, $cssImportRules)
1041 {
1042 $css = $cssImportRules;
1043
1044 $cssRulesRelevantForDocument = \array_filter($cssRules, [$this, 'existsMatchForSelectorInCssRule']);
1045
1046 // avoid including unneeded class dependency if there are no rules
1047 if ($cssRulesRelevantForDocument !== []) {
1048 // support use without autoload
1049 if (!\class_exists(CssConcatenator::class)) {
1050 require_once __DIR__ . '/Emogrifier/Utilities/CssConcatenator.php';
1051 }
1052
1053 $cssConcatenator = new CssConcatenator();
1054 foreach ($cssRulesRelevantForDocument as $cssRule) {
1055 $cssConcatenator->append([$cssRule['selector']], $cssRule['declarationsBlock'], $cssRule['media']);
1056 }
1057
1058 $css .= $cssConcatenator->getCss();
1059 }
1060
1061 // avoid adding empty style element
1062 if ($css !== '') {
1063 $this->addStyleElementToDocument($css);
1064 }
1065 }
1066
1067 /**
1068 * Checks whether there is at least one matching element for the CSS selector contained in the `selector` element
1069 * of the provided CSS rule.
1070 *
1071 * Any dynamic pseudo-classes will be assumed to apply. If the selector matches a pseudo-element,
1072 * it will test for a match with its originating element.
1073 *
1074 * @param string[] $cssRule
1075 *
1076 * @return bool
1077 *
1078 * @throws \InvalidArgumentException
1079 */
1080 private function existsMatchForSelectorInCssRule(array $cssRule)
1081 {
1082 $selector = $cssRule['selector'];
1083 if ($cssRule['hasUnmatchablePseudo']) {
1084 $selector = $this->removeUnmatchablePseudoComponents($selector);
1085 }
1086 return $this->existsMatchForCssSelector($selector);
1087 }
1088
1089 /**
1090 * Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary.
1091 * If such a pseudo-component is within the argument of `:not`, the entire `:not` component is removed or replaced.
1092 *
1093 * @param string $selector
1094 *
1095 * @return string Selector which will match the relevant DOM elements if the pseudo-classes are assumed to apply,
1096 * or in the case of pseudo-elements will match their originating element.
1097 */
1098 private function removeUnmatchablePseudoComponents($selector)
1099 {
1100 // The regex allows nested brackets via `(?2)`.
1101 // A space is temporarily prepended because the callback can't determine if the match was at the very start.
1102 $selectorWithoutNots = \ltrim(\preg_replace_callback(
1103 '/(\\s?+):not(\\([^()]*+(?:(?2)[^()]*+)*+\\))/i',
1104 [$this, 'replaceUnmatchableNotComponent'],
1105 ' ' . $selector
1106 ));
1107
1108 $pseudoComponentMatcher = ':(?!' . self::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+';
1109 return \preg_replace(
1110 ['/(\\s|^)' . $pseudoComponentMatcher . '/i', '/' . $pseudoComponentMatcher . '/i'],
1111 ['$1*', ''],
1112 $selectorWithoutNots
1113 );
1114 }
1115
1116 /**
1117 * Helps `removeUnmatchablePseudoComponents()` replace or remove a selector `:not(...)` component if its argument
1118 * contains pseudo-elements or dynamic pseudo-classes.
1119 *
1120 * @param string[] $matches array of elements matched by the regular expression
1121 *
1122 * @return string the full match if there were no unmatchable pseudo components within; otherwise, any preceding
1123 * whitespace followed by "*", or an empty string if there was no preceding whitespace
1124 */
1125 private function replaceUnmatchableNotComponent(array $matches)
1126 {
1127 list($notComponentWithAnyPrecedingWhitespace, $anyPrecedingWhitespace, $notArgumentInBrackets) = $matches;
1128
1129 $hasUnmatchablePseudo = \preg_match(
1130 '/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-:]/i',
1131 $notArgumentInBrackets
1132 );
1133
1134 if ($hasUnmatchablePseudo) {
1135 return $anyPrecedingWhitespace !== '' ? $anyPrecedingWhitespace . '*' : '';
1136 }
1137 return $notComponentWithAnyPrecedingWhitespace;
1138 }
1139
1140 /**
1141 * Checks whether there is at least one matching element for $cssSelector.
1142 * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
1143 * just not implemented/recognized yet by Emogrifier).
1144 *
1145 * @param string $cssSelector
1146 *
1147 * @return bool
1148 *
1149 * @throws \InvalidArgumentException
1150 */
1151 private function existsMatchForCssSelector($cssSelector)
1152 {
1153 try {
1154 $nodesMatchingSelector = $this->xPath->query($this->translateCssToXpath($cssSelector));
1155 } catch (\InvalidArgumentException $e) {
1156 if ($this->debug) {
1157 throw $e;
1158 }
1159 return true;
1160 }
1161
1162 return $nodesMatchingSelector !== false && $nodesMatchingSelector->length !== 0;
1163 }
1164
1165 /**
1166 * Returns CSS content.
1167 *
1168 * @return string
1169 */
1170 private function getCssFromAllStyleNodes()
1171 {
1172 $styleNodes = $this->xPath->query('//style');
1173
1174 if ($styleNodes === false) {
1175 return '';
1176 }
1177
1178 $css = '';
1179 /** @var \DOMNode $styleNode */
1180 foreach ($styleNodes as $styleNode) {
1181 $css .= "\n\n" . $styleNode->nodeValue;
1182 $styleNode->parentNode->removeChild($styleNode);
1183 }
1184
1185 return $css;
1186 }
1187
1188 /**
1189 * Adds a style element with $css to $this->domDocument.
1190 *
1191 * This method is protected to allow overriding.
1192 *
1193 * @see https://github.com/MyIntervals/emogrifier/issues/103
1194 *
1195 * @param string $css
1196 *
1197 * @return void
1198 */
1199 protected function addStyleElementToDocument($css)
1200 {
1201 $styleElement = $this->domDocument->createElement('style', $css);
1202 $styleAttribute = $this->domDocument->createAttribute('type');
1203 $styleAttribute->value = 'text/css';
1204 $styleElement->appendChild($styleAttribute);
1205
1206 $headElement = $this->getHeadElement();
1207 $headElement->appendChild($styleElement);
1208 }
1209
1210 /**
1211 * Checks that $this->domDocument has a BODY element and adds it if it is missing.
1212 *
1213 * @return void
1214 *
1215 * @throws \UnexpectedValueException
1216 */
1217 private function ensureExistenceOfBodyElement()
1218 {
1219 if ($this->domDocument->getElementsByTagName('body')->item(0) !== null) {
1220 return;
1221 }
1222
1223 $htmlElement = $this->domDocument->getElementsByTagName('html')->item(0);
1224 if ($htmlElement === null) {
1225 throw new \UnexpectedValueException('There is no HTML element although there should be one.', 1569930874);
1226 }
1227 $htmlElement->appendChild($this->domDocument->createElement('body'));
1228 }
1229
1230 /**
1231 * Removes comments from the supplied CSS.
1232 *
1233 * @param string $css
1234 *
1235 * @return string CSS with the comments removed
1236 */
1237 private function removeCssComments($css)
1238 {
1239 return \preg_replace('%/\\*[^*]*+(?:\\*(?!/)[^*]*+)*+\\*/%', '', $css);
1240 }
1241
1242 /**
1243 * Extracts `@import` and `@charset` rules from the supplied CSS. These rules must not be preceded by any other
1244 * rules, or they will be ignored. (From the CSS 2.1 specification: "CSS 2.1 user agents must ignore any '@import'
1245 * rule that occurs inside a block or after any non-ignored statement other than an @charset or an @import rule."
1246 * Note also that `@charset` is case sensitive whereas `@import` is not.)
1247 *
1248 * @param string $css CSS with comments removed
1249 *
1250 * @return string[] The first element is the CSS with the valid `@import` and `@charset` rules removed. The second
1251 * element contains a concatenation of the valid `@import` rules, each followed by whatever whitespace followed it
1252 * in the original CSS (so that either unminified or minified formatting is preserved); if there were no `@import`
1253 * rules, it will be an empty string. The (valid) `@charset` rules are discarded.
1254 */
1255 private function extractImportAndCharsetRules($css)
1256 {
1257 $possiblyModifiedCss = $css;
1258 $importRules = '';
1259
1260 while (
1261 \preg_match(
1262 '/^\\s*+(@((?i)import(?-i)|charset)\\s[^;]++;\\s*+)/',
1263 $possiblyModifiedCss,
1264 $matches
1265 )
1266 ) {
1267 list($fullMatch, $atRuleAndFollowingWhitespace, $atRuleName) = $matches;
1268
1269 if (\strtolower($atRuleName) === 'import') {
1270 $importRules .= $atRuleAndFollowingWhitespace;
1271 }
1272
1273 $possiblyModifiedCss = \substr($possiblyModifiedCss, \strlen($fullMatch));
1274 }
1275
1276 return [$possiblyModifiedCss, $importRules];
1277 }
1278
1279 /**
1280 * Splits input CSS code into an array of parts for different media queries, in order.
1281 * Each part is an array where:
1282 *
1283 * - key "css" will contain clean CSS code (for @media rules this will be the group rule body within "{...}")
1284 * - key "media" will contain "@media " followed by the media query list, for all allowed media queries,
1285 * or an empty string for CSS not within a media query
1286 *
1287 * Example:
1288 *
1289 * The CSS code
1290 *
1291 * "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
1292 *
1293 * will be parsed into the following array:
1294 *
1295 * 0 => [
1296 * "css" => "h1 { color:red; }",
1297 * "media" => ""
1298 * ],
1299 * 1 => [
1300 * "css" => " h1 {}",
1301 * "media" => "@media "
1302 * ]
1303 *
1304 * @param string $css
1305 *
1306 * @return string[][]
1307 */
1308 private function splitCssAndMediaQuery($css)
1309 {
1310 $mediaTypesExpression = '';
1311 if (!empty($this->allowedMediaTypes)) {
1312 $mediaTypesExpression = '|' . \implode('|', \array_keys($this->allowedMediaTypes));
1313 }
1314
1315 $mediaRuleBodyMatcher = '[^{]*+{(?:[^{}]*+{.*})?\\s*+}\\s*+';
1316
1317 $cssSplitForAllowedMediaTypes = \preg_split(
1318 '#(@media\\s++(?:only\\s++)?+(?:(?=[{(])' . $mediaTypesExpression . ')' . $mediaRuleBodyMatcher
1319 . ')#misU',
1320 $css,
1321 -1,
1322 PREG_SPLIT_DELIM_CAPTURE
1323 );
1324
1325 // filter the CSS outside/between allowed @media rules
1326 $cssCleaningMatchers = [
1327 'import/charset directives' => '/\\s*+@(?:import|charset)\\s[^;]++;/i',
1328 'remaining media enclosures' => '/\\s*+@media\\s' . $mediaRuleBodyMatcher . '/isU',
1329 ];
1330
1331 $splitCss = [];
1332 foreach ($cssSplitForAllowedMediaTypes as $index => $cssPart) {
1333 $isMediaRule = $index % 2 !== 0;
1334 if ($isMediaRule) {
1335 \preg_match('/^([^{]*+){(.*)}[^}]*+$/s', $cssPart, $matches);
1336 $splitCss[] = [
1337 'css' => $matches[2],
1338 'media' => $matches[1],
1339 ];
1340 } else {
1341 $cleanedCss = \trim(\preg_replace($cssCleaningMatchers, '', $cssPart));
1342 if ($cleanedCss !== '') {
1343 $splitCss[] = [
1344 'css' => $cleanedCss,
1345 'media' => '',
1346 ];
1347 }
1348 }
1349 }
1350 return $splitCss;
1351 }
1352
1353 /**
1354 * Removes empty unprocessable tags from the DOM document.
1355 *
1356 * @return void
1357 */
1358 private function removeUnprocessableTags()
1359 {
1360 foreach ($this->unprocessableHtmlTags as $tagName) {
1361 // Deleting nodes from a 'live' NodeList invalidates iteration on it, so a copy must be made to iterate.
1362 $nodes = [];
1363 foreach ($this->domDocument->getElementsByTagName($tagName) as $node) {
1364 $nodes[] = $node;
1365 }
1366 /** @var \DOMNode $node */
1367 foreach ($nodes as $node) {
1368 if (!$node->hasChildNodes()) {
1369 $node->parentNode->removeChild($node);
1370 }
1371 }
1372 }
1373 }
1374
1375 /**
1376 * Makes sure that the passed HTML has a document type.
1377 *
1378 * @param string $html
1379 *
1380 * @return string HTML with document type
1381 */
1382 private function ensureDocumentType($html)
1383 {
1384 $hasDocumentType = \stripos($html, '<!DOCTYPE') !== false;
1385 if ($hasDocumentType) {
1386 return $html;
1387 }
1388
1389 return self::DEFAULT_DOCUMENT_TYPE . $html;
1390 }
1391
1392 /**
1393 * Adds a Content-Type meta tag for the charset.
1394 *
1395 * This method also ensures that there is a HEAD element.
1396 *
1397 * @param string $html
1398 *
1399 * @return string the HTML with the meta tag added
1400 */
1401 private function addContentTypeMetaTag($html)
1402 {
1403 $hasContentTypeMetaTag = \stripos($html, 'Content-Type') !== false;
1404 if ($hasContentTypeMetaTag) {
1405 return $html;
1406 }
1407
1408 // We are trying to insert the meta tag to the right spot in the DOM.
1409 // If we just prepended it to the HTML, we would lose attributes set to the HTML tag.
1410 $hasHeadTag = \stripos($html, '<head') !== false;
1411 $hasHtmlTag = \stripos($html, '<html') !== false;
1412
1413 if ($hasHeadTag) {
1414 $reworkedHtml = \preg_replace('/<head(.*?)>/i', '<head$1>' . self::CONTENT_TYPE_META_TAG, $html);
1415 } elseif ($hasHtmlTag) {
1416 $reworkedHtml = \preg_replace(
1417 '/<html(.*?)>/i',
1418 '<html$1><head>' . self::CONTENT_TYPE_META_TAG . '</head>',
1419 $html
1420 );
1421 } else {
1422 $reworkedHtml = self::CONTENT_TYPE_META_TAG . $html;
1423 }
1424
1425 return $reworkedHtml;
1426 }
1427
1428 /**
1429 * Makes sure that any self-closing tags not recognized as such by PHP's DOMDocument implementation have a
1430 * self-closing slash.
1431 *
1432 * @param string $html
1433 *
1434 * @return string HTML with problematic tags converted.
1435 */
1436 private function ensurePhpUnrecognizedSelfClosingTagsAreXml($html)
1437 {
1438 return \preg_replace(
1439 '%<' . self::PHP_UNRECOGNIZED_VOID_TAGNAME_MATCHER . '\\b[^>]*+(?<!/)(?=>)%',
1440 '$0/',
1441 $html
1442 );
1443 }
1444
1445 /**
1446 * @param string[] $a
1447 * @param string[] $b
1448 *
1449 * @return int
1450 */
1451 private function sortBySelectorPrecedence(array $a, array $b)
1452 {
1453 $precedenceA = $this->getCssSelectorPrecedence($a['selector']);
1454 $precedenceB = $this->getCssSelectorPrecedence($b['selector']);
1455
1456 // We want these sorted in ascending order so selectors with lesser precedence get processed first and
1457 // selectors with greater precedence get sorted last.
1458 $precedenceForEquals = ($a['line'] < $b['line'] ? -1 : 1);
1459 $precedenceForNotEquals = ($precedenceA < $precedenceB ? -1 : 1);
1460 return ($precedenceA === $precedenceB) ? $precedenceForEquals : $precedenceForNotEquals;
1461 }
1462
1463 /**
1464 * @param string $selector
1465 *
1466 * @return int
1467 */
1468 private function getCssSelectorPrecedence($selector)
1469 {
1470 $selectorKey = \md5($selector);
1471 if (!isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
1472 $precedence = 0;
1473 foreach ($this->selectorPrecedenceMatchers as $matcher => $value) {
1474 if (\trim($selector) === '') {
1475 break;
1476 }
1477 $number = 0;
1478 $selector = \preg_replace('/' . $matcher . '\\w+/', '', $selector, -1, $number);
1479 $precedence += ($value * $number);
1480 }
1481 $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
1482 }
1483
1484 return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
1485 }
1486
1487 /**
1488 * Maps a CSS selector to an XPath query string.
1489 *
1490 * @see http://plasmasturm.org/log/444/
1491 *
1492 * @param string $cssSelector a CSS selector
1493 *
1494 * @return string the corresponding XPath selector
1495 */
1496 private function translateCssToXpath($cssSelector)
1497 {
1498 $paddedSelector = ' ' . $cssSelector . ' ';
1499 $lowercasePaddedSelector = \preg_replace_callback(
1500 '/\\s+\\w+\\s+/',
1501 static function (array $matches) {
1502 return \strtolower($matches[0]);
1503 },
1504 $paddedSelector
1505 );
1506 $trimmedLowercaseSelector = \trim($lowercasePaddedSelector);
1507 $xPathKey = \md5($trimmedLowercaseSelector);
1508 if (isset($this->caches[self::CACHE_KEY_XPATH][$xPathKey])) {
1509 return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1510 }
1511
1512 $hasNotSelector = (bool)\preg_match(
1513 '/^([^:]+):not\\(\\s*([[:ascii:]]+)\\s*\\)$/',
1514 $trimmedLowercaseSelector,
1515 $matches
1516 );
1517 if ($hasNotSelector) {
1518 /** @var string[] $matches */
1519 list(, $partBeforeNot, $notContents) = $matches;
1520 $xPath = '//' . $this->translateCssToXpathPass($partBeforeNot) .
1521 '[not(' . $this->translateCssToXpathPassInline($notContents) . ')]';
1522 } else {
1523 $xPath = '//' . $this->translateCssToXpathPass($trimmedLowercaseSelector);
1524 }
1525 $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey] = $xPath;
1526
1527 return $this->caches[self::CACHE_KEY_SELECTOR][$xPathKey];
1528 }
1529
1530 /**
1531 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector.
1532 *
1533 * @param string $trimmedLowercaseSelector
1534 *
1535 * @return string
1536 */
1537 private function translateCssToXpathPass($trimmedLowercaseSelector)
1538 {
1539 return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1540 $trimmedLowercaseSelector,
1541 [$this, 'matchClassAttributes']
1542 );
1543 }
1544
1545 /**
1546 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector for inline usage.
1547 *
1548 * @param string $trimmedLowercaseSelector
1549 *
1550 * @return string
1551 */
1552 private function translateCssToXpathPassInline($trimmedLowercaseSelector)
1553 {
1554 return $this->translateCssToXpathPassWithMatchClassAttributesCallback(
1555 $trimmedLowercaseSelector,
1556 [$this, 'matchClassAttributesInline']
1557 );
1558 }
1559
1560 /**
1561 * Flexibly translates the CSS selector $trimmedLowercaseSelector to an xPath selector while using
1562 * $matchClassAttributesCallback as to match the class attributes.
1563 *
1564 * @param string $trimmedLowercaseSelector
1565 * @param callable $matchClassAttributesCallback
1566 *
1567 * @return string
1568 */
1569 private function translateCssToXpathPassWithMatchClassAttributesCallback(
1570 $trimmedLowercaseSelector,
1571 callable $matchClassAttributesCallback
1572 ) {
1573 $roughXpath = \preg_replace(\array_keys($this->xPathRules), $this->xPathRules, $trimmedLowercaseSelector);
1574 $xPathWithIdAttributeMatchers = \preg_replace_callback(
1575 self::ID_ATTRIBUTE_MATCHER,
1576 [$this, 'matchIdAttributes'],
1577 $roughXpath
1578 );
1579 $xPathWithIdAttributeAndClassMatchers = \preg_replace_callback(
1580 self::CLASS_ATTRIBUTE_MATCHER,
1581 $matchClassAttributesCallback,
1582 $xPathWithIdAttributeMatchers
1583 );
1584
1585 // Advanced selectors are going to require a bit more advanced emogrification.
1586 $xPathWithIdAttributeAndClassMatchers = \preg_replace_callback(
1587 '/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1588 [$this, 'translateNthChild'],
1589 $xPathWithIdAttributeAndClassMatchers
1590 );
1591 $finalXpath = \preg_replace_callback(
1592 '/([^\\/]+):nth-of-type\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
1593 [$this, 'translateNthOfType'],
1594 $xPathWithIdAttributeAndClassMatchers
1595 );
1596
1597 return $finalXpath;
1598 }
1599
1600 /**
1601 * @param string[] $match
1602 *
1603 * @return string
1604 */
1605 private function matchIdAttributes(array $match)
1606 {
1607 return ($match[1] !== '' ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
1608 }
1609
1610 /**
1611 * @param string[] $match
1612 *
1613 * @return string xPath class attribute query wrapped in element selector
1614 */
1615 private function matchClassAttributes(array $match)
1616 {
1617 return ($match[1] !== '' ? $match[1] : '*') . '[' . $this->matchClassAttributesInline($match) . ']';
1618 }
1619
1620 /**
1621 * @param string[] $match
1622 *
1623 * @return string xPath class attribute query
1624 */
1625 private function matchClassAttributesInline(array $match)
1626 {
1627 return 'contains(concat(" ",@class," "),concat(" ","' .
1628 \str_replace('.', '"," "))][contains(concat(" ",@class," "),concat(" ","', \substr($match[2], 1)) .
1629 '"," "))';
1630 }
1631
1632 /**
1633 * @param string[] $match
1634 *
1635 * @return string
1636 */
1637 private function translateNthChild(array $match)
1638 {
1639 $parseResult = $this->parseNth($match);
1640
1641 if (isset($parseResult[self::MULTIPLIER])) {
1642 if ($parseResult[self::MULTIPLIER] < 0) {
1643 $parseResult[self::MULTIPLIER] = \abs($parseResult[self::MULTIPLIER]);
1644 $xPathExpression = \sprintf(
1645 '*[(last() - position()) mod %1%u = %2$u]/self::%3$s',
1646 $parseResult[self::MULTIPLIER],
1647 $parseResult[self::INDEX],
1648 $match[1]
1649 );
1650 } else {
1651 $xPathExpression = \sprintf(
1652 '*[position() mod %1$u = %2$u]/self::%3$s',
1653 $parseResult[self::MULTIPLIER],
1654 $parseResult[self::INDEX],
1655 $match[1]
1656 );
1657 }
1658 } else {
1659 $xPathExpression = \sprintf('*[%1$u]/self::%2$s', $parseResult[self::INDEX], $match[1]);
1660 }
1661
1662 return $xPathExpression;
1663 }
1664
1665 /**
1666 * @param string[] $match
1667 *
1668 * @return string
1669 */
1670 private function translateNthOfType(array $match)
1671 {
1672 $parseResult = $this->parseNth($match);
1673
1674 if (isset($parseResult[self::MULTIPLIER])) {
1675 if ($parseResult[self::MULTIPLIER] < 0) {
1676 $parseResult[self::MULTIPLIER] = \abs($parseResult[self::MULTIPLIER]);
1677 $xPathExpression = \sprintf(
1678 '%1$s[(last() - position()) mod %2$u = %3$u]',
1679 $match[1],
1680 $parseResult[self::MULTIPLIER],
1681 $parseResult[self::INDEX]
1682 );
1683 } else {
1684 $xPathExpression = \sprintf(
1685 '%1$s[position() mod %2$u = %3$u]',
1686 $match[1],
1687 $parseResult[self::MULTIPLIER],
1688 $parseResult[self::INDEX]
1689 );
1690 }
1691 } else {
1692 $xPathExpression = \sprintf('%1$s[%2$u]', $match[1], $parseResult[self::INDEX]);
1693 }
1694
1695 return $xPathExpression;
1696 }
1697
1698 /**
1699 * @param string[] $match
1700 *
1701 * @return int[]
1702 */
1703 private function parseNth(array $match)
1704 {
1705 if (\in_array(\strtolower($match[2]), ['even', 'odd'], true)) {
1706 // we have "even" or "odd"
1707 $index = \strtolower($match[2]) === 'even' ? 0 : 1;
1708 return [self::MULTIPLIER => 2, self::INDEX => $index];
1709 }
1710 if (\stripos($match[2], 'n') === false) {
1711 // if there is a multiplier
1712 $index = (int)\str_replace(' ', '', $match[2]);
1713 return [self::INDEX => $index];
1714 }
1715
1716 if (isset($match[3])) {
1717 $multipleTerm = \str_replace($match[3], '', $match[2]);
1718 $index = (int)\str_replace(' ', '', $match[3]);
1719 } else {
1720 $multipleTerm = $match[2];
1721 $index = 0;
1722 }
1723
1724 $multiplier = \str_ireplace('n', '', $multipleTerm);
1725
1726 if ($multiplier === '') {
1727 $multiplier = 1;
1728 } elseif ($multiplier === '0') {
1729 return [self::INDEX => $index];
1730 } else {
1731 $multiplier = (int)$multiplier;
1732 }
1733
1734 while ($index < 0) {
1735 $index += \abs($multiplier);
1736 }
1737
1738 return [self::MULTIPLIER => $multiplier, self::INDEX => $index];
1739 }
1740
1741 /**
1742 * Parses a CSS declaration block into property name/value pairs.
1743 *
1744 * Example:
1745 *
1746 * The declaration block
1747 *
1748 * "color: #000; font-weight: bold;"
1749 *
1750 * will be parsed into the following array:
1751 *
1752 * "color" => "#000"
1753 * "font-weight" => "bold"
1754 *
1755 * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
1756 *
1757 * @return string[]
1758 * the CSS declarations with the property names as array keys and the property values as array values
1759 */
1760 private function parseCssDeclarationsBlock($cssDeclarationsBlock)
1761 {
1762 if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
1763 return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
1764 }
1765
1766 $properties = [];
1767 foreach (\preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock) as $declaration) {
1768 $matches = [];
1769 if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) {
1770 continue;
1771 }
1772
1773 $propertyName = \strtolower($matches[1]);
1774 $propertyValue = $matches[2];
1775 $properties[$propertyName] = $propertyValue;
1776 }
1777 $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
1778
1779 return $properties;
1780 }
1781
1782 /**
1783 * Find the nodes that are not to be emogrified.
1784 *
1785 * @return \DOMElement[]
1786 *
1787 * @throws \InvalidArgumentException
1788 */
1789 private function getNodesToExclude()
1790 {
1791 $excludedNodes = [];
1792 foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) {
1793 try {
1794 $matchingNodes = $this->xPath->query($this->translateCssToXpath($selectorToExclude));
1795 } catch (\InvalidArgumentException $e) {
1796 if ($this->debug) {
1797 throw $e;
1798 }
1799 continue;
1800 }
1801 foreach ($matchingNodes as $node) {
1802 $excludedNodes[] = $node;
1803 }
1804 }
1805
1806 return $excludedNodes;
1807 }
1808
1809 /**
1810 * Handles invalid xPath expression warnings, generated during the process() method,
1811 * during querying \DOMDocument and trigger an \InvalidArgumentException with an invalid selector
1812 * or \RuntimeException, depending on the source of the warning.
1813 *
1814 * @param int $type
1815 * @param string $message
1816 * @param string $file
1817 * @param int $line
1818 * @param array $context
1819 *
1820 * @return bool always false
1821 *
1822 * @throws \InvalidArgumentException
1823 * @throws \RuntimeException
1824 */
1825 public function handleXpathQueryWarnings(// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
1826 $type,
1827 $message,
1828 $file,
1829 $line,
1830 array $context
1831 ) {
1832 $selector = '';
1833 if (isset($context['cssRule']['selector'])) {
1834 // warnings generated by invalid/unrecognized selectors in method process()
1835 $selector = $context['cssRule']['selector'];
1836 } elseif (isset($context['selectorToExclude'])) {
1837 // warnings generated by invalid/unrecognized selectors in method getNodesToExclude()
1838 $selector = $context['selectorToExclude'];
1839 } elseif (isset($context['cssSelector'])) {
1840 // warnings generated by invalid/unrecognized selectors in method existsMatchForCssSelector()
1841 $selector = $context['cssSelector'];
1842 }
1843
1844 if ($selector !== '') {
1845 throw new \InvalidArgumentException(
1846 \sprintf('%1$s in selector >> %2$s << in %3$s on line %4$u', $message, $selector, $file, $line),
1847 1509279985
1848 );
1849 }
1850
1851 // Catches eventual warnings generated by method getAllNodesWithStyleAttribute()
1852 if (isset($context['xPath'])) {
1853 throw new \RuntimeException(
1854 \sprintf('%1$s in %2$s on line %3$u', $message, $file, $line),
1855 1509280067
1856 );
1857 }
1858
1859 // the normal error handling continues when handler return false
1860 return false;
1861 }
1862
1863 /**
1864 * Sets the debug mode.
1865 *
1866 * @param bool $debug set to true to enable debug mode
1867 *
1868 * @return void
1869 */
1870 public function setDebug($debug)
1871 {
1872 $this->debug = $debug;
1873 }
1874 }
1875