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

MyIntervals / PHP-CSS-Parser / 20772587941

07 Jan 2026 06:19AM UTC coverage: 70.316% (+1.1%) from 69.191%
20772587941

Pull #1442

github

web-flow
Merge 44fbf83d1 into 2b61cd568
Pull Request #1442: [FEATURE] Convert legacy color notation to modern css 4 notation

4 of 9 new or added lines in 2 files covered. (44.44%)

65 existing lines in 7 files now uncovered.

1400 of 1991 relevant lines covered (70.32%)

32.28 hits per line

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

92.53
/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<non-empty-string, Value|string> $colorValues
20
     * @param int<1, max>|null $lineNumber
21
     */
22
    public function __construct(array $colorValues, ?int $lineNumber = null)
49✔
23
    {
24
        parent::__construct(\implode('', \array_keys($colorValues)), $colorValues, ',', $lineNumber);
49✔
25
    }
49✔
26

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

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

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

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

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

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

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

119
        $containsVar = false;
94✔
120
        $containsNone = false;
94✔
121
        $isLegacySyntax = false;
94✔
122
        $expectedArgumentCount = $parserState->strlen($colorModeForParsing);
94✔
123
        for ($argumentIndex = 0; $argumentIndex < $expectedArgumentCount; ++$argumentIndex) {
94✔
124
            $parserState->consumeWhiteSpace();
94✔
125
            $valueKey = $colorModeForParsing[$argumentIndex];
94✔
126
            if ($parserState->comes('var')) {
94✔
127
                $colorValues[$valueKey] = CSSFunction::parseIdentifierOrFunction($parserState);
29✔
128
                $containsVar = true;
29✔
129
            } elseif (!$isLegacySyntax && $parserState->comes('none')) {
93✔
130
                $colorValues[$valueKey] = $parserState->parseIdentifier();
10✔
131
                $containsNone = true;
10✔
132
            } else {
133
                $colorValues[$valueKey] = Size::parse($parserState, true);
93✔
134
            }
135

136
            // This must be done first, to consume comments as well, so that the `comes` test will work.
137
            $parserState->consumeWhiteSpace();
94✔
138

139
            // With a `var` argument, the function can have fewer arguments.
140
            // And as of CSS Color Module Level 4, the alpha argument is optional.
141
            $canCloseNow =
142
                $containsVar
94✔
143
                || ($mayHaveOptionalAlpha && $argumentIndex >= $expectedArgumentCount - 2);
94✔
144
            if ($canCloseNow && $parserState->comes(')')) {
94✔
145
                break;
72✔
146
            }
147

148
            // "Legacy" syntax is comma-delimited, and does not allow the `none` keyword.
149
            // "Modern" syntax is space-delimited, with `/` as alpha delimiter.
150
            // They cannot be mixed.
151
            if ($argumentIndex === 0 && !$containsNone) {
93✔
152
                // An immediate closing parenthesis is not valid.
153
                if ($parserState->comes(')')) {
89✔
154
                    throw new UnexpectedTokenException(
4✔
155
                        'Color function with no arguments',
4✔
156
                        '',
4✔
157
                        'custom',
4✔
158
                        $parserState->currentLine()
4✔
159
                    );
160
                }
161
                $isLegacySyntax = $parserState->comes(',');
85✔
162
            }
163

164
            if ($isLegacySyntax && $argumentIndex < ($expectedArgumentCount - 1)) {
89✔
165
                $parserState->consume(',');
44✔
166
            }
167

168
            // In the "modern" syntax, the alpha value must be delimited with `/`.
169
            if (!$isLegacySyntax) {
89✔
170
                if ($containsVar) {
45✔
171
                    // If the `var` substitution encompasses more than one argument,
172
                    // the alpha deliminator may come at any time.
173
                    if ($parserState->comes('/')) {
9✔
174
                        $parserState->consume('/');
9✔
175
                    }
176
                } elseif (($colorModeForParsing[$argumentIndex + 1] ?? '') === 'a') {
40✔
177
                    // Alpha value is the next expected argument.
178
                    // Since a closing parenthesis was not found, a `/` separator is now required.
179
                    $parserState->consume('/');
17✔
180
                }
181
            }
182
        }
