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

MyIntervals / PHP-CSS-Parser / 13000630813

27 Jan 2025 11:52PM UTC coverage: 44.974% (+0.9%) from 44.039%
13000630813

Pull #840

github

web-flow
Merge ae3531656 into c6cfc2e85
Pull Request #840: [BUGFIX] Render RGB functions with "modern" syntax if required

31 of 33 new or added lines in 1 file covered. (93.94%)

54 existing lines in 1 file now uncovered.

792 of 1761 relevant lines covered (44.97%)

10.71 hits per line

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

94.97
/src/Value/Color.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Sabberworm\CSS\Value;
6

7
use Sabberworm\CSS\OutputFormat;
8
use Sabberworm\CSS\Parsing\ParserState;
9
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
10
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
11

12
/**
13
 * `Color's can be input in the form #rrggbb, #rgb or schema(val1, val2, …) but are always stored as an array of
14
 * ('s' => val1, 'c' => val2, 'h' => val3, …) and output in the second form.
15
 */
16
class Color extends CSSFunction
17
{
18
    /**
19
     * @param array<array-key, Value|string> $colorValues
20
     * @param int $lineNumber
21
     */
22
    public function __construct(array $colorValues, $lineNumber = 0)
54✔
23
    {
24
        parent::__construct(\implode('', \array_keys($colorValues)), $colorValues, ',', $lineNumber);
54✔
25
    }
54✔
26

27
    /**
28
     * @throws UnexpectedEOFException
29
     * @throws UnexpectedTokenException
30
     */
31
    public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction
105✔
32
    {
33
        return
34
            $parserState->comes('#')
105✔
35
            ? self::parseHexColor($parserState)
23✔
36
            : self::parseColorFunction($parserState);
97✔
37
    }
38

39
    /**
40
     * @throws UnexpectedEOFException
41
     * @throws UnexpectedTokenException
42
     */
43
    private static function parseHexColor(ParserState $parserState): CSSFunction
23✔
44
    {
45
        $parserState->consume('#');
23✔
46
        $hexValue = $parserState->parseIdentifier(false);
23✔
47
        if ($parserState->strlen($hexValue) === 3) {
22✔
48
            $hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2];
6✔
49
        } elseif ($parserState->strlen($hexValue) === 4) {
18✔
50
            $hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2]
3✔
51
                . $hexValue[3] . $hexValue[3];
3✔
52
        }
53

54
        if ($parserState->strlen($hexValue) === 8) {
22✔
55
            $colorValues = [
56
                'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()),
4✔
57
                'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()),
4✔
58
                'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()),
4✔
59
                'a' => new Size(
4✔
60
                    \round(self::mapRange(\intval($hexValue[6] . $hexValue[7], 16), 0, 255, 0, 1), 2),
4✔
61
                    null,
4✔
62
                    true,
4✔
63
                    $parserState->currentLine()
4✔
64
                ),
65
            ];
66
        } elseif ($parserState->strlen($hexValue) === 6) {
19✔
67
            $colorValues = [
68
                'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()),
12✔
69
                'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()),
12✔
70
                'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()),
12✔
71
            ];
72
        } else {
73
            throw new UnexpectedTokenException(
9✔
74
                'Invalid hex color value',
9✔
75
                $hexValue,
76
                'custom',
9✔
77
                $parserState->currentLine()
9✔
78
            );
79
        }
80

81
        return new Color($colorValues, $parserState->currentLine());
15✔
82
    }
83

84
    /**
85
     * @throws UnexpectedEOFException
86
     * @throws UnexpectedTokenException
87
     */
88
    private static function parseColorFunction(ParserState $parserState): CSSFunction
