• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MyIntervals / emogrifier / 26132382955

19 May 2026 11:50PM UTC coverage: 98.252%. Remained the same
26132382955

Pull #1628

github

web-flow
Merge 7f65b194e into 6a54e8954
Pull Request #1628: [BUGFIX] Use `@phpstan-ignore` not `@phpstan-ignore-next-line`

843 of 858 relevant lines covered (98.25%)

260.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.57
/src/HtmlProcessor/CssVariableEvaluator.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Pelago\Emogrifier\HtmlProcessor;
6

7
use Pelago\Emogrifier\Utilities\DeclarationBlockParser;
8

9
use function Safe\preg_match;
10
use function Safe\preg_replace_callback;
11

12
/**
13
 * This class can evaluate CSS custom properties that are defined and used in inline style attributes.
14
 */
15
final class CssVariableEvaluator extends AbstractHtmlProcessor
16
{
17
    /**
18
     * temporary collection used by {@see replaceVariablesInDeclarations} and callee methods
19
     *
20
     * @var array<non-empty-string, string>
21
     */
22
    private $currentVariableDefinitions = [];
23

24
    /**
25
     * Replaces all CSS custom property references in inline style attributes with their corresponding values where
26
     * defined in inline style attributes (either from the element itself or the nearest ancestor).
27
     *
28
     * @return $this
29
     *
30
     * @throws \UnexpectedValueException
31
     */
32
    public function evaluateVariables(): self
33✔
33
    {
34
        /**
35
         * @var list<array{element: \DOMElement, ancestorDefinitions: array<non-empty-string, string>}>
36
         *      $elementsToEvaluate
37
         */
38
        $elementsToEvaluate = [['element' => $this->getHtmlElement(), 'ancestorDefinitions' => []]];
33✔
39

40
        while (($currentElementData = \array_pop($elementsToEvaluate)) !== null) {
33✔
41
            $currentElement = $currentElementData['element'];
33✔
42
            $currentAncestorDefinitions = $currentElementData['ancestorDefinitions'];
33✔
43

44
            $style = $currentElement->getAttribute('style');
33✔
45

46
            // Avoid parsing declarations if none use or define a variable
47
            if (preg_match('/(?<![\\w\\-])--[\\w\\-]/', $style) !== 0) {
33✔
48
                $declarations = DeclarationBlockParser::parse($style);
28✔
49
                $variableDefinitions
28✔
50
                    = $this->getVariableDefinitionsFromDeclarations($declarations) + $currentAncestorDefinitions;
28✔
51
                $this->currentVariableDefinitions = $variableDefinitions;
28✔
52

53
                $newDeclarations = $this->replaceVariablesInDeclarations($declarations);
28✔
54
                if ($newDeclarations !== null) {
28✔
55
                    $currentElement->setAttribute('style', $this->getDeclarationsAsString($newDeclarations));
26✔
56
                }
57
            } else {
58
                $variableDefinitions = $currentAncestorDefinitions;
33✔
59
            }
60

61
            foreach ($currentElement->childNodes as $child) {
33✔
62
                if ($child instanceof \DOMElement) {
33✔
63
                    $elementsToEvaluate[] = ['element' => $child, 'ancestorDefinitions' => $variableDefinitions];
33✔
64
                }
65
            }
66
        }
67

68
        return $this;
33✔
69
    }
70

71
    /**
72
     * @param array<non-empty-string, string> $declarations
73
     *
74
     * @return array<non-empty-string, string>
75
     */
76
    private function getVariableDefinitionsFromDeclarations(array $declarations): array
28✔
77
    {
78
        return \array_filter(
28✔
79
            $declarations,
28✔
80
            static function (string $key): bool {
28✔
81
                return \substr($key, 0, 2) === '--';
28✔
82
            },
28✔
83
            ARRAY_FILTER_USE_KEY,
28✔
84
        );
28✔
85
    }
86

87
    /**
88
     * Callback function for {@see replaceVariablesInPropertyValue} performing regular expression replacement.
89
     *
90
     * @param array<mixed> $matches
91
     *        This will actaully be `non-empty-list<string>` but the type annotation cannot be any tighter due to use of
92
     *        `Safe\preg_replace_callback()` which does not precisely type the `$callback` parameter.
93
     */
94
    private function getPropertyValueReplacement(array $matches): string
28✔
95
    {
96
        \assert(\is_string($matches[1] ?? null));
28✔
97
        $variableName = $matches[1];
28✔
98

99
        if (isset($this->currentVariableDefinitions[$variableName])) {
28✔
100
            $variableValue = $this->currentVariableDefinitions[$variableName];
13✔
101
        } else {
102
            $fallbackValueSeparator = $matches[2] ?? '';
16✔
103
            if ($fallbackValueSeparator !== '') {
16✔
104
                \assert(\is_string($matches[3] ?? null));
14✔
105
                $fallbackValue = $matches[3];
14✔
106
                // The fallback value may use other CSS variables, so recurse
107
                $variableValue = $this->replaceVariablesInPropertyValue($fallbackValue);
14✔
108
            } else {
109
                \assert(\is_string($matches[0] ?? null));
3✔
110
                $variableValue = $matches[0];
3✔
111
            }
112
        }
113

114
        return $variableValue;
28✔
115
    }
116

117
    /**
118
     * Regular expression based on {@see https://stackoverflow.com/a/54143883/2511031 a StackOverflow answer}.
119
     */
120
    private function replaceVariablesInPropertyValue(string $propertyValue): string
28✔
121
    {
122
        $pattern = '/
28✔
123
                var\\(
124
                    \\s*+
125
                    # capture variable name including `--` prefix
126
                    (
127
                        --[^\\s\\),]++
128
                    )
129
                    \\s*+
130
                    # capture optional fallback value
131
                    (?:
132
                        # capture separator to confirm there is a fallback value
133
                        (,)\\s*
134
                        # begin capture with named group that can be used recursively
135
                        (?<recursable>
136
                            # begin named group to match sequence without parentheses, except in strings
137
                            (?<noparentheses>
138
                                # repeated zero or more times:
139
                                (?:
140
                                    # sequence without parentheses or quotes
141
                                    [^\\(\\)\'"]++
142
                                    |
143
                                    # string in double quotes
144
                                    "(?>[^"\\\\]++|\\\\.)*"
145
                                    |
146
                                    # string in single quotes
147
                                    \'(?>[^\'\\\\]++|\\\\.)*\'
148
                                )*+
149
                            )
150
                            # repeated zero or more times:
151
                            (?:
152
                                # sequence in parentheses
153
                                \\(
154
                                    # using the named recursable pattern
155
                                    (?&recursable)
156
                                \\)
157
                                # sequence without parentheses, except in strings
158
                                (?&noparentheses)
159
                            )*+
160
                        )
161
                    )?+
162
                \\)