183
        $parserState->consume(')');
76✔
184

185
        return $containsVar
72✔
186
            ? new CSSFunction($colorMode, \array_values($colorValues), ',', $parserState->currentLine())
29✔
187
            : new Color($colorValues, $parserState->currentLine());
72✔
188
    }
189

190
    private static function mapRange(float $value, float $fromMin, float $fromMax, float $toMin, float $toMax): float
2✔
191
    {
192
        $fromRange = $fromMax - $fromMin;
2✔
193
        $toRange = $toMax - $toMin;
2✔
194
        $multiplier = $toRange / $fromRange;
2✔
195
        $newValue = $value - $fromMin;
2✔
196
        $newValue *= $multiplier;
2✔
197

198
        return $newValue + $toMin;
2✔
199
    }
200

201
    /**
202
     * @return array<non-empty-string, Value|string>
203
     */
204
    public function getColor(): array
×
205
    {
206
        return $this->components;
×
207
    }
208

209
    /**
210
     * @param array<non-empty-string, Value|string> $colorValues
211
     */
212
    public function setColor(array $colorValues): void
×
213
    {
214
        $this->setName(\implode('', \array_keys($colorValues)));
×
215
        $this->components = $colorValues;
×
216
    }
×
217

218
    /**
219
     * @return non-empty-string
220
     */
221
    public function getColorDescription(): string
×
222
    {
223
        return $this->getName();
×
224
    }
225

226
    /**
227
     * @return non-empty-string
228
     */
229
    public function render(OutputFormat $outputFormat): string
48✔
230
    {
231
        if ($this->shouldRenderAsHex($outputFormat)) {
48✔
232
            return $this->renderAsHex();
7✔
233
        }
234

235
        if ($this->shouldRenderInModernSyntax($outputFormat)) {
41✔
236
            return $this->renderInModernSyntax($outputFormat);
17✔
237
        }
238

239
        return parent::render($outputFormat);
24✔
240
    }
241

242
    /**
243
     * @return array<string, bool|int|float|string|array<mixed>|null>
244
     *
245
     * @internal
246
     */
247
    public function getArrayRepresentation(): array
1✔
248
    {
249
        throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
1✔
250
    }
251

252
    private function shouldRenderAsHex(OutputFormat $outputFormat): bool
48✔
253
    {
254
        return
255
            $outputFormat->usesRgbHashNotation()
48✔
256
            && $this->getRealName() === 'rgb'
48✔
257
            && $this->allComponentsAreNumbers();
48✔
258
    }
259

260
    /**
261
     * The function name is a concatenation of the array keys of the components, which is passed to the constructor.
262
     * However, this can be changed by calling {@see CSSFunction::setName},
263
     * so is not reliable in situations where it's necessary to determine the function name based on the components.
264
     */
265
    private function getRealName(): string
48✔
266
    {
267
        return \implode('', \array_keys($this->components));
48✔
268
    }
269

270
    /**
271
     * Test whether all color components are absolute numbers (CSS type `number`), not percentages or anything else.
272
     * If any component is not an instance of `Size`, the method will also return `false`.
273
     */
274
    private function allComponentsAreNumbers(): bool
18✔
275
    {
276
        foreach ($this->components as $component) {
18✔
277
            if (!($component instanceof Size) || $component->getUnit() !== null) {
18✔
278
                return false;
11✔
279
            }
280
        }
281

282
        return true;
7✔
283
    }
284

285
    /**
286
     * Note that this method assumes the following:
287
     * - The `components` array has keys for `r`, `g` and `b`;
288
     * - The values in the array are all instances of `Size`.
289
     *
290
     * Errors will be triggered or thrown if this is not the case.
291
     *
292
     * @return non-empty-string
293
     */
294
    private function renderAsHex(): string
7✔
295
    {
296
        $result = \sprintf(
7✔
297
            '%02x%02x%02x',
7✔
298
            $this->components['r']->getSize(),
7✔
299
            $this->components['g']->getSize(),
7✔
300
            $this->components['b']->getSize()
7✔
301
        );
302
        $canUseShortVariant = ($result[0] === $result[1]) && ($result[2] === $result[3]) && ($result[4] === $result[5]);
7✔
303

304
        return '#' . ($canUseShortVariant ? $result[0] . $result[2] . $result[4] : $result);
7✔
305
    }