86✔
89
    {
90
        $colorValues = [];
86✔
91

92
        $colorMode = $parserState->parseIdentifier(true);
86✔
93
        $parserState->consumeWhiteSpace();
86✔
94
        $parserState->consume('(');
86✔
95

96
        // CSS Color Module Level 4 says that `rgb` and `rgba` are now aliases; likewise `hsl` and `hsla`.
97
        // So, attempt to parse with the `a`, and allow for it not being there.
98
        switch ($colorMode) {
86✔
99
            case 'rgb':
86✔
100
                $colorModeForParsing = 'rgba';
42✔
101
                $mayHaveOptionalAlpha = true;
42✔
102
                break;
42✔
103
            case 'hsl':
47✔
104
                $colorModeForParsing = 'hsla';
18✔
105
                $mayHaveOptionalAlpha = true;
18✔
106
                break;
18✔
107
            case 'rgba':
31✔
108
                // This is handled identically to the following case.
109
            case 'hsla':
5✔
110
                $colorModeForParsing = $colorMode;
31✔
111
                $mayHaveOptionalAlpha = true;
31✔
112
                break;
31✔
113
            default:
114
                $colorModeForParsing = $colorMode;
×
115
                $mayHaveOptionalAlpha = false;
×
116
        }
117

118
        $containsVar = false;
86✔
119
        $isLegacySyntax = false;
86✔
120
        $expectedArgumentCount = $parserState->strlen($colorModeForParsing);
86✔
121
        for ($argumentIndex = 0; $argumentIndex < $expectedArgumentCount; ++$argumentIndex) {
86✔
122
            $parserState->consumeWhiteSpace();
86✔
123
            if ($parserState->comes('var')) {
86✔
124
                $colorValues[$colorModeForParsing[$argumentIndex]] = CSSFunction::parseIdentifierOrFunction($parserState);
31✔
125
                $containsVar = true;
31✔
126
            } else {
127
                $colorValues[$colorModeForParsing[$argumentIndex]] = Size::parse($parserState, true);
85✔
128
            }
129

130
            // This must be done first, to consume comments as well, so that the `comes` test will work.
131
            $parserState->consumeWhiteSpace();
86✔
132

133
            // With a `var` argument, the function can have fewer arguments.
134
            // And as of CSS Color Module Level 4, the alpha argument is optional.
135
            $canCloseNow =
136
                $containsVar ||
86✔
137
                ($mayHaveOptionalAlpha && $argumentIndex >= $expectedArgumentCount - 2);
86✔
138
            if ($canCloseNow && $parserState->comes(')')) {
86✔
139
                break;
72✔
140
            }
141

142
            // "Legacy" syntax is comma-delimited.
143
            // "Modern" syntax is space-delimited, with `/` as alpha delimiter.
144
            // They cannot be mixed.
145
            if ($argumentIndex === 0) {
85✔
146
                // An immediate closing parenthesis is not valid.
147
                if ($parserState->comes(')')) {
85✔
148
                    throw new UnexpectedTokenException(
4✔
149
                        'Color function with no arguments',
4✔
150
                        '',
4✔
151
                        'custom',
4✔
152
                        $parserState->currentLine()
4✔
153
                    );
154
                }
155
                $isLegacySyntax = $parserState->comes(',');
81✔
156
            }
157

158
            if ($isLegacySyntax && $argumentIndex < ($expectedArgumentCount - 1)) {
81✔
159
                $parserState->consume(',');
46✔
160
            }
161

162
            // In the "modern" syntax, the alpha value must be delimited with `/`.
163
            if (!$isLegacySyntax) {
81✔
164
                if ($containsVar) {
35✔
165
                    // If the `var` substitution encompasses more than one argument,
166
                    // the alpha deliminator may come at any time.
167
                    if ($parserState->comes('/')) {
9✔
168
                        $parserState->consume('/');
9✔
169
                    }
170
                } elseif (($colorModeForParsing[$argumentIndex + 1] ?? '') === 'a') {
30✔
171
                    // Alpha value is the next expected argument.
172
                    // Since a closing parenthesis was not found, a `/` separator is now required.
173
                    $parserState->consume('/');
13✔
174
                }
175
            }
176
        }
177
        $parserState->consume(')');
74✔
178

179
        return
180
            $containsVar
72✔
181
            ? new CSSFunction($colorMode, \array_values($colorValues), ',', $parserState->currentLine())
31✔
182
            : new Color($colorValues, $parserState->currentLine());
72✔
183
    }
184

185
    private static function mapRange(float $value, float $fromMin, float $fromMax, float $toMin, float $toMax): float
4✔
186
    {
187
        $fromRange = $fromMax - $fromMin;
4✔
188
        $toRange = $toMax - $toMin;
4✔
189
        $multiplier = $toRange / $fromRange;
4✔
190
        $newValue = $value - $fromMin;
4✔
191
        $newValue *= $multiplier;
4✔
192
        return $newValue + $toMin;
4✔
193
    }
194

195
    /**
196
     * @return array<array-key, Value|string>
197
     */
198
    public function getColor()
2✔
199
    {
200
        return $this->aComponents;
2✔
201
    }
202

203
    /**
204
     * @param array<array-key, Value|string> $colorValues
205
     */
206
    public function setColor(array $colorValues): void
×
207
    {
208
        $this->setName(\implode('', \array_keys($colorValues)));
×
209
        $this->aComponents = $colorValues;
×
210
    }
×
211

212
    /**
213
     * @return string
214
     */
215
    public function getColorDescription()
1✔
216
    {
217
        return $this->getName();
1✔
218
    }
219

220
    public function __toString(): string
40✔
221
    {
222
        return $this->render(new OutputFormat());
40✔
223
    }
224

225
    public function render(OutputFormat $outputFormat): string
