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

MyIntervals / emogrifier / 19426853529

17 Nov 2025 10:44AM UTC coverage: 96.425% (-0.09%) from 96.518%
19426853529

Pull #1503

github

web-flow
Merge 2e1f932ec into 84b03c746
Pull Request #1503: [TASK] Use safe preg functions in `CssVariableEvaluator`

7 of 8 new or added lines in 1 file covered. (87.5%)

890 of 923 relevant lines covered (96.42%)

245.09 hits per line

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

98.46
/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
29✔
33
    {
34
        return $this->evaluateVariablesInElementAndDescendants($this->getHtmlElement(), []);
29✔
35
    }
36

37
    /**
38
     * @param array<non-empty-string, string> $declarations
39
     *
40
     * @return array<non-empty-string, string>
41
     */
42
    private function getVariableDefinitionsFromDeclarations(array $declarations): array
28✔
43
    {
44
        return \array_filter(
28✔
45
            $declarations,
28✔
46
            static function (string $key): bool {
28✔
47
                return \substr($key, 0, 2) === '--';
28✔
48
            },
28✔
49
            ARRAY_FILTER_USE_KEY
28✔
50
        );
28✔
51
    }
52

53
    /**
54
     * Callback function for {@see replaceVariablesInPropertyValue} performing regular expression replacement.
55
     *
56
     * @param array<int, string> $matches
57
     *
58
     * @internal
59
     */
60
    public function getPropertyValueReplacement(array $matches): string
28✔
61
    {
62
        $variableName = $matches[1];
28✔
63

64
        if (isset($this->currentVariableDefinitions[$variableName])) {
28✔
65
            $variableValue = $this->currentVariableDefinitions[$variableName];
13✔
66
        } else {
67
            $fallbackValueSeparator = $matches[2] ?? '';
16✔
68
            if ($fallbackValueSeparator !== '') {
16✔
69
                $fallbackValue = $matches[3];
14✔
70
                // The fallback value may use other CSS variables, so recurse
71
                $variableValue = $this->replaceVariablesInPropertyValue($fallbackValue);
14✔
72
            } else {
73
                $variableValue = $matches[0];
3✔
74
            }
75
        }
76

77
        return $variableValue;
28✔
78
    }
79

80
    /**
81
     * Regular expression based on {@see https://stackoverflow.com/a/54143883/2511031 a StackOverflow answer}.
82
     */
83
    private function replaceVariablesInPropertyValue(string $propertyValue): string
28✔
84
    {
85
        $pattern = '/
28✔
86
                var\\(
87
                    \\s*+
88
                    # capture variable name including `--` prefix
89
                    (
90
                        --[^\\s\\),]++
91
                    )
92
                    \\s*+
93
                    # capture optional fallback value
94
                    (?:
95
                        # capture separator to confirm there is a fallback value
96
                        (,)\\s*
97
                        # begin capture with named group that can be used recursively
98
                        (?<recursable>
99
                            # begin named group to match sequence without parentheses, except in strings
100
                            (?<noparentheses>
101
                                # repeated zero or more times:
102
                                (?:
103
                                    # sequence without parentheses or quotes
104
                                    [^\\(\\)\'"]++
105
                                    |
106
                                    # string in double quotes
107
                                    "(?>[^"\\\\]++|\\\\.)*"
108
                                    |
109
                                    # string in single quotes
110
                                    \'(?>[^\'\\\\]++|\\\\.)*\'
111
                                )*+
112
                            )
113
                            # repeated zero or more times:
114
                            (?:
115
                                # sequence in parentheses
116
                                \\(
117
                                    # using the named recursable pattern
118
                                    (?&recursable)
119
                                \\)
120
                                # sequence without parentheses, except in strings
121
                                (?&noparentheses)
122
                            )*+
123
                        )
124
                    )?+
125
                \\)
126
            /x';
28✔
127

128
        $callable = [$this, 'getPropertyValueReplacement'];
28✔
129
        // The safe version is only available in "thecodingmachine/safe" for PHP >= 8.1.
130
        if (\function_exists('Safe\\preg_replace_callback')) {
28✔
131
            $result = preg_replace_callback($pattern, $callable, $propertyValue);
28✔
132
        } else {
NEW
133
            $result = \preg_replace_callback($pattern, $callable, $propertyValue);
×
134
        }
135
        \assert(\is_string($result));
28✔
136

137
        return $result;
28✔
138
    }
139

140
    /**
141
     * @param array<non-empty-string, string> $declarations
142
     *
143
     * @return array<non-empty-string, string>|null `null` is returned if no substitutions were made.
144
     */
145
    private function replaceVariablesInDeclarations(array $declarations): ?array
28✔
146
    {
147
        $substitutionsMade = false;
28✔
148
        $result = \array_map(
28✔
149
            function (string $propertyValue) use (&$substitutionsMade): string {
28✔
150
                $newPropertyValue = $this->replaceVariablesInPropertyValue($propertyValue);
28✔
151
                if ($newPropertyValue !== $propertyValue) {
28✔
152
                    $substitutionsMade = true;
26✔
153
                }
154
                return $newPropertyValue;
28✔
155
            },
28✔
156
            $declarations
28✔
157
        );
28✔
158

159
        return $substitutionsMade ? $result : null;
28✔
160
    }
161

162
    /**
163
     * @param array<non-empty-string, string> $declarations
164
     */
165
    private function getDeclarationsAsString(array $declarations): string
26✔
166
    {
167
        $declarationStrings = \array_map(
26✔
168
            static function (string $key, string $value): string {
26✔
169
                return $key . ': ' . $value;
26✔
170
            },
26✔
171
            \array_keys($declarations),
26✔
172
            \array_values($declarations)
26✔
173
        );
26✔
174

175
        return \implode('; ', $declarationStrings) . ';';
26✔
176
    }
177

178
    /**
179
     * @param array<non-empty-string, string> $ancestorVariableDefinitions
180
     *
181
     * @return $this
182
     */
183
    private function evaluateVariablesInElementAndDescendants(
29✔
184
        \DOMElement $element,
185
        array $ancestorVariableDefinitions
186
    ): self {
187
        $style = $element->getAttribute('style');
29✔
188

189
        // Avoid parsing declarations if none use or define a variable
190
        if (preg_match('/(?<![\\w\\-])--[\\w\\-]/', $style) !== 0) {
29✔
191
            $declarations = (new DeclarationBlockParser())->parse($style);
28✔
192
            $variableDefinitions =
28✔
193
                $this->getVariableDefinitionsFromDeclarations($declarations) + $ancestorVariableDefinitions;
28✔
194
            $this->currentVariableDefinitions = $variableDefinitions;
28✔
195

196
            $newDeclarations = $this->replaceVariablesInDeclarations($declarations);
28✔
197
            if ($newDeclarations !== null) {
28✔
198
                $element->setAttribute('style', $this->getDeclarationsAsString($newDeclarations));
26✔
199
            }
200
        } else {
201
            $variableDefinitions = $ancestorVariableDefinitions;
29✔
202
        }
203

204
        foreach ($element->childNodes as $child) {
29✔
205
            if ($child instanceof \DOMElement) {
29✔
206
                $this->evaluateVariablesInElementAndDescendants($child, $variableDefinitions);
29✔
207
            }
208
        }
209

210
        return $this;
29✔
211
    }
212
}
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