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

MyIntervals / emogrifier / 19615242042

23 Nov 2025 06:09PM UTC coverage: 96.213% (-0.003%) from 96.216%
19615242042

Pull #1517

github

web-flow
Merge b775ca529 into 37e1dbe49
Pull Request #1517: [TASK] Add PHPStan rules for Safe-PHP

7 of 10 new or added lines in 3 files covered. (70.0%)

1 existing line in 1 file now uncovered.

813 of 845 relevant lines covered (96.21%)

262.46 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
use function Safe\preg_match;
9
use function Safe\preg_replace_callback;
10

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

23
    /**
24
     * Replaces all CSS custom property references in inline style attributes with their corresponding values where
25
     * defined in inline style attributes (either from the element itself or the nearest ancestor).
26
     *
27
     * @return $this
28
     *
29
     * @throws \UnexpectedValueException
30
     */
31
    public function evaluateVariables(): self
29✔
32
    {
33
        return $this->evaluateVariablesInElementAndDescendants($this->getHtmlElement(), []);
29✔
34
    }
35

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

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

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

74
        return $variableValue;
28✔
75
    }
76

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

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

134
        return $result;
28✔
135
    }
136

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

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

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

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

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

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

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

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

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