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

MyIntervals / PHP-CSS-Parser / 13964715114

20 Mar 2025 08:13AM UTC coverage: 51.611%. Remained the same
13964715114

Pull #1204

github

web-flow
Merge 709b06532 into 43b8afa88
Pull Request #1204: [TASK] Add native type declarations for `Color`

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

945 of 1831 relevant lines covered (51.61%)

6.72 hits per line

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

92.94
/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<0, max> $lineNumber
21
     */
22
    public function __construct(array $colorValues, int $lineNumber = 0)
48✔
23
    {
24
        parent::__construct(\implode('', \array_keys($colorValues)), $colorValues, ',', $lineNumber);
48✔
25
    }
48✔
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
     */
NEW
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()) {
41✔
236
            return $this->renderInModernSyntax($outputFormat);
17✔
237
        }
238

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

242
    private function shouldRenderAsHex(OutputFormat $outputFormat): bool
48✔
243
    {
244
        return
245
            $outputFormat->usesRgbHashNotation()
48✔
246
            && $this->getRealName() === 'rgb'
48✔
247
            && $this->allComponentsAreNumbers();
48✔
248
    }
249

250
    /**
251
     * The function name is a concatenation of the array keys of the components, which is passed to the constructor.
252
     * However, this can be changed by calling {@see CSSFunction::setName},
253
     * so is not reliable in situations where it's necessary to determine the function name based on the components.
254
     */
255
    private function getRealName(): string
48✔
256
    {
257
        return \implode('', \array_keys($this->components));
48✔
258
    }
259

260
    /**
261
     * Test whether all color components are absolute numbers (CSS type `number`), not percentages or anything else.
262
     * If any component is not an instance of `Size`, the method will also return `false`.
263
     */
264
    private function allComponentsAreNumbers(): bool
18✔
265
    {
266
        foreach ($this->components as $component) {
18✔
267
            if (!($component instanceof Size) || $component->getUnit() !== null) {
18✔
268
                return false;
11✔
269
            }
270
        }
271

272
        return true;
7✔
273
    }
274

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

294
        return '#' . ($canUseShortVariant ? $result[0] . $result[2] . $result[4] : $result);
7✔
295
    }
296

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

318
        if (!$this->colorFunctionMayHaveMixedValueTypes($this->getRealName())) {
33✔
319
            return false;
11✔
320
        }
321

322
        $hasPercentage = false;
22✔
323
        $hasNumber = false;
22✔
324
        foreach ($this->components as $key => $value) {
22✔
325
            if ($key === 'a') {
22✔
326
                // Alpha can have units that don't match those of the RGB components in the "legacy" syntax.
327
                // So it is not necessary to check it.  It's also always last, hence `break` rather than `continue`.
328
                break;
14✔
329
            }
330
            if (!($value instanceof Size)) {
22✔
331
                // Unexpected, unknown, or modified via the API
332
                return false;
×
333
            }
334
            $unit = $value->getUnit();
22✔
335
            // `switch` only does loose comparison
336
            if ($unit === null) {
22✔
337
                $hasNumber = true;
16✔
338
            } elseif ($unit === '%') {
15✔
339
                $hasPercentage = true;
15✔
340
            } else {
341
                // Invalid unit
342
                return false;
×
343
            }
344
        }
345

346
        return $hasPercentage && $hasNumber;
22✔
347
    }
348

349
    private function hasNoneAsComponentValue(): bool
41✔
350
    {
351
        return \in_array('none', $this->components, true);
41✔
352
    }
353

354
    /**
355
     * Some color functions, such as `rgb`,
356
     * may have a mixture of `percentage`, `number`, or possibly other types in their arguments.
357
     *
358
     * Note that this excludes the alpha component, which is treated separately.
359
     */
360
    private function colorFunctionMayHaveMixedValueTypes(string $function): bool
33✔
361
    {
362
        $functionsThatMayHaveMixedValueTypes = ['rgb', 'rgba'];
33✔
363

364
        return \in_array($function, $functionsThatMayHaveMixedValueTypes, true);
33✔
365
    }
366

367
    /**
368
     * @return non-empty-string
369
     */
370
    private function renderInModernSyntax(OutputFormat $outputFormat): string
17✔
371
    {
372
        // Maybe not yet without alpha, but will be...
373
        $componentsWithoutAlpha = $this->components;
17✔
374
        \end($componentsWithoutAlpha);
17✔
375
        if (\key($componentsWithoutAlpha) === 'a') {
17✔
376
            $alpha = $this->components['a'];
5✔
377
            unset($componentsWithoutAlpha['a']);
5✔
378
        }
379

380
        $formatter = $outputFormat->getFormatter();
17✔
381
        $arguments = $formatter->implode(' ', $componentsWithoutAlpha);
17✔
382
        if (isset($alpha)) {
17✔
383
            $separator = $formatter->spaceBeforeListArgumentSeparator('/')
5✔
384
                . '/' . $formatter->spaceAfterListArgumentSeparator('/');
5✔
385
            $arguments = $formatter->implode($separator, [$arguments, $alpha]);
5✔
386
        }
387

388
        return $this->getName() . '(' . $arguments . ')';
17✔
389
    }
390
}
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