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

MyIntervals / emogrifier / 19446892823

17 Nov 2025 10:35PM UTC coverage: 96.425% (-0.09%) from 96.518%
19446892823

push

github

web-flow
[TASK] Use safe preg functions in `CssVariableEvaluator` (#1503)

Part of #919

5 of 6 new or added lines in 1 file covered. (83.33%)

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
    private function getPropertyValueReplacement(array $matches): string
28✔
59
    {
60
        $variableName = $matches[1];
28✔
61

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

75
        return $variableValue;
28✔
76
    }
77

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

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

135
        return $result;
28✔
136
    }
137

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

157
        return $substitutionsMade ? $result : null;
28✔
158
    }
159

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

173
        return \implode('; ', $declarationStrings) . ';';
26✔
174
    }
175

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

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

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

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

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

© 2025 Coveralls, Inc