163
            /x';
28✔
164

165
        $callable = \Closure::fromCallable([$this, 'getPropertyValueReplacement']);
28✔
166
        if (\function_exists('Safe\\preg_replace_callback')) {
28✔
167
            $result = preg_replace_callback($pattern, $callable, $propertyValue);
28✔
168
        } else {
169
            // The safe version is only available in "thecodingmachine/safe" for PHP >= 8.1.
170
            // @phpstan-ignore theCodingMachineSafe.function
171
            $result = \preg_replace_callback($pattern, $callable, $propertyValue);
×
172
        }
173
        \assert(\is_string($result));
28✔
174

175
        return $result;
28✔
176
    }
177

178
    /**
179
     * @param array<non-empty-string, string> $declarations
180
     *
181
     * @return array<non-empty-string, string>|null `null` is returned if no substitutions were made.
182
     */
183
    private function replaceVariablesInDeclarations(array $declarations): ?array
28✔
184
    {
185
        $substitutionsMade = false;
28✔
186
        $result = \array_map(
28✔
187
            function (string $propertyValue) use (&$substitutionsMade): string {
28✔
188
                $newPropertyValue = $this->replaceVariablesInPropertyValue($propertyValue);
28✔
189
                if ($newPropertyValue !== $propertyValue) {
28✔
190
                    $substitutionsMade = true;
26✔
191
                }
192
                return $newPropertyValue;
28✔
193
            },
28✔
194
            $declarations,
28✔
195
        );
28✔
196

197
        return $substitutionsMade ? $result : null;
28✔
198
    }
199

200
    /**
201
     * @param array<non-empty-string, string> $declarations
202
     */
203
    private function getDeclarationsAsString(array $declarations): string
26✔
204
    {
205
        $declarationStrings = \array_map(
26✔
206
            static function (string $key, string $value): string {
26✔
207
                return $key . ': ' . $value;
26✔
208
            },
26✔
209
            \array_keys($declarations),
26✔
210
            \array_values($declarations),
26✔
211
        );
26✔
212

213
        return \implode('; ', $declarationStrings) . ';';
26✔
214
    }
215
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc