PluginProbe
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress / 11.0.3
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress v11.0.3
11.1.1 11.1.0 11.0.5 11.0.4 11.0.3 11.0.2 11.0.1 11.0.0 10.11.1 10.11.0 10.10.2 10.10.1 10.10.0 10.9.1 trunk 10.0.0 10.0.1 10.1.0 10.1.1 10.1.2 10.1.3 10.1.4 10.2.0 10.2.1 10.2.2 All 62 releases
acymailing / back / Libraries / Pelago / Emogrifier / src / CssInliner.php

CssInliner.php in AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress 11.0.3, at back/Libraries/Pelago/Emogrifier/src/CssInliner.php

1,178 lines 43.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace AcyMailing\Libraries\Pelago\Emogrifier;
6
7 use AcyMailing\Libraries\Pelago\Emogrifier\Css\CssDocument;
8 use AcyMailing\Libraries\Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
9 use AcyMailing\Libraries\Pelago\Emogrifier\Utilities\CssConcatenator;
10 use AcyMailing\Libraries\Pelago\Emogrifier\Utilities\DeclarationBlockParser;
11 use AcyMailing\Libraries\Pelago\Emogrifier\Utilities\Preg;
12 use AcyMailing\Libraries\Symfony\CssSelector\CssSelectorConverter;
13 use AcyMailing\Libraries\Symfony\CssSelector\Exception\ParseException;
14
15 /**
16 * version 8.1.0
17 *
18 * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
19 */
20 final class CssInliner extends AbstractHtmlProcessor
21 {
22 /**
23 * @var int<0, 1>
24 */
25 private const CACHE_KEY_SELECTOR = 0;
26
27 /**
28 * @var int<0, 1>
29 */
30 private const CACHE_KEY_COMBINED_STYLES = 1;
31
32 /**
33 * Regular expression component matching a static pseudo class in a selector, without the preceding ":",
34 * for which the applicable elements can be determined (by converting the selector to an XPath expression).
35 * (Contains alternation without a group and is intended to be placed within a capturing, non-capturing or lookahead
36 * group, as appropriate for the usage context.)
37 *
38 * @var non-empty-string
39 */
40 private const PSEUDO_CLASS_MATCHER
41 = 'empty|(?:first|last|nth(?:-last)?+|only)-(?:child|of-type)|not\\([[:ascii:]]*\\)|root';
42
43 /**
44 * This regular expression component matches an `...of-type` pseudo class name, without the preceding ":". These
45 * pseudo-classes can currently online be inlined if they have an associated type in the selector expression.
46 *
47 * @var non-empty-string
48 */
49 private const OF_TYPE_PSEUDO_CLASS_MATCHER = '(?:first|last|nth(?:-last)?+|only)-of-type';
50
51 /**
52 * regular expression component to match a selector combinator
53 *
54 * @var non-empty-string
55 */
56 private const COMBINATOR_MATCHER = '(?:\\s++|\\s*+[>+~]\\s*+)(?=[[:alpha:]_\\-.#*:\\[])';
57
58 /**
59 * options array key for `querySelectorAll`
60 *
61 * @var non-empty-string
62 */
63 private const QSA_ALWAYS_THROW_PARSE_EXCEPTION = 'alwaysThrowParseException';
64
65 /**
66 * @var array<non-empty-string, true>
67 */
68 private $excludedSelectors = [];
69
70 /**
71 * @var array<non-empty-string, true>
72 */
73 private $excludedCssSelectors = [];
74
75 /**
76 * @var array<non-empty-string, true>
77 */
78 private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
79
80 /**
81 * @var array{
82 * 0: array<non-empty-string, int<0, max>>,
83 * 1: array<non-empty-string, string>
84 * }
85 */
86 private $caches = [
87 self::CACHE_KEY_SELECTOR => [],
88 self::CACHE_KEY_COMBINED_STYLES => [],
89 ];
90
91 /**
92 * @var CssSelectorConverter|null
93 */
94 private $cssSelectorConverter = null;
95
96 /**
97 * the visited nodes with the XPath paths as array keys
98 *
99 * @var array<non-empty-string, \DOMElement>
100 */
101 private $visitedNodes = [];
102
103 /**
104 * the styles to apply to the nodes with the XPath paths as array keys for the outer array
105 * and the attribute names/values as key/value pairs for the inner array
106 *
107 * @var array<non-empty-string, array<string, string>>
108 */
109 private $styleAttributesForNodes = [];
110
111 /**
112 * Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
113 * If set to false, the value of the style attributes will be discarded.
114 *
115 * @var bool
116 */
117 private $isInlineStyleAttributesParsingEnabled = true;
118
119 /**
120 * Determines whether the `<style>` blocks in the HTML passed to this class should be parsed.
121 *
122 * If set to true, the `<style>` blocks will be removed from the HTML and their contents will be applied to the HTML
123 * via inline styles.
124 *
125 * If set to false, the `<style>` blocks will be left as they are in the HTML.
126 *
127 * @var bool
128 */
129 private $isStyleBlocksParsingEnabled = true;
130
131 /**
132 * For calculating selector precedence order.
133 * Keys are a regular expression part to match before a CSS name.
134 * Values are a multiplier factor per match to weight specificity.
135 *
136 * @var array<string, int<1, max>>
137 */
138 private $selectorPrecedenceMatchers = [
139 // IDs: worth 10000
140 '\\#' => 10000,
141 // classes, attributes, pseudo-classes (not pseudo-elements) except `:not`: worth 100
142 '(?:\\.|\\[|(?<!:):(?!not\\())' => 100,
143 // elements (not attribute values or `:not`), pseudo-elements: worth 1
144 '(?:(?<![="\':\\w\\-])|::)' => 1,
145 ];
146
147 /**
148 * array of data describing CSS rules which apply to the document but cannot be inlined, in the format returned by
149 * {@see collateCssRules}
150 *
151 * @var array<array-key, array{
152 * media: string,
153 * selector: non-empty-string,
154 * hasUnmatchablePseudo: bool,
155 * declarationsBlock: string,
156 * line: int<0, max>
157 * }>|null
158 */
159 private $matchingUninlinableCssRules = null;
160
161 /**
162 * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
163 *
164 * @var bool
165 */
166 private $debug = false;
167
168 /**
169 * Inlines the given CSS into the existing HTML.
170 *
171 * @param string $css the CSS to inline, must be UTF-8-encoded
172 *
173 * @return $this
174 *
175 * @throws ParseException in debug mode, if an invalid selector is encountered
176 * @throws \RuntimeException
177 * in debug mode, if an internal PCRE error occurs
178 * or `CssSelectorConverter::toXPath` returns an invalid XPath expression
179 * @throws \UnexpectedValueException
180 * if a selector query result includes a node which is not a `DOMElement`
181 */
182 public function inlineCss(string $css = ''): self
183 {
184 $this->clearAllCaches();
185 $this->purgeVisitedNodes();
186
187 $this->normalizeStyleAttributesOfAllNodes();
188
189 $combinedCss = $css;
190 // grab any existing style blocks from the HTML and append them to the existing CSS
191 // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
192 if ($this->isStyleBlocksParsingEnabled) {
193 $combinedCss .= $this->getCssFromAllStyleNodes();
194 }
195 $parsedCss = new CssDocument($combinedCss, $this->debug);
196
197 $excludedNodes = $this->getNodesToExclude();
198 $cssRules = $this->collateCssRules($parsedCss);
199 foreach ($cssRules['inlinable'] as $cssRule) {
200 foreach ($this->querySelectorAll($cssRule['selector']) as $node) {
201 if (\in_array($node, $excludedNodes, true)) {
202 continue;
203 }
204 $this->copyInlinableCssToStyleAttribute($this->ensureNodeIsElement($node), $cssRule);
205 }
206 }
207
208 if ($this->isInlineStyleAttributesParsingEnabled) {
209 $this->fillStyleAttributesWithMergedStyles();
210 }
211
212 $this->removeImportantAnnotationFromAllInlineStyles();
213
214 $this->determineMatchingUninlinableCssRules($cssRules['uninlinable']);
215 $this->copyUninlinableCssToStyleNode($parsedCss);
216
217 return $this;
218 }
219
220 /**
221 * Disables the parsing of inline styles.
222 *
223 * @return $this
224 */
225 public function disableInlineStyleAttributesParsing(): self
226 {
227 $this->isInlineStyleAttributesParsingEnabled = false;
228
229 return $this;
230 }
231
232 /**
233 * Disables the parsing of `<style>` blocks.
234 *
235 * @return $this
236 */
237 public function disableStyleBlocksParsing(): self
238 {
239 $this->isStyleBlocksParsingEnabled = false;
240
241 return $this;
242 }
243
244 /**
245 * Marks a media query type to keep.
246 *
247 * @param non-empty-string $mediaName the media type name, e.g., "braille"
248 *
249 * @return $this
250 */
251 public function addAllowedMediaType(string $mediaName): self
252 {
253 $this->allowedMediaTypes[$mediaName] = true;
254
255 return $this;
256 }
257
258 /**
259 * Drops a media query type from the allowed list.
260 *
261 * @param non-empty-string $mediaName the tag name, e.g., "braille"
262 *
263 * @return $this
264 */
265 public function removeAllowedMediaType(string $mediaName): self
266 {
267 if (isset($this->allowedMediaTypes[$mediaName])) {
268 unset($this->allowedMediaTypes[$mediaName]);
269 }
270
271 return $this;
272 }
273
274 /**
275 * Adds a selector to exclude nodes from emogrification.
276 *
277 * Any nodes that match the selector will not have their style altered.
278 *
279 * @param non-empty-string $selector the selector to exclude, e.g., ".editor"
280 *
281 * @return $this
282 */
283 public function addExcludedSelector(string $selector): self
284 {
285 $this->excludedSelectors[$selector] = true;
286
287 return $this;
288 }
289
290 /**
291 * No longer excludes the nodes matching this selector from emogrification.
292 *
293 * @param non-empty-string $selector the selector to no longer exclude, e.g., ".editor"
294 *
295 * @return $this
296 */
297 public function removeExcludedSelector(string $selector): self
298 {
299 if (isset($this->excludedSelectors[$selector])) {
300 unset($this->excludedSelectors[$selector]);
301 }
302
303 return $this;
304 }
305
306 /**
307 * Adds a selector to exclude CSS selector from emogrification.
308 *
309 * @param non-empty-string $selector the selector to exclude, e.g., `.editor`
310 *
311 * @return $this
312 */
313 public function addExcludedCssSelector(string $selector): self
314 {
315 $this->excludedCssSelectors[$selector] = true;
316
317 return $this;
318 }
319
320 /**
321 * No longer excludes the CSS selector from emogrification.
322 *
323 * @param non-empty-string $selector the selector to no longer exclude, e.g., `.editor`
324 *
325 * @return $this
326 */
327 public function removeExcludedCssSelector(string $selector): self
328 {
329 if (isset($this->excludedCssSelectors[$selector])) {
330 unset($this->excludedCssSelectors[$selector]);
331 }
332
333 return $this;
334 }
335
336 /**
337 * Sets the debug mode.
338 *
339 * @param bool $debug set to true to enable debug mode
340 *
341 * @return $this
342 */
343 public function setDebug(bool $debug): self
344 {
345 $this->debug = $debug;
346
347 return $this;
348 }
349
350 /**
351 * Gets the array of selectors present in the CSS provided to `inlineCss()` for which the declarations could not be
352 * applied as inline styles, but which may affect elements in the HTML. The relevant CSS will have been placed in a
353 * `<style>` element. The selectors may include those used within `@media` rules or those involving dynamic
354 * pseudo-classes (such as `:hover`) or pseudo-elements (such as `::after`).
355 *
356 * @return array<array-key, string>
357 *
358 * @throws \BadMethodCallException if `inlineCss` has not been called first
359 */
360 public function getMatchingUninlinableSelectors(): array
361 {
362 $selectors = \array_column($this->getMatchingUninlinableCssRules(), 'selector');
363 $selectors[] = '.hideonline';
364 $selectors[] = '#acym__wysid__template center > table';
365
366 return $selectors;
367 }
368
369 /**
370 * @return array<array-key, array{
371 * media: string,
372 * selector: non-empty-string,
373 * hasUnmatchablePseudo: bool,
374 * declarationsBlock: string,
375 * line: int<0, max>
376 * }>
377 *
378 * @throws \BadMethodCallException if `inlineCss` has not been called first
379 */
380 private function getMatchingUninlinableCssRules(): array
381 {
382 if (!\is_array($this->matchingUninlinableCssRules)) {
383 throw new \BadMethodCallException('inlineCss must be called first', 1568385221);
384 }
385
386 return $this->matchingUninlinableCssRules;
387 }
388
389 /**
390 * Clears all caches.
391 */
392 private function clearAllCaches(): void
393 {
394 $this->caches = [
395 self::CACHE_KEY_SELECTOR => [],
396 self::CACHE_KEY_COMBINED_STYLES => [],
397 ];
398 }
399
400 /**
401 * Purges the visited nodes.
402 */
403 private function purgeVisitedNodes(): void
404 {
405 $this->visitedNodes = [];
406 $this->styleAttributesForNodes = [];
407 }
408
409 /**
410 * Parses the document and normalizes all existing CSS attributes.
411 * This changes 'DISPLAY: none' to 'display: none'.
412 * We wouldn't have to do this if DOMXPath supported XPath 2.0.
413 * Also stores a reference of nodes with existing inline styles so we don't overwrite them.
414 */
415 private function normalizeStyleAttributesOfAllNodes(): void
416 {
417 foreach ($this->getAllNodesWithStyleAttribute() as $node) {
418 if ($this->isInlineStyleAttributesParsingEnabled) {
419 $this->normalizeStyleAttributes($node);
420 }
421 // Remove style attribute in every case, so we can add them back (if inline style attributes
422 // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
423 // else original inline style rules may remain at the beginning of the final inline style definition
424 // of a node, which may give not the desired results
425 $node->removeAttribute('style');
426 }
427 }
428
429 /**
430 * Returns a list with all DOM nodes that have a style attribute.
431 *
432 * @return \DOMNodeList<\DOMElement>
433 *
434 * @throws \RuntimeException
435 */
436 private function getAllNodesWithStyleAttribute(): \DOMNodeList
437 {
438 $query = '//*[@style]';
439 $matches = $this->getXPath()->query($query);
440 if (!$matches instanceof \DOMNodeList) {
441 throw new \RuntimeException('XPatch query failed: ' . $query, 1618577797);
442 }
443 /** @var \DOMNodeList<\DOMElement> $matches */
444
445 return $matches;
446 }
447
448 /**
449 * Normalizes the value of the "style" attribute and saves it.
450 */
451 private function normalizeStyleAttributes(\DOMElement $node): void
452 {
453 $declarationBlockParser = new DeclarationBlockParser();
454
455 $pattern = '/-{0,2}+[_a-zA-Z][\\w\\-]*+(?=:)/S';
456 /** @param array<array-key, string> $propertyNameMatches */
457 $callback = static function (array $propertyNameMatches) use ($declarationBlockParser): string {
458 return $declarationBlockParser->normalizePropertyName($propertyNameMatches[0]);
459 };
460 $normalizedOriginalStyle = (new Preg())->throwExceptions($this->debug)
461 ->replaceCallback($pattern, $callback, $node->getAttribute('style'));
462
463 // In order to not overwrite existing style attributes in the HTML, we have to save the original HTML styles.
464 $nodePath = $node->getNodePath();
465 if (\is_string($nodePath) && ($nodePath !== '') && !isset($this->styleAttributesForNodes[$nodePath])) {
466 $this->styleAttributesForNodes[$nodePath] = $declarationBlockParser->parse($normalizedOriginalStyle);
467 $this->visitedNodes[$nodePath] = $node;
468 }
469
470 $node->setAttribute('style', $normalizedOriginalStyle);
471 }
472
473 /**
474 * Returns CSS content.
475 */
476 private function getCssFromAllStyleNodes(): string
477 {
478 $styleNodes = $this->getXPath()->query('//style');
479 if ($styleNodes === false) {
480 return '';
481 }
482
483 $css = '';
484 foreach ($styleNodes as $styleNode) {
485 if (\is_string($styleNode->nodeValue)) {
486 $css .= "\n\n" . $styleNode->nodeValue;
487 }
488 $parentNode = $styleNode->parentNode;
489 if ($parentNode instanceof \DOMNode) {
490 $parentNode->removeChild($styleNode);
491 }
492 }
493
494 return $css;
495 }
496
497 /**
498 * Find the nodes that are not to be emogrified.
499 *
500 * @return list<\DOMElement>
501 *
502 * @throws ParseException in debug mode, if an invalid selector is encountered
503 * @throws \RuntimeException in debug mode, if `CssSelectorConverter::toXPath` returns an invalid XPath expression
504 * @throws \UnexpectedValueException if the selector query result includes a node which is not a `DOMElement`
505 */
506 private function getNodesToExclude(): array
507 {
508 $excludedNodes = [];
509 foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) {
510 foreach ($this->querySelectorAll($selectorToExclude) as $node) {
511 $excludedNodes[] = $this->ensureNodeIsElement($node);
512 }
513 }
514
515 return $excludedNodes;
516 }
517
518 /**
519 * @param array{alwaysThrowParseException?: bool} $options
520 * This is an array of option values to control behaviour:
521 * - `QSA_ALWAYS_THROW_PARSE_EXCEPTION` - `bool` - throw any `ParseException` regardless of debug setting.
522 *
523 * @return \DOMNodeList<\DOMElement> the HTML elements that match the provided CSS `$selectors`
524 *
525 * @throws ParseException
526 * in debug mode (or with `QSA_ALWAYS_THROW_PARSE_EXCEPTION` option), if an invalid selector is encountered
527 * @throws \RuntimeException in debug mode, if `CssSelectorConverter::toXPath` returns an invalid XPath expression
528 */
529 private function querySelectorAll(string $selectors, array $options = []): \DOMNodeList
530 {
531 try {
532 $result = $this->getXPath()->query($this->getCssSelectorConverter()->toXPath($selectors));
533
534 if ($result === false) {
535 throw new \RuntimeException('query failed with selector \'' . $selectors . '\'', 1726533051);
536 }
537 /** @var \DOMNodeList<\DOMElement> $result */
538
539 return $result;
540 } catch (ParseException $exception) {
541 $alwaysThrowParseException = $options[self::QSA_ALWAYS_THROW_PARSE_EXCEPTION] ?? false;
542 if ($this->debug || $alwaysThrowParseException) {
543 throw $exception;
544 }
545 $list = new \DOMNodeList();
546 /** @var \DOMNodeList<\DOMElement> $list */
547 return $list;
548 } catch (\RuntimeException $exception) {
549 if (
550 $this->debug
551 ) {
552 throw $exception;
553 }
554 // `RuntimeException` indicates a bug in CssSelector so pass the message to the error handler.
555 \trigger_error($exception->getMessage());
556 $list = new \DOMNodeList();
557 /** @var \DOMNodeList<\DOMElement> $list */
558 return $list;
559 }
560 }
561
562 /**
563 * @throws \UnexpectedValueException if `$node` is not a `DOMElement`
564 */
565 private function ensureNodeIsElement(\DOMNode $node): \DOMElement
566 {
567 if (!($node instanceof \DOMElement)) {
568 $path = $node->getNodePath() ?? '$node';
569 throw new \UnexpectedValueException($path . ' is not a DOMElement.', 1617975914);
570 }
571
572 return $node;
573 }
574
575 private function getCssSelectorConverter(): CssSelectorConverter
576 {
577 if (!$this->cssSelectorConverter instanceof CssSelectorConverter) {
578 $this->cssSelectorConverter = new CssSelectorConverter();
579 }
580
581 return $this->cssSelectorConverter;
582 }
583
584 /**
585 * Collates the individual rules from a `CssDocument` object.
586 *
587 * @return array<string, array<array-key, array{
588 * media: string,
589 * selector: non-empty-string,
590 * hasUnmatchablePseudo: bool,
591 * declarationsBlock: string,
592 * line: int<0, max>
593 * }>>
594 * This 2-entry array has the key "inlinable" containing rules which can be inlined as `style` attributes
595 * and the key "uninlinable" containing rules which cannot. Each value is an array of sub-arrays with the
596 * following keys:
597 * - "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
598 * or an empty string if not from a `@media` rule);
599 * - "selector" (the CSS selector, e.g., "*" or "header h1");
600 * - "hasUnmatchablePseudo" (`true` if that selector contains pseudo-elements or dynamic pseudo-classes such
601 * that the declarations cannot be applied inline);
602 * - "declarationsBlock" (the semicolon-separated CSS declarations for that selector,
603 * e.g., `color: red; height: 4px;`);
604 * - "line" (the line number, e.g. 42).
605 */
606 private function collateCssRules(CssDocument $parsedCss): array
607 {
608 $matches = $parsedCss->getStyleRulesData(\array_keys($this->allowedMediaTypes));
609
610 $preg = (new Preg())->throwExceptions($this->debug);
611 $cssRules = [
612 'inlinable' => [],
613 'uninlinable' => [],
614 ];
615 foreach ($matches as $key => $cssRule) {
616 if (!$cssRule->hasAtLeastOneDeclaration()) {
617 continue;
618 }
619
620 $mediaQuery = $cssRule->getContainingAtRule();
621 $declarationsBlock = $cssRule->getDeclarationAsText();
622 $selectors = $cssRule->getSelectors();
623
624 // Maybe exclude CSS selectors
625 if (\count($this->excludedCssSelectors) > 0) {
626 // Normalize spaces, line breaks & tabs
627 $selectorsNormalized = \array_map(static function (string $selector) use ($preg): string {
628 return $preg->replace('@\\s++@u', ' ', $selector);
629 }, $selectors);
630 /** @var array<non-empty-string> $selectors */
631 $selectors = \array_filter($selectorsNormalized, function (string $selector): bool {
632 return !isset($this->excludedCssSelectors[$selector]);
633 });
634 }
635
636 foreach ($selectors as $selector) {
637 // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
638 // only allow structural pseudo-classes
639 $hasPseudoElement = \strpos($selector, '::') !== false;
640 $hasUnmatchablePseudo = $hasPseudoElement || $this->hasUnsupportedPseudoClass($selector);
641
642 $parsedCssRule = [
643 'media' => $mediaQuery,
644 'selector' => $selector,
645 'hasUnmatchablePseudo' => $hasUnmatchablePseudo,
646 'declarationsBlock' => $declarationsBlock,
647 // keep track of where it appears in the file, since order is important
648 'line' => $key,
649 ];
650 $ruleType = (!$cssRule->hasContainingAtRule() && !$hasUnmatchablePseudo) ? 'inlinable' : 'uninlinable';
651 $cssRules[$ruleType][] = $parsedCssRule;
652 }
653 }
654
655 \usort(
656 $cssRules['inlinable'],
657 /**
658 * @param array{selector: non-empty-string, line: int<0, max>} $first
659 * @param array{selector: non-empty-string, line: int<0, max>} $second
660 */
661 function (array $first, array $second): int {
662 return $this->sortBySelectorPrecedence($first, $second);
663 }
664 );
665
666 return $cssRules;
667 }
668
669 /**
670 * Tests if a selector contains a pseudo-class which would mean it cannot be converted to an XPath expression for
671 * inlining CSS declarations.
672 *
673 * Any pseudo class that does not match {@see PSEUDO_CLASS_MATCHER} cannot be converted. Additionally, `...of-type`
674 * pseudo-classes cannot be converted if they are not associated with a type selector.
675 */
676 private function hasUnsupportedPseudoClass(string $selector): bool
677 {
678 $preg = (new Preg())->throwExceptions($this->debug);
679
680 if ($preg->match('/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i', $selector) !== 0) {
681 return true;
682 }
683
684 if ($preg->match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selector) === 0) {
685 return false;
686 }
687
688 foreach ($preg->split('/' . self::COMBINATOR_MATCHER . '/', $selector) as $selectorPart) {
689 if ($this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
690 return true;
691 }
692 }
693
694 return false;
695 }
696
697 /**
698 * Tests if part of a selector contains an `...of-type` pseudo-class such that it cannot be converted to an XPath
699 * expression.
700 *
701 * @param string $selectorPart part of a selector which has been split up at combinators
702 *
703 * @return bool `true` if the selector part does not have a type but does have an `...of-type` pseudo-class
704 */
705 private function selectorPartHasUnsupportedOfTypePseudoClass(string $selectorPart): bool
706 {
707 $preg = (new Preg())->throwExceptions($this->debug);
708
709 if ($preg->match('/^[\\w\\-]/', $selectorPart) !== 0) {
710 return false;
711 }
712
713 return $preg->match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selectorPart) !== 0;
714 }
715
716 /**
717 * @param array{selector: non-empty-string, line: int<0, max>} $first
718 * @param array{selector: non-empty-string, line: int<0, max>} $second
719 */
720 private function sortBySelectorPrecedence(array $first, array $second): int
721 {
722 $precedenceOfFirst = $this->getCssSelectorPrecedence($first['selector']);
723 $precedenceOfSecond = $this->getCssSelectorPrecedence($second['selector']);
724
725 // We want these sorted in ascending order so selectors with lesser precedence get processed first and
726 // selectors with greater precedence get sorted last.
727 $precedenceForEquals = $first['line'] < $second['line'] ? -1 : 1;
728 $precedenceForNotEquals = $precedenceOfFirst < $precedenceOfSecond ? -1 : 1;
729 return ($precedenceOfFirst === $precedenceOfSecond) ? $precedenceForEquals : $precedenceForNotEquals;
730 }
731
732 /**
733 * @param non-empty-string $selector
734 *
735 * @return int<0, max>
736 */
737 private function getCssSelectorPrecedence(string $selector): int
738 {
739 $selectorKey = $selector;
740 if (isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
741 return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
742 }
743
744 $preg = (new Preg())->throwExceptions($this->debug);
745 $precedence = 0;
746 foreach ($this->selectorPrecedenceMatchers as $matcher => $value) {
747 if (\trim($selector) === '') {
748 break;
749 }
750 $count = 0;
751 $selector = $preg->replace('/' . $matcher . '\\w+/', '', $selector, -1, $count);
752 $precedence += ($value * $count);
753 \assert($precedence >= 0);
754 }
755 $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
756
757 return $precedence;
758 }
759
760 /**
761 * Copies `$cssRule` into the style attribute of `$node`.
762 *
763 * Note: This method does not check whether $cssRule matches $node.
764 *
765 * @param array{
766 * media: string,
767 * selector: non-empty-string,
768 * hasUnmatchablePseudo: bool,
769 * declarationsBlock: string,
770 * line: int<0, max>
771 * } $cssRule
772 */
773 private function copyInlinableCssToStyleAttribute(\DOMElement $node, array $cssRule): void
774 {
775 $declarationsBlock = $cssRule['declarationsBlock'];
776 $declarationBlockParser = new DeclarationBlockParser();
777 $newStyleDeclarations = $declarationBlockParser->parse($declarationsBlock);
778 if ($newStyleDeclarations === []) {
779 return;
780 }
781
782 // if it has a style attribute, get it, process it, and append (overwrite) new stuff
783 if ($node->hasAttribute('style')) {
784 // break it up into an associative array
785 $oldStyleDeclarations = $declarationBlockParser->parse($node->getAttribute('style'));
786 } else {
787 $oldStyleDeclarations = [];
788 }
789 $node->setAttribute(
790 'style',
791 $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
792 );
793 }
794
795 /**
796 * This method merges old or existing name/value array with new name/value array
797 * and then generates a string of the combined style suitable for placing inline.
798 * This becomes the single point for CSS string generation allowing for consistent
799 * CSS output no matter where the CSS originally came from.
800 *
801 * @param array<string, string> $oldStyles
802 * @param array<string, string> $newStyles
803 *
804 * @throws \UnexpectedValueException if an empty property name is encountered (which should not happen)
805 */
806 private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles): string
807 {
808 $cacheKey = \serialize([$oldStyles, $newStyles]);
809 \assert($cacheKey !== '');
810 if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
811 return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
812 }
813
814 // Unset the overridden styles to preserve order, important if shorthand and individual properties are mixed
815 foreach ($oldStyles as $attributeName => $attributeValue) {
816 if (!isset($newStyles[$attributeName])) {
817 continue;
818 }
819
820 $newAttributeValue = $newStyles[$attributeName];
821 if (
822 $this->attributeValueIsImportant($attributeValue)
823 && !$this->attributeValueIsImportant($newAttributeValue)
824 ) {
825 unset($newStyles[$attributeName]);
826 } else {
827 unset($oldStyles[$attributeName]);
828 }
829 }
830
831 $combinedStyles = \array_merge($oldStyles, $newStyles);
832
833 $declarationBlockParser = new DeclarationBlockParser();
834 $style = '';
835 foreach ($combinedStyles as $attributeName => $attributeValue) {
836 $trimmedAttributeName = \trim($attributeName);
837 if ($trimmedAttributeName === '') {
838 throw new \UnexpectedValueException('An empty property name was encountered.', 1727046078);
839 }
840 $propertyName = $declarationBlockParser->normalizePropertyName($trimmedAttributeName);
841 $propertyValue = \trim($attributeValue);
842 $style .= $propertyName . ': ' . $propertyValue . '; ';
843 }
844 $trimmedStyle = \rtrim($style);
845
846 $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
847
848 return $trimmedStyle;
849 }
850
851 /**
852 * Checks whether `$attributeValue` is marked as `!important`.
853 */
854 private function attributeValueIsImportant(string $attributeValue): bool
855 {
856 return (new Preg())->throwExceptions($this->debug)->match('/!\\s*+important$/i', $attributeValue) !== 0;
857 }
858
859 /**
860 * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
861 */
862 private function fillStyleAttributesWithMergedStyles(): void
863 {
864 $declarationBlockParser = new DeclarationBlockParser();
865 foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
866 $node = $this->visitedNodes[$nodePath];
867 $currentStyleAttributes = $declarationBlockParser->parse($node->getAttribute('style'));
868 $node->setAttribute(
869 'style',
870 $this->generateStyleStringFromDeclarationsArrays(
871 $currentStyleAttributes,
872 $styleAttributesForNode
873 )
874 );
875 }
876 }
877
878 /**
879 * Searches for all nodes with a style attribute and removes the "!important" annotations out of
880 * the inline style declarations, eventually by rearranging declarations.
881 *
882 * @throws \RuntimeException
883 */
884 private function removeImportantAnnotationFromAllInlineStyles(): void
885 {
886 foreach ($this->getAllNodesWithStyleAttribute() as $node) {
887 $this->removeImportantAnnotationFromNodeInlineStyle($node);
888 }
889 }
890
891 /**
892 * Removes the "!important" annotations out of the inline style declarations,
893 * eventually by rearranging declarations.
894 * Rearranging needed when !important shorthand properties are followed by some of their
895 * not !important expanded-version properties.
896 * For example "font: 12px serif !important; font-size: 13px;" must be reordered
897 * to "font-size: 13px; font: 12px serif;" in order to remain correct.
898 *
899 * @throws \RuntimeException
900 */
901 private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node): void
902 {
903 $style = $node->getAttribute('style');
904 $inlineStyleDeclarations = (new DeclarationBlockParser())->parse((bool) $style ? $style : '');
905 /** @var array<string, string> $regularStyleDeclarations */
906 $regularStyleDeclarations = [];
907 /** @var array<string, string> $importantStyleDeclarations */
908 $importantStyleDeclarations = [];
909 foreach ($inlineStyleDeclarations as $property => $value) {
910 if ($this->attributeValueIsImportant($value)) {
911 $importantStyleDeclarations[$property]
912 = (new Preg())->throwExceptions($this->debug)->replace('/\\s*+!\\s*+important$/i', '', $value);
913 } else {
914 $regularStyleDeclarations[$property] = $value;
915 }
916 }
917 $inlineStyleDeclarationsInNewOrder = \array_merge($regularStyleDeclarations, $importantStyleDeclarations);
918 $node->setAttribute(
919 'style',
920 $this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder)
921 );
922 }
923
924 /**
925 * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
926 *
927 * @param array<string, string> $styleDeclarations
928 */
929 private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations): string
930 {
931 return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
932 }
933
934 /**
935 * Determines which of `$cssRules` actually apply to `$this->domDocument`, and sets them in
936 * `$this->matchingUninlinableCssRules`.
937 *
938 * @param array<array-key, array{
939 * media: string,
940 * selector: non-empty-string,
941 * hasUnmatchablePseudo: bool,
942 * declarationsBlock: string,
943 * line: int<0, max>
944 * }> $cssRules
945 * the "uninlinable" array of CSS rules returned by `collateCssRules`
946 */
947 private function determineMatchingUninlinableCssRules(array $cssRules): void
948 {
949 $this->matchingUninlinableCssRules = \array_filter(
950 $cssRules,
951 function (array $cssRule): bool {
952 return $this->existsMatchForSelectorInCssRule($cssRule);
953 }
954 );
955 }
956
957 /**
958 * Checks whether there is at least one matching element for the CSS selector contained in the `selector` element
959 * of the provided CSS rule.
960 *
961 * Any dynamic pseudo-classes will be assumed to apply. If the selector matches a pseudo-element,
962 * it will test for a match with its originating element.
963 *
964 * @param array{
965 * media: string,
966 * selector: non-empty-string,
967 * hasUnmatchablePseudo: bool,
968 * declarationsBlock: string,
969 * line: int<0, max>
970 * } $cssRule
971 *
972 * @throws ParseException
973 */
974 private function existsMatchForSelectorInCssRule(array $cssRule): bool
975 {
976 $selector = $cssRule['selector'];
977 if ($cssRule['hasUnmatchablePseudo']) {
978 $selector = $this->removeUnmatchablePseudoComponents($selector);
979 }
980 return $this->existsMatchForCssSelector($selector);
981 }
982
983 /**
984 * Checks whether there is at least one matching element for $cssSelector.
985 * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
986 * just not implemented/recognized yet by Emogrifier).
987 *
988 * @throws ParseException in debug mode, if an invalid selector is encountered
989 * @throws \RuntimeException in debug mode, if `CssSelectorConverter::toXPath` returns an invalid XPath expression
990 */
991 private function existsMatchForCssSelector(string $cssSelector): bool
992 {
993 try {
994 $nodesMatchingSelector
995 = $this->querySelectorAll($cssSelector, [self::QSA_ALWAYS_THROW_PARSE_EXCEPTION => true]);
996 } catch (ParseException $e) {
997 if ($this->debug) {
998 throw $e;
999 }
1000 return true;
1001 }
1002
1003 return $nodesMatchingSelector->length !== 0;
1004 }
1005
1006 /**
1007 * Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary.
1008 * If such a pseudo-component is within the argument of `:not`, the entire `:not` component is removed or replaced.
1009 *
1010 * @return string
1011 * selector which will match the relevant DOM elements if the pseudo-classes are assumed to apply, or in the
1012 * case of pseudo-elements will match their originating element
1013 */
1014 private function removeUnmatchablePseudoComponents(string $selector): string
1015 {
1016 $preg = (new Preg())->throwExceptions($this->debug);
1017
1018 // The regex allows nested brackets via `(?2)`.
1019 // A space is temporarily prepended because the callback can't determine if the match was at the very start.
1020 $pattern = '/([\\s>+~]?+):not(\\([^()]*+(?:(?2)[^()]*+)*+\\))/i';
1021 /** @param array<array-key, string> $matches */
1022 $callback = function (array $matches): string {
1023 return $this->replaceUnmatchableNotComponent($matches);
1024 };
1025 $untrimmedSelectorWithoutNots = (new Preg())->throwExceptions($this->debug)
1026 ->replaceCallback($pattern, $callback, ' ' . $selector);
1027 $selectorWithoutNots = \ltrim($untrimmedSelectorWithoutNots);
1028
1029 $selectorWithoutUnmatchablePseudoComponents = $this->removeSelectorComponents(
1030 ':(?!' . self::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+',
1031 $selectorWithoutNots
1032 );
1033
1034 if ($preg->match(
1035 '/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i',
1036 $selectorWithoutUnmatchablePseudoComponents
1037 ) === 0) {
1038 return $selectorWithoutUnmatchablePseudoComponents;
1039 }
1040
1041 $selectorParts = $preg->split(
1042 '/(' . self::COMBINATOR_MATCHER . ')/',
1043 $selectorWithoutUnmatchablePseudoComponents,
1044 -1,
1045 PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
1046 );
1047
1048 return \implode('', \array_map(
1049 function (string $selectorPart): string {
1050 return $this->removeUnsupportedOfTypePseudoClasses($selectorPart);
1051 },
1052 $selectorParts
1053 ));
1054 }
1055
1056 /**
1057 * Helps `removeUnmatchablePseudoComponents()` replace or remove a selector `:not(...)` component if its argument
1058 * contains pseudo-elements or dynamic pseudo-classes.
1059 *
1060 * @param array<array-key, string> $matches array of elements matched by the regular expression
1061 *
1062 * @return string
1063 * the full match if there were no unmatchable pseudo components within; otherwise, any preceding combinator
1064 * followed by "*", or an empty string if there was no preceding combinator
1065 */
1066 private function replaceUnmatchableNotComponent(array $matches): string
1067 {
1068 [$notComponentWithAnyPrecedingCombinator, $anyPrecedingCombinator, $notArgumentInBrackets] = $matches;
1069
1070 if ($this->hasUnsupportedPseudoClass($notArgumentInBrackets)) {
1071 return $anyPrecedingCombinator !== '' ? $anyPrecedingCombinator . '*' : '';
1072 }
1073 return $notComponentWithAnyPrecedingCombinator;
1074 }
1075
1076 /**
1077 * Removes components from a CSS selector, replacing them with "*" if necessary.
1078 *
1079 * @param string $matcher regular expression part to match the components to remove
1080 *
1081 * @return string
1082 * selector which will match the relevant DOM elements if the removed components are assumed to apply (or in
1083 * the case of pseudo-elements will match their originating element)
1084 */
1085 private function removeSelectorComponents(string $matcher, string $selector): string
1086 {
1087 return (new Preg())->throwExceptions($this->debug)->replace(
1088 ['/([\\s>+~]|^)' . $matcher . '/i', '/' . $matcher . '/i'],
1089 ['$1*', ''],
1090 $selector
1091 );
1092 }
1093
1094 /**
1095 * Removes any `...-of-type` pseudo-classes from part of a CSS selector, if it does not have a type, replacing them
1096 * with "*" if necessary.
1097 *
1098 * @param string $selectorPart part of a selector which has been split up at combinators
1099 *
1100 * @return string
1101 * selector part which will match the relevant DOM elements if the pseudo-classes are assumed to apply
1102 */
1103 private function removeUnsupportedOfTypePseudoClasses(string $selectorPart): string
1104 {
1105 if (!$this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
1106 return $selectorPart;
1107 }
1108
1109 return $this->removeSelectorComponents(
1110 ':(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')(?:\\([^\\)]*+\\))?+',
1111 $selectorPart
1112 );
1113 }
1114
1115 /**
1116 * Applies `$this->matchingUninlinableCssRules` to `$this->domDocument` by placing them as CSS in a `<style>`
1117 * element.
1118 * If there are no uninlinable CSS rules to copy there, a `<style>` element will be created containing only the
1119 * applicable at-rules from `$parsedCss`.
1120 * If there are none of either, an empty `<style>` element will not be created.
1121 *
1122 * @param CssDocument $parsedCss
1123 * This may contain various at-rules whose content `CssInliner` does not currently attempt to inline or
1124 * process in any other way, such as `@import`, `@font-face`, `@keyframes`, etc., and which should precede
1125 * the processed but found-to-be-uninlinable CSS placed in the `<style>` element.
1126 * Note that `CssInliner` processes `@media` rules so that they can be ordered correctly with respect to
1127 * other uninlinable rules; these will not be duplicated from `$parsedCss`.
1128 */
1129 private function copyUninlinableCssToStyleNode(CssDocument $parsedCss): void
1130 {
1131 $css = $parsedCss->renderNonConditionalAtRules();
1132
1133 // avoid including unneeded class dependency if there are no rules
1134 if ($this->getMatchingUninlinableCssRules() !== []) {
1135 $cssConcatenator = new CssConcatenator();
1136 foreach ($this->getMatchingUninlinableCssRules() as $cssRule) {
1137 $cssConcatenator->append([$cssRule['selector']], $cssRule['declarationsBlock'], $cssRule['media']);
1138 }
1139 $css .= $cssConcatenator->getCss();
1140 }
1141
1142 // avoid adding empty style element
1143 if ($css !== '') {
1144 $this->addStyleElementToDocument($css);
1145 }
1146 }
1147
1148 /**
1149 * Adds a style element with `$css` to `$this->domDocument`.
1150 *
1151 * This method is protected to allow overriding.
1152 *
1153 * @see https://github.com/MyIntervals/emogrifier/issues/103
1154 */
1155 protected function addStyleElementToDocument(string $css): void
1156 {
1157 global $acymailingEmogrifiedMediaCSS;
1158 $acymailingEmogrifiedMediaCSS = $css;
1159 }
1160
1161 /**
1162 * Returns the `HEAD` element.
1163 *
1164 * This method assumes that there always is a HEAD element.
1165 *
1166 * @throws \UnexpectedValueException
1167 */
1168 private function getHeadElement(): \DOMElement
1169 {
1170 $node = $this->getDomDocument()->getElementsByTagName('head')->item(0);
1171 if (!$node instanceof \DOMElement) {
1172 throw new \UnexpectedValueException('There is no HEAD element. This should never happen.', 1617923227);
1173 }
1174
1175 return $node;
1176 }
1177 }
1178