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

MyIntervals / PHP-CSS-Parser / 13955519945

19 Mar 2025 07:54PM UTC coverage: 51.499%. Remained the same
13955519945

Pull #1197

github

web-flow
Merge 0b6cca91e into 45cd62ff0
Pull Request #1197: [TASK] Add native type declarations for `CSSFunction`

0 of 5 new or added lines in 2 files covered. (0.0%)

1 existing line in 1 file now uncovered.

945 of 1835 relevant lines covered (51.5%)

6.71 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<array-key, 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
36
            $parserState->comes('#')
105✔
37
                ? self::parseHexColor($parserState)
11✔
38
                : self::parseColorFunction($parserState);
99✔
39
    }
40

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

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

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

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

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

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

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

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

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

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

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

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

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

192
    private static function mapRange(float $value, float $fromMin, float $fromMax, float $toMin, float $toMax): float
2✔
193
    {
194
        $fromRange = $fromMax - $fromMin;
2✔
195
        $toRange = $toMax - $toMin;
2✔
196
        $multiplier = $toRange / $fromRange;
2✔
197
        $newValue = $value - $fromMin;
2✔
198
        $newValue *= $multiplier;
2✔
199
        return $newValue + $toMin;
2✔
200
    }
201

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

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

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

227
    public function render(OutputFormat $outputFormat): string
48✔
228
    {
229
        if ($this->shouldRenderAsHex($outputFormat)) {
48✔
230
            return $this->renderAsHex();
7✔
231
        }
232

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

237
        return parent::render($outputFormat);
24✔
238
    }
239

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

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

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

270
        return true;
7✔
271
    }
272

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

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

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

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

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

344
        return $hasPercentage && $hasNumber;
22✔
345
    }
346

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

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

362
        return \in_array($function, $functionsThatMayHaveMixedValueTypes, true);
33✔
363
    }
364

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

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

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