PluginProbe ʕ •ᴥ•ʔ
Paid Membership Plugin, Ecommerce, User Registration Form, Login Form, User Profile & Restrict Content – ProfilePress / 4.17.0
Paid Membership Plugin, Ecommerce, User Registration Form, Login Form, User Profile & Restrict Content – ProfilePress v4.17.0
4.17.2 4.17.1 4.17.0 4.16.19 4.16.18 4.16.17 4.16.16 trunk 1.0 1.0.1 1.0.2 1.1 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5a 1.1.6 1.1.7 1.2 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.4 1.4.1 1.4.2 1.5 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.7 1.7.1 1.7.2 1.8 1.8.1 1.8.10 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.1.9 2.2.10 2.2.11 2.2.12 2.2.13 2.2.14 2.2.15 2.2.16 2.2.2 2.2.5 2.2.6 2.2.7 2.2.8 2.2.9 3.0 3.1 3.1.1 3.1.10 3.1.11 3.1.12 3.1.13 3.1.14 3.1.15 3.1.16 3.1.17 3.1.18 3.1.19 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 3.1.8 3.1.9 3.2.0 3.2.1 3.2.10 3.2.11 3.2.12 3.2.13 3.2.14 3.2.15 3.2.16 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.2.7 3.2.8 3.2.9 4.0.0 4.0.1 4.0.2 4.0.3 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.10.0 4.10.1 4.10.2 4.10.3 4.11.0 4.12.0 4.13.0 4.13.1 4.13.2 4.13.3 4.13.4 4.14.0 4.14.1 4.14.2 4.14.3 4.14.4 4.15.0 4.15.1 4.15.10 4.15.11 4.15.12 4.15.13 4.15.14 4.15.15 4.15.16 4.15.17 4.15.18 4.15.19 4.15.2 4.15.20 4.15.20.1 4.15.21 4.15.22 4.15.23 4.15.24 4.15.25 4.15.3 4.15.4 4.15.5 4.15.6 4.15.7 4.15.8 4.15.9 4.16.0 4.16.1 4.16.10 4.16.11 4.16.12 4.16.13 4.16.14 4.16.15 4.16.2 4.16.3 4.16.4 4.16.5 4.16.6 4.16.7 4.16.8 4.16.9 4.2.0 4.3.0 4.3.1 4.3.2 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.7.0 4.8.0 4.9.0
wp-user-avatar / third-party / vendor / pelago / emogrifier / src / CssInliner.php
wp-user-avatar / third-party / vendor / pelago / emogrifier / src Last commit date
Caching 2 months ago Css 2 months ago HtmlProcessor 2 months ago Utilities 2 months ago CssInliner.php 2 months ago
CssInliner.php
1017 lines
1 <?php
2
3 declare (strict_types=1);
4 namespace ProfilePressVendor\Pelago\Emogrifier;
5
6 use ProfilePressVendor\Pelago\Emogrifier\Css\CssDocument;
7 use ProfilePressVendor\Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
8 use ProfilePressVendor\Pelago\Emogrifier\Utilities\CssConcatenator;
9 use ProfilePressVendor\Symfony\Component\CssSelector\CssSelectorConverter;
10 use ProfilePressVendor\Symfony\Component\CssSelector\Exception\ParseException;
11 /**
12 * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
13 */
14 class CssInliner extends AbstractHtmlProcessor
15 {
16 /**
17 * @var int
18 */
19 private const CACHE_KEY_SELECTOR = 0;
20 /**
21 * @var int
22 */
23 private const CACHE_KEY_CSS_DECLARATIONS_BLOCK = 1;
24 /**
25 * @var int
26 */
27 private const CACHE_KEY_COMBINED_STYLES = 2;
28 /**
29 * Regular expression component matching a static pseudo class in a selector, without the preceding ":",
30 * for which the applicable elements can be determined (by converting the selector to an XPath expression).
31 * (Contains alternation without a group and is intended to be placed within a capturing, non-capturing or lookahead
32 * group, as appropriate for the usage context.)
33 *
34 * @var string
35 */
36 private const PSEUDO_CLASS_MATCHER = 'empty|(?:first|last|nth(?:-last)?+|only)-(?:child|of-type)|not\([[:ascii:]]*\)';
37 /**
38 * This regular expression componenet matches an `...of-type` pseudo class name, without the preceding ":". These
39 * pseudo-classes can currently online be inlined if they have an associated type in the selector expression.
40 *
41 * @var string
42 */
43 private const OF_TYPE_PSEUDO_CLASS_MATCHER = '(?:first|last|nth(?:-last)?+|only)-of-type';
44 /**
45 * regular expression component to match a selector combinator
46 *
47 * @var string
48 */
49 private const COMBINATOR_MATCHER = '(?:\s++|\s*+[>+~]\s*+)(?=[[:alpha:]_\-.#*:\[])';
50 /**
51 * @var array<string, bool>
52 */
53 private $excludedSelectors = [];
54 /**
55 * @var array<string, bool>
56 */
57 private $allowedMediaTypes = ['all' => \true, 'screen' => \true, 'print' => \true];
58 /**
59 * @var array{
60 * 0: array<string, int>,
61 * 1: array<string, array<string, string>>,
62 * 2: array<string, string>
63 * }
64 */
65 private $caches = [self::CACHE_KEY_SELECTOR => [], self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [], self::CACHE_KEY_COMBINED_STYLES => []];
66 /**
67 * @var ?CssSelectorConverter
68 */
69 private $cssSelectorConverter = null;
70 /**
71 * the visited nodes with the XPath paths as array keys
72 *
73 * @var array<string, \DOMElement>
74 */
75 private $visitedNodes = [];
76 /**
77 * the styles to apply to the nodes with the XPath paths as array keys for the outer array
78 * and the attribute names/values as key/value pairs for the inner array
79 *
80 * @var array<string, array<string, string>>
81 */
82 private $styleAttributesForNodes = [];
83 /**
84 * Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
85 * If set to false, the value of the style attributes will be discarded.
86 *
87 * @var bool
88 */
89 private $isInlineStyleAttributesParsingEnabled = \true;
90 /**
91 * Determines whether the `<style>` blocks in the HTML passed to this class should be parsed.
92 *
93 * If set to true, the `<style>` blocks will be removed from the HTML and their contents will be applied to the HTML
94 * via inline styles.
95 *
96 * If set to false, the `<style>` blocks will be left as they are in the HTML.
97 *
98 * @var bool
99 */
100 private $isStyleBlocksParsingEnabled = \true;
101 /**
102 * For calculating selector precedence order.
103 * Keys are a regular expression part to match before a CSS name.
104 * Values are a multiplier factor per match to weight specificity.
105 *
106 * @var array<string, int>
107 */
108 private $selectorPrecedenceMatchers = [
109 // IDs: worth 10000
110 '\#' => 10000,
111 // classes, attributes, pseudo-classes (not pseudo-elements) except `:not`: worth 100
112 '(?:\.|\[|(?<!:):(?!not\())' => 100,
113 // elements (not attribute values or `:not`), pseudo-elements: worth 1
114 '(?:(?<![="\':\w\-])|::)' => 1,
115 ];
116 /**
117 * array of data describing CSS rules which apply to the document but cannot be inlined, in the format returned by
118 * {@see collateCssRules}
119 *
120 * @var array<array-key, array{
121 * media: string,
122 * selector: string,
123 * hasUnmatchablePseudo: bool,
124 * declarationsBlock: string,
125 * line: int
126 * }>|null
127 */
128 private $matchingUninlinableCssRules = null;
129 /**
130 * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
131 *
132 * @var bool
133 */
134 private $debug = \false;
135 /**
136 * Inlines the given CSS into the existing HTML.
137 *
138 * @param string $css the CSS to inline, must be UTF-8-encoded
139 *
140 * @return self fluent interface
141 *
142 * @throws ParseException in debug mode, if an invalid selector is encountered
143 * @throws \RuntimeException in debug mode, if an internal PCRE error occurs
144 */
145 public function inlineCss(string $css = ''): self
146 {
147 $this->clearAllCaches();
148 $this->purgeVisitedNodes();
149 $this->normalizeStyleAttributesOfAllNodes();
150 $combinedCss = $css;
151 // grab any existing style blocks from the HTML and append them to the existing CSS
152 // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
153 if ($this->isStyleBlocksParsingEnabled) {
154 $combinedCss .= $this->getCssFromAllStyleNodes();
155 }
156 $parsedCss = new CssDocument($combinedCss);
157 $excludedNodes = $this->getNodesToExclude();
158 $cssRules = $this->collateCssRules($parsedCss);
159 $cssSelectorConverter = $this->getCssSelectorConverter();
160 foreach ($cssRules['inlinable'] as $cssRule) {
161 try {
162 $nodesMatchingCssSelectors = $this->getXPath()->query($cssSelectorConverter->toXPath($cssRule['selector']));
163 /** @var \DOMElement $node */
164 foreach ($nodesMatchingCssSelectors as $node) {
165 if (\in_array($node, $excludedNodes, \true)) {
166 continue;
167 }
168 $this->copyInlinableCssToStyleAttribute($node, $cssRule);
169 }
170 } catch (ParseException $e) {
171 if ($this->debug) {
172 throw $e;
173 }
174 }
175 }
176 if ($this->isInlineStyleAttributesParsingEnabled) {
177 $this->fillStyleAttributesWithMergedStyles();
178 }
179 $this->removeImportantAnnotationFromAllInlineStyles();
180 $this->determineMatchingUninlinableCssRules($cssRules['uninlinable']);
181 $this->copyUninlinableCssToStyleNode($parsedCss);
182 return $this;
183 }
184 /**
185 * Disables the parsing of inline styles.
186 *
187 * @return self fluent interface
188 */
189 public function disableInlineStyleAttributesParsing(): self
190 {
191 $this->isInlineStyleAttributesParsingEnabled = \false;
192 return $this;
193 }
194 /**
195 * Disables the parsing of `<style>` blocks.
196 *
197 * @return self fluent interface
198 */
199 public function disableStyleBlocksParsing(): self
200 {
201 $this->isStyleBlocksParsingEnabled = \false;
202 return $this;
203 }
204 /**
205 * Marks a media query type to keep.
206 *
207 * @param string $mediaName the media type name, e.g., "braille"
208 *
209 * @return self fluent interface
210 */
211 public function addAllowedMediaType(string $mediaName): self
212 {
213 $this->allowedMediaTypes[$mediaName] = \true;
214 return $this;
215 }
216 /**
217 * Drops a media query type from the allowed list.
218 *
219 * @param string $mediaName the tag name, e.g., "braille"
220 *
221 * @return self fluent interface
222 */
223 public function removeAllowedMediaType(string $mediaName): self
224 {
225 if (isset($this->allowedMediaTypes[$mediaName])) {
226 unset($this->allowedMediaTypes[$mediaName]);
227 }
228 return $this;
229 }
230 /**
231 * Adds a selector to exclude nodes from emogrification.
232 *
233 * Any nodes that match the selector will not have their style altered.
234 *
235 * @param string $selector the selector to exclude, e.g., ".editor"
236 *
237 * @return self fluent interface
238 */
239 public function addExcludedSelector(string $selector): self
240 {
241 $this->excludedSelectors[$selector] = \true;
242 return $this;
243 }
244 /**
245 * No longer excludes the nodes matching this selector from emogrification.
246 *
247 * @param string $selector the selector to no longer exclude, e.g., ".editor"
248 *
249 * @return self fluent interface
250 */
251 public function removeExcludedSelector(string $selector): self
252 {
253 if (isset($this->excludedSelectors[$selector])) {
254 unset($this->excludedSelectors[$selector]);
255 }
256 return $this;
257 }
258 /**
259 * Sets the debug mode.
260 *
261 * @param bool $debug set to true to enable debug mode
262 *
263 * @return self fluent interface
264 */
265 public function setDebug(bool $debug): self
266 {
267 $this->debug = $debug;
268 return $this;
269 }
270 /**
271 * Gets the array of selectors present in the CSS provided to `inlineCss()` for which the declarations could not be
272 * applied as inline styles, but which may affect elements in the HTML. The relevant CSS will have been placed in a
273 * `<style>` element. The selectors may include those used within `@media` rules or those involving dynamic
274 * pseudo-classes (such as `:hover`) or pseudo-elements (such as `::after`).
275 *
276 * @return array<array-key, string>
277 *
278 * @throws \BadMethodCallException if `inlineCss` has not been called first
279 */
280 public function getMatchingUninlinableSelectors(): array
281 {
282 return \array_column($this->getMatchingUninlinableCssRules(), 'selector');
283 }
284 /**
285 * @return array<array-key, array{
286 * media: string,
287 * selector: string,
288 * hasUnmatchablePseudo: bool,
289 * declarationsBlock: string,
290 * line: int
291 * }>
292 *
293 * @throws \BadMethodCallException if `inlineCss` has not been called first
294 */
295 private function getMatchingUninlinableCssRules(): array
296 {
297 if (!\is_array($this->matchingUninlinableCssRules)) {
298 throw new \BadMethodCallException('inlineCss must be called first', 1568385221);
299 }
300 return $this->matchingUninlinableCssRules;
301 }
302 /**
303 * Clears all caches.
304 */
305 private function clearAllCaches(): void
306 {
307 $this->caches = [self::CACHE_KEY_SELECTOR => [], self::CACHE_KEY_CSS_DECLARATIONS_BLOCK => [], self::CACHE_KEY_COMBINED_STYLES => []];
308 }
309 /**
310 * Purges the visited nodes.
311 */
312 private function purgeVisitedNodes(): void
313 {
314 $this->visitedNodes = [];
315 $this->styleAttributesForNodes = [];
316 }
317 /**
318 * Parses the document and normalizes all existing CSS attributes.
319 * This changes 'DISPLAY: none' to 'display: none'.
320 * We wouldn't have to do this if DOMXPath supported XPath 2.0.
321 * Also stores a reference of nodes with existing inline styles so we don't overwrite them.
322 */
323 private function normalizeStyleAttributesOfAllNodes(): void
324 {
325 /** @var \DOMElement $node */
326 foreach ($this->getAllNodesWithStyleAttribute() as $node) {
327 if ($this->isInlineStyleAttributesParsingEnabled) {
328 $this->normalizeStyleAttributes($node);
329 }
330 // Remove style attribute in every case, so we can add them back (if inline style attributes
331 // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
332 // else original inline style rules may remain at the beginning of the final inline style definition
333 // of a node, which may give not the desired results
334 $node->removeAttribute('style');
335 }
336 }
337 /**
338 * Returns a list with all DOM nodes that have a style attribute.
339 *
340 * @return \DOMNodeList
341 *
342 * @throws \RuntimeException
343 */
344 private function getAllNodesWithStyleAttribute(): \DOMNodeList
345 {
346 $query = '//*[@style]';
347 $matches = $this->getXPath()->query($query);
348 if (!$matches instanceof \DOMNodeList) {
349 throw new \RuntimeException('XPatch query failed: ' . $query, 1618577797);
350 }
351 return $matches;
352 }
353 /**
354 * Normalizes the value of the "style" attribute and saves it.
355 *
356 * @param \DOMElement $node
357 */
358 private function normalizeStyleAttributes(\DOMElement $node): void
359 {
360 $normalizedOriginalStyle = \preg_replace_callback(
361 '/-?+[_a-zA-Z][\w\-]*+(?=:)/S',
362 /** @param array<array-key, string> $propertyNameMatches */
363 static function (array $propertyNameMatches): string {
364 return \strtolower($propertyNameMatches[0]);
365 },
366 $node->getAttribute('style')
367 );
368 // In order to not overwrite existing style attributes in the HTML, we have to save the original HTML styles.
369 $nodePath = $node->getNodePath();
370 if (\is_string($nodePath) && !isset($this->styleAttributesForNodes[$nodePath])) {
371 $this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationsBlock($normalizedOriginalStyle);
372 $this->visitedNodes[$nodePath] = $node;
373 }
374 $node->setAttribute('style', $normalizedOriginalStyle);
375 }
376 /**
377 * Parses a CSS declaration block into property name/value pairs.
378 *
379 * Example:
380 *
381 * The declaration block
382 *
383 * "color: #000; font-weight: bold;"
384 *
385 * will be parsed into the following array:
386 *
387 * "color" => "#000"
388 * "font-weight" => "bold"
389 *
390 * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
391 *
392 * @return array<string, string>
393 * the CSS declarations with the property names as array keys and the property values as array values
394 */
395 private function parseCssDeclarationsBlock(string $cssDeclarationsBlock): array
396 {
397 if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock])) {
398 return $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock];
399 }
400 $properties = [];
401 foreach (\preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock) as $declaration) {
402 /** @var array<int, string> $matches */
403 $matches = [];
404 if (!\preg_match('/^([A-Za-z\-]+)\s*:\s*(.+)$/s', \trim($declaration), $matches)) {
405 continue;
406 }
407 $propertyName = \strtolower($matches[1]);
408 $propertyValue = $matches[2];
409 $properties[$propertyName] = $propertyValue;
410 }
411 $this->caches[self::CACHE_KEY_CSS_DECLARATIONS_BLOCK][$cssDeclarationsBlock] = $properties;
412 return $properties;
413 }
414 /**
415 * Returns CSS content.
416 *
417 * @return string
418 */
419 private function getCssFromAllStyleNodes(): string
420 {
421 $styleNodes = $this->getXPath()->query('//style');
422 if ($styleNodes === \false) {
423 return '';
424 }
425 $css = '';
426 foreach ($styleNodes as $styleNode) {
427 $css .= "\n\n" . $styleNode->nodeValue;
428 $parentNode = $styleNode->parentNode;
429 if ($parentNode instanceof \DOMNode) {
430 $parentNode->removeChild($styleNode);
431 }
432 }
433 return $css;
434 }
435 /**
436 * Find the nodes that are not to be emogrified.
437 *
438 * @return array<int, \DOMElement>
439 *
440 * @throws ParseException
441 * @throws \UnexpectedValueException
442 */
443 private function getNodesToExclude(): array
444 {
445 $excludedNodes = [];
446 foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) {
447 try {
448 $matchingNodes = $this->getXPath()->query($this->getCssSelectorConverter()->toXPath($selectorToExclude));
449 foreach ($matchingNodes as $node) {
450 if (!$node instanceof \DOMElement) {
451 $path = $node->getNodePath() ?? '$node';
452 throw new \UnexpectedValueException($path . ' is not a DOMElement.', 1617975914);
453 }
454 $excludedNodes[] = $node;
455 }
456 } catch (ParseException $e) {
457 if ($this->debug) {
458 throw $e;
459 }
460 }
461 }
462 return $excludedNodes;
463 }
464 /**
465 * @return CssSelectorConverter
466 */
467 private function getCssSelectorConverter(): CssSelectorConverter
468 {
469 if (!$this->cssSelectorConverter instanceof CssSelectorConverter) {
470 $this->cssSelectorConverter = new CssSelectorConverter();
471 }
472 return $this->cssSelectorConverter;
473 }
474 /**
475 * Collates the individual rules from a `CssDocument` object.
476 *
477 * @param CssDocument $parsedCss
478 *
479 * @return array<string, array<array-key, array{
480 * media: string,
481 * selector: string,
482 * hasUnmatchablePseudo: bool,
483 * declarationsBlock: string,
484 * line: int
485 * }>>
486 * This 2-entry array has the key "inlinable" containing rules which can be inlined as `style` attributes
487 * and the key "uninlinable" containing rules which cannot. Each value is an array of sub-arrays with the
488 * following keys:
489 * - "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
490 * or an empty string if not from a `@media` rule);
491 * - "selector" (the CSS selector, e.g., "*" or "header h1");
492 * - "hasUnmatchablePseudo" (`true` if that selector contains pseudo-elements or dynamic pseudo-classes such
493 * that the declarations cannot be applied inline);
494 * - "declarationsBlock" (the semicolon-separated CSS declarations for that selector,
495 * e.g., `color: red; height: 4px;`);
496 * - "line" (the line number, e.g. 42).
497 */
498 private function collateCssRules(CssDocument $parsedCss): array
499 {
500 $matches = $parsedCss->getStyleRulesData(\array_keys($this->allowedMediaTypes));
501 $cssRules = ['inlinable' => [], 'uninlinable' => []];
502 foreach ($matches as $key => $cssRule) {
503 if (!$cssRule->hasAtLeastOneDeclaration()) {
504 continue;
505 }
506 $mediaQuery = $cssRule->getContainingAtRule();
507 $declarationsBlock = $cssRule->getDeclarationAsText();
508 foreach ($cssRule->getSelectors() as $selector) {
509 // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
510 // only allow structural pseudo-classes
511 $hasPseudoElement = \strpos($selector, '::') !== \false;
512 $hasUnmatchablePseudo = $hasPseudoElement || $this->hasUnsupportedPseudoClass($selector);
513 $parsedCssRule = [
514 'media' => $mediaQuery,
515 'selector' => $selector,
516 'hasUnmatchablePseudo' => $hasUnmatchablePseudo,
517 'declarationsBlock' => $declarationsBlock,
518 // keep track of where it appears in the file, since order is important
519 'line' => $key,
520 ];
521 $ruleType = !$cssRule->hasContainingAtRule() && !$hasUnmatchablePseudo ? 'inlinable' : 'uninlinable';
522 $cssRules[$ruleType][] = $parsedCssRule;
523 }
524 }
525 \usort(
526 $cssRules['inlinable'],
527 /**
528 * @param array{selector: string, line: int} $first
529 * @param array{selector: string, line: int} $second
530 */
531 function (array $first, array $second): int {
532 return $this->sortBySelectorPrecedence($first, $second);
533 }
534 );
535 return $cssRules;
536 }
537 /**
538 * Tests if a selector contains a pseudo-class which would mean it cannot be converted to an XPath expression for
539 * inlining CSS declarations.
540 *
541 * Any pseudo class that does not match {@see PSEUDO_CLASS_MATCHER} cannot be converted. Additionally, `...of-type`
542 * pseudo-classes cannot be converted if they are not associated with a type selector.
543 *
544 * @param string $selector
545 *
546 * @return bool
547 */
548 private function hasUnsupportedPseudoClass(string $selector): bool
549 {
550 if (\preg_match('/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\w\-]/i', $selector)) {
551 return \true;
552 }
553 if (!\preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selector)) {
554 return \false;
555 }
556 foreach (\preg_split('/' . self::COMBINATOR_MATCHER . '/', $selector) as $selectorPart) {
557 if ($this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
558 return \true;
559 }
560 }
561 return \false;
562 }
563 /**
564 * Tests if part of a selector contains an `...of-type` pseudo-class such that it cannot be converted to an XPath
565 * expression.
566 *
567 * @param string $selectorPart part of a selector which has been split up at combinators
568 *
569 * @return bool `true` if the selector part does not have a type but does have an `...of-type` pseudo-class
570 */
571 private function selectorPartHasUnsupportedOfTypePseudoClass(string $selectorPart): bool
572 {
573 if (\preg_match('/^[\w\-]/', $selectorPart)) {
574 return \false;
575 }
576 return (bool) \preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selectorPart);
577 }
578 /**
579 * @param array{selector: string, line: int} $first
580 * @param array{selector: string, line: int} $second
581 *
582 * @return int
583 */
584 private function sortBySelectorPrecedence(array $first, array $second): int
585 {
586 $precedenceOfFirst = $this->getCssSelectorPrecedence($first['selector']);
587 $precedenceOfSecond = $this->getCssSelectorPrecedence($second['selector']);
588 // We want these sorted in ascending order so selectors with lesser precedence get processed first and
589 // selectors with greater precedence get sorted last.
590 $precedenceForEquals = $first['line'] < $second['line'] ? -1 : 1;
591 $precedenceForNotEquals = $precedenceOfFirst < $precedenceOfSecond ? -1 : 1;
592 return $precedenceOfFirst === $precedenceOfSecond ? $precedenceForEquals : $precedenceForNotEquals;
593 }
594 /**
595 * @param string $selector
596 *
597 * @return int
598 */
599 private function getCssSelectorPrecedence(string $selector): int
600 {
601 $selectorKey = \md5($selector);
602 if (isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
603 return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
604 }
605 $precedence = 0;
606 foreach ($this->selectorPrecedenceMatchers as $matcher => $value) {
607 if (\trim($selector) === '') {
608 break;
609 }
610 $number = 0;
611 $selector = \preg_replace('/' . $matcher . '\w+/', '', $selector, -1, $number);
612 $precedence += $value * (int) $number;
613 }
614 $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
615 return $precedence;
616 }
617 /**
618 * Copies $cssRule into the style attribute of $node.
619 *
620 * Note: This method does not check whether $cssRule matches $node.
621 *
622 * @param \DOMElement $node
623 * @param array{
624 * media: string,
625 * selector: string,
626 * hasUnmatchablePseudo: bool,
627 * declarationsBlock: string,
628 * line: int
629 * } $cssRule
630 */
631 private function copyInlinableCssToStyleAttribute(\DOMElement $node, array $cssRule): void
632 {
633 $declarationsBlock = $cssRule['declarationsBlock'];
634 $newStyleDeclarations = $this->parseCssDeclarationsBlock($declarationsBlock);
635 if ($newStyleDeclarations === []) {
636 return;
637 }
638 // if it has a style attribute, get it, process it, and append (overwrite) new stuff
639 if ($node->hasAttribute('style')) {
640 // break it up into an associative array
641 $oldStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
642 } else {
643 $oldStyleDeclarations = [];
644 }
645 $node->setAttribute('style', $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations));
646 }
647 /**
648 * This method merges old or existing name/value array with new name/value array
649 * and then generates a string of the combined style suitable for placing inline.
650 * This becomes the single point for CSS string generation allowing for consistent
651 * CSS output no matter where the CSS originally came from.
652 *
653 * @param array<string, string> $oldStyles
654 * @param array<string, string> $newStyles
655 *
656 * @return string
657 */
658 private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles): string
659 {
660 $cacheKey = \serialize([$oldStyles, $newStyles]);
661 if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
662 return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
663 }
664 // Unset the overridden styles to preserve order, important if shorthand and individual properties are mixed
665 foreach ($oldStyles as $attributeName => $attributeValue) {
666 if (!isset($newStyles[$attributeName])) {
667 continue;
668 }
669 $newAttributeValue = $newStyles[$attributeName];
670 if ($this->attributeValueIsImportant($attributeValue) && !$this->attributeValueIsImportant($newAttributeValue)) {
671 unset($newStyles[$attributeName]);
672 } else {
673 unset($oldStyles[$attributeName]);
674 }
675 }
676 $combinedStyles = \array_merge($oldStyles, $newStyles);
677 $style = '';
678 foreach ($combinedStyles as $attributeName => $attributeValue) {
679 $style .= \strtolower(\trim($attributeName)) . ': ' . \trim($attributeValue) . '; ';
680 }
681 $trimmedStyle = \rtrim($style);
682 $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
683 return $trimmedStyle;
684 }
685 /**
686 * Checks whether $attributeValue is marked as !important.
687 *
688 * @param string $attributeValue
689 *
690 * @return bool
691 */
692 private function attributeValueIsImportant(string $attributeValue): bool
693 {
694 return (bool) \preg_match('/!\s*+important$/i', $attributeValue);
695 }
696 /**
697 * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
698 */
699 private function fillStyleAttributesWithMergedStyles(): void
700 {
701 foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
702 $node = $this->visitedNodes[$nodePath];
703 $currentStyleAttributes = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
704 $node->setAttribute('style', $this->generateStyleStringFromDeclarationsArrays($currentStyleAttributes, $styleAttributesForNode));
705 }
706 }
707 /**
708 * Searches for all nodes with a style attribute and removes the "!important" annotations out of
709 * the inline style declarations, eventually by rearranging declarations.
710 *
711 * @throws \RuntimeException
712 */
713 private function removeImportantAnnotationFromAllInlineStyles(): void
714 {
715 /** @var \DOMElement $node */
716 foreach ($this->getAllNodesWithStyleAttribute() as $node) {
717 $this->removeImportantAnnotationFromNodeInlineStyle($node);
718 }
719 }
720 /**
721 * Removes the "!important" annotations out of the inline style declarations,
722 * eventually by rearranging declarations.
723 * Rearranging needed when !important shorthand properties are followed by some of their
724 * not !important expanded-version properties.
725 * For example "font: 12px serif !important; font-size: 13px;" must be reordered
726 * to "font-size: 13px; font: 12px serif;" in order to remain correct.
727 *
728 * @param \DOMElement $node
729 *
730 * @throws \RuntimeException
731 */
732 private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node): void
733 {
734 $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
735 /** @var array<string, string> $regularStyleDeclarations */
736 $regularStyleDeclarations = [];
737 /** @var array<string, string> $importantStyleDeclarations */
738 $importantStyleDeclarations = [];
739 foreach ($inlineStyleDeclarations as $property => $value) {
740 if ($this->attributeValueIsImportant($value)) {
741 $importantStyleDeclarations[$property] = $this->pregReplace('/\s*+!\s*+important$/i', '', $value);
742 } else {
743 $regularStyleDeclarations[$property] = $value;
744 }
745 }
746 $inlineStyleDeclarationsInNewOrder = \array_merge($regularStyleDeclarations, $importantStyleDeclarations);
747 $node->setAttribute('style', $this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder));
748 }
749 /**
750 * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
751 *
752 * @param array<string, string> $styleDeclarations
753 *
754 * @return string
755 */
756 private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations): string
757 {
758 return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
759 }
760 /**
761 * Determines which of `$cssRules` actually apply to `$this->domDocument`, and sets them in
762 * `$this->matchingUninlinableCssRules`.
763 *
764 * @param array<array-key, array{
765 * media: string,
766 * selector: string,
767 * hasUnmatchablePseudo: bool,
768 * declarationsBlock: string,
769 * line: int
770 * }> $cssRules
771 * the "uninlinable" array of CSS rules returned by `collateCssRules`
772 */
773 private function determineMatchingUninlinableCssRules(array $cssRules): void
774 {
775 $this->matchingUninlinableCssRules = \array_filter($cssRules, function (array $cssRule): bool {
776 return $this->existsMatchForSelectorInCssRule($cssRule);
777 });
778 }
779 /**
780 * Checks whether there is at least one matching element for the CSS selector contained in the `selector` element
781 * of the provided CSS rule.
782 *
783 * Any dynamic pseudo-classes will be assumed to apply. If the selector matches a pseudo-element,
784 * it will test for a match with its originating element.
785 *
786 * @param array{
787 * media: string,
788 * selector: string,
789 * hasUnmatchablePseudo: bool,
790 * declarationsBlock: string,
791 * line: int
792 * } $cssRule
793 *
794 * @return bool
795 *
796 * @throws ParseException
797 */
798 private function existsMatchForSelectorInCssRule(array $cssRule): bool
799 {
800 $selector = $cssRule['selector'];
801 if ($cssRule['hasUnmatchablePseudo']) {
802 $selector = $this->removeUnmatchablePseudoComponents($selector);
803 }
804 return $this->existsMatchForCssSelector($selector);
805 }
806 /**
807 * Checks whether there is at least one matching element for $cssSelector.
808 * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
809 * just not implemented/recognized yet by Emogrifier).
810 *
811 * @param string $cssSelector
812 *
813 * @return bool
814 *
815 * @throws ParseException
816 */
817 private function existsMatchForCssSelector(string $cssSelector): bool
818 {
819 try {
820 $nodesMatchingSelector = $this->getXPath()->query($this->getCssSelectorConverter()->toXPath($cssSelector));
821 } catch (ParseException $e) {
822 if ($this->debug) {
823 throw $e;
824 }
825 return \true;
826 }
827 return $nodesMatchingSelector !== \false && $nodesMatchingSelector->length !== 0;
828 }
829 /**
830 * Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary.
831 * If such a pseudo-component is within the argument of `:not`, the entire `:not` component is removed or replaced.
832 *
833 * @param string $selector
834 *
835 * @return string
836 * selector which will match the relevant DOM elements if the pseudo-classes are assumed to apply, or in the
837 * case of pseudo-elements will match their originating element
838 */
839 private function removeUnmatchablePseudoComponents(string $selector): string
840 {
841 // The regex allows nested brackets via `(?2)`.
842 // A space is temporarily prepended because the callback can't determine if the match was at the very start.
843 $selectorWithoutNots = \ltrim(\preg_replace_callback(
844 '/([\s>+~]?+):not(\([^()]*+(?:(?2)[^()]*+)*+\))/i',
845 /** @param array<array-key, string> $matches */
846 function (array $matches): string {
847 return $this->replaceUnmatchableNotComponent($matches);
848 },
849 ' ' . $selector
850 ));
851 $selectorWithoutUnmatchablePseudoComponents = $this->removeSelectorComponents(':(?!' . self::PSEUDO_CLASS_MATCHER . '):?+[\w\-]++(?:\([^\)]*+\))?+', $selectorWithoutNots);
852 if (!\preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selectorWithoutUnmatchablePseudoComponents)) {
853 return $selectorWithoutUnmatchablePseudoComponents;
854 }
855 return \implode('', \array_map(function (string $selectorPart): string {
856 return $this->removeUnsupportedOfTypePseudoClasses($selectorPart);
857 }, \preg_split('/(' . self::COMBINATOR_MATCHER . ')/', $selectorWithoutUnmatchablePseudoComponents, -1, \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY)));
858 }
859 /**
860 * Helps `removeUnmatchablePseudoComponents()` replace or remove a selector `:not(...)` component if its argument
861 * contains pseudo-elements or dynamic pseudo-classes.
862 *
863 * @param array<array-key, string> $matches array of elements matched by the regular expression
864 *
865 * @return string
866 * the full match if there were no unmatchable pseudo components within; otherwise, any preceding combinator
867 * followed by "*", or an empty string if there was no preceding combinator
868 */
869 private function replaceUnmatchableNotComponent(array $matches): string
870 {
871 [$notComponentWithAnyPrecedingCombinator, $anyPrecedingCombinator, $notArgumentInBrackets] = $matches;
872 if ($this->hasUnsupportedPseudoClass($notArgumentInBrackets)) {
873 return $anyPrecedingCombinator !== '' ? $anyPrecedingCombinator . '*' : '';
874 }
875 return $notComponentWithAnyPrecedingCombinator;
876 }
877 /**
878 * Removes components from a CSS selector, replacing them with "*" if necessary.
879 *
880 * @param string $matcher regular expression part to match the components to remove
881 * @param string $selector
882 *
883 * @return string
884 * selector which will match the relevant DOM elements if the removed components are assumed to apply (or in
885 * the case of pseudo-elements will match their originating element)
886 */
887 private function removeSelectorComponents(string $matcher, string $selector): string
888 {
889 return \preg_replace(['/([\s>+~]|^)' . $matcher . '/i', '/' . $matcher . '/i'], ['$1*', ''], $selector);
890 }
891 /**
892 * Removes any `...-of-type` pseudo-classes from part of a CSS selector, if it does not have a type, replacing them
893 * with "*" if necessary.
894 *
895 * @param string $selectorPart part of a selector which has been split up at combinators
896 *
897 * @return string
898 * selector part which will match the relevant DOM elements if the pseudo-classes are assumed to apply
899 */
900 private function removeUnsupportedOfTypePseudoClasses(string $selectorPart): string
901 {
902 if (!$this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
903 return $selectorPart;
904 }
905 return $this->removeSelectorComponents(':(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')(?:\([^\)]*+\))?+', $selectorPart);
906 }
907 /**
908 * Applies `$this->matchingUninlinableCssRules` to `$this->domDocument` by placing them as CSS in a `<style>`
909 * element.
910 * If there are no uninlinable CSS rules to copy there, a `<style>` element will be created containing only the
911 * applicable at-rules from `$parsedCss`.
912 * If there are none of either, an empty `<style>` element will not be created.
913 *
914 * @param CssDocument $parsedCss
915 * This may contain various at-rules whose content `CssInliner` does not currently attempt to inline or
916 * process in any other way, such as `@import`, `@font-face`, `@keyframes`, etc., and which should precede
917 * the processed but found-to-be-uninlinable CSS placed in the `<style>` element.
918 * Note that `CssInliner` processes `@media` rules so that they can be ordered correctly with respect to
919 * other uninlinable rules; these will not be duplicated from `$parsedCss`.
920 */
921 private function copyUninlinableCssToStyleNode(CssDocument $parsedCss): void
922 {
923 $css = $parsedCss->renderNonConditionalAtRules();
924 // avoid including unneeded class dependency if there are no rules
925 if ($this->getMatchingUninlinableCssRules() !== []) {
926 $cssConcatenator = new CssConcatenator();
927 foreach ($this->getMatchingUninlinableCssRules() as $cssRule) {
928 $cssConcatenator->append([$cssRule['selector']], $cssRule['declarationsBlock'], $cssRule['media']);
929 }
930 $css .= $cssConcatenator->getCss();
931 }
932 // avoid adding empty style element
933 if ($css !== '') {
934 $this->addStyleElementToDocument($css);
935 }
936 }
937 /**
938 * Adds a style element with $css to $this->domDocument.
939 *
940 * This method is protected to allow overriding.
941 *
942 * @see https://github.com/MyIntervals/emogrifier/issues/103
943 *
944 * @param string $css
945 */
946 protected function addStyleElementToDocument(string $css): void
947 {
948 $domDocument = $this->getDomDocument();
949 $styleElement = $domDocument->createElement('style', $css);
950 $styleAttribute = $domDocument->createAttribute('type');
951 $styleAttribute->value = 'text/css';
952 $styleElement->appendChild($styleAttribute);
953 $headElement = $this->getHeadElement();
954 $headElement->appendChild($styleElement);
955 }
956 /**
957 * Returns the HEAD element.
958 *
959 * This method assumes that there always is a HEAD element.
960 *
961 * @return \DOMElement
962 *
963 * @throws \UnexpectedValueException
964 */
965 private function getHeadElement(): \DOMElement
966 {
967 $node = $this->getDomDocument()->getElementsByTagName('head')->item(0);
968 if (!$node instanceof \DOMElement) {
969 throw new \UnexpectedValueException('There is no HEAD element. This should never happen.', 1617923227);
970 }
971 return $node;
972 }
973 /**
974 * Wraps `preg_replace`. If an error occurs (which is highly unlikely), either it is logged and the original
975 * `$subject` is returned, or in debug mode an exception is thrown.
976 *
977 * This method only supports strings, not arrays of strings.
978 *
979 * @param string $pattern
980 * @param string $replacement
981 * @param string $subject
982 *
983 * @return string
984 *
985 * @throws \RuntimeException
986 */
987 private function pregReplace(string $pattern, string $replacement, string $subject): string
988 {
989 $result = \preg_replace($pattern, $replacement, $subject);
990 if (!\is_string($result)) {
991 $this->logOrThrowPregLastError();
992 $result = $subject;
993 }
994 return $result;
995 }
996 /**
997 * Obtains the name of the error constant for `preg_last_error` (based on code posted at
998 * {@see https://www.php.net/manual/en/function.preg-last-error.php#124124}) and puts it into an error message
999 * which is either passed to `trigger_error` (in non-debug mode) or an exception which is thrown (in debug mode).
1000 *
1001 * @throws \RuntimeException
1002 */
1003 private function logOrThrowPregLastError(): void
1004 {
1005 $pcreConstants = \get_defined_constants(\true)['pcre'];
1006 $pcreErrorConstantNames = \array_flip(\array_filter($pcreConstants, static function (string $key): bool {
1007 return \substr($key, -6) === '_ERROR';
1008 }, \ARRAY_FILTER_USE_KEY));
1009 $pregLastError = \preg_last_error();
1010 $message = 'PCRE regex execution error `' . (string) ($pcreErrorConstantNames[$pregLastError] ?? $pregLastError) . '`';
1011 if ($this->debug) {
1012 throw new \RuntimeException($message, 1592870147);
1013 }
1014 \trigger_error($message);
1015 }
1016 }
1017