51✔
226
    {
227
        // Shorthand RGB color values
228
        if (
229
            $outputFormat->getRGBHashNotation()
51✔
230
            && $this->getRealName() === 'rgb'
51✔
231
            && $this->allComponentsAreNumbers()
51✔
232
        ) {
233
            return $this->renderAsHex();
15✔
234
        } elseif ($this->shouldRenderInModernSyntax()) {
40✔
235
            return $this->renderInModernSyntax($outputFormat);
9✔
236
        }
237

238
        return parent::render($outputFormat);
31✔
239
    }
240

241
    /**
242
     * The function name is a concatenation of the array keys of the components, which is passed to the constructor.
243
     * However, this can be changed by calling {@see CSSFunction::setName},
244
     * so is not reliable in situations where it's necessary to determine the function name based on the components.
245
     */
246
    private function getRealName(): string
51✔
247
    {
248
        return \implode('', \array_keys($this->aComponents));
51✔
249
    }
250

251
    /**
252
     * Test whether all color components are absolute numbers (CSS type `number`), not percentages or anything else.
253
     * If any component is not an instance of `Size`, the method will also return `false`.
254
     */
255
    private function allComponentsAreNumbers(): bool
23✔
256
    {
257
        foreach ($this->aComponents as $component) {
23✔
258
            if (!$component instanceof Size || $component->getUnit() !== null) {
23✔
259
                return false;
8✔
260
            }
261
        }
262

263
        return true;
15✔
264
    }
265

266
    /**
267
     * Note that this method assumes the following:
268
     * - The `aComponents` array has keys for `r`, `g` and `b`;
269
     * - The values in the array are all instances of `Size`.
270
     *
271
     * Errors will be triggered or thrown if this is not the case.
272
     *
273
     * @return non-empty-string
274
     */
275
    private function renderAsHex(): string
15✔
276
    {
277
        $result = \sprintf(
15✔
278
            '%02x%02x%02x',
15✔
279
            $this->aComponents['r']->getSize(),
15✔
280
            $this->aComponents['g']->getSize(),
15✔
281
            $this->aComponents['b']->getSize()
15✔
282
        );
283
        $canUseShortVariant = ($result[0] == $result[1]) && ($result[2] == $result[3]) && ($result[4] == $result[5]);
15✔
284

285
        return '#' . ($canUseShortVariant ? $result[0] . $result[2] . $result[4] : $result);
15✔
286
    }
287

288
    /**
289
     * The "legacy" syntax does not allow RGB colors to have a mixture of `percentage`s and `number`s.
290
     */
291
    private function shouldRenderInModernSyntax(): bool
40✔
292
    {
293
        $function = $this->getRealName();
40✔
294
        if ($function !== 'rgb' && $function !== 'rgba') {
40✔
295
            return false;
15✔
296
        }
297

298
        $hasPercentage = false;
27✔
299
        $hasNumber = false;
27✔
300
        foreach ($this->aComponents as $key => $value) {
27✔
301
            if ($key === 'a') {
27✔
302
                // Alpha can have units that don't match those of the RGB components in the "legacy" syntax.
303
                // So it is not necessary to check it.  It's also always last, hence `break` rather than `continue`.
304
                break;
19✔
305
            }
306
            if (!$value instanceof Size) {
27✔
307
                // Unexpected, unknown, or modified via the API
NEW
308
                return false;
×
309
            }
310
            $unit = $value->getUnit();
27✔
311
            // `switch` only does loose comparison
312
            if ($unit === null) {
27✔
313
                $hasNumber = true;
21✔
314
            } elseif ($unit === '%') {
15✔
315
                $hasPercentage = true;
15✔
316
            } else {
317
                // Invalid unit
NEW
318
                return false;
×
319
            }
320
        }
321

322
        return $hasPercentage && $hasNumber;
27✔
323
    }
324

325
    /**
326
     * @return non-empty-string
327
     */
328
    private function renderInModernSyntax(OutputFormat $outputFormat): string
9✔
329
    {
330
        \end($this->aComponents);
9✔
331
        if (\key($this->aComponents) === 'a') {
9✔
332
            $alpha = $this->aComponents['a'];
3✔
333
            $componentsWithoutAlpha = \array_diff_key($this->aComponents, ['a' => 0]);
3✔
334
        } else {
335
            $componentsWithoutAlpha = $this->aComponents;
6✔
336
        }
337

338
        $arguments = $outputFormat->implode(' ', $componentsWithoutAlpha);
9✔
339
        if (isset($alpha)) {
9✔
340
            $arguments = $outputFormat->implode(
3✔
341
                $outputFormat->spaceBeforeListArgumentSeparator('/') . '/'
3✔
342
                    . $outputFormat->spaceAfterListArgumentSeparator('/'),
3✔
343
                [$arguments, $alpha]
3✔
344
            );
345
        }
346

347
        return $this->getName() . '(' . $arguments . ')';
9✔
348
    }
349
}
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