• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Build has been canceled!

MyIntervals / emogrifier / 21182845992

20 Jan 2026 06:27PM UTC coverage: 96.226% (+0.009%) from 96.217%
21182845992

Pull #1560

github

web-flow
Merge ee9671d48 into 6421c2de0
Pull Request #1560: [BUGFIX] Avoid recursive method call in `CssVariableEvaluator`

18 of 18 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

816 of 848 relevant lines covered (96.23%)

263.66 hits per line

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

98.51
/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
        $elementsToEvaluate = [['element' => $this->getHtmlElement(), 'ancestorDefinitions' => []]];
33✔
35

36
        while (($currentElementData = \array_pop($elementsToEvaluate)) !== null) {
33✔
37
            $currentElement = $currentElementData['element'];
33✔
38
            $currentAncestorDefinitions = $currentElementData['ancestorDefinitions'];
33✔
39

40
            $style = $currentElement->getAttribute('style');
33✔
41

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

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

57
            foreach ($currentElement->childNodes as $child) {
33✔
58
                if ($child instanceof \DOMElement) {
33✔
59
                    $elementsToEvaluate[] = ['element' => $child, 'ancestorDefinitions' => $variableDefinitions];
33✔
60
                }
61
            }
62
        }
63

64
        return $this;
33✔
65
    }
66

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

83
    /**
84
     * Callback function for {@see replaceVariablesInPropertyValue} performing regular expression replacement.
85
     *
86
     * @param array<int, string> $matches
87
     */
88
    private function getPropertyValueReplacement(array $matches): string
28✔
89
    {
90
        $variableName = $matches[1];
28✔
91

92
        if (isset($this->currentVariableDefinitions[$variableName])) {
28✔
93
            $variableValue = $this->currentVariableDefinitions[$variableName];
13✔
94
        } else {
95
            $fallbackValueSeparator = $matches[2] ?? '';
16✔
96
            if ($fallbackValueSeparator !== '') {
16✔
97
                $fallbackValue = $matches[3];
14✔
98
                // The fallback value may use other CSS variables, so recurse
99
                $variableValue = $this->replaceVariablesInPropertyValue($fallbackValue);
14✔
100
            } else {
101
                $variableValue = $matches[0];
3✔
102
            }
103
        }
104

105
        return $variableValue;
28✔
106
    }
107

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

156
        $callable = \Closure::fromCallable([$this, 'getPropertyValueReplacement']);
28✔
157
        if (\function_exists('Safe\\preg_replace_callback')) {
28✔
158
            $result = preg_replace_callback($pattern, $callable, $propertyValue);
28✔
159
        } else {
160
            // @phpstan-ignore-next-line The safe version is only available in "thecodingmachine/safe" for PHP >= 8.1.
UNCOV
161
            $result = \preg_replace_callback($pattern, $callable, $propertyValue);
×
162
        }
163
        \assert(\is_string($result));
28✔
164

165
        return $result;
28✔
166
    }
167

168
    /**
169
     * @param array<non-empty-string, string> $declarations
170
     *
171
     * @return array<non-empty-string, string>|null `null` is returned if no substitutions were made.
172
     */
173
    private function replaceVariablesInDeclarations(array $declarations): ?array
28✔
174
    {
175
        $substitutionsMade = false;
28✔
176
        $result = \array_map(
28✔
177
            function (string $propertyValue) use (&$substitutionsMade): string {
28✔
178
                $newPropertyValue = $this->replaceVariablesInPropertyValue($propertyValue);
28✔
179
                if ($newPropertyValue !== $propertyValue) {
28✔
180
                    $substitutionsMade = true;
26✔
181
                }
182
                return $newPropertyValue;
28✔
183
            },
28✔
184
            $declarations
28✔
185
        );
28✔
186

187
        return $substitutionsMade ? $result : null;
28✔
188
    }
189

190
    /**
191
     * @param array<non-empty-string, string> $declarations
192
     */
193
    private function getDeclarationsAsString(array $declarations): string
26✔
194
    {
195
        $declarationStrings = \array_map(
26✔
196
            static function (string $key, string $value): string {
26✔
197
                return $key . ': ' . $value;
26✔
198
            },
26✔
199
            \array_keys($declarations),
26✔
200
            \array_values($declarations)
26✔
201
        );
26✔
202

203
        return \implode('; ', $declarationStrings) . ';';
26✔
204
    }
205
}
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