306

307
    /**
308
     * The "legacy" syntax does not allow RGB colors to have a mixture of `percentage`s and `number`s,
309
     * and does not allow `none` as any component value.
310
     *
311
     * The "legacy" and "modern" monikers are part of the formal W3C syntax.
312
     * See the following for more information:
313
     * - {@link
314
     *     https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb#formal_syntax
315
     *     Description of the formal syntax for `rgb()` on MDN
316
     *   };
317
     * - {@link
318
     *     https://www.w3.org/TR/css-color-4/#rgb-functions
319
     *     The same in the CSS Color Module Level 4 W3C Candidate Recommendation Draft
320
     *   } (as of 13 February 2024, at time of writing).
321
     */
322
    private function shouldRenderInModernSyntax(OutputFormat $outputFormat): bool
41✔
323
    {
324
        if ($this->hasNoneAsComponentValue()) {
41✔
325
            return true;
8✔
326
        }
327

328
        if (!$this->colorFunctionMayHaveMixedValueTypes($this->getRealName())) {
33✔
329
            return false;
11✔
330
        }
331

332
        if ($outputFormat->usesModernColorSyntax()) {
22✔
UNCOV
333
            return true;
×
334
        }
335

336
        $hasPercentage = false;
22✔
337
        $hasNumber = false;
22✔
338
        foreach ($this->components as $key => $value) {
22✔
339
            if ($key === 'a') {
22✔
340
                // Alpha can have units that don't match those of the RGB components in the "legacy" syntax.
341
                // So it is not necessary to check it.  It's also always last, hence `break` rather than `continue`.
342
                break;
14✔
343
            }
344
            if (!($value instanceof Size)) {
22✔
345
                // Unexpected, unknown, or modified via the API
346
                return false;
×
347
            }
348
            $unit = $value->getUnit();
22✔
349
            // `switch` only does loose comparison
350
            if ($unit === null) {
22✔
351
                $hasNumber = true;
16✔
352
            } elseif ($unit === '%') {
15✔
353
                $hasPercentage = true;
15✔
354
            } else {
355
                // Invalid unit
UNCOV
356
                return false;
×
357
            }
358
        }
359

360
        return $hasPercentage && $hasNumber;
22✔
361
    }
362

363
    private function hasNoneAsComponentValue(): bool
41✔
364
    {
365
        return \in_array('none', $this->components, true);
41✔
366
    }
367

368
    /**
369
     * Some color functions, such as `rgb`,
370
     * may have a mixture of `percentage`, `number`, or possibly other types in their arguments.
371
     *
372
     * Note that this excludes the alpha component, which is treated separately.
373
     */
374
    private function colorFunctionMayHaveMixedValueTypes(string $function): bool
33✔
375
    {
376
        $functionsThatMayHaveMixedValueTypes = ['rgb', 'rgba'];
33✔
377

378
        return \in_array($function, $functionsThatMayHaveMixedValueTypes, true);
33✔
379
    }
380

381
    /**
382
     * @return non-empty-string
383
     */
384
    private function renderInModernSyntax(OutputFormat $outputFormat): string
17✔
385
    {
386
        // Maybe not yet without alpha, but will be...
387
        $componentsWithoutAlpha = $this->components;
17✔
388
        \end($componentsWithoutAlpha);
17✔
389
        if (\key($componentsWithoutAlpha) === 'a') {
17✔
390
            $alpha = $this->components['a'];
5✔
391
            unset($componentsWithoutAlpha['a']);
5✔
392
        }
393

394
        $formatter = $outputFormat->getFormatter();
17✔
395
        $arguments = $formatter->implode(' ', $componentsWithoutAlpha);
17✔
396
        if (isset($alpha)) {
17✔
397
            $separator = $formatter->spaceBeforeListArgumentSeparator('/')
5✔
398
                . '/' . $formatter->spaceAfterListArgumentSeparator('/');
5✔
399
            $arguments = $formatter->implode($separator, [$arguments, $alpha]);
5✔
400
        }
401

402
        return $this->getName() . '(' . $arguments . ')';
17✔
403
    }
404
}
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