Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.66% covered (warning)
83.66%
1823 / 2179
19.49% covered (danger)
19.49%
23 / 118
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValueParser
83.66% covered (warning)
83.66%
1823 / 2179
19.49% covered (danger)
19.49%
23 / 118
6355.54
0.00% covered (danger)
0.00%
0 / 1
 parseFromString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 parseSlashList
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 parseSpaceList
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 parseSingle
93.02% covered (success)
93.02%
40 / 43
0.00% covered (danger)
0.00%
0 / 1
18.11
 parseFunction
98.32% covered (success)
98.32%
117 / 119
0.00% covered (danger)
0.00%
0 / 1
72
 parseTransform
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 postProcessTransform
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 parseFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 postProcessFilter
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
8.19
 postProcessFontFeatureSettings
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
9.53
 postProcessFontVariationSettings
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
9.53
 parseFontVariationEntry
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
7.77
 parseFontFeatureEntry
68.00% covered (warning)
68.00%
17 / 25
0.00% covered (danger)
0.00%
0 / 1
20.42
 valueToFilterFunction
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 valueToTransformFunction
70.73% covered (warning)
70.73%
29 / 41
0.00% covered (danger)
0.00%
0 / 1
80.12
 buildTranslate
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
16.73
 buildRotate3d
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 buildScale
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 buildSkew
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 toLengthOrPct
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
19.12
 toAngleDeg
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
10.40
 toFloat
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
 parseArgs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseRgbFunction
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
8
 extractRgbComponent
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 extractAlphaComponent
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 splitRgbSpaceForm
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
8.10
 splitOnSlash
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
10.02
 parseHslFunction
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
9.53
 parseHwbFunction
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
90
 extractHueComponent
57.89% covered (warning)
57.89%
11 / 19
0.00% covered (danger)
0.00%
0 / 1
22.75
 extractPercentageComponent
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 hslToRgb
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
8.16
 parseHexColor
74.07% covered (warning)
74.07%
20 / 27
0.00% covered (danger)
0.00%
0 / 1
6.63
 parseVarFunction
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
 parseColorFunction
70.27% covered (warning)
70.27%
26 / 37
0.00% covered (danger)
0.00%
0 / 1
39.14
 extractColorComponent
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 parseLabFunction
84.78% covered (warning)
84.78%
39 / 46
0.00% covered (danger)
0.00%
0 / 1
26.03
 extractLabLightness
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
7.29
 extractLabAxisComponent
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 extractChromaComponent
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 extractLabHueComponent
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
16.53
 normaliseHueDegrees
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseRelativeColorIfPresent
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 parseRelativeColorBody
76.19% covered (warning)
76.19%
48 / 63
0.00% covered (danger)
0.00%
0 / 1
57.49
 splitParenAwareSpaceForm
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
13.62
 collectColorRun
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 parseRelativeComponent
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 parseColorMixFunction
95.00% covered (success)
95.00%
38 / 40
0.00% covered (danger)
0.00%
0 / 1
10
 parseColorMixMethod
70.00% covered (warning)
70.00%
35 / 50
0.00% covered (danger)
0.00%
0 / 1
70.99
 parseColorMixColorAndPercent
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
7.77
 parseColorFromTokens
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 serializeTokens
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
16
 parseCalcFunction
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 parseCalcSum
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
15.07
 parseCalcProduct
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
15.07
 parseCalcValue
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
16.61
 isMatchingParenWrap
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
9.01
 parseLinearGradient
88.37% covered (warning)
88.37%
38 / 43
0.00% covered (danger)
0.00%
0 / 1
11.19
 splitOnInterpolationKeyword
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
14.27
 parseLinearAngleHeader
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
9.02
 sidesToAngle
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
22.50
 parseGradientStop
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
6.14
 parseStopPosition
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
9.73
 parseRadialGradient
89.58% covered (warning)
89.58%
43 / 48
0.00% covered (danger)
0.00%
0 / 1
12.16
 parseConicGradient
88.00% covered (warning)
88.00%
44 / 50
0.00% covered (danger)
0.00%
0 / 1
14.34
 isConicHeader
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 parseConicHeader
85.00% covered (warning)
85.00%
51 / 60
0.00% covered (danger)
0.00%
0 / 1
27.11
 parseAnchorFunction
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 parseAnchorSizeFunction
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 parseAnchorOrSize
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
15.19
 parseBasicShape
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 parseRectShape
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
10.07
 parseXywhShape
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
8.16
 parsePathShape
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
9.09
 parseCircleShape
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 parseEllipseShape
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 parseInsetShape
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
9.06
 parsePolygonShape
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
13.40
 isShapePositionValue
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 parseShapeRadius
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
9.49
 parsePositionXY
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
9.08
 splitOnAtKeyword
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
9.49
 splitOnRoundKeyword
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
9.49
 parseCubicBezierFunction
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
9.04
 parseStepsFunction
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
11.35
 parseLinearEasingFunction
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
13.14
 extractSinglePercentage
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 parseAttrFunction
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
 parseEnvFunction
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
10.17
 parseTargetFunction
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 buildTargetCounter
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 buildTargetCounters
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
8.15
 buildTargetText
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 parseCounterIdent
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 parseStringFunction
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
9.02
 parseViewTimeline
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
11.09
 parseScrollTimeline
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
9.01
 parsePaintFunction
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 parseElementFunction
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
9.02
 parseDeviceCmyk
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
14.62
 parseCmykComponent
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 parseContrastColor
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 parseCrossFade
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 parseCrossFadeEntry
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
 parseLightDark
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 parseImageSet
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 parseImageSetOption
81.03% covered (warning)
81.03%
47 / 58
0.00% covered (danger)
0.00%
0 / 1
25.30
 collectFunctionLikeRun
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
 isResolutionUnit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolutionToDppx
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 isRadialHeader
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 parseRadialHeader
75.61% covered (warning)
75.61%
31 / 41
0.00% covered (danger)
0.00%
0 / 1
21.19
 trimWhitespace
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 splitTopLevel
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
12
 splitTopLevelDelim
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
13
 splitOnWhitespace
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
14
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css;
6
7use Phpdftk\Css\Token\CommaToken;
8use Phpdftk\Css\Token\DelimToken;
9use Phpdftk\Css\Token\DimensionToken;
10use Phpdftk\Css\Token\EofToken;
11use Phpdftk\Css\Token\FunctionToken;
12use Phpdftk\Css\Token\HashToken;
13use Phpdftk\Css\Token\IdentToken;
14use Phpdftk\Css\Token\LeftBraceToken;
15use Phpdftk\Css\Token\LeftBracketToken;
16use Phpdftk\Css\Token\LeftParenToken;
17use Phpdftk\Css\Token\NumberToken;
18use Phpdftk\Css\Token\PercentageToken;
19use Phpdftk\Css\Token\RightBraceToken;
20use Phpdftk\Css\Token\RightBracketToken;
21use Phpdftk\Css\Token\RightParenToken;
22use Phpdftk\Css\Token\StringToken;
23use Phpdftk\Css\Token\Token;
24use Phpdftk\Css\Token\UrlToken;
25use Phpdftk\Css\Token\WhitespaceToken;
26use Phpdftk\Css\Value\AnchorFunction;
27use Phpdftk\Css\Value\AnchorSizeFunction;
28use Phpdftk\Css\Value\AttrFunction;
29use Phpdftk\Css\Value\BasicShape;
30use Phpdftk\Css\Value\CircleShape;
31use Phpdftk\Css\Value\EllipseShape;
32use Phpdftk\Css\Value\EnvFunction;
33use Phpdftk\Css\Value\InsetShape;
34use Phpdftk\Css\Value\PathShape;
35use Phpdftk\Css\Value\PolygonShape;
36use Phpdftk\Css\Value\RectShape;
37use Phpdftk\Css\Value\XywhShape;
38use Phpdftk\Css\Value\Filter;
39use Phpdftk\Css\Value\FontFeatureSettings;
40use Phpdftk\Css\Value\FontFeatureValue;
41use Phpdftk\Css\Value\FontVariationSettings;
42use Phpdftk\Css\Value\FontVariationValue;
43use Phpdftk\Css\Value\FilterFunction;
44use Phpdftk\Css\Value\FilterKind;
45use Phpdftk\Css\Value\Angle;
46use Phpdftk\Css\Value\AngleUnit;
47use Phpdftk\Css\Value\Calc;
48use Phpdftk\Css\Value\CalcBinary;
49use Phpdftk\Css\Value\CalcExpression;
50use Phpdftk\Css\Value\CalcFunc;
51use Phpdftk\Css\Value\CalcFunction;
52use Phpdftk\Css\Value\CalcLeaf;
53use Phpdftk\Css\Value\CalcOp;
54use Phpdftk\Css\Value\Color;
55use Phpdftk\Css\Value\ColorMix;
56use Phpdftk\Css\Value\CubicBezier;
57use Phpdftk\Css\Value\StepsEasing;
58use Phpdftk\Css\Value\StepsJumpTerm;
59use Phpdftk\Css\Value\HueInterpolation;
60use Phpdftk\Css\Value\ColorSpace;
61use Phpdftk\Css\Value\CssFunction;
62use Phpdftk\Css\Value\CustomProperty;
63use Phpdftk\Css\Value\DeviceCmyk;
64use Phpdftk\Css\Value\ElementFunction;
65use Phpdftk\Css\Value\Gradient;
66use Phpdftk\Css\Value\ConicGradient;
67use Phpdftk\Css\Value\ContrastColor;
68use Phpdftk\Css\Value\CrossFade;
69use Phpdftk\Css\Value\CrossFadeOption;
70use Phpdftk\Css\Value\GradientShape;
71use Phpdftk\Css\Value\GradientStop;
72use Phpdftk\Css\Value\ImageSet;
73use Phpdftk\Css\Value\ImageSetOption;
74use Phpdftk\Css\Value\Integer;
75use Phpdftk\Css\Value\Keyword;
76use Phpdftk\Css\Value\Length;
77use Phpdftk\Css\Value\LengthUnit;
78use Phpdftk\Css\Value\LightDark;
79use Phpdftk\Css\Value\LinearEasing;
80use Phpdftk\Css\Value\LinearEasingStop;
81use Phpdftk\Css\Value\LinearGradient;
82use Phpdftk\Css\Value\ListSeparator;
83use Phpdftk\Css\Value\MatrixTransform;
84use Phpdftk\Css\Value\NamedColors;
85use Phpdftk\Css\Value\Number;
86use Phpdftk\Css\Value\PaintFunction;
87use Phpdftk\Css\Value\Percentage;
88use Phpdftk\Css\Value\RadialGradient;
89use Phpdftk\Css\Value\RelativeColor;
90use Phpdftk\Css\Value\ScrollTimeline;
91use Phpdftk\Css\Value\RotateTransform;
92use Phpdftk\Css\Value\ScaleTransform;
93use Phpdftk\Css\Value\SkewTransform;
94use Phpdftk\Css\Value\StringFunction;
95use Phpdftk\Css\Value\StringValue;
96use Phpdftk\Css\Value\TargetFunction;
97use Phpdftk\Css\Value\TargetFunctionKind;
98use Phpdftk\Css\Value\Time;
99use Phpdftk\Css\Value\TimeUnit;
100use Phpdftk\Css\Value\Transform;
101use Phpdftk\Css\Value\TransformFunction;
102use Phpdftk\Css\Value\TranslateTransform;
103use Phpdftk\Css\Value\Url;
104use Phpdftk\Css\Value\Value;
105use Phpdftk\Css\Value\ViewTimeline;
106use Phpdftk\Css\Value\ValueList;
107
108/**
109 * Parses a CSS token stream into typed Value instances per CSS Values 4.
110 *
111 * Phase 1A.2 covers the common path: keywords, numbers, integers,
112 * percentages, lengths (all units), colors (hex / named / rgb / rgba /
113 * hsl / hsla / transparent), strings, urls, single-argument function
114 * fallback. Calc, gradients, transforms, and color() with explicit space
115 * are deferred â€” they get dedicated parsers in 1A.2-bis.
116 */
117final class ValueParser
118{
119    /**
120     * Parse the entire token stream as a single value. Whitespace-separated
121     * tokens become a `ValueList(Space)`; comma-separated become
122     * `ValueList(Comma)`. A lone value is returned bare.
123     */
124    public function parseFromString(string $css): Value
125    {
126        $tokens = (new Tokenizer($css))->tokenize();
127        return $this->parse($tokens);
128    }
129
130    /** @param list<Token> $tokens */
131    public function parse(array $tokens): Value
132    {
133        $tokens = self::trimWhitespace($tokens);
134
135        // Split on top-level commas first.
136        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
137        if (count($commaGroups) > 1) {
138            $values = array_map(fn($g): Value => $this->parseSlashList($g), $commaGroups);
139            return new ValueList($values, ListSeparator::Comma);
140        }
141        return $this->parseSlashList($tokens);
142    }
143
144    /**
145     * Per CSS Values 4, `/` is a top-level separator inside a comma group
146     * for several shorthands (`font: 16px/1.5 sans-serif`, `border-radius:
147     * 4px / 8px`, `background: <bg-color> / <bg-size>` etc.). Splits the
148     * group on top-level `/` delims, then recurses into the space-list
149     * parser for each slash segment.
150     *
151     * @param list<Token> $tokens
152     */
153    private function parseSlashList(array $tokens): Value
154    {
155        $slashGroups = self::splitTopLevelDelim($tokens, '/');
156        if (count($slashGroups) > 1) {
157            $values = array_map(fn($g): Value => $this->parseSpaceList($g), $slashGroups);
158            return new ValueList($values, ListSeparator::Slash);
159        }
160        return $this->parseSpaceList($tokens);
161    }
162
163    /** @param list<Token> $tokens */
164    private function parseSpaceList(array $tokens): Value
165    {
166        $tokens = self::trimWhitespace($tokens);
167        $parts = self::splitOnWhitespace($tokens);
168        if (count($parts) === 1) {
169            return $this->parseSingle($parts[0]);
170        }
171        $values = array_map(fn($p): Value => $this->parseSingle($p), $parts);
172        return new ValueList($values, ListSeparator::Space);
173    }
174
175    /** @param list<Token> $tokens A single value without separators. */
176    private function parseSingle(array $tokens): Value
177    {
178        if ($tokens === []) {
179            return new Keyword(''); // shouldn't happen in well-formed input
180        }
181        $head = $tokens[0];
182        if ($head instanceof IdentToken) {
183            $name = strtolower($head->value);
184            if ($name === 'transparent') {
185                return new Color(0, 0, 0, 0);
186            }
187            $named = NamedColors::lookup($name);
188            if ($named !== null) {
189                return $named;
190            }
191            return new Keyword($name);
192        }
193        if ($head instanceof HashToken) {
194            $color = $this->parseHexColor($head->value);
195            if ($color !== null) {
196                return $color;
197            }
198            return new Keyword('#' . $head->value);
199        }
200        if ($head instanceof NumberToken) {
201            return $head->type->name === 'Integer'
202                ? new Integer((int) $head->value)
203                : new Number($head->value);
204        }
205        if ($head instanceof PercentageToken) {
206            return new Percentage($head->value);
207        }
208        if ($head instanceof DimensionToken) {
209            $unit = strtolower($head->unit);
210            $lengthUnit = LengthUnit::tryFrom($unit);
211            if ($lengthUnit !== null) {
212                return new Length($head->value, $lengthUnit);
213            }
214            $angleUnit = AngleUnit::tryFrom($unit);
215            if ($angleUnit !== null) {
216                return new Angle($head->value, $angleUnit);
217            }
218            $timeUnit = TimeUnit::tryFrom($unit);
219            if ($timeUnit !== null) {
220                return new Time($head->value, $timeUnit);
221            }
222            // Unknown unit â€” fall back to a CssFunction representation so the
223            // value survives round-trip without being silently dropped.
224            return new CssFunction($head->unit, [new Number($head->value)]);
225        }
226        if ($head instanceof StringToken) {
227            return new StringValue($head->value);
228        }
229        if ($head instanceof UrlToken) {
230            return new Url($head->value);
231        }
232        if ($head instanceof FunctionToken) {
233            return $this->parseFunction($head->name, array_slice($tokens, 1));
234        }
235        // Fallback â€” keep the raw delim as a one-char keyword.
236        if ($head instanceof DelimToken) {
237            return new Keyword($head->value);
238        }
239        return new Keyword('');
240    }
241
242    /**
243     * @param list<Token> $tokens The function body, excluding the
244     *        FunctionToken header and the closing RightParenToken.
245     */
246    private function parseFunction(string $name, array $tokens): Value
247    {
248        $name = strtolower($name);
249        // Drop the trailing ) if present.
250        if ($tokens !== [] && end($tokens) instanceof RightParenToken) {
251            array_pop($tokens);
252        }
253        if ($name === 'url') {
254            // url("...") form.
255            foreach ($tokens as $tok) {
256                if ($tok instanceof StringToken) {
257                    return new Url($tok->value);
258                }
259            }
260            return new Url('');
261        }
262        // CSS Color 5 Â§4 â€” relative color syntax must run BEFORE
263        // the regular rgb / hsl / lab / etc. function parsers so
264        // they don't trip on the leading `from` keyword.
265        if (in_array($name, ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color'], true)) {
266            $relative = $this->parseRelativeColorIfPresent($name, $tokens);
267            if ($relative !== null) {
268                return $relative;
269            }
270        }
271        if ($name === 'rgb' || $name === 'rgba') {
272            return $this->parseRgbFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
273        }
274        if ($name === 'hsl' || $name === 'hsla') {
275            return $this->parseHslFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
276        }
277        if ($name === 'hwb') {
278            return $this->parseHwbFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
279        }
280        if ($name === 'var') {
281            return $this->parseVarFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
282        }
283        if ($name === 'color') {
284            return $this->parseColorFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
285        }
286        if ($name === 'lab' || $name === 'lch' || $name === 'oklab' || $name === 'oklch') {
287            return $this->parseLabFunction($name, $tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
288        }
289        if ($name === 'color-mix') {
290            return $this->parseColorMixFunction($tokens) ?? new CssFunction($name, $this->parseArgs($tokens));
291        }
292        if (CalcFunction::tryFrom($name) !== null) {
293            $calc = $this->parseCalcFunction(CalcFunction::from($name), $tokens);
294            if ($calc !== null) {
295                return $calc;
296            }
297        }
298        if ($name === 'linear-gradient' || $name === 'repeating-linear-gradient') {
299            $g = $this->parseLinearGradient($tokens, $name === 'repeating-linear-gradient');
300            if ($g !== null) {
301                return $g;
302            }
303        }
304        if ($name === 'radial-gradient' || $name === 'repeating-radial-gradient') {
305            $g = $this->parseRadialGradient($tokens, $name === 'repeating-radial-gradient');
306            if ($g !== null) {
307                return $g;
308            }
309        }
310        if ($name === 'conic-gradient' || $name === 'repeating-conic-gradient') {
311            $g = $this->parseConicGradient($tokens, $name === 'repeating-conic-gradient');
312            if ($g !== null) {
313                return $g;
314            }
315        }
316        if ($name === 'image-set' || $name === '-webkit-image-set') {
317            $set = $this->parseImageSet($tokens);
318            if ($set !== null) {
319                return $set;
320            }
321        }
322        if ($name === 'anchor') {
323            $a = $this->parseAnchorFunction($tokens);
324            if ($a !== null) {
325                return $a;
326            }
327        }
328        if ($name === 'anchor-size') {
329            $a = $this->parseAnchorSizeFunction($tokens);
330            if ($a !== null) {
331                return $a;
332            }
333        }
334        if ($name === 'attr') {
335            $a = $this->parseAttrFunction($tokens);
336            if ($a !== null) {
337                return $a;
338            }
339        }
340        if ($name === 'env') {
341            $e = $this->parseEnvFunction($tokens);
342            if ($e !== null) {
343                return $e;
344            }
345        }
346        if ($name === 'target-counter' || $name === 'target-counters' || $name === 'target-text') {
347            $t = $this->parseTargetFunction($name, $tokens);
348            if ($t !== null) {
349                return $t;
350            }
351        }
352        if ($name === 'string') {
353            $s = $this->parseStringFunction($tokens);
354            if ($s !== null) {
355                return $s;
356            }
357        }
358        if ($name === 'element') {
359            $e = $this->parseElementFunction($tokens);
360            if ($e !== null) {
361                return $e;
362            }
363        }
364        if ($name === 'paint') {
365            $p = $this->parsePaintFunction($tokens);
366            if ($p !== null) {
367                return $p;
368            }
369        }
370        if ($name === 'view') {
371            $v = $this->parseViewTimeline($tokens);
372            if ($v !== null) {
373                return $v;
374            }
375        }
376        if ($name === 'scroll') {
377            $s = $this->parseScrollTimeline($tokens);
378            if ($s !== null) {
379                return $s;
380            }
381        }
382        if ($name === 'light-dark') {
383            $ld = $this->parseLightDark($tokens);
384            if ($ld !== null) {
385                return $ld;
386            }
387        }
388        if ($name === 'contrast-color') {
389            $cc = $this->parseContrastColor($tokens);
390            if ($cc !== null) {
391                return $cc;
392            }
393        }
394        if ($name === 'cross-fade') {
395            $cf = $this->parseCrossFade($tokens);
396            if ($cf !== null) {
397                return $cf;
398            }
399        }
400        if ($name === 'device-cmyk') {
401            $dc = $this->parseDeviceCmyk($tokens);
402            if ($dc !== null) {
403                return $dc;
404            }
405        }
406        if ($name === 'linear') {
407            $le = $this->parseLinearEasingFunction($tokens);
408            if ($le !== null) {
409                return $le;
410            }
411        }
412        if ($name === 'cubic-bezier') {
413            $cb = $this->parseCubicBezierFunction($tokens);
414            if ($cb !== null) {
415                return $cb;
416            }
417        }
418        if ($name === 'steps') {
419            $st = $this->parseStepsFunction($tokens);
420            if ($st !== null) {
421                return $st;
422            }
423        }
424        if (in_array($name, ['circle', 'ellipse', 'inset', 'polygon', 'rect', 'xywh', 'path'], true)) {
425            $shape = $this->parseBasicShape($name, $tokens);
426            if ($shape !== null) {
427                return $shape;
428            }
429        }
430        // Generic fallback: each comma-separated group becomes one argument.
431        return new CssFunction($name, $this->parseArgs($tokens));
432    }
433
434    /**
435     * Parse a sequence of transform functions into a single Transform value.
436     * Call from the cascade when the property being computed is `transform`.
437     */
438    public function parseTransform(string $css): Value
439    {
440        return $this->postProcessTransform($this->parseFromString($css));
441    }
442
443    /**
444     * Convert an already-parsed generic value (`CssFunction` or
445     * `ValueList` of `CssFunction`s) into a typed `Transform` value.
446     * Falls back to the original value when a function isn't a
447     * recognised transform â€” preserves `none` and any future
448     * additions that aren't yet handled.
449     */
450    public function postProcessTransform(Value $value): Value
451    {
452        if ($value instanceof Transform) {
453            return $value;
454        }
455        $items = $value instanceof ValueList ? $value->values : [$value];
456        $fns = [];
457        foreach ($items as $v) {
458            $fn = $this->valueToTransformFunction($v);
459            if ($fn === null) {
460                return $value;
461            }
462            $fns[] = $fn;
463        }
464        return new Transform($fns);
465    }
466
467    /**
468     * Parse + type a `filter:` declaration. Convenience entry
469     * point for tests + the cascade post-processor.
470     */
471    public function parseFilter(string $css): Value
472    {
473        return $this->postProcessFilter($this->parseFromString($css));
474    }
475
476    /**
477     * Convert a generic parsed value (CssFunction, ValueList of
478     * CssFunctions, Url) into a typed `Filter` so the painter can
479     * dispatch by FilterKind without re-inspecting strings. The
480     * keyword `none` (initial value) and any unrecognised
481     * function fall through unchanged.
482     */
483    public function postProcessFilter(Value $value): Value
484    {
485        if ($value instanceof Filter) {
486            return $value;
487        }
488        if ($value instanceof Keyword && strtolower($value->name) === 'none') {
489            return $value;
490        }
491        $items = $value instanceof ValueList ? $value->values : [$value];
492        $fns = [];
493        foreach ($items as $v) {
494            $fn = $this->valueToFilterFunction($v);
495            if ($fn === null) {
496                return $value;
497            }
498            $fns[] = $fn;
499        }
500        if ($fns === []) {
501            return $value;
502        }
503        return new Filter($fns);
504    }
505
506    /**
507     * CSS Fonts 4 Â§6.4 â€” lift `font-feature-settings`'s
508     * comma-separated `<feature-tag-value>` list into a typed
509     * {@see FontFeatureSettings}. Each entry is a 4-character
510     * OpenType tag (a StringValue) optionally followed by an
511     * integer / `on` / `off`.
512     *
513     *   font-feature-settings: "tnum", "liga" off, "ss01" 1;
514     *
515     * `normal` passes through unchanged; malformed entries cause
516     * the whole declaration to fall back to its original value so
517     * authors don't lose the surrounding context.
518     */
519    public function postProcessFontFeatureSettings(Value $value): Value
520    {
521        if ($value instanceof FontFeatureSettings) {
522            return $value;
523        }
524        if ($value instanceof Keyword && strtolower($value->name) === 'normal') {
525            return $value;
526        }
527        $entries = [];
528        if ($value instanceof ValueList && $value->separator === ListSeparator::Comma) {
529            $candidates = $value->values;
530        } else {
531            $candidates = [$value];
532        }
533        foreach ($candidates as $entry) {
534            $parsed = $this->parseFontFeatureEntry($entry);
535            if ($parsed === null) {
536                return $value;
537            }
538            $entries[] = $parsed;
539        }
540        if ($entries === []) {
541            return $value;
542        }
543        return new FontFeatureSettings($entries);
544    }
545
546    /**
547     * CSS Fonts 4 Â§6.5 â€” counterpart to font-feature-settings for
548     * the variable-font axes. Lifts `<string> <number>` pairs into
549     * {@see FontVariationSettings} entries.
550     *
551     *   font-variation-settings: "wght" 600, "wdth" 95.5;
552     */
553    public function postProcessFontVariationSettings(Value $value): Value
554    {
555        if ($value instanceof FontVariationSettings) {
556            return $value;
557        }
558        if ($value instanceof Keyword && strtolower($value->name) === 'normal') {
559            return $value;
560        }
561        $entries = [];
562        $candidates = $value instanceof ValueList && $value->separator === ListSeparator::Comma
563            ? $value->values
564            : [$value];
565        foreach ($candidates as $entry) {
566            $parsed = $this->parseFontVariationEntry($entry);
567            if ($parsed === null) {
568                return $value;
569            }
570            $entries[] = $parsed;
571        }
572        if ($entries === []) {
573            return $value;
574        }
575        return new FontVariationSettings($entries);
576    }
577
578    private function parseFontVariationEntry(Value $value): ?FontVariationValue
579    {
580        if (!($value instanceof ValueList) || $value->separator !== ListSeparator::Space) {
581            return null;
582        }
583        $parts = $value->values;
584        if (count($parts) !== 2 || !($parts[0] instanceof StringValue)) {
585            return null;
586        }
587        $tag = $parts[0]->value;
588        $val = $parts[1];
589        if ($val instanceof Integer) {
590            return new FontVariationValue($tag, (float) $val->value);
591        }
592        if ($val instanceof Number) {
593            return new FontVariationValue($tag, (float) $val->value);
594        }
595        return null;
596    }
597
598    private function parseFontFeatureEntry(Value $value): ?FontFeatureValue
599    {
600        if ($value instanceof StringValue) {
601            return new FontFeatureValue($value->value, 1);
602        }
603        if (!($value instanceof ValueList) || $value->separator !== ListSeparator::Space) {
604            return null;
605        }
606        $parts = $value->values;
607        if (count($parts) < 1 || count($parts) > 2) {
608            return null;
609        }
610        if (!($parts[0] instanceof StringValue)) {
611            return null;
612        }
613        $tag = $parts[0]->value;
614        if (!isset($parts[1])) {
615            return new FontFeatureValue($tag, 1);
616        }
617        $val = $parts[1];
618        if ($val instanceof Keyword) {
619            $kw = strtolower($val->name);
620            if ($kw === 'on') {
621                return new FontFeatureValue($tag, 1);
622            }
623            if ($kw === 'off') {
624                return new FontFeatureValue($tag, 0);
625            }
626            return null;
627        }
628        if ($val instanceof Integer) {
629            return new FontFeatureValue($tag, $val->value);
630        }
631        if ($val instanceof Number && (int) $val->value == $val->value) {
632            return new FontFeatureValue($tag, (int) $val->value);
633        }
634        return null;
635    }
636
637    private function valueToFilterFunction(Value $value): ?FilterFunction
638    {
639        // url() references for SVG filter chains are valid filter
640        // values too.
641        if ($value instanceof Url) {
642            return new FilterFunction(FilterKind::Url, [$value]);
643        }
644        if (!($value instanceof CssFunction)) {
645            return null;
646        }
647        $kind = FilterKind::tryFrom(strtolower($value->name));
648        if ($kind === null) {
649            return null;
650        }
651        return new FilterFunction($kind, $value->arguments);
652    }
653
654    private function valueToTransformFunction(Value $value): ?TransformFunction
655    {
656        if (!$value instanceof CssFunction) {
657            return null;
658        }
659        $name = strtolower($value->name);
660        $args = $value->arguments;
661        return match ($name) {
662            'translate' => $this->buildTranslate($args, false),
663            'translatex' => count($args) === 1 ? new TranslateTransform($this->toLengthOrPct($args[0]), new Length(0, LengthUnit::Px)) : null,
664            'translatey' => count($args) === 1 ? new TranslateTransform(new Length(0, LengthUnit::Px), $this->toLengthOrPct($args[0])) : null,
665            'translatez' => count($args) === 1 && $args[0] instanceof Length ? new TranslateTransform(new Length(0, LengthUnit::Px), new Length(0, LengthUnit::Px), $args[0]) : null,
666            'translate3d' => $this->buildTranslate($args, true),
667            'rotate' => count($args) === 1 ? new RotateTransform($this->toAngleDeg($args[0])) : null,
668            'rotatex' => count($args) === 1 ? new RotateTransform($this->toAngleDeg($args[0]), 1.0, 0.0, 0.0) : null,
669            'rotatey' => count($args) === 1 ? new RotateTransform($this->toAngleDeg($args[0]), 0.0, 1.0, 0.0) : null,
670            'rotatez' => count($args) === 1 ? new RotateTransform($this->toAngleDeg($args[0]), 0.0, 0.0, 1.0) : null,
671            'rotate3d' => $this->buildRotate3d($args),
672            'scale' => $this->buildScale($args),
673            'scalex' => count($args) === 1 ? new ScaleTransform($this->toFloat($args[0]), 1.0) : null,
674            'scaley' => count($args) === 1 ? new ScaleTransform(1.0, $this->toFloat($args[0])) : null,
675            'scalez' => count($args) === 1 ? new ScaleTransform(1.0, 1.0, $this->toFloat($args[0])) : null,
676            'scale3d' => count($args) === 3 ? new ScaleTransform($this->toFloat($args[0]), $this->toFloat($args[1]), $this->toFloat($args[2])) : null,
677            'skew' => $this->buildSkew($args),
678            'skewx' => count($args) === 1 ? new SkewTransform($this->toAngleDeg($args[0])) : null,
679            'skewy' => count($args) === 1 ? new SkewTransform(0.0, $this->toAngleDeg($args[0])) : null,
680            'matrix' => count($args) === 6 ? new MatrixTransform(
681                $this->toFloat($args[0]),
682                $this->toFloat($args[1]),
683                $this->toFloat($args[2]),
684                $this->toFloat($args[3]),
685                $this->toFloat($args[4]),
686                $this->toFloat($args[5]),
687            ) : null,
688            // CSS Transforms 2 Â§6.6 â€” `matrix3d(a1..a16)` in column-
689            // major order. Print is 2D, so we extract the affine
690            // (a, b, c, d, e, f) entries by projecting the 4×4 onto
691            // the X / Y plane: the 2D matrix `matrix(m11, m12, m21,
692            // m22, m41, m42)` corresponds to input indices 0, 1, 4,
693            // 5, 12, 13. The 3D rotation / perspective components
694            // collapse out, which is the same flattening other 3D
695            // transforms get.
696            'matrix3d' => count($args) === 16 ? new MatrixTransform(
697                $this->toFloat($args[0]),
698                $this->toFloat($args[1]),
699                $this->toFloat($args[4]),
700                $this->toFloat($args[5]),
701                $this->toFloat($args[12]),
702                $this->toFloat($args[13]),
703            ) : null,
704            // `perspective(<length>)` accepts the syntax but the
705            // print medium has no depth, so the function flattens
706            // to identity. Authors typically combine `perspective`
707            // with `rotateX` / `rotateY`; in print only the
708            // rotation portion contributes visually (via the
709            // cos-flatten in the painter).
710            'perspective' => new MatrixTransform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0),
711            default => null,
712        };
713    }
714
715    /** @param list<Value> $args */
716    private function buildTranslate(array $args, bool $is3d): ?TransformFunction
717    {
718        if ($is3d) {
719            if (count($args) !== 3) {
720                return null;
721            }
722            $z = $args[2] instanceof Length ? $args[2] : null;
723            if ($z === null) {
724                return null;
725            }
726            return new TranslateTransform($this->toLengthOrPct($args[0]), $this->toLengthOrPct($args[1]), $z);
727        }
728        if (count($args) === 1) {
729            return new TranslateTransform($this->toLengthOrPct($args[0]), new Length(0, LengthUnit::Px));
730        }
731        if (count($args) === 2) {
732            return new TranslateTransform($this->toLengthOrPct($args[0]), $this->toLengthOrPct($args[1]));
733        }
734        return null;
735    }
736
737    /** @param list<Value> $args */
738    private function buildRotate3d(array $args): ?RotateTransform
739    {
740        if (count($args) !== 4) {
741            return null;
742        }
743        return new RotateTransform(
744            $this->toAngleDeg($args[3]),
745            $this->toFloat($args[0]),
746            $this->toFloat($args[1]),
747            $this->toFloat($args[2]),
748        );
749    }
750
751    /** @param list<Value> $args */
752    private function buildScale(array $args): ?ScaleTransform
753    {
754        if (count($args) === 1) {
755            $sx = $this->toFloat($args[0]);
756            return new ScaleTransform($sx, $sx);
757        }
758        if (count($args) === 2) {
759            return new ScaleTransform($this->toFloat($args[0]), $this->toFloat($args[1]));
760        }
761        return null;
762    }
763
764    /** @param list<Value> $args */
765    private function buildSkew(array $args): ?SkewTransform
766    {
767        if (count($args) === 1) {
768            return new SkewTransform($this->toAngleDeg($args[0]));
769        }
770        if (count($args) === 2) {
771            return new SkewTransform($this->toAngleDeg($args[0]), $this->toAngleDeg($args[1]));
772        }
773        return null;
774    }
775
776    private function toLengthOrPct(Value $v): Length|Percentage
777    {
778        if ($v instanceof Length) {
779            return $v;
780        }
781        if ($v instanceof Percentage) {
782            return $v;
783        }
784        if ($v instanceof Number || $v instanceof Integer) {
785            // Per spec: in some contexts unitless numbers are treated as pixels.
786            return new Length((float) ($v instanceof Number ? $v->value : $v->value), LengthUnit::Px);
787        }
788        return new Length(0, LengthUnit::Px);
789    }
790
791    private function toAngleDeg(Value $v): float
792    {
793        if ($v instanceof Angle) {
794            return $v->toDegrees();
795        }
796        if ($v instanceof Number || $v instanceof Integer) {
797            // Unitless 0 is the only valid angle without a unit per spec;
798            // other unitless values are technically a parse error, but we
799            // accept them by treating as degrees.
800            return (float) ($v instanceof Number ? $v->value : $v->value);
801        }
802        return 0.0;
803    }
804
805    private function toFloat(Value $v): float
806    {
807        if ($v instanceof Number || $v instanceof Integer) {
808            return (float) ($v instanceof Number ? $v->value : $v->value);
809        }
810        return 0.0;
811    }
812
813    /** @param list<Token> $tokens
814     *  @return list<Value>
815     */
816    private function parseArgs(array $tokens): array
817    {
818        $groups = self::splitTopLevel($tokens, CommaToken::class);
819        return array_map(fn($g): Value => $this->parseSpaceList($g), $groups);
820    }
821
822    /** @param list<Token> $tokens */
823    private function parseRgbFunction(array $tokens): ?Color
824    {
825        // Accept both legacy comma form `rgb(r, g, b[, a])` and modern
826        // space form `rgb(r g b [/ a])`. Components are number 0-255 or
827        // percentage 0-100; alpha is number 0-1 or percentage.
828        $tokens = self::trimWhitespace($tokens);
829        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
830        if (count($commaGroups) >= 3) {
831            $groups = $commaGroups;
832        } else {
833            // Space-separated; the slash separates alpha.
834            $groups = self::splitRgbSpaceForm($tokens);
835        }
836        $count = count($groups);
837        if ($count < 3 || $count > 4) {
838            return null;
839        }
840        $rgb = [];
841        for ($i = 0; $i < 3; $i++) {
842            $v = $this->extractRgbComponent($groups[$i]);
843            if ($v === null) {
844                return null;
845            }
846            $rgb[] = $v;
847        }
848        $a = 1.0;
849        if ($count === 4) {
850            $alphaTok = $this->extractAlphaComponent($groups[3]);
851            if ($alphaTok === null) {
852                return null;
853            }
854            $a = $alphaTok;
855        }
856        return new Color($rgb[0], $rgb[1], $rgb[2], $a);
857    }
858
859    /** @param list<Token> $group */
860    private function extractRgbComponent(array $group): ?float
861    {
862        $group = self::trimWhitespace($group);
863        if (count($group) !== 1) {
864            return null;
865        }
866        $t = $group[0];
867        if ($t instanceof NumberToken) {
868            return max(0.0, min(1.0, $t->value / 255.0));
869        }
870        if ($t instanceof PercentageToken) {
871            return max(0.0, min(1.0, $t->value / 100.0));
872        }
873        return null;
874    }
875
876    /** @param list<Token> $group */
877    private function extractAlphaComponent(array $group): ?float
878    {
879        $group = self::trimWhitespace($group);
880        if (count($group) !== 1) {
881            return null;
882        }
883        $t = $group[0];
884        if ($t instanceof NumberToken) {
885            return max(0.0, min(1.0, $t->value));
886        }
887        if ($t instanceof PercentageToken) {
888            return max(0.0, min(1.0, $t->value / 100.0));
889        }
890        return null;
891    }
892
893    /**
894     * Split tokens by top-level whitespace, with `/` recognised as the
895     * alpha separator that yields a 4th group.
896     *
897     * @param list<Token> $tokens
898     * @return list<list<Token>>
899     */
900    private static function splitRgbSpaceForm(array $tokens): array
901    {
902        $groups = [];
903        $current = [];
904        foreach ($tokens as $t) {
905            if ($t instanceof WhitespaceToken) {
906                if ($current !== []) {
907                    $groups[] = $current;
908                    $current = [];
909                }
910                continue;
911            }
912            if ($t instanceof DelimToken && $t->value === '/') {
913                if ($current !== []) {
914                    $groups[] = $current;
915                    $current = [];
916                }
917                continue;
918            }
919            $current[] = $t;
920        }
921        if ($current !== []) {
922            $groups[] = $current;
923        }
924        return $groups;
925    }
926
927    /**
928     * Top-level split on a `/` delim token only â€” components are
929     * left intact (including their inner whitespace). Used for
930     * grammar fragments like `device-cmyk(<c> <m> <y> <k> / <a>)`
931     * where the slash separates a multi-token primary list from
932     * the alpha tail.
933     *
934     * @param list<Token> $tokens
935     * @return list<list<Token>>
936     */
937    private static function splitOnSlash(array $tokens): array
938    {
939        $groups = [];
940        $current = [];
941        $depth = 0;
942        foreach ($tokens as $t) {
943            if ($t instanceof FunctionToken || $t instanceof LeftParenToken) {
944                $depth++;
945                $current[] = $t;
946                continue;
947            }
948            if ($t instanceof RightParenToken) {
949                $depth--;
950                $current[] = $t;
951                continue;
952            }
953            if ($depth === 0 && $t instanceof DelimToken && $t->value === '/') {
954                $groups[] = $current;
955                $current = [];
956                continue;
957            }
958            $current[] = $t;
959        }
960        $groups[] = $current;
961        return $groups;
962    }
963
964    /** @param list<Token> $tokens */
965    private function parseHslFunction(array $tokens): ?Color
966    {
967        $tokens = self::trimWhitespace($tokens);
968        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
969        $groups = count($commaGroups) >= 3 ? $commaGroups : self::splitRgbSpaceForm($tokens);
970        $count = count($groups);
971        if ($count < 3 || $count > 4) {
972            return null;
973        }
974        $h = $this->extractHueComponent($groups[0]);
975        $s = $this->extractPercentageComponent($groups[1]);
976        $l = $this->extractPercentageComponent($groups[2]);
977        if ($h === null || $s === null || $l === null) {
978            return null;
979        }
980        $a = $count === 4 ? $this->extractAlphaComponent($groups[3]) : 1.0;
981        if ($a === null) {
982            return null;
983        }
984        [$r, $g, $b] = self::hslToRgb($h, $s, $l);
985        return new Color($r, $g, $b, $a);
986    }
987
988    /**
989     * CSS Color 4 Â§6 â€” `hwb(<hue> <whiteness>% <blackness>% [/ <alpha>])`.
990     * Whiteness/blackness sum >= 1 collapses to a grey; otherwise we mix
991     * the pure-saturation hue with white and black per the spec formula
992     * and store the result as sRGB.
993     *
994     * @param list<Token> $tokens
995     */
996    private function parseHwbFunction(array $tokens): ?Color
997    {
998        $tokens = self::trimWhitespace($tokens);
999        $groups = self::splitRgbSpaceForm($tokens);
1000        $count = count($groups);
1001        if ($count < 3 || $count > 4) {
1002            return null;
1003        }
1004        $h = $this->extractHueComponent($groups[0]);
1005        $w = $this->extractPercentageComponent($groups[1]);
1006        $b = $this->extractPercentageComponent($groups[2]);
1007        if ($h === null || $w === null || $b === null) {
1008            return null;
1009        }
1010        $a = $count === 4 ? $this->extractAlphaComponent($groups[3]) : 1.0;
1011        if ($a === null) {
1012            return null;
1013        }
1014        // extractHueComponent returns the hue normalised to [0, 1). Scale
1015        // back to degrees so the converter can do its standard hue→rgb.
1016        $hDeg = $h * 360.0;
1017        if ($w + $b >= 1.0) {
1018            $gray = $w / ($w + $b);
1019            return new Color($gray, $gray, $gray, $a);
1020        }
1021        [$rh, $gh, $bh] = self::hslToRgb($h, 1.0, 0.5);
1022        $r = $rh * (1.0 - $w - $b) + $w;
1023        $g = $gh * (1.0 - $w - $b) + $w;
1024        $b2 = $bh * (1.0 - $w - $b) + $w;
1025        return new Color(
1026            max(0.0, min(1.0, $r)),
1027            max(0.0, min(1.0, $g)),
1028            max(0.0, min(1.0, $b2)),
1029            $a,
1030        );
1031    }
1032
1033    /** @param list<Token> $group */
1034    private function extractHueComponent(array $group): ?float
1035    {
1036        $group = self::trimWhitespace($group);
1037        if (count($group) !== 1) {
1038            return null;
1039        }
1040        $t = $group[0];
1041        if ($t instanceof IdentToken && strtolower($t->value) === 'none') {
1042            // CSS Color 4 Â§10.5 â€” `none` in a literal context resolves
1043            // to 0. The "missing-component" tag is only meaningful in
1044            // interpolation, which we don't model here.
1045            return 0.0;
1046        }
1047        if ($t instanceof NumberToken) {
1048            return fmod(fmod($t->value, 360) + 360, 360) / 360.0; // normalise to [0, 1)
1049        }
1050        if ($t instanceof DimensionToken) {
1051            $degrees = match (strtolower($t->unit)) {
1052                'deg' => $t->value,
1053                'rad' => $t->value * 180 / M_PI,
1054                'grad' => $t->value * 0.9,
1055                'turn' => $t->value * 360,
1056                default => null,
1057            };
1058            if ($degrees === null) {
1059                return null;
1060            }
1061            return fmod(fmod($degrees, 360) + 360, 360) / 360.0;
1062        }
1063        return null;
1064    }
1065
1066    /** @param list<Token> $group */
1067    private function extractPercentageComponent(array $group): ?float
1068    {
1069        $group = self::trimWhitespace($group);
1070        if (count($group) !== 1) {
1071            return null;
1072        }
1073        $t = $group[0];
1074        if ($t instanceof IdentToken && strtolower($t->value) === 'none') {
1075            // CSS Color 4 Â§10.5 â€” `none` resolves to 0 outside
1076            // interpolation contexts.
1077            return 0.0;
1078        }
1079        if ($t instanceof PercentageToken) {
1080            return max(0.0, min(1.0, $t->value / 100.0));
1081        }
1082        return null;
1083    }
1084
1085    /**
1086     * Convert HSL (each component in [0, 1]) to sRGB (each component in [0, 1])
1087     * per CSS Color 4 algorithm.
1088     *
1089     * @return array{0:float,1:float,2:float}
1090     */
1091    private static function hslToRgb(float $h, float $s, float $l): array
1092    {
1093        if ($s === 0.0) {
1094            return [$l, $l, $l];
1095        }
1096        $q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
1097        $p = 2 * $l - $q;
1098        $hueToRgb = static function (float $p, float $q, float $t): float {
1099            if ($t < 0) {
1100                $t += 1;
1101            }
1102            if ($t > 1) {
1103                $t -= 1;
1104            }
1105            if ($t < 1 / 6) {
1106                return $p + ($q - $p) * 6 * $t;
1107            }
1108            if ($t < 1 / 2) {
1109                return $q;
1110            }
1111            if ($t < 2 / 3) {
1112                return $p + ($q - $p) * (2 / 3 - $t) * 6;
1113            }
1114            return $p;
1115        };
1116        return [
1117            $hueToRgb($p, $q, $h + 1 / 3),
1118            $hueToRgb($p, $q, $h),
1119            $hueToRgb($p, $q, $h - 1 / 3),
1120        ];
1121    }
1122
1123    private function parseHexColor(string $hex): ?Color
1124    {
1125        $hex = strtolower($hex);
1126        // Accept 3, 4, 6, 8 hex digits.
1127        if (!preg_match('/^[0-9a-f]+$/', $hex)) {
1128            return null;
1129        }
1130        $len = strlen($hex);
1131        if ($len === 3) {
1132            $r = hexdec($hex[0] . $hex[0]);
1133            $g = hexdec($hex[1] . $hex[1]);
1134            $b = hexdec($hex[2] . $hex[2]);
1135            return new Color($r / 255.0, $g / 255.0, $b / 255.0);
1136        }
1137        if ($len === 4) {
1138            $r = hexdec($hex[0] . $hex[0]);
1139            $g = hexdec($hex[1] . $hex[1]);
1140            $b = hexdec($hex[2] . $hex[2]);
1141            $a = hexdec($hex[3] . $hex[3]);
1142            return new Color($r / 255.0, $g / 255.0, $b / 255.0, $a / 255.0);
1143        }
1144        if ($len === 6) {
1145            $r = hexdec(substr($hex, 0, 2));
1146            $g = hexdec(substr($hex, 2, 2));
1147            $b = hexdec(substr($hex, 4, 2));
1148            return new Color($r / 255.0, $g / 255.0, $b / 255.0);
1149        }
1150        if ($len === 8) {
1151            $r = hexdec(substr($hex, 0, 2));
1152            $g = hexdec(substr($hex, 2, 2));
1153            $b = hexdec(substr($hex, 4, 2));
1154            $a = hexdec(substr($hex, 6, 2));
1155            return new Color($r / 255.0, $g / 255.0, $b / 255.0, $a / 255.0);
1156        }
1157        return null;
1158    }
1159
1160    // ============================================================
1161    // var(--name, fallback)
1162    // ============================================================
1163    /** @param list<Token> $tokens */
1164    private function parseVarFunction(array $tokens): ?CustomProperty
1165    {
1166        $tokens = self::trimWhitespace($tokens);
1167        $groups = self::splitTopLevel($tokens, CommaToken::class);
1168        if (count($groups) < 1 || count($groups) > 2) {
1169            return null;
1170        }
1171        $nameTokens = self::trimWhitespace($groups[0]);
1172        if (count($nameTokens) !== 1 || !($nameTokens[0] instanceof IdentToken)) {
1173            return null;
1174        }
1175        $name = $nameTokens[0]->value;
1176        if (!str_starts_with($name, '--')) {
1177            return null;
1178        }
1179        $fallback = null;
1180        if (count($groups) === 2) {
1181            $fallback = $this->parseSpaceList($groups[1]);
1182        }
1183        return new CustomProperty($name, $fallback);
1184    }
1185
1186    // ============================================================
1187    // color(<space> r g b [/ a])
1188    // ============================================================
1189    /** @param list<Token> $tokens */
1190    private function parseColorFunction(array $tokens): ?Color
1191    {
1192        $tokens = self::trimWhitespace($tokens);
1193        $groups = self::splitRgbSpaceForm($tokens);
1194        // Expect: [space] [r] [g] [b] (4 groups), with optional [a] (5 groups).
1195        if (count($groups) < 4 || count($groups) > 5) {
1196            return null;
1197        }
1198        $spaceTokens = self::trimWhitespace($groups[0]);
1199        if (count($spaceTokens) !== 1 || !($spaceTokens[0] instanceof IdentToken)) {
1200            return null;
1201        }
1202        $space = match (strtolower($spaceTokens[0]->value)) {
1203            'srgb' => ColorSpace::sRGB,
1204            'srgb-linear' => ColorSpace::sRGBLinear,
1205            'display-p3' => ColorSpace::DisplayP3,
1206            'display-p3-linear' => ColorSpace::DisplayP3Linear,
1207            'a98-rgb' => ColorSpace::A98RGB,
1208            'a98-rgb-linear' => ColorSpace::A98RGBLinear,
1209            'prophoto-rgb' => ColorSpace::ProPhotoRGB,
1210            'prophoto-rgb-linear' => ColorSpace::ProPhotoRGBLinear,
1211            'rec2020' => ColorSpace::Rec2020,
1212            'rec2020-linear' => ColorSpace::Rec2020Linear,
1213            'xyz', 'xyz-d65' => ColorSpace::XYZD65,
1214            'xyz-d50' => ColorSpace::XYZD50,
1215            default => null,
1216        };
1217        if ($space === null) {
1218            return null;
1219        }
1220        $isXyzSpace = $space === ColorSpace::XYZD50 || $space === ColorSpace::XYZD65;
1221        $components = [];
1222        for ($i = 1; $i <= 3; $i++) {
1223            $c = $this->extractColorComponent($groups[$i], $isXyzSpace);
1224            if ($c === null) {
1225                return null;
1226            }
1227            $components[] = $c;
1228        }
1229        $a = 1.0;
1230        if (count($groups) === 5) {
1231            $aValue = $this->extractAlphaComponent($groups[4]);
1232            if ($aValue === null) {
1233                return null;
1234            }
1235            $a = $aValue;
1236        }
1237        return new Color($components[0], $components[1], $components[2], $a, $space);
1238    }
1239
1240    /**
1241     * Extract a `color()` component. For sRGB-ish spaces this is
1242     * 0-1; CSS Color 4 Â§6 also allows out-of-gamut values, so we
1243     * preserve numeric inputs as-is rather than clamping. For XYZ
1244     * spaces, percentages map to the sRGB white-point's Y = 100%
1245     * (which is XYZ 1) so the divide-by-100 is still right.
1246     *
1247     * `none` ident resolves to 0 â€” a Missing-typed component lands
1248     * once the 4E color engine ships.
1249     *
1250     * @param list<Token> $group
1251     */
1252    private function extractColorComponent(array $group, bool $isXyzSpace = false): ?float
1253    {
1254        unset($isXyzSpace);
1255        $group = self::trimWhitespace($group);
1256        if (count($group) !== 1) {
1257            return null;
1258        }
1259        $t = $group[0];
1260        if ($t instanceof IdentToken && strtolower($t->value) === 'none') {
1261            return 0.0;
1262        }
1263        if ($t instanceof NumberToken) {
1264            // CSS Color 4 Â§6 allows out-of-gamut values; preserve
1265            // the raw number and let the gamut-mapping algorithm
1266            // (4E) decide what to do with it.
1267            return (float) $t->value;
1268        }
1269        if ($t instanceof PercentageToken) {
1270            return (float) $t->value / 100.0;
1271        }
1272        return null;
1273    }
1274
1275    // ============================================================
1276    // lab() / lch() / oklab() / oklch()  â€” CSS Color 4 Â§10
1277    // ============================================================
1278    /**
1279     * Parse a `lab()`, `lch()`, `oklab()`, or `oklch()` functional
1280     * notation. CSS Color 4 Â§10 syntax:
1281     *
1282     *   lab(L a b [/ alpha])
1283     *   lch(L C H [/ alpha])
1284     *   oklab(L a b [/ alpha])
1285     *   oklch(L C H [/ alpha])
1286     *
1287     * Component value ranges per CSS Color 4:
1288     *
1289     *   lab    L: 0-100 (or 0-100%); a, b: Â±125 (±100% = Â±125)
1290     *   lch    L: 0-100 (or 0-100%); C: 0-150 (0-100% = 0-150); H: angle
1291     *   oklab  L: 0-1   (or 0-100%); a, b: Â±0.4 (±100% = Â±0.4)
1292     *   oklch  L: 0-1   (or 0-100%); C: 0-0.4 (0-100% = 0-0.4); H: angle
1293     *
1294     * Resolved values are stored in the Color's r/g/b slots without
1295     * normalisation â€” the color space tag identifies which axes
1296     * they represent. Downstream consumers (the 4E color engine)
1297     * do the gamut-conversion math.
1298     *
1299     * @param list<Token> $tokens
1300     */
1301    private function parseLabFunction(string $name, array $tokens): ?Color
1302    {
1303        $tokens = self::trimWhitespace($tokens);
1304        $groups = self::splitRgbSpaceForm($tokens);
1305        if (count($groups) < 3 || count($groups) > 4) {
1306            return null;
1307        }
1308
1309        $isLightnessPct100 = ($name === 'lab' || $name === 'lch');
1310        $L = $this->extractLabLightness($groups[0], $isLightnessPct100);
1311        if ($L === null) {
1312            return null;
1313        }
1314        // CSS Color 4 Â§10.5 â€” `lab` / `lch` lightness clamps to
1315        // [0, 100]; `oklab` / `oklch` lightness clamps to [0, 1].
1316        // The negative range, and values past the 100 / 1 ceiling,
1317        // are valid syntax but render at the boundary (WPT
1318        // lab-l-over-100-*, oklab-l-over-1-*).
1319        $lMax = $isLightnessPct100 ? 100.0 : 1.0;
1320        $L = max(0.0, min($lMax, $L));
1321
1322        $space = match ($name) {
1323            'lab' => ColorSpace::Lab,
1324            'lch' => ColorSpace::Lch,
1325            'oklab' => ColorSpace::OKLab,
1326            'oklch' => ColorSpace::OKLCH,
1327            default => null,
1328        };
1329        if ($space === null) {
1330            return null;
1331        }
1332
1333        $isPolar = ($name === 'lch' || $name === 'oklch');
1334        if ($isPolar) {
1335            // C (chroma) â€” 0-150 for lch, 0-0.4 for oklch.
1336            $chromaMax = $name === 'lch' ? 150.0 : 0.4;
1337            $c = $this->extractChromaComponent($groups[1], $chromaMax);
1338            if ($c === null) {
1339                return null;
1340            }
1341            $h = $this->extractLabHueComponent($groups[2]);
1342            if ($h === null) {
1343                return null;
1344            }
1345            $a = 1.0;
1346            if (count($groups) === 4) {
1347                $aValue = $this->extractAlphaComponent($groups[3]);
1348                if ($aValue === null) {
1349                    return null;
1350                }
1351                $a = $aValue;
1352            }
1353            return new Color($L, $c, $h, $a, $space);
1354        }
1355
1356        // lab / oklab â€” a and b in Cartesian coordinates.
1357        $axisMax = $name === 'lab' ? 125.0 : 0.4;
1358        $aAxis = $this->extractLabAxisComponent($groups[1], $axisMax);
1359        $bAxis = $this->extractLabAxisComponent($groups[2], $axisMax);
1360        if ($aAxis === null || $bAxis === null) {
1361            return null;
1362        }
1363        $alpha = 1.0;
1364        if (count($groups) === 4) {
1365            $aValue = $this->extractAlphaComponent($groups[3]);
1366            if ($aValue === null) {
1367                return null;
1368            }
1369            $alpha = $aValue;
1370        }
1371        return new Color($L, $aAxis, $bAxis, $alpha, $space);
1372    }
1373
1374    /**
1375     * Extract a `lab()` / `lch()` / `oklab()` / `oklch()` lightness.
1376     * `$pctTo100` selects the percentage mapping:
1377     *   true  â†’ 0-100% maps to 0-100 (lab / lch)
1378     *   false â†’ 0-100% maps to 0-1   (oklab / oklch)
1379     *
1380     * `none` is parsed as a missing component per CSS Color 4 Â§10.5;
1381     * we resolve it to 0 for now (a Missing-typed component lands
1382     * once the 4E color engine ships).
1383     *
1384     * @param list<Token> $group
1385     */
1386    private function extractLabLightness(array $group, bool $pctTo100): ?float
1387    {
1388        $group = self::trimWhitespace($group);
1389        if (count($group) !== 1) {
1390            return null;
1391        }
1392        $t = $group[0];
1393        if ($t instanceof IdentToken && strtolower($t->value) === 'none') {
1394            return 0.0;
1395        }
1396        if ($t instanceof NumberToken) {
1397            return (float) $t->value;
1398        }
1399        if ($t instanceof PercentageToken) {
1400            return $pctTo100 ? (float) $t->value : ((float) $t->value / 100.0);
1401        }
1402        return null;
1403    }
1404
1405    /**
1406     * Extract a `lab()` / `oklab()` axis component (a or b).
1407     * Percentages map Â±100% â†’ Â±$axisMax. Numbers pass through.
1408     * `none` resolves to 0.
1409     *
1410     * @param list<Token> $group
1411     */
1412    private function extractLabAxisComponent(array $group, float $axisMax): ?float
1413    {
1414        $group = self::trimWhitespace($group);
1415        if (count($group) !== 1) {
1416            return null;
1417        }
1418        $t = $group[0];
1419        if ($t instanceof IdentToken && strtolower($t->value) === 'none') {
1420            return 0.0;
1421        }
1422        if ($t instanceof NumberToken) {
1423            return (float) $t->value;
1424        }
1425        if ($t instanceof PercentageToken) {
1426            return ((float) $t->value / 100.0) * $axisMax;
1427        }
1428        return null;
1429    }
1430
1431    /**
1432     * Extract an `lch()` / `oklch()` chroma component.
1433     * Percentages map 0-100% â†’ 0-$chromaMax. Numbers pass through.
1434     * `none` resolves to 0.
1435     *
1436     * @param list<Token> $group
1437     */
1438    private function extractChromaComponent(array $group, float $chromaMax): ?float
1439    {
1440        $group = self::trimWhitespace($group);
1441        if (count($group) !== 1) {
1442            return null;
1443        }
1444        $t = $group[0];
1445        if ($t instanceof IdentToken && strtolower($t->value) === 'none') {
1446            return 0.0;
1447        }
1448        if ($t instanceof NumberToken) {
1449            return max(0.0, (float) $t->value);
1450        }
1451        if ($t instanceof PercentageToken) {
1452            return max(0.0, ((float) $t->value / 100.0) * $chromaMax);
1453        }
1454        return null;
1455    }
1456
1457    /**
1458     * Extract an `lch()` / `oklch()` hue component in degrees
1459     * (CSS Color 4 stores hues as degrees, not the 0-1 normalised
1460     * form HSL uses). Accepts number (degrees implied), `deg`,
1461     * `rad`, `grad`, `turn` dimension units, or `none`.
1462     *
1463     * @param list<Token> $group
1464     */
1465    private function extractLabHueComponent(array $group): ?float
1466    {
1467        $group = self::trimWhitespace($group);
1468        if (count($group) !== 1) {
1469            return null;
1470        }
1471        $t = $group[0];
1472        if ($t instanceof IdentToken && strtolower($t->value) === 'none') {
1473            return 0.0;
1474        }
1475        if ($t instanceof NumberToken) {
1476            return self::normaliseHueDegrees((float) $t->value);
1477        }
1478        if ($t instanceof DimensionToken) {
1479            $degrees = match (strtolower($t->unit)) {
1480                'deg' => (float) $t->value,
1481                'rad' => (float) $t->value * 180 / M_PI,
1482                'grad' => (float) $t->value * 0.9,
1483                'turn' => (float) $t->value * 360,
1484                default => null,
1485            };
1486            if ($degrees === null) {
1487                return null;
1488            }
1489            return self::normaliseHueDegrees($degrees);
1490        }
1491        return null;
1492    }
1493
1494    /**
1495     * Wrap a hue angle into the canonical [0, 360) range.
1496     */
1497    private static function normaliseHueDegrees(float $degrees): float
1498    {
1499        return fmod(fmod($degrees, 360.0) + 360.0, 360.0);
1500    }
1501
1502    // ============================================================
1503    // relative color syntax â€” CSS Color 5 Â§4
1504    // ============================================================
1505    /**
1506     * Detect a `<colorFn>(from <color> ...)` prefix and parse the
1507     * relative-color form. Returns null when the function doesn't
1508     * start with `from`, so the caller falls back to its non-
1509     * relative parser.
1510     *
1511     * @param list<Token> $tokens
1512     */
1513    private function parseRelativeColorIfPresent(string $name, array $tokens): ?RelativeColor
1514    {
1515        $trimmed = self::trimWhitespace($tokens);
1516        if ($trimmed === []) {
1517            return null;
1518        }
1519        $first = $trimmed[0];
1520        if (!($first instanceof IdentToken) || strtolower($first->value) !== 'from') {
1521            return null;
1522        }
1523        return $this->parseRelativeColorBody($name, array_slice($trimmed, 1));
1524    }
1525
1526    /**
1527     * Parse the body of `<colorFn>(from <source> <c1> <c2> <c3>
1528     * [/ alpha])` (or the `color(from <source> <space> <c1> <c2>
1529     * <c3> [/ alpha])` form for `color()`).
1530     *
1531     * @param list<Token> $tokensAfterFrom
1532     */
1533    private function parseRelativeColorBody(string $name, array $tokensAfterFrom): ?RelativeColor
1534    {
1535        $tokensAfterFrom = self::trimWhitespace($tokensAfterFrom);
1536
1537        // The source color is the next "value" â€” could be a hex
1538        // (HashToken), an ident (named color), or a function call.
1539        // Use collectFunctionLikeRun for function-call runs; for
1540        // hash / ident, just one token.
1541        if ($tokensAfterFrom === []) {
1542            return null;
1543        }
1544        $sourceConsumed = self::collectColorRun($tokensAfterFrom, 0);
1545        if ($sourceConsumed === 0) {
1546            return null;
1547        }
1548        $sourceCss = self::serializeTokens(array_slice($tokensAfterFrom, 0, $sourceConsumed));
1549        $source = $this->parseFromString($sourceCss);
1550        // `from` accepts a concrete `<color>` OR the `currentcolor` /
1551        // `transparent` keywords (CSS Color 5 Â§4 â€” those still name
1552        // colors at the using element). The painter resolves a
1553        // keyword source to a concrete sRGB color before evaluating
1554        // the relative formula.
1555        if (!($source instanceof Color)
1556            && !($source instanceof Keyword
1557                && in_array(strtolower($source->name), ['currentcolor', 'transparent'], true))
1558        ) {
1559            return null;
1560        }
1561
1562        $rest = self::trimWhitespace(array_slice($tokensAfterFrom, $sourceConsumed));
1563
1564        // For `color()`, an additional <space> ident follows.
1565        $space = null;
1566        if ($name === 'color') {
1567            if ($rest === [] || !($rest[0] instanceof IdentToken)) {
1568                return null;
1569            }
1570            $space = match (strtolower($rest[0]->value)) {
1571                'srgb' => ColorSpace::sRGB,
1572                'srgb-linear' => ColorSpace::sRGBLinear,
1573                'display-p3' => ColorSpace::DisplayP3,
1574                'display-p3-linear' => ColorSpace::DisplayP3Linear,
1575                'a98-rgb' => ColorSpace::A98RGB,
1576                'a98-rgb-linear' => ColorSpace::A98RGBLinear,
1577                'prophoto-rgb' => ColorSpace::ProPhotoRGB,
1578                'prophoto-rgb-linear' => ColorSpace::ProPhotoRGBLinear,
1579                'rec2020' => ColorSpace::Rec2020,
1580                'rec2020-linear' => ColorSpace::Rec2020Linear,
1581                'xyz', 'xyz-d65' => ColorSpace::XYZD65,
1582                'xyz-d50' => ColorSpace::XYZD50,
1583                default => null,
1584            };
1585            if ($space === null) {
1586                return null;
1587            }
1588            $rest = self::trimWhitespace(array_slice($rest, 1));
1589        } else {
1590            $space = match ($name) {
1591                'rgb', 'rgba' => ColorSpace::sRGB,
1592                'hsl', 'hsla' => ColorSpace::sRGB,    // polar in CSS; stored as sRGB
1593                'hwb' => ColorSpace::sRGB,
1594                'lab' => ColorSpace::Lab,
1595                'lch' => ColorSpace::Lch,
1596                'oklab' => ColorSpace::OKLab,
1597                'oklch' => ColorSpace::OKLCH,
1598                default => null,
1599            };
1600            if ($space === null) {
1601                return null;
1602            }
1603        }
1604
1605        // Split the remainder on `/` (alpha separator) and then
1606        // by whitespace into three component groups. Use the
1607        // paren-aware splitter so `calc(r + 10)` stays one group
1608        // despite the inner whitespace.
1609        $slashGroups = self::splitTopLevelDelim($rest, '/');
1610        $componentTokens = self::trimWhitespace($slashGroups[0]);
1611        $componentGroups = self::splitParenAwareSpaceForm($componentTokens);
1612        if (count($componentGroups) !== 3) {
1613            return null;
1614        }
1615
1616        $c1 = $this->parseRelativeComponent($componentGroups[0]);
1617        $c2 = $this->parseRelativeComponent($componentGroups[1]);
1618        $c3 = $this->parseRelativeComponent($componentGroups[2]);
1619        if ($c1 === null || $c2 === null || $c3 === null) {
1620            return null;
1621        }
1622
1623        $alpha = new Number(1.0);
1624        if (count($slashGroups) === 2) {
1625            $alphaTokens = self::trimWhitespace($slashGroups[1]);
1626            $parsedAlpha = $this->parseRelativeComponent($alphaTokens);
1627            if ($parsedAlpha === null) {
1628                return null;
1629            }
1630            $alpha = $parsedAlpha;
1631        }
1632
1633        return new RelativeColor($space, $source, $c1, $c2, $c3, $alpha);
1634    }
1635
1636    /**
1637     * Like {@see splitRgbSpaceForm} but skips whitespace / `/`
1638     * delimiters inside paren-balanced runs (function calls etc).
1639     * Lets `rgb(from red calc(r + 10) g b)` split into 3 groups
1640     * even though `calc(r + 10)` carries inner whitespace.
1641     *
1642     * @param list<Token> $tokens
1643     * @return list<list<Token>>
1644     */
1645    private static function splitParenAwareSpaceForm(array $tokens): array
1646    {
1647        $groups = [];
1648        $current = [];
1649        $depth = 0;
1650        foreach ($tokens as $t) {
1651            if ($t instanceof LeftParenToken || $t instanceof FunctionToken) {
1652                $depth++;
1653                $current[] = $t;
1654                continue;
1655            }
1656            if ($t instanceof RightParenToken) {
1657                $depth--;
1658                $current[] = $t;
1659                continue;
1660            }
1661            if ($depth === 0 && $t instanceof WhitespaceToken) {
1662                if ($current !== []) {
1663                    $groups[] = $current;
1664                    $current = [];
1665                }
1666                continue;
1667            }
1668            if ($depth === 0 && $t instanceof DelimToken && $t->value === '/') {
1669                if ($current !== []) {
1670                    $groups[] = $current;
1671                    $current = [];
1672                }
1673                continue;
1674            }
1675            $current[] = $t;
1676        }
1677        if ($current !== []) {
1678            $groups[] = $current;
1679        }
1680        return $groups;
1681    }
1682
1683    /**
1684     * Collect tokens for a single color value at the start of the
1685     * sequence: a hash, a function call (possibly nested), or a
1686     * single ident (named color). Whitespace consumed too. Returns
1687     * the count of tokens consumed (0 on no progress).
1688     *
1689     * @param list<Token> $tokens
1690     */
1691    private static function collectColorRun(array $tokens, int $start): int
1692    {
1693        $head = $tokens[$start] ?? null;
1694        if ($head === null) {
1695            return 0;
1696        }
1697        if ($head instanceof FunctionToken) {
1698            return self::collectFunctionLikeRun($tokens, $start);
1699        }
1700        if ($head instanceof HashToken || $head instanceof IdentToken) {
1701            return 1;
1702        }
1703        return 0;
1704    }
1705
1706    /**
1707     * Parse a single relative-color component expression. The
1708     * input may be an ident (e.g. `r`, `none`, `alpha`), a
1709     * number, a percentage, a dimension, or a calc()-like
1710     * function â€” any Value the cascade can represent. Returns
1711     * null when the token group is empty or doesn't parse.
1712     *
1713     * @param list<Token> $tokens
1714     */
1715    private function parseRelativeComponent(array $tokens): ?Value
1716    {
1717        $tokens = self::trimWhitespace($tokens);
1718        if ($tokens === []) {
1719            return null;
1720        }
1721        $css = self::serializeTokens($tokens);
1722        if ($css === '') {
1723            return null;
1724        }
1725        return $this->parseFromString($css);
1726    }
1727
1728    // ============================================================
1729    // color-mix() â€” CSS Color 5 Â§3
1730    // ============================================================
1731    /**
1732     * Parse `color-mix(in <space> [<hue-interpolation> hue], <color>
1733     * [<percentage>], <color> [<percentage>])` per CSS Color 5 Â§3.
1734     *
1735     * Percentage normalisation per Â§3.1:
1736     *
1737     *   - Both omitted          â†’ 50% / 50%
1738     *   - One given (p)         â†’ other = 100% âˆ’ p
1739     *   - Both given, sum > 0%  â†’ multiply each by 100/(p1+p2) and
1740     *                             remember the original sum as
1741     *                             `alphaMultiplier` so the resulting
1742     *                             color's alpha can be scaled.
1743     *   - Both 0%               â†’ invalid; return null.
1744     *
1745     * Per-space hue interpolation method is captured for polar
1746     * spaces (HSL, HWB, LCH, OKLCH) and ignored for the rest.
1747     *
1748     * @param list<Token> $tokens
1749     */
1750    private function parseColorMixFunction(array $tokens): ?ColorMix
1751    {
1752        $tokens = self::trimWhitespace($tokens);
1753        $groups = self::splitTopLevel($tokens, CommaToken::class);
1754        if (count($groups) !== 3) {
1755            return null;
1756        }
1757
1758        // Group 0: "in <space> [<hue-interp> hue]"
1759        $methodSpec = $this->parseColorMixMethod(self::trimWhitespace($groups[0]));
1760        if ($methodSpec === null) {
1761            return null;
1762        }
1763        [$space, $hueInterpolation] = $methodSpec;
1764
1765        // Groups 1 + 2: each is `<color> [<percentage>]`.
1766        $left = $this->parseColorMixColorAndPercent(self::trimWhitespace($groups[1]));
1767        $right = $this->parseColorMixColorAndPercent(self::trimWhitespace($groups[2]));
1768        if ($left === null || $right === null) {
1769            return null;
1770        }
1771        [$color1, $p1] = $left;
1772        [$color2, $p2] = $right;
1773
1774        // Normalise percentages.
1775        if ($p1 === null && $p2 === null) {
1776            $p1 = 50.0;
1777            $p2 = 50.0;
1778            $alphaMultiplier = 1.0;
1779        } elseif ($p1 === null) {
1780            assert($p2 !== null);
1781            $p1 = 100.0 - $p2;
1782            $alphaMultiplier = 1.0;
1783        } elseif ($p2 === null) {
1784            $p2 = 100.0 - $p1;
1785            $alphaMultiplier = 1.0;
1786        } else {
1787            $sum = $p1 + $p2;
1788            if ($sum <= 0.0) {
1789                return null;
1790            }
1791            // Per Â§3.1, when both percentages are present and sum
1792            // â‰  100, we scale them and remember `(p1+p2)/100` as
1793            // the alpha multiplier so the result's alpha can be
1794            // multiplied by it.
1795            $alphaMultiplier = min(1.0, $sum / 100.0);
1796            $p1 = ($p1 / $sum) * 100.0;
1797            $p2 = ($p2 / $sum) * 100.0;
1798        }
1799
1800        return new ColorMix(
1801            space: $space,
1802            color1: $color1,
1803            percentage1: $p1,
1804            color2: $color2,
1805            percentage2: $p2,
1806            alphaMultiplier: $alphaMultiplier,
1807            hueInterpolation: $hueInterpolation,
1808        );
1809    }
1810
1811    /**
1812     * Parse the `in <space> [<hue-interp> hue]` head of a
1813     * color-mix() function. Returns `[ColorSpace, ?HueInterpolation]`
1814     * or null if malformed.
1815     *
1816     * @param list<Token> $tokens
1817     * @return array{0: ColorSpace, 1: ?HueInterpolation}|null
1818     */
1819    private function parseColorMixMethod(array $tokens): ?array
1820    {
1821        if ($tokens === []) {
1822            return null;
1823        }
1824        $first = array_shift($tokens);
1825        if (!($first instanceof IdentToken) || strtolower($first->value) !== 'in') {
1826            return null;
1827        }
1828        $tokens = self::trimWhitespace($tokens);
1829        if ($tokens === []) {
1830            return null;
1831        }
1832        $spaceTok = array_shift($tokens);
1833        if (!($spaceTok instanceof IdentToken)) {
1834            return null;
1835        }
1836        $space = match (strtolower($spaceTok->value)) {
1837            'srgb' => ColorSpace::sRGB,
1838            'srgb-linear' => ColorSpace::sRGBLinear,
1839            'display-p3' => ColorSpace::DisplayP3,
1840            'display-p3-linear' => ColorSpace::DisplayP3Linear,
1841            'a98-rgb' => ColorSpace::A98RGB,
1842            'a98-rgb-linear' => ColorSpace::A98RGBLinear,
1843            'prophoto-rgb' => ColorSpace::ProPhotoRGB,
1844            'prophoto-rgb-linear' => ColorSpace::ProPhotoRGBLinear,
1845            'rec2020' => ColorSpace::Rec2020,
1846            'rec2020-linear' => ColorSpace::Rec2020Linear,
1847            'lab' => ColorSpace::Lab,
1848            'lch' => ColorSpace::Lch,
1849            'oklab' => ColorSpace::OKLab,
1850            'oklch' => ColorSpace::OKLCH,
1851            'xyz', 'xyz-d65' => ColorSpace::XYZD65,
1852            'xyz-d50' => ColorSpace::XYZD50,
1853            // HSL and HWB are polar spaces for mixing only â€”
1854            // they're handled by the engine via a sRGB round-trip;
1855            // we accept them at parse time but tag as sRGB so the
1856            // engine can lift back into the polar space for hue
1857            // interpolation.
1858            'hsl', 'hwb' => ColorSpace::sRGB,
1859            default => null,
1860        };
1861        if ($space === null) {
1862            return null;
1863        }
1864
1865        // Optional `<hue-interp> hue` clause â€” only meaningful for
1866        // polar spaces.
1867        $tokens = self::trimWhitespace($tokens);
1868        if ($tokens === []) {
1869            return [$space, null];
1870        }
1871        $hueInterp = array_shift($tokens);
1872        if (!($hueInterp instanceof IdentToken)) {
1873            return null;
1874        }
1875        $method = match (strtolower($hueInterp->value)) {
1876            'shorter' => HueInterpolation::Shorter,
1877            'longer' => HueInterpolation::Longer,
1878            'increasing' => HueInterpolation::Increasing,
1879            'decreasing' => HueInterpolation::Decreasing,
1880            default => null,
1881        };
1882        if ($method === null) {
1883            return null;
1884        }
1885        $tokens = self::trimWhitespace($tokens);
1886        // Expect the literal `hue` keyword.
1887        if (count($tokens) !== 1 || !($tokens[0] instanceof IdentToken) || strtolower($tokens[0]->value) !== 'hue') {
1888            return null;
1889        }
1890        return [$space, $method];
1891    }
1892
1893    /**
1894     * Parse `<color> [<percentage>]` â€” the percentage may come
1895     * before or after the color per CSS Color 5 Â§3.1.
1896     *
1897     * @param list<Token> $tokens
1898     * @return array{0: Color, 1: ?float}|null
1899     */
1900    private function parseColorMixColorAndPercent(array $tokens): ?array
1901    {
1902        // Find a percentage token at start or end.
1903        $percent = null;
1904        if ($tokens !== []) {
1905            $last = $tokens[count($tokens) - 1];
1906            if ($last instanceof PercentageToken) {
1907                $percent = (float) $last->value;
1908                array_pop($tokens);
1909            }
1910        }
1911        $tokens = self::trimWhitespace($tokens);
1912        if ($percent === null && $tokens !== [] && $tokens[0] instanceof PercentageToken) {
1913            $percent = (float) $tokens[0]->value;
1914            array_shift($tokens);
1915            $tokens = self::trimWhitespace($tokens);
1916        }
1917        $tokens = self::trimWhitespace($tokens);
1918
1919        // Whatever's left should be a parseable color.
1920        $color = $this->parseColorFromTokens($tokens);
1921        if ($color === null) {
1922            return null;
1923        }
1924        return [$color, $percent];
1925    }
1926
1927    /**
1928     * Parse a token sequence into a Color, dispatching to the
1929     * function / hash / named-color parsers as appropriate. Returns
1930     * null if the tokens don't resolve to a color.
1931     *
1932     * @param list<Token> $tokens
1933     */
1934    private function parseColorFromTokens(array $tokens): ?Color
1935    {
1936        $tokens = self::trimWhitespace($tokens);
1937        if ($tokens === []) {
1938            return null;
1939        }
1940        // Re-serialise the inner tokens by stripping outer parens
1941        // and re-parsing through the public entry point so we get
1942        // hash colors, named colors, and any function-color through
1943        // one path.
1944        $css = self::serializeTokens($tokens);
1945        $value = $this->parseFromString($css);
1946        return $value instanceof Color ? $value : null;
1947    }
1948
1949    /**
1950     * Roughly reconstruct CSS text from a token list. Adequate for
1951     * round-tripping color tokens through parseFromString. The CSS
1952     * Syntax 3 serialisation algorithm has edge cases this doesn't
1953     * cover (escape sequences, etc.) but for the well-formed color
1954     * tokens color-mix() argues over, it's enough.
1955     *
1956     * @param list<Token> $tokens
1957     */
1958    private static function serializeTokens(array $tokens): string
1959    {
1960        $out = '';
1961        foreach ($tokens as $t) {
1962            $out .= match (true) {
1963                $t instanceof IdentToken => $t->value,
1964                $t instanceof NumberToken => (string) $t->value,
1965                $t instanceof PercentageToken => $t->value . '%',
1966                $t instanceof DimensionToken => $t->value . $t->unit,
1967                $t instanceof HashToken => '#' . $t->value,
1968                $t instanceof StringToken => '"' . str_replace('"', '\\"', $t->value) . '"',
1969                $t instanceof FunctionToken => $t->name . '(',
1970                $t instanceof UrlToken => 'url(' . $t->value . ')',
1971                $t instanceof LeftParenToken => '(',
1972                $t instanceof RightParenToken => ')',
1973                $t instanceof CommaToken => ',',
1974                $t instanceof WhitespaceToken => ' ',
1975                $t instanceof DelimToken => $t->value,
1976                default => '',
1977            };
1978        }
1979        return $out;
1980    }
1981
1982    // ============================================================
1983    // calc() / min() / max() / clamp() / sin() / cos() / ...
1984    // ============================================================
1985    /** @param list<Token> $tokens */
1986    private function parseCalcFunction(CalcFunction $func, array $tokens): ?Calc
1987    {
1988        $tokens = self::trimWhitespace($tokens);
1989        if ($func === CalcFunction::Calc) {
1990            $expr = $this->parseCalcSum($tokens);
1991            if ($expr === null) {
1992                return null;
1993            }
1994            return new Calc($expr);
1995        }
1996        // Multi-argument math functions: comma-separated arguments.
1997        $groups = self::splitTopLevel($tokens, CommaToken::class);
1998        $args = [];
1999        foreach ($groups as $group) {
2000            $expr = $this->parseCalcSum(self::trimWhitespace($group));
2001            if ($expr === null) {
2002                return null;
2003            }
2004            $args[] = $expr;
2005        }
2006        return new Calc(new CalcFunc($func, $args));
2007    }
2008
2009    /**
2010     * Parse a calc-sum expression: product (('+' | '-') product)*. Per CSS
2011     * Values 4 the +/- operators MUST have whitespace on both sides; the
2012     * tokenizer guarantees this by greedily consuming `+2`/`-2` as signed
2013     * numbers when there's no whitespace.
2014     *
2015     * @param list<Token> $tokens
2016     */
2017    private function parseCalcSum(array $tokens): ?CalcExpression
2018    {
2019        $tokens = self::trimWhitespace($tokens);
2020        if ($tokens === []) {
2021            return null;
2022        }
2023        // Split on top-level +/- delim tokens.
2024        $parts = []; // alternating: [expr, op, expr, op, ...]
2025        $current = [];
2026        $depth = 0;
2027        foreach ($tokens as $t) {
2028            if ($depth === 0 && $t instanceof DelimToken && ($t->value === '+' || $t->value === '-')) {
2029                $parts[] = self::trimWhitespace($current);
2030                $parts[] = $t->value;
2031                $current = [];
2032                continue;
2033            }
2034            $cls = $t::class;
2035            if (str_ends_with($cls, '\\LeftParenToken') || $t instanceof FunctionToken) {
2036                $depth++;
2037            } elseif (str_ends_with($cls, '\\RightParenToken')) {
2038                if ($depth > 0) {
2039                    $depth--;
2040                }
2041            }
2042            $current[] = $t;
2043        }
2044        $parts[] = self::trimWhitespace($current);
2045
2046        $left = $this->parseCalcProduct($parts[0]);
2047        if ($left === null) {
2048            return null;
2049        }
2050        for ($i = 1; $i < count($parts); $i += 2) {
2051            $op = $parts[$i] === '+' ? CalcOp::Add : CalcOp::Sub;
2052            $right = $this->parseCalcProduct($parts[$i + 1] ?? []);
2053            if ($right === null) {
2054                return null;
2055            }
2056            $left = new CalcBinary($left, $op, $right);
2057        }
2058        return $left;
2059    }
2060
2061    /** @param list<Token> $tokens */
2062    private function parseCalcProduct(array $tokens): ?CalcExpression
2063    {
2064        $tokens = self::trimWhitespace($tokens);
2065        if ($tokens === []) {
2066            return null;
2067        }
2068        $parts = [];
2069        $current = [];
2070        $depth = 0;
2071        foreach ($tokens as $t) {
2072            if ($depth === 0 && $t instanceof DelimToken && ($t->value === '*' || $t->value === '/')) {
2073                $parts[] = self::trimWhitespace($current);
2074                $parts[] = $t->value;
2075                $current = [];
2076                continue;
2077            }
2078            $cls = $t::class;
2079            if (str_ends_with($cls, '\\LeftParenToken') || $t instanceof FunctionToken) {
2080                $depth++;
2081            } elseif (str_ends_with($cls, '\\RightParenToken')) {
2082                if ($depth > 0) {
2083                    $depth--;
2084                }
2085            }
2086            $current[] = $t;
2087        }
2088        $parts[] = self::trimWhitespace($current);
2089
2090        $left = $this->parseCalcValue($parts[0]);
2091        if ($left === null) {
2092            return null;
2093        }
2094        for ($i = 1; $i < count($parts); $i += 2) {
2095            $op = $parts[$i] === '*' ? CalcOp::Mul : CalcOp::Div;
2096            $right = $this->parseCalcValue($parts[$i + 1] ?? []);
2097            if ($right === null) {
2098                return null;
2099            }
2100            $left = new CalcBinary($left, $op, $right);
2101        }
2102        return $left;
2103    }
2104
2105    /** @param list<Token> $tokens */
2106    private function parseCalcValue(array $tokens): ?CalcExpression
2107    {
2108        $tokens = self::trimWhitespace($tokens);
2109        if ($tokens === []) {
2110            return null;
2111        }
2112        // Parenthesised expression â€” strip a balanced outer pair.
2113        $head = $tokens[0];
2114        $last = $tokens[count($tokens) - 1];
2115        if ($this->isMatchingParenWrap($tokens)) {
2116            return $this->parseCalcSum(array_slice($tokens, 1, count($tokens) - 2));
2117        }
2118        // Inline math function â€” e.g. nested calc, min, max.
2119        if ($head instanceof FunctionToken && $last instanceof RightParenToken && count($tokens) >= 2) {
2120            $inner = array_slice($tokens, 1, count($tokens) - 2);
2121            $name = strtolower($head->name);
2122            $func = CalcFunction::tryFrom($name);
2123            if ($func === CalcFunction::Calc) {
2124                return $this->parseCalcSum($inner);
2125            }
2126            if ($func !== null) {
2127                $groups = self::splitTopLevel($inner, CommaToken::class);
2128                $args = [];
2129                foreach ($groups as $g) {
2130                    $expr = $this->parseCalcSum(self::trimWhitespace($g));
2131                    if ($expr === null) {
2132                        return null;
2133                    }
2134                    $args[] = $expr;
2135                }
2136                return new CalcFunc($func, $args);
2137            }
2138            return null;
2139        }
2140        // Single primitive value.
2141        if (count($tokens) === 1) {
2142            $value = $this->parseSingle($tokens);
2143            if ($value instanceof Number || $value instanceof Integer
2144                || $value instanceof Length || $value instanceof Percentage
2145                || $value instanceof Angle
2146            ) {
2147                return new CalcLeaf($value);
2148            }
2149        }
2150        return null;
2151    }
2152
2153    /** @param list<Token> $tokens */
2154    private function isMatchingParenWrap(array $tokens): bool
2155    {
2156        if (count($tokens) < 2) {
2157            return false;
2158        }
2159        $head = $tokens[0];
2160        $last = $tokens[count($tokens) - 1];
2161        if (!str_ends_with($head::class, '\\LeftParenToken')) {
2162            return false;
2163        }
2164        if (!str_ends_with($last::class, '\\RightParenToken')) {
2165            return false;
2166        }
2167        // Walk to verify the leading paren matches the trailing one (not
2168        // a separate nested pair).
2169        $depth = 0;
2170        $matched = -1;
2171        foreach ($tokens as $i => $t) {
2172            if (str_ends_with($t::class, '\\LeftParenToken') || $t instanceof FunctionToken) {
2173                $depth++;
2174            } elseif (str_ends_with($t::class, '\\RightParenToken')) {
2175                $depth--;
2176                if ($depth === 0) {
2177                    $matched = $i;
2178                    break;
2179                }
2180            }
2181        }
2182        return $matched === count($tokens) - 1;
2183    }
2184
2185    // ============================================================
2186    // linear-gradient / radial-gradient
2187    // ============================================================
2188    /** @param list<Token> $tokens */
2189    private function parseLinearGradient(array $tokens, bool $repeating): ?LinearGradient
2190    {
2191        $tokens = self::trimWhitespace($tokens);
2192        $groups = self::splitTopLevel($tokens, CommaToken::class);
2193        if (count($groups) < 2) {
2194            return null;
2195        }
2196        // First group may be:
2197        //   - angle/side spec (`45deg`, `to top right`),
2198        //   - interpolation method only (`in oklch`),
2199        //   - both combined (`45deg in oklch`),
2200        //   - or directly a colour stop.
2201        $first = self::trimWhitespace($groups[0]);
2202        $angleDeg = 180.0;
2203        $interpSpace = null;
2204        $hueInterp = null;
2205        $stopGroups = $groups;
2206
2207        // Pull the trailing `in <space> [<hue> hue]` clause off the
2208        // first group if it has one. Whether the remainder is an
2209        // angle/side header or empty (interpolation-only) drives
2210        // the dispatch below.
2211        [$headerTokens, $methodTokens] = $this->splitOnInterpolationKeyword($first);
2212        if ($methodTokens !== null) {
2213            $method = $this->parseColorMixMethod($methodTokens);
2214            if ($method === null) {
2215                return null;
2216            }
2217            [$interpSpace, $hueInterp] = $method;
2218            $headerTokens = self::trimWhitespace($headerTokens);
2219            if ($headerTokens === []) {
2220                // Interpolation-only header â€” default angle, consume
2221                // the whole first group.
2222                $stopGroups = array_slice($groups, 1);
2223            } else {
2224                $maybeAngle = $this->parseLinearAngleHeader($headerTokens);
2225                if ($maybeAngle === null) {
2226                    return null;
2227                }
2228                $angleDeg = $maybeAngle;
2229                $stopGroups = array_slice($groups, 1);
2230            }
2231        } else {
2232            $maybeAngle = $this->parseLinearAngleHeader($first);
2233            if ($maybeAngle !== null) {
2234                $angleDeg = $maybeAngle;
2235                $stopGroups = array_slice($groups, 1);
2236            }
2237        }
2238
2239        $stops = [];
2240        foreach ($stopGroups as $g) {
2241            $parsed = $this->parseGradientStop($g);
2242            if ($parsed === null) {
2243                return null;
2244            }
2245            foreach ($parsed as $stop) {
2246                $stops[] = $stop;
2247            }
2248        }
2249        if (count($stops) < 2) {
2250            return null;
2251        }
2252        return new LinearGradient(
2253            angleDeg: $angleDeg,
2254            stops: $stops,
2255            repeating: $repeating,
2256            interpolationSpace: $interpSpace,
2257            hueInterpolation: $hueInterp,
2258        );
2259    }
2260
2261    /**
2262     * Split a token sequence at the first top-level `in` ident
2263     * token followed by an ident (i.e. the CSS Images 4 Â§3.1.2
2264     * interpolation-method clause). Returns [headerTokens,
2265     * methodTokens] â€” methodTokens is null when no `in` clause is
2266     * present. The returned methodTokens include the `in` keyword
2267     * itself so they can be fed straight into
2268     * {@see parseColorMixMethod}.
2269     *
2270     * @param list<Token> $tokens
2271     * @return array{0: list<Token>, 1: list<Token>|null}
2272     */
2273    private function splitOnInterpolationKeyword(array $tokens): array
2274    {
2275        $depth = 0;
2276        $count = count($tokens);
2277        for ($i = 0; $i < $count; $i++) {
2278            $t = $tokens[$i];
2279            if ($t instanceof FunctionToken || $t instanceof LeftParenToken) {
2280                $depth++;
2281                continue;
2282            }
2283            if ($t instanceof RightParenToken) {
2284                $depth--;
2285                continue;
2286            }
2287            if ($depth !== 0) {
2288                continue;
2289            }
2290            if ($t instanceof IdentToken && strtolower($t->value) === 'in') {
2291                // Look ahead â€” must be followed by another ident
2292                // (the colorspace name) to count as the interpolation
2293                // clause. Bare `in` is unlikely in any other gradient
2294                // header but the guard is cheap.
2295                for ($j = $i + 1; $j < $count; $j++) {
2296                    if ($tokens[$j] instanceof WhitespaceToken) {
2297                        continue;
2298                    }
2299                    if ($tokens[$j] instanceof IdentToken) {
2300                        return [array_slice($tokens, 0, $i), array_slice($tokens, $i)];
2301                    }
2302                    break;
2303                }
2304            }
2305        }
2306        return [$tokens, null];
2307    }
2308
2309    /**
2310     * Parse `<angle>` or `to <side-or-corner>` into degrees, or null if the
2311     * first comma-group is actually a colour stop.
2312     *
2313     * @param list<Token> $tokens
2314     */
2315    private function parseLinearAngleHeader(array $tokens): ?float
2316    {
2317        $tokens = self::trimWhitespace($tokens);
2318        if ($tokens === []) {
2319            return null;
2320        }
2321        $head = $tokens[0];
2322        // Direct angle: e.g. `45deg`, `0.25turn`.
2323        if ($head instanceof DimensionToken) {
2324            $unit = AngleUnit::tryFrom(strtolower($head->unit));
2325            if ($unit !== null) {
2326                return $unit->toDegrees($head->value);
2327            }
2328        }
2329        // `to <side> [<side>]`.
2330        if ($head instanceof IdentToken && strtolower($head->value) === 'to') {
2331            $sides = [];
2332            for ($i = 1; $i < count($tokens); $i++) {
2333                if ($tokens[$i] instanceof WhitespaceToken) {
2334                    continue;
2335                }
2336                if ($tokens[$i] instanceof IdentToken) {
2337                    $sides[] = strtolower($tokens[$i]->value);
2338                }
2339            }
2340            return self::sidesToAngle($sides);
2341        }
2342        return null;
2343    }
2344
2345    /** @param list<string> $sides */
2346    private static function sidesToAngle(array $sides): float
2347    {
2348        // Per spec table.
2349        sort($sides);
2350        $key = implode(' ', $sides);
2351        return match ($key) {
2352            'top' => 0.0,
2353            'right top', 'top right' => 45.0,
2354            'right' => 90.0,
2355            'bottom right', 'right bottom' => 135.0,
2356            'bottom' => 180.0,
2357            'bottom left', 'left bottom' => 225.0,
2358            'left' => 270.0,
2359            'left top', 'top left' => 315.0,
2360            default => 180.0,
2361        };
2362    }
2363
2364    /** @param list<Token> $tokens */
2365    /**
2366     * Parse one comma-delimited segment of a gradient's stop list. Per
2367     * CSS Images 4 Â§3.5.1 the grammar admits two position slots:
2368     *
2369     *   <linear-color-stop> := <color> [<length-percentage>]{0,2}
2370     *
2371     * Two positions are shorthand for a doubled stop carrying the same
2372     * colour at each â€” `red 0% 50%` is equivalent to `red 0%, red 50%`.
2373     * Returning a list lets callers splat the result so the rest of the
2374     * gradient pipeline never needs to care that the shorthand existed.
2375     *
2376     * @param  list<Token> $tokens
2377     * @return list<GradientStop>|null
2378     */
2379    private function parseGradientStop(array $tokens): ?array
2380    {
2381        $tokens = self::trimWhitespace($tokens);
2382        if ($tokens === []) {
2383            return null;
2384        }
2385        $parts = self::splitOnWhitespace($tokens);
2386        if ($parts === []) {
2387            return null;
2388        }
2389        $colorValue = $this->parseSingle($parts[0]);
2390        if (!$colorValue instanceof Color) {
2391            return null;
2392        }
2393        $position1 = $this->parseStopPosition($parts[1] ?? null);
2394        $position2 = $position1 !== null
2395            ? $this->parseStopPosition($parts[2] ?? null)
2396            : null;
2397        if ($position2 !== null) {
2398            return [
2399                new GradientStop($colorValue, $position1),
2400                new GradientStop($colorValue, $position2),
2401            ];
2402        }
2403        return [new GradientStop($colorValue, $position1)];
2404    }
2405
2406    /**
2407     * Resolve a gradient stop position token list. Accepts `<length>` /
2408     * `<percentage>` and the unitless-zero form CSS Values 4 Â§5.2
2409     * carves out as a valid length. Returns `null` for absent or
2410     * unrecognised tokens (caller keeps the stop with no position).
2411     *
2412     * @param list<Token>|null $tokens
2413     */
2414    private function parseStopPosition(?array $tokens): Length|Percentage|null
2415    {
2416        if ($tokens === null) {
2417            return null;
2418        }
2419        $val = $this->parseSingle($tokens);
2420        if ($val instanceof Length || $val instanceof Percentage) {
2421            return $val;
2422        }
2423        if ($val instanceof Integer && $val->value === 0) {
2424            return new Length(0.0, LengthUnit::Px);
2425        }
2426        if ($val instanceof Number && $val->value === 0.0) {
2427            return new Length(0.0, LengthUnit::Px);
2428        }
2429        return null;
2430    }
2431
2432    /** @param list<Token> $tokens */
2433    private function parseRadialGradient(array $tokens, bool $repeating): ?RadialGradient
2434    {
2435        $tokens = self::trimWhitespace($tokens);
2436        $groups = self::splitTopLevel($tokens, CommaToken::class);
2437        if (count($groups) < 2) {
2438            return null;
2439        }
2440        // First group MAY be shape/size [at position] [in <space>];
2441        // otherwise it's a stop.
2442        $first = self::trimWhitespace($groups[0]);
2443        $shape = GradientShape::Ellipse;
2444        $sizeX = null;
2445        $sizeY = null;
2446        $centerX = null;
2447        $centerY = null;
2448        $interpSpace = null;
2449        $hueInterp = null;
2450        $stopGroups = $groups;
2451        [$headerTokens, $methodTokens] = $this->splitOnInterpolationKeyword($first);
2452        if ($methodTokens !== null) {
2453            $method = $this->parseColorMixMethod($methodTokens);
2454            if ($method === null) {
2455                return null;
2456            }
2457            [$interpSpace, $hueInterp] = $method;
2458            $headerTokens = self::trimWhitespace($headerTokens);
2459            if ($headerTokens === [] || $this->isRadialHeader($headerTokens)) {
2460                if ($headerTokens !== []) {
2461                    [$shape, $sizeX, $sizeY, $centerX, $centerY] = $this->parseRadialHeader($headerTokens);
2462                }
2463                $stopGroups = array_slice($groups, 1);
2464            } else {
2465                return null;
2466            }
2467        } elseif ($this->isRadialHeader($first)) {
2468            [$shape, $sizeX, $sizeY, $centerX, $centerY] = $this->parseRadialHeader($first);
2469            $stopGroups = array_slice($groups, 1);
2470        }
2471        $stops = [];
2472        foreach ($stopGroups as $g) {
2473            $parsed = $this->parseGradientStop($g);
2474            if ($parsed === null) {
2475                return null;
2476            }
2477            foreach ($parsed as $stop) {
2478                $stops[] = $stop;
2479            }
2480        }
2481        if (count($stops) < 2) {
2482            return null;
2483        }
2484        return new RadialGradient(
2485            shape: $shape,
2486            sizeX: $sizeX,
2487            sizeY: $sizeY,
2488            centerX: $centerX,
2489            centerY: $centerY,
2490            stops: $stops,
2491            repeating: $repeating,
2492            interpolationSpace: $interpSpace,
2493            hueInterpolation: $hueInterp,
2494        );
2495    }
2496
2497    // ============================================================
2498    // conic-gradient â€” CSS Backgrounds 4 / Images 4 Â§3.5
2499    // ============================================================
2500    /**
2501     * Parse `conic-gradient([from <angle>]? [at <position>]?,
2502     * <color-stop-list>)`.
2503     *
2504     *   conic-gradient(red, blue)
2505     *   conic-gradient(from 90deg, red, blue)
2506     *   conic-gradient(at 25% 75%, red, blue)
2507     *   conic-gradient(from 0deg at center, red, blue, red)
2508     *   conic-gradient(red 0deg, yellow 90deg, blue 180deg)
2509     *
2510     * Stops can use angular positions (degrees) per CSS Images 4
2511     * Â§3.5, but `parseGradientStop` only handles length /
2512     * percentage positions for now â€” angle-positioned stops fall
2513     * through to "stop position null" and the engine interpolates
2514     * uniformly. Full angular stop parsing lands once the
2515     * gradient painter ships conic rendering.
2516     *
2517     * @param list<Token> $tokens
2518     */
2519    private function parseConicGradient(array $tokens, bool $repeating): ?ConicGradient
2520    {
2521        $tokens = self::trimWhitespace($tokens);
2522        $groups = self::splitTopLevel($tokens, CommaToken::class);
2523        if (count($groups) < 2) {
2524            return null;
2525        }
2526        $first = self::trimWhitespace($groups[0]);
2527        $fromAngle = 0.0;
2528        $centerX = null;
2529        $centerY = null;
2530        $interpSpace = null;
2531        $hueInterp = null;
2532        $stopGroups = $groups;
2533        [$headerTokens, $methodTokens] = $this->splitOnInterpolationKeyword($first);
2534        if ($methodTokens !== null) {
2535            $method = $this->parseColorMixMethod($methodTokens);
2536            if ($method === null) {
2537                return null;
2538            }
2539            [$interpSpace, $hueInterp] = $method;
2540            $headerTokens = self::trimWhitespace($headerTokens);
2541            if ($headerTokens === [] || $this->isConicHeader($headerTokens)) {
2542                if ($headerTokens !== []) {
2543                    $header = $this->parseConicHeader($headerTokens);
2544                    if ($header === null) {
2545                        return null;
2546                    }
2547                    [$fromAngle, $centerX, $centerY] = $header;
2548                }
2549                $stopGroups = array_slice($groups, 1);
2550            } else {
2551                return null;
2552            }
2553        } elseif ($this->isConicHeader($first)) {
2554            $header = $this->parseConicHeader($first);
2555            if ($header === null) {
2556                return null;
2557            }
2558            [$fromAngle, $centerX, $centerY] = $header;
2559            $stopGroups = array_slice($groups, 1);
2560        }
2561        if (count($stopGroups) < 2) {
2562            return null;
2563        }
2564        $stops = [];
2565        foreach ($stopGroups as $g) {
2566            $parsed = $this->parseGradientStop($g);
2567            if ($parsed === null) {
2568                return null;
2569            }
2570            foreach ($parsed as $stop) {
2571                $stops[] = $stop;
2572            }
2573        }
2574        return new ConicGradient(
2575            fromAngleDeg: $fromAngle,
2576            centerX: $centerX,
2577            centerY: $centerY,
2578            stops: $stops,
2579            repeating: $repeating,
2580            interpolationSpace: $interpSpace,
2581            hueInterpolation: $hueInterp,
2582        );
2583    }
2584
2585    /** @param list<Token> $tokens */
2586    private function isConicHeader(array $tokens): bool
2587    {
2588        foreach ($tokens as $t) {
2589            if ($t instanceof IdentToken
2590                && in_array(strtolower($t->value), ['from', 'at'], true)
2591            ) {
2592                return true;
2593            }
2594        }
2595        return false;
2596    }
2597
2598    /**
2599     * Parse the optional `from <angle> [at <position>]` /
2600     * `at <position>` header of a conic-gradient.
2601     *
2602     * @param list<Token> $tokens
2603     * @return array{0: float, 1: ?float, 2: ?float}|null
2604     */
2605    private function parseConicHeader(array $tokens): ?array
2606    {
2607        $fromAngle = 0.0;
2608        $centerX = null;
2609        $centerY = null;
2610
2611        // Walk the tokens looking for `from <angle>` followed by
2612        // optional `at <position-x> <position-y>`. Both clauses
2613        // optional but at least one of them must be present (the
2614        // caller's `isConicHeader` already verified that).
2615        $i = 0;
2616        $count = count($tokens);
2617        while ($i < $count) {
2618            $t = $tokens[$i];
2619            if ($t instanceof WhitespaceToken) {
2620                $i++;
2621                continue;
2622            }
2623            if ($t instanceof IdentToken && strtolower($t->value) === 'from') {
2624                $i++;
2625                // Skip whitespace.
2626                while ($i < $count && $tokens[$i] instanceof WhitespaceToken) {
2627                    $i++;
2628                }
2629                if ($i >= $count) {
2630                    return null;
2631                }
2632                $angleTok = $tokens[$i];
2633                if (!($angleTok instanceof DimensionToken) && !($angleTok instanceof NumberToken)) {
2634                    return null;
2635                }
2636                if ($angleTok instanceof DimensionToken) {
2637                    $unit = AngleUnit::tryFrom(strtolower($angleTok->unit));
2638                    if ($unit === null) {
2639                        return null;
2640                    }
2641                    $fromAngle = $unit->toDegrees((float) $angleTok->value);
2642                } else {
2643                    $fromAngle = (float) $angleTok->value;
2644                }
2645                $fromAngle = fmod(fmod($fromAngle, 360.0) + 360.0, 360.0);
2646                $i++;
2647                continue;
2648            }
2649            if ($t instanceof IdentToken && strtolower($t->value) === 'at') {
2650                $i++;
2651                // Collect the next two position values.
2652                $positions = [];
2653                while ($i < $count && count($positions) < 2) {
2654                    $p = $tokens[$i];
2655                    if ($p instanceof WhitespaceToken) {
2656                        $i++;
2657                        continue;
2658                    }
2659                    if ($p instanceof PercentageToken) {
2660                        $positions[] = (float) $p->value / 100.0;
2661                        $i++;
2662                        continue;
2663                    }
2664                    if ($p instanceof IdentToken) {
2665                        $name = strtolower($p->value);
2666                        $val = match ($name) {
2667                            'left', 'top' => 0.0,
2668                            'center', 'centre' => 0.5,
2669                            'right', 'bottom' => 1.0,
2670                            default => null,
2671                        };
2672                        if ($val === null) {
2673                            return null;
2674                        }
2675                        $positions[] = $val;
2676                        $i++;
2677                        continue;
2678                    }
2679                    return null;
2680                }
2681                if ($positions === []) {
2682                    return null;
2683                }
2684                $centerX = $positions[0];
2685                $centerY = $positions[1] ?? $positions[0];
2686                continue;
2687            }
2688            // Unknown header token.
2689            return null;
2690        }
2691        return [$fromAngle, $centerX, $centerY];
2692    }
2693
2694    // ============================================================
2695    // anchor() / anchor-size() â€” CSS Anchor Positioning 1 Â§6, Â§7
2696    // ============================================================
2697    /** @param list<Token> $tokens */
2698    private function parseAnchorFunction(array $tokens): ?AnchorFunction
2699    {
2700        return $this->parseAnchorOrSize(
2701            $tokens,
2702            ['top', 'bottom', 'left', 'right', 'start', 'end',
2703                'self-start', 'self-end', 'center', 'inside', 'outside'],
2704            isSize: false,
2705        );
2706    }
2707
2708    /** @param list<Token> $tokens */
2709    private function parseAnchorSizeFunction(array $tokens): ?AnchorSizeFunction
2710    {
2711        return $this->parseAnchorOrSize(
2712            $tokens,
2713            ['width', 'height', 'block', 'inline', 'self-block', 'self-inline'],
2714            isSize: true,
2715        );
2716    }
2717
2718    /**
2719     * Shared parser for `anchor()` / `anchor-size()`. The two
2720     * differ only in the keyword vocabulary their middle slot
2721     * accepts.
2722     *
2723     * @param list<Token> $tokens
2724     * @param list<string> $validSideKeywords
2725     * @return AnchorFunction|AnchorSizeFunction|null
2726     */
2727    private function parseAnchorOrSize(array $tokens, array $validSideKeywords, bool $isSize): AnchorFunction|AnchorSizeFunction|null
2728    {
2729        $tokens = self::trimWhitespace($tokens);
2730        // Split on top-level comma â€” yields [main, fallback?].
2731        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
2732        if (count($commaGroups) < 1 || count($commaGroups) > 2) {
2733            return null;
2734        }
2735        $mainGroup = self::trimWhitespace($commaGroups[0]);
2736        if ($mainGroup === []) {
2737            return null;
2738        }
2739
2740        $anchorName = null;
2741        $first = $mainGroup[0];
2742        // `--my-anchor` starts with `--`; the tokenizer emits this
2743        // as an IdentToken whose value starts with `--`.
2744        if ($first instanceof IdentToken && str_starts_with($first->value, '--')) {
2745            $anchorName = $first->value;
2746            $mainGroup = self::trimWhitespace(array_slice($mainGroup, 1));
2747            if ($mainGroup === []) {
2748                return null;
2749            }
2750        }
2751
2752        // The middle slot â€” keyword from the per-function list,
2753        // or a percentage (anchor() only).
2754        if (count($mainGroup) !== 1) {
2755            return null;
2756        }
2757        $sideTok = $mainGroup[0];
2758        $side = null;
2759        if ($sideTok instanceof IdentToken
2760            && in_array(strtolower($sideTok->value), $validSideKeywords, true)
2761        ) {
2762            $side = new Keyword(strtolower($sideTok->value));
2763        } elseif (!$isSize && $sideTok instanceof PercentageToken) {
2764            $side = new Percentage((float) $sideTok->value);
2765        }
2766        if ($side === null) {
2767            return null;
2768        }
2769
2770        // Optional fallback expression.
2771        $fallback = null;
2772        if (count($commaGroups) === 2) {
2773            $fallbackCss = self::serializeTokens(self::trimWhitespace($commaGroups[1]));
2774            $fallback = $this->parseFromString($fallbackCss);
2775        }
2776
2777        return $isSize
2778            ? new AnchorSizeFunction($anchorName, $side, $fallback)
2779            : new AnchorFunction($anchorName, $side, $fallback);
2780    }
2781
2782    // ============================================================
2783    // basic-shape â€” CSS Shapes 1 Â§3
2784    // ============================================================
2785    /**
2786     * Dispatch for the four most commonly-used basic shapes.
2787     * `path()`, `rect()`, `xywh()` (CSS Shapes 2) still fall
2788     * through to generic CssFunction.
2789     *
2790     * @param list<Token> $tokens
2791     */
2792    private function parseBasicShape(string $name, array $tokens): ?BasicShape
2793    {
2794        return match ($name) {
2795            'circle' => $this->parseCircleShape($tokens),
2796            'ellipse' => $this->parseEllipseShape($tokens),
2797            'inset' => $this->parseInsetShape($tokens),
2798            'polygon' => $this->parsePolygonShape($tokens),
2799            'rect' => $this->parseRectShape($tokens),
2800            'xywh' => $this->parseXywhShape($tokens),
2801            'path' => $this->parsePathShape($tokens),
2802            default => null,
2803        };
2804    }
2805
2806    /** @param list<Token> $tokens */
2807    private function parseRectShape(array $tokens): ?RectShape
2808    {
2809        $tokens = self::trimWhitespace($tokens);
2810        if ($tokens === []) {
2811            return null;
2812        }
2813        [$edgeTokens, $roundTokens] = self::splitOnRoundKeyword($tokens);
2814        $edgeGroups = self::splitParenAwareSpaceForm(self::trimWhitespace($edgeTokens));
2815        if (count($edgeGroups) !== 4) {
2816            return null;
2817        }
2818        $edges = [];
2819        foreach ($edgeGroups as $group) {
2820            $value = $this->parseFromString(self::serializeTokens(self::trimWhitespace($group)));
2821            if (!self::isShapePositionValue($value)
2822                && !($value instanceof Keyword && strtolower($value->name) === 'auto')
2823            ) {
2824                return null;
2825            }
2826            $edges[] = $value;
2827        }
2828        $radius = null;
2829        if ($roundTokens !== []) {
2830            $roundGroups = self::splitParenAwareSpaceForm(self::trimWhitespace($roundTokens));
2831            $radius = [];
2832            foreach ($roundGroups as $group) {
2833                $radius[] = $this->parseFromString(self::serializeTokens(self::trimWhitespace($group)));
2834            }
2835            if ($radius === []) {
2836                $radius = null;
2837            }
2838        }
2839        return new RectShape($edges, $radius);
2840    }
2841
2842    /** @param list<Token> $tokens */
2843    private function parseXywhShape(array $tokens): ?XywhShape
2844    {
2845        $tokens = self::trimWhitespace($tokens);
2846        if ($tokens === []) {
2847            return null;
2848        }
2849        [$mainTokens, $roundTokens] = self::splitOnRoundKeyword($tokens);
2850        $groups = self::splitParenAwareSpaceForm(self::trimWhitespace($mainTokens));
2851        if (count($groups) !== 4) {
2852            return null;
2853        }
2854        $values = [];
2855        foreach ($groups as $group) {
2856            $value = $this->parseFromString(self::serializeTokens(self::trimWhitespace($group)));
2857            if (!self::isShapePositionValue($value)) {
2858                return null;
2859            }
2860            $values[] = $value;
2861        }
2862        $radius = null;
2863        if ($roundTokens !== []) {
2864            $roundGroups = self::splitParenAwareSpaceForm(self::trimWhitespace($roundTokens));
2865            $radius = [];
2866            foreach ($roundGroups as $group) {
2867                $radius[] = $this->parseFromString(self::serializeTokens(self::trimWhitespace($group)));
2868            }
2869            if ($radius === []) {
2870                $radius = null;
2871            }
2872        }
2873        return new XywhShape($values[0], $values[1], $values[2], $values[3], $radius);
2874    }
2875
2876    /** @param list<Token> $tokens */
2877    private function parsePathShape(array $tokens): ?PathShape
2878    {
2879        $tokens = self::trimWhitespace($tokens);
2880        if ($tokens === []) {
2881            return null;
2882        }
2883        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
2884        $fillRule = 'nonzero';
2885        // Optional leading fill-rule ident.
2886        if (count($commaGroups) === 2) {
2887            $first = self::trimWhitespace($commaGroups[0]);
2888            if (count($first) !== 1 || !($first[0] instanceof IdentToken)) {
2889                return null;
2890            }
2891            $rule = strtolower($first[0]->value);
2892            if ($rule !== 'nonzero' && $rule !== 'evenodd') {
2893                return null;
2894            }
2895            $fillRule = $rule;
2896            $pathGroup = $commaGroups[1];
2897        } else {
2898            $pathGroup = $commaGroups[0];
2899        }
2900        // Path data string.
2901        $pathTokens = self::trimWhitespace($pathGroup);
2902        if (count($pathTokens) !== 1 || !($pathTokens[0] instanceof StringToken)) {
2903            return null;
2904        }
2905        return new PathShape($fillRule, $pathTokens[0]->value);
2906    }
2907
2908    /** @param list<Token> $tokens */
2909    private function parseCircleShape(array $tokens): ?CircleShape
2910    {
2911        $tokens = self::trimWhitespace($tokens);
2912        // Possible forms:
2913        //   circle()
2914        //   circle(<radius>)
2915        //   circle(at <position>)
2916        //   circle(<radius> at <position>)
2917        if ($tokens === []) {
2918            return new CircleShape();
2919        }
2920        // Look for the `at` keyword to split radius from position.
2921        [$radiusTokens, $positionTokens] = self::splitOnAtKeyword($tokens);
2922        $radius = $radiusTokens === [] ? null : $this->parseShapeRadius($radiusTokens);
2923        if ($radiusTokens !== [] && $radius === null) {
2924            return null;
2925        }
2926        [$cx, $cy] = $this->parsePositionXY($positionTokens);
2927        return new CircleShape($radius, $cx, $cy);
2928    }
2929
2930    /** @param list<Token> $tokens */
2931    private function parseEllipseShape(array $tokens): ?EllipseShape
2932    {
2933        $tokens = self::trimWhitespace($tokens);
2934        if ($tokens === []) {
2935            return new EllipseShape();
2936        }
2937        [$radiusTokens, $positionTokens] = self::splitOnAtKeyword($tokens);
2938        $rx = null;
2939        $ry = null;
2940        if ($radiusTokens !== []) {
2941            // Expect two space-separated radius tokens.
2942            $rGroups = self::splitParenAwareSpaceForm($radiusTokens);
2943            if (count($rGroups) !== 2) {
2944                return null;
2945            }
2946            $rx = $this->parseShapeRadius($rGroups[0]);
2947            $ry = $this->parseShapeRadius($rGroups[1]);
2948            if ($rx === null || $ry === null) {
2949                return null;
2950            }
2951        }
2952        [$cx, $cy] = $this->parsePositionXY($positionTokens);
2953        return new EllipseShape($rx, $ry, $cx, $cy);
2954    }
2955
2956    /** @param list<Token> $tokens */
2957    private function parseInsetShape(array $tokens): ?InsetShape
2958    {
2959        $tokens = self::trimWhitespace($tokens);
2960        if ($tokens === []) {
2961            return null;
2962        }
2963        // Split on `round` keyword if present.
2964        [$insetTokens, $roundTokens] = self::splitOnRoundKeyword($tokens);
2965        $insetGroups = self::splitParenAwareSpaceForm(self::trimWhitespace($insetTokens));
2966        if (count($insetGroups) < 1 || count($insetGroups) > 4) {
2967            return null;
2968        }
2969        $insets = [];
2970        foreach ($insetGroups as $group) {
2971            $value = $this->parseFromString(self::serializeTokens(self::trimWhitespace($group)));
2972            if (!self::isShapePositionValue($value)) {
2973                return null;
2974            }
2975            $insets[] = $value;
2976        }
2977        $radius = null;
2978        if ($roundTokens !== []) {
2979            $roundGroups = self::splitParenAwareSpaceForm(self::trimWhitespace($roundTokens));
2980            $radius = [];
2981            foreach ($roundGroups as $group) {
2982                $radius[] = $this->parseFromString(self::serializeTokens(self::trimWhitespace($group)));
2983            }
2984            if ($radius === []) {
2985                $radius = null;
2986            }
2987        }
2988        return new InsetShape($insets, $radius);
2989    }
2990
2991    /** @param list<Token> $tokens */
2992    private function parsePolygonShape(array $tokens): ?PolygonShape
2993    {
2994        $tokens = self::trimWhitespace($tokens);
2995        if ($tokens === []) {
2996            return null;
2997        }
2998        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
2999        if ($commaGroups === []) {
3000            return null;
3001        }
3002        $fillRule = 'nonzero';
3003        // Detect leading fill-rule ident.
3004        $firstGroup = self::trimWhitespace($commaGroups[0]);
3005        if ($firstGroup !== [] && $firstGroup[0] instanceof IdentToken) {
3006            $rule = strtolower($firstGroup[0]->value);
3007            if ($rule === 'nonzero' || $rule === 'evenodd') {
3008                $fillRule = $rule;
3009                // Remove the fill-rule ident from the first group.
3010                $remainder = self::trimWhitespace(array_slice($firstGroup, 1));
3011                if ($remainder !== []) {
3012                    $commaGroups[0] = $remainder;
3013                } else {
3014                    array_shift($commaGroups);
3015                }
3016            }
3017        }
3018        $vertices = [];
3019        foreach ($commaGroups as $group) {
3020            $group = self::trimWhitespace($group);
3021            $parts = self::splitParenAwareSpaceForm($group);
3022            if (count($parts) !== 2) {
3023                return null;
3024            }
3025            $x = $this->parseFromString(self::serializeTokens(self::trimWhitespace($parts[0])));
3026            $y = $this->parseFromString(self::serializeTokens(self::trimWhitespace($parts[1])));
3027            // Polygon vertices accept length / percentage / calc / number
3028            // (bare `0` parses as Integer; treat zeros as 0px).
3029            if (!self::isShapePositionValue($x) || !self::isShapePositionValue($y)) {
3030                return null;
3031            }
3032            $vertices[] = [$x, $y];
3033        }
3034        if ($vertices === []) {
3035            return null;
3036        }
3037        return new PolygonShape($fillRule, $vertices);
3038    }
3039
3040    /**
3041     * Accept the set of value types that may appear in a basic-
3042     * shape vertex / inset slot: Length, Percentage, Calc, plus
3043     * the literal `0` zero shorthand (Integer / Number).
3044     */
3045    private static function isShapePositionValue(?Value $value): bool
3046    {
3047        return $value instanceof Length
3048            || $value instanceof Percentage
3049            || $value instanceof Calc
3050            || $value instanceof Integer
3051            || $value instanceof Number;
3052    }
3053
3054    /**
3055     * Parse a `<shape-radius>` â€” Length / Percentage / Keyword
3056     * (`closest-side` or `farthest-side`).
3057     *
3058     * @param list<Token> $tokens
3059     */
3060    private function parseShapeRadius(array $tokens): ?Value
3061    {
3062        $tokens = self::trimWhitespace($tokens);
3063        if ($tokens === []) {
3064            return null;
3065        }
3066        if (count($tokens) === 1 && $tokens[0] instanceof IdentToken) {
3067            $kw = strtolower($tokens[0]->value);
3068            if ($kw === 'closest-side' || $kw === 'farthest-side') {
3069                return new Keyword($kw);
3070            }
3071        }
3072        $value = $this->parseFromString(self::serializeTokens($tokens));
3073        if ($value instanceof Length || $value instanceof Percentage || $value instanceof Calc) {
3074            return $value;
3075        }
3076        return null;
3077    }
3078
3079    /**
3080     * Parse a `<position>` token sequence into `[centerX, centerY]`
3081     * Value pairs. Accepts:
3082     *   - empty list â†’ [null, null]
3083     *   - single keyword â†’ applies to both axes via shape semantics
3084     *   - two values (keywords / percentages / lengths) â†’ x then y
3085     *
3086     * @param list<Token> $tokens
3087     * @return array{?Value, ?Value}
3088     */
3089    private function parsePositionXY(array $tokens): array
3090    {
3091        if ($tokens === []) {
3092            return [null, null];
3093        }
3094        $groups = self::splitParenAwareSpaceForm(self::trimWhitespace($tokens));
3095        $values = [];
3096        foreach ($groups as $group) {
3097            $group = self::trimWhitespace($group);
3098            if ($group === []) {
3099                continue;
3100            }
3101            // Position keywords (left / center / right / top / bottom).
3102            if (count($group) === 1 && $group[0] instanceof IdentToken) {
3103                $kw = strtolower($group[0]->value);
3104                if (in_array($kw, ['left', 'center', 'right', 'top', 'bottom'], true)) {
3105                    $values[] = new Keyword($kw);
3106                    continue;
3107                }
3108            }
3109            $value = $this->parseFromString(self::serializeTokens($group));
3110            $values[] = $value;
3111        }
3112        if (count($values) === 1) {
3113            return [$values[0], $values[0]];
3114        }
3115        if (count($values) === 2) {
3116            return [$values[0], $values[1]];
3117        }
3118        return [null, null];
3119    }
3120
3121    /**
3122     * Split a token sequence at the first top-level `at` ident.
3123     *
3124     * @param list<Token> $tokens
3125     * @return array{list<Token>, list<Token>}
3126     */
3127    private static function splitOnAtKeyword(array $tokens): array
3128    {
3129        $depth = 0;
3130        foreach ($tokens as $i => $t) {
3131            if ($t instanceof LeftParenToken || $t instanceof FunctionToken) {
3132                $depth++;
3133                continue;
3134            }
3135            if ($t instanceof RightParenToken) {
3136                $depth--;
3137                continue;
3138            }
3139            if ($depth === 0 && $t instanceof IdentToken && strtolower($t->value) === 'at') {
3140                return [
3141                    self::trimWhitespace(array_slice($tokens, 0, $i)),
3142                    self::trimWhitespace(array_slice($tokens, $i + 1)),
3143                ];
3144            }
3145        }
3146        return [self::trimWhitespace($tokens), []];
3147    }
3148
3149    /**
3150     * Split a token sequence at the first top-level `round` ident
3151     * (CSS Shapes 1 `inset()` round-corner separator).
3152     *
3153     * @param list<Token> $tokens
3154     * @return array{list<Token>, list<Token>}
3155     */
3156    private static function splitOnRoundKeyword(array $tokens): array
3157    {
3158        $depth = 0;
3159        foreach ($tokens as $i => $t) {
3160            if ($t instanceof LeftParenToken || $t instanceof FunctionToken) {
3161                $depth++;
3162                continue;
3163            }
3164            if ($t instanceof RightParenToken) {
3165                $depth--;
3166                continue;
3167            }
3168            if ($depth === 0 && $t instanceof IdentToken && strtolower($t->value) === 'round') {
3169                return [
3170                    self::trimWhitespace(array_slice($tokens, 0, $i)),
3171                    self::trimWhitespace(array_slice($tokens, $i + 1)),
3172                ];
3173            }
3174        }
3175        return [self::trimWhitespace($tokens), []];
3176    }
3177
3178    // ============================================================
3179    // cubic-bezier() + steps() â€” CSS Easing 1 Â§3.4 / Â§3.5
3180    // ============================================================
3181    /** @param list<Token> $tokens */
3182    private function parseCubicBezierFunction(array $tokens): ?CubicBezier
3183    {
3184        $tokens = self::trimWhitespace($tokens);
3185        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
3186        if (count($commaGroups) !== 4) {
3187            return null;
3188        }
3189        $values = [];
3190        foreach ($commaGroups as $group) {
3191            $g = self::trimWhitespace($group);
3192            if (count($g) !== 1 || !($g[0] instanceof NumberToken)) {
3193                return null;
3194            }
3195            $values[] = (float) $g[0]->value;
3196        }
3197        // CSS Easing 1 Â§3.4 â€” x coordinates must be in [0, 1].
3198        if ($values[0] < 0.0 || $values[0] > 1.0 || $values[2] < 0.0 || $values[2] > 1.0) {
3199            return null;
3200        }
3201        return new CubicBezier($values[0], $values[1], $values[2], $values[3]);
3202    }
3203
3204    /** @param list<Token> $tokens */
3205    private function parseStepsFunction(array $tokens): ?StepsEasing
3206    {
3207        $tokens = self::trimWhitespace($tokens);
3208        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
3209        if (count($commaGroups) < 1 || count($commaGroups) > 2) {
3210            return null;
3211        }
3212        // Count.
3213        $countTokens = self::trimWhitespace($commaGroups[0]);
3214        if (count($countTokens) !== 1 || !($countTokens[0] instanceof NumberToken)) {
3215            return null;
3216        }
3217        $countValue = $countTokens[0]->value;
3218        if ($countValue !== floor($countValue) || $countValue < 1) {
3219            return null;
3220        }
3221        $count = (int) $countValue;
3222        // Optional jump term.
3223        $jump = StepsJumpTerm::End;
3224        if (count($commaGroups) === 2) {
3225            $jumpTokens = self::trimWhitespace($commaGroups[1]);
3226            if (count($jumpTokens) !== 1 || !($jumpTokens[0] instanceof IdentToken)) {
3227                return null;
3228            }
3229            $parsed = StepsJumpTerm::tryFrom(strtolower($jumpTokens[0]->value));
3230            if ($parsed === null) {
3231                return null;
3232            }
3233            $jump = $parsed;
3234        }
3235        return new StepsEasing($count, $jump);
3236    }
3237
3238    // ============================================================
3239    // linear() â€” CSS Easing 2 Â§3.1
3240    // ============================================================
3241    /**
3242     * Parse `linear(<linear-stop-list>)` where each stop is
3243     * `<number> [<percentage>]?` (CSS Easing 2 Â§3.1) or the range
3244     * form `<number> <percentage> <percentage>` (§3.2). Returns
3245     * null when the stop list is empty or malformed.
3246     *
3247     * @param list<Token> $tokens
3248     */
3249    private function parseLinearEasingFunction(array $tokens): ?LinearEasing
3250    {
3251        $tokens = self::trimWhitespace($tokens);
3252        if ($tokens === []) {
3253            return null;
3254        }
3255        $groups = self::splitTopLevel($tokens, CommaToken::class);
3256        $stops = [];
3257        foreach ($groups as $group) {
3258            $group = self::trimWhitespace($group);
3259            $parts = self::splitParenAwareSpaceForm($group);
3260            if (count($parts) === 0 || count($parts) > 3) {
3261                return null;
3262            }
3263            // First part is the output number.
3264            $outputTokens = self::trimWhitespace($parts[0]);
3265            if (count($outputTokens) !== 1 || !($outputTokens[0] instanceof NumberToken)) {
3266                return null;
3267            }
3268            $output = (float) $outputTokens[0]->value;
3269
3270            // Range form: 3 parts (output, percentageFrom, percentageTo)
3271            // emits two stops sharing the output.
3272            if (count($parts) === 3) {
3273                $fromPct = self::extractSinglePercentage(self::trimWhitespace($parts[1]));
3274                $toPct = self::extractSinglePercentage(self::trimWhitespace($parts[2]));
3275                if ($fromPct === null || $toPct === null) {
3276                    return null;
3277                }
3278                $stops[] = new LinearEasingStop($output, $fromPct);
3279                $stops[] = new LinearEasingStop($output, $toPct);
3280                continue;
3281            }
3282
3283            // Regular form: 1 or 2 parts (output, optional percentage).
3284            $inputPct = null;
3285            if (count($parts) === 2) {
3286                $pct = self::extractSinglePercentage(self::trimWhitespace($parts[1]));
3287                if ($pct === null) {
3288                    return null;
3289                }
3290                $inputPct = $pct;
3291            }
3292            $stops[] = new LinearEasingStop($output, $inputPct);
3293        }
3294        if ($stops === []) {
3295            return null;
3296        }
3297        return new LinearEasing($stops);
3298    }
3299
3300    /**
3301     * Extract a single percentage value from a token sequence
3302     * already trimmed of surrounding whitespace.
3303     *
3304     * @param list<Token> $tokens
3305     */
3306    private static function extractSinglePercentage(array $tokens): ?float
3307    {
3308        if (count($tokens) !== 1) {
3309            return null;
3310        }
3311        $t = $tokens[0];
3312        if (!($t instanceof PercentageToken)) {
3313            return null;
3314        }
3315        return (float) $t->value;
3316    }
3317
3318    // ============================================================
3319    // attr() â€” CSS Values 5 Â§11
3320    // ============================================================
3321    /** @param list<Token> $tokens */
3322    private function parseAttrFunction(array $tokens): ?AttrFunction
3323    {
3324        $tokens = self::trimWhitespace($tokens);
3325        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
3326        if (count($commaGroups) < 1 || count($commaGroups) > 2) {
3327            return null;
3328        }
3329        $head = self::trimWhitespace($commaGroups[0]);
3330        if ($head === []) {
3331            return null;
3332        }
3333        if (!($head[0] instanceof IdentToken)) {
3334            return null;
3335        }
3336        $attributeName = $head[0]->value;
3337        $typeOrUnit = null;
3338        if (count($head) >= 2) {
3339            $rest = self::trimWhitespace(array_slice($head, 1));
3340            if ($rest !== []) {
3341                // Type/unit hint â€” accept any single ident or
3342                // dimension unit; just serialise verbatim so the
3343                // cascade preserves the author's intent.
3344                $typeOrUnit = self::serializeTokens($rest);
3345            }
3346        }
3347        $fallback = null;
3348        if (count($commaGroups) === 2) {
3349            $fbCss = self::serializeTokens(self::trimWhitespace($commaGroups[1]));
3350            $fallback = $this->parseFromString($fbCss);
3351        }
3352        return new AttrFunction($attributeName, $typeOrUnit, $fallback);
3353    }
3354
3355    // ============================================================
3356    // env() â€” CSS Environment Variables 1 Â§3
3357    // ============================================================
3358    /** @param list<Token> $tokens */
3359    private function parseEnvFunction(array $tokens): ?EnvFunction
3360    {
3361        $tokens = self::trimWhitespace($tokens);
3362        $commaGroups = self::splitTopLevel($tokens, CommaToken::class);
3363        if (count($commaGroups) < 1 || count($commaGroups) > 2) {
3364            return null;
3365        }
3366        $head = self::trimWhitespace($commaGroups[0]);
3367        if ($head === []) {
3368            return null;
3369        }
3370        if (!($head[0] instanceof IdentToken)) {
3371            return null;
3372        }
3373        $envName = $head[0]->value;
3374        $indices = [];
3375        $rest = array_slice($head, 1);
3376        foreach ($rest as $t) {
3377            if ($t instanceof WhitespaceToken) {
3378                continue;
3379            }
3380            if (!($t instanceof NumberToken)) {
3381                return null;
3382            }
3383            // Indices must be integers per CSS Env Vars 1 Â§3.
3384            if ($t->value !== floor($t->value)) {
3385                return null;
3386            }
3387            $indices[] = (int) $t->value;
3388        }
3389        $fallback = null;
3390        if (count($commaGroups) === 2) {
3391            $fbCss = self::serializeTokens(self::trimWhitespace($commaGroups[1]));
3392            $fallback = $this->parseFromString($fbCss);
3393        }
3394        return new EnvFunction($envName, $indices, $fallback);
3395    }
3396
3397    // ============================================================
3398    // target-counter / target-counters / target-text â€” GCPM 3 Â§3
3399    // ============================================================
3400    /**
3401     * Parse cross-reference functions used in paged-media TOCs:
3402     *
3403     *   target-counter(<url-or-attr>, <counter>, <style>?)
3404     *   target-counters(<url-or-attr>, <counter>, <string>, <style>?)
3405     *   target-text(<url-or-attr>, [content | before | after | first-letter]?)
3406     *
3407     * Returns null on malformed input (caller falls back to the
3408     * generic function-token preservation path).
3409     *
3410     * @param list<Token> $tokens
3411     */
3412    private function parseTargetFunction(string $name, array $tokens): ?TargetFunction
3413    {
3414        $kind = TargetFunctionKind::tryFrom(strtolower($name));
3415        if ($kind === null) {
3416            return null;
3417        }
3418        $groups = self::splitTopLevel(self::trimWhitespace($tokens), CommaToken::class);
3419        if ($groups === []) {
3420            return null;
3421        }
3422        $target = $this->parseFromString(self::serializeTokens(self::trimWhitespace($groups[0])));
3423        return match ($kind) {
3424            TargetFunctionKind::Counter   => $this->buildTargetCounter($kind, $target, $groups),
3425            TargetFunctionKind::Counters  => $this->buildTargetCounters($kind, $target, $groups),
3426            TargetFunctionKind::Text      => $this->buildTargetText($kind, $target, $groups),
3427        };
3428    }
3429
3430    /**
3431     * @param list<list<Token>> $groups
3432     */
3433    private function buildTargetCounter(TargetFunctionKind $kind, Value $target, array $groups): ?TargetFunction
3434    {
3435        if (count($groups) < 2 || count($groups) > 3) {
3436            return null;
3437        }
3438        $name = $this->parseCounterIdent($groups[1]);
3439        if ($name === null) {
3440            return null;
3441        }
3442        $style = null;
3443        if (count($groups) === 3) {
3444            $style = $this->parseCounterIdent($groups[2]);
3445            if ($style === null) {
3446                return null;
3447            }
3448        }
3449        return new TargetFunction($kind, $target, $name, null, $style);
3450    }
3451
3452    /**
3453     * @param list<list<Token>> $groups
3454     */
3455    private function buildTargetCounters(TargetFunctionKind $kind, Value $target, array $groups): ?TargetFunction
3456    {
3457        if (count($groups) < 3 || count($groups) > 4) {
3458            return null;
3459        }
3460        $name = $this->parseCounterIdent($groups[1]);
3461        if ($name === null) {
3462            return null;
3463        }
3464        $sepTokens = self::trimWhitespace($groups[2]);
3465        if (count($sepTokens) !== 1 || !($sepTokens[0] instanceof StringToken)) {
3466            return null;
3467        }
3468        $separator = new StringValue($sepTokens[0]->value);
3469        $style = null;
3470        if (count($groups) === 4) {
3471            $style = $this->parseCounterIdent($groups[3]);
3472            if ($style === null) {
3473                return null;
3474            }
3475        }
3476        return new TargetFunction($kind, $target, $name, $separator, $style);
3477    }
3478
3479    /**
3480     * @param list<list<Token>> $groups
3481     */
3482    private function buildTargetText(TargetFunctionKind $kind, Value $target, array $groups): ?TargetFunction
3483    {
3484        if (count($groups) < 1 || count($groups) > 2) {
3485            return null;
3486        }
3487        $source = null;
3488        if (count($groups) === 2) {
3489            $srcTokens = self::trimWhitespace($groups[1]);
3490            if (count($srcTokens) !== 1 || !($srcTokens[0] instanceof IdentToken)) {
3491                return null;
3492            }
3493            $kw = strtolower($srcTokens[0]->value);
3494            if (!in_array($kw, ['content', 'before', 'after', 'first-letter'], true)) {
3495                return null;
3496            }
3497            $source = new Keyword($kw);
3498        }
3499        return new TargetFunction($kind, $target, null, $source, null);
3500    }
3501
3502    /**
3503     * @param list<Token> $group
3504     */
3505    private function parseCounterIdent(array $group): ?Keyword
3506    {
3507        $trim = self::trimWhitespace($group);
3508        if (count($trim) !== 1 || !($trim[0] instanceof IdentToken)) {
3509            return null;
3510        }
3511        return new Keyword($trim[0]->value);
3512    }
3513
3514    // ============================================================
3515    // string() â€” CSS Generated Content for Paged Media 3 Â§5.2
3516    // ============================================================
3517    /**
3518     * Parse `string(<name> [, first | start | last | first-except]?)`.
3519     * The fetch-target keyword defaults to `first`.
3520     *
3521     * @param list<Token> $tokens
3522     */
3523    private function parseStringFunction(array $tokens): ?StringFunction
3524    {
3525        $groups = self::splitTopLevel(self::trimWhitespace($tokens), CommaToken::class);
3526        if (count($groups) < 1 || count($groups) > 2) {
3527            return null;
3528        }
3529        $nameTokens = self::trimWhitespace($groups[0]);
3530        if (count($nameTokens) !== 1 || !($nameTokens[0] instanceof IdentToken)) {
3531            return null;
3532        }
3533        $name = $nameTokens[0]->value;
3534        $target = 'first';
3535        if (count($groups) === 2) {
3536            $tgtTokens = self::trimWhitespace($groups[1]);
3537            if (count($tgtTokens) !== 1 || !($tgtTokens[0] instanceof IdentToken)) {
3538                return null;
3539            }
3540            $kw = strtolower($tgtTokens[0]->value);
3541            if (!in_array($kw, ['first', 'start', 'last', 'first-except'], true)) {
3542                return null;
3543            }
3544            $target = $kw;
3545        }
3546        return new StringFunction($name, $target);
3547    }
3548
3549    // ============================================================
3550    // view() / scroll() â€” CSS Scroll-driven Animations 1 Â§3.2/§4.2
3551    // ============================================================
3552    /**
3553     * Parse `view([<axis>?] [<inset>?])`. Axis is one of
3554     * `block | inline | x | y`; inset is one or two
3555     * <length-percentage> values.
3556     *
3557     * @param list<Token> $tokens
3558     */
3559    private function parseViewTimeline(array $tokens): ?ViewTimeline
3560    {
3561        $trim = self::trimWhitespace($tokens);
3562        if ($trim === []) {
3563            return new ViewTimeline();
3564        }
3565        $axisKw = ['block', 'inline', 'x', 'y'];
3566        $axis = null;
3567        $insetStart = null;
3568        $insetEnd = null;
3569        $insetCss = [];
3570        foreach (self::splitOnWhitespace($trim) as $part) {
3571            if ($axis === null && count($part) === 1 && $part[0] instanceof IdentToken
3572                && in_array(strtolower($part[0]->value), $axisKw, true)
3573            ) {
3574                $axis = strtolower($part[0]->value);
3575                continue;
3576            }
3577            // Anything else collects as a length/percentage inset.
3578            $value = $this->parseFromString(self::serializeTokens($part));
3579            if (!($value instanceof Length) && !($value instanceof Percentage)) {
3580                return null;
3581            }
3582            if ($insetStart === null) {
3583                $insetStart = $value;
3584            } elseif ($insetEnd === null) {
3585                $insetEnd = $value;
3586            } else {
3587                return null;
3588            }
3589        }
3590        return new ViewTimeline($axis, $insetStart, $insetEnd);
3591    }
3592
3593    /**
3594     * Parse `scroll([<scroller>?] [<axis>?])`. Scroller is
3595     * `nearest | root | self`; axis is `block | inline | x | y`.
3596     *
3597     * @param list<Token> $tokens
3598     */
3599    private function parseScrollTimeline(array $tokens): ?ScrollTimeline
3600    {
3601        $trim = self::trimWhitespace($tokens);
3602        if ($trim === []) {
3603            return new ScrollTimeline();
3604        }
3605        $scrollerKw = ['nearest', 'root', 'self'];
3606        $axisKw = ['block', 'inline', 'x', 'y'];
3607        $scroller = null;
3608        $axis = null;
3609        foreach (self::splitOnWhitespace($trim) as $part) {
3610            if (count($part) !== 1 || !($part[0] instanceof IdentToken)) {
3611                return null;
3612            }
3613            $lc = strtolower($part[0]->value);
3614            if ($scroller === null && in_array($lc, $scrollerKw, true)) {
3615                $scroller = $lc;
3616                continue;
3617            }
3618            if ($axis === null && in_array($lc, $axisKw, true)) {
3619                $axis = $lc;
3620                continue;
3621            }
3622            return null;
3623        }
3624        return new ScrollTimeline($scroller, $axis);
3625    }
3626
3627    // ============================================================
3628    // paint() â€” CSS Painting API Level 1
3629    // ============================================================
3630    /**
3631     * Parse `paint(<name> [, <arg>]*)`. The CSS Houdini paint
3632     * worklet is JS-side; for print rendering this is purely
3633     * declarative preservation â€” the cascade keeps the call shape
3634     * so external tooling can read it.
3635     *
3636     * @param list<Token> $tokens
3637     */
3638    private function parsePaintFunction(array $tokens): ?PaintFunction
3639    {
3640        $groups = self::splitTopLevel(self::trimWhitespace($tokens), CommaToken::class);
3641        if ($groups === []) {
3642            return null;
3643        }
3644        $nameTokens = self::trimWhitespace($groups[0]);
3645        if (count($nameTokens) !== 1 || !($nameTokens[0] instanceof IdentToken)) {
3646            return null;
3647        }
3648        $name = $nameTokens[0]->value;
3649        $args = [];
3650        for ($i = 1; $i < count($groups); $i++) {
3651            $args[] = $this->parseFromString(self::serializeTokens(self::trimWhitespace($groups[$i])));
3652        }
3653        return new PaintFunction($name, $args);
3654    }
3655
3656    // ============================================================
3657    // element() â€” CSS Generated Content for Paged Media 3 Â§4.2
3658    // ============================================================
3659    /**
3660     * Parse `element(<name> [, first | start | last | first-except]?)`.
3661     * Same grammar shape as `string()` per Â§4.2.
3662     *
3663     * @param list<Token> $tokens
3664     */
3665    private function parseElementFunction(array $tokens): ?ElementFunction
3666    {
3667        $groups = self::splitTopLevel(self::trimWhitespace($tokens), CommaToken::class);
3668        if (count($groups) < 1 || count($groups) > 2) {
3669            return null;
3670        }
3671        $nameTokens = self::trimWhitespace($groups[0]);
3672        if (count($nameTokens) !== 1 || !($nameTokens[0] instanceof IdentToken)) {
3673            return null;
3674        }
3675        $name = $nameTokens[0]->value;
3676        $target = 'first';
3677        if (count($groups) === 2) {
3678            $tgtTokens = self::trimWhitespace($groups[1]);
3679            if (count($tgtTokens) !== 1 || !($tgtTokens[0] instanceof IdentToken)) {
3680                return null;
3681            }
3682            $kw = strtolower($tgtTokens[0]->value);
3683            if (!in_array($kw, ['first', 'start', 'last', 'first-except'], true)) {
3684                return null;
3685            }
3686            $target = $kw;
3687        }
3688        return new ElementFunction($name, $target);
3689    }
3690
3691    // ============================================================
3692    // device-cmyk â€” CSS Color 5 Â§6
3693    // ============================================================
3694    /**
3695     * Parse `device-cmyk(<c> <m> <y> <k> [/ <alpha>]? [, <rgb-fallback>]?)`.
3696     *
3697     * Components accept percentages (0%..100%) or numbers (0..1);
3698     * both are normalised to the 0..1 storage range. Alpha
3699     * defaults to 1. Optional sRGB fallback is the second comma-
3700     * group (a color value the engine may use when CMYK isn't
3701     * applicable).
3702     *
3703     * @param list<Token> $tokens
3704     */
3705    private function parseDeviceCmyk(array $tokens): ?DeviceCmyk
3706    {
3707        $groups = self::splitTopLevel(self::trimWhitespace($tokens), CommaToken::class);
3708        if (count($groups) < 1 || count($groups) > 2) {
3709            return null;
3710        }
3711        $main = self::trimWhitespace($groups[0]);
3712        // Split the main group on top-level `/` for alpha. The
3713        // components themselves are space-separated, so the slash
3714        // sits at depth-0.
3715        $slashGroups = self::splitOnSlash($main);
3716        if (count($slashGroups) < 1 || count($slashGroups) > 2) {
3717            return null;
3718        }
3719        $components = self::splitOnWhitespace(self::trimWhitespace($slashGroups[0]));
3720        if (count($components) !== 4) {
3721            return null;
3722        }
3723        $cmyk = [];
3724        foreach ($components as $group) {
3725            $val = $this->parseCmykComponent($group);
3726            if ($val === null) {
3727                return null;
3728            }
3729            $cmyk[] = $val;
3730        }
3731        $alpha = 1.0;
3732        if (count($slashGroups) === 2) {
3733            $alphaTokens = self::trimWhitespace($slashGroups[1]);
3734            if (count($alphaTokens) !== 1) {
3735                return null;
3736            }
3737            $a = $alphaTokens[0];
3738            if ($a instanceof PercentageToken) {
3739                $alpha = max(0.0, min(1.0, (float) $a->value / 100.0));
3740            } elseif ($a instanceof NumberToken) {
3741                $alpha = max(0.0, min(1.0, (float) $a->value));
3742            } else {
3743                return null;
3744            }
3745        }
3746        $fallback = null;
3747        if (count($groups) === 2) {
3748            $f = $this->parseFromString(self::serializeTokens(self::trimWhitespace($groups[1])));
3749            if (!$f instanceof Color) {
3750                return null;
3751            }
3752            $fallback = $f;
3753        }
3754        return new DeviceCmyk($cmyk[0], $cmyk[1], $cmyk[2], $cmyk[3], $alpha, $fallback);
3755    }
3756
3757    /**
3758     * Parse a single CMYK component â€” accept percentage (0..100%)
3759     * or number (0..1). Returns the normalised 0..1 value or null
3760     * if the input isn't a single value token.
3761     *
3762     * @param list<Token> $tokens
3763     */
3764    private function parseCmykComponent(array $tokens): ?float
3765    {
3766        $tokens = self::trimWhitespace($tokens);
3767        if (count($tokens) !== 1) {
3768            return null;
3769        }
3770        $t = $tokens[0];
3771        if ($t instanceof PercentageToken) {
3772            return max(0.0, min(1.0, (float) $t->value / 100.0));
3773        }
3774        if ($t instanceof NumberToken) {
3775            return max(0.0, min(1.0, (float) $t->value));
3776        }
3777        return null;
3778    }
3779
3780    // ============================================================
3781    // contrast-color â€” CSS Color 7 Â§4
3782    // ============================================================
3783    /**
3784     * Parse `contrast-color(<color>)`. Stores the base color
3785     * verbatim; the renderer picks the higher-contrast UA color
3786     * (baseline: black vs white via relative luminance) at paint
3787     * time.
3788     *
3789     * @param list<Token> $tokens
3790     */
3791    private function parseContrastColor(array $tokens): ?ContrastColor
3792    {
3793        $trim = self::trimWhitespace($tokens);
3794        if ($trim === []) {
3795            return null;
3796        }
3797        $base = $this->parseFromString(self::serializeTokens($trim));
3798        return new ContrastColor($base);
3799    }
3800
3801    // ============================================================
3802    // cross-fade â€” CSS Images 4 Â§4
3803    // ============================================================
3804    /**
3805     * Parse `cross-fade(<entry> [, <entry>]*)` where each entry
3806     * is `[<percentage>]? <image>`. Percentages are 0..100; the
3807     * unlabeled entries share whatever weight remains after the
3808     * labeled entries.
3809     *
3810     * @param list<Token> $tokens
3811     */
3812    private function parseCrossFade(array $tokens): ?CrossFade
3813    {
3814        $groups = self::splitTopLevel(self::trimWhitespace($tokens), CommaToken::class);
3815        if ($groups === []) {
3816            return null;
3817        }
3818        $options = [];
3819        foreach ($groups as $group) {
3820            $opt = $this->parseCrossFadeEntry(self::trimWhitespace($group));
3821            if ($opt === null) {
3822                return null;
3823            }
3824            $options[] = $opt;
3825        }
3826        if (count($options) < 1) {
3827            return null;
3828        }
3829        return new CrossFade($options);
3830    }
3831
3832    /**
3833     * @param list<Token> $tokens
3834     */
3835    private function parseCrossFadeEntry(array $tokens): ?CrossFadeOption
3836    {
3837        if ($tokens === []) {
3838            return null;
3839        }
3840        $percent = null;
3841        $i = 0;
3842        // Optional leading percentage.
3843        if ($tokens[0] instanceof PercentageToken) {
3844            $percent = (float) $tokens[0]->value;
3845            if ($percent < 0.0 || $percent > 100.0) {
3846                return null;
3847            }
3848            $i = 1;
3849            // Eat trailing whitespace before the image.
3850            while ($i < count($tokens) && $tokens[$i] instanceof WhitespaceToken) {
3851                $i++;
3852            }
3853        }
3854        $rest = array_slice($tokens, $i);
3855        if ($rest === []) {
3856            return null;
3857        }
3858        $image = $this->parseFromString(self::serializeTokens($rest));
3859        return new CrossFadeOption($image, $percent);
3860    }
3861
3862    // ============================================================
3863    // light-dark â€” CSS Color 5 Â§5
3864    // ============================================================
3865    /**
3866     * Parse `light-dark(<color>, <color>)`. The renderer selects
3867     * the active branch at paint time based on the resolved
3868     * `color-scheme` for the document / element.
3869     *
3870     * @param list<Token> $tokens
3871     */
3872    private function parseLightDark(array $tokens): ?LightDark
3873    {
3874        $groups = self::splitTopLevel(self::trimWhitespace($tokens), CommaToken::class);
3875        if (count($groups) !== 2) {
3876            return null;
3877        }
3878        $light = $this->parseFromString(self::serializeTokens(self::trimWhitespace($groups[0])));
3879        $dark = $this->parseFromString(self::serializeTokens(self::trimWhitespace($groups[1])));
3880        return new LightDark($light, $dark);
3881    }
3882
3883    // ============================================================
3884    // image-set â€” CSS Images 4 Â§6
3885    // ============================================================
3886    /**
3887     * Parse `image-set(<option> [, <option>]*)` where each option
3888     * is `<image> [<resolution>]? [type(<mime>)]?`. Resolutions
3889     * accept the `x` / `dppx` / `dpcm` / `dpi` units; type() takes
3890     * a string-literal MIME.
3891     *
3892     * @param list<Token> $tokens
3893     */
3894    private function parseImageSet(array $tokens): ?ImageSet
3895    {
3896        $tokens = self::trimWhitespace($tokens);
3897        if ($tokens === []) {
3898            return null;
3899        }
3900        $groups = self::splitTopLevel($tokens, CommaToken::class);
3901        $options = [];
3902        foreach ($groups as $group) {
3903            $opt = $this->parseImageSetOption(self::trimWhitespace($group));
3904            if ($opt === null) {
3905                return null;
3906            }
3907            $options[] = $opt;
3908        }
3909        return new ImageSet($options);
3910    }
3911
3912    /**
3913     * @param list<Token> $tokens
3914     */
3915    private function parseImageSetOption(array $tokens): ?ImageSetOption
3916    {
3917        if ($tokens === []) {
3918            return null;
3919        }
3920        // First non-WS token may be either a URL function, a
3921        // string literal, or a nested image-* function (gradient
3922        // etc). For simplicity, treat the head as the "image"
3923        // until we hit a resolution token or a `type(` function.
3924        $image = null;
3925        $resolution = null;
3926        $mime = null;
3927
3928        $i = 0;
3929        $count = count($tokens);
3930        while ($i < $count) {
3931            $t = $tokens[$i];
3932            if ($t instanceof WhitespaceToken) {
3933                $i++;
3934                continue;
3935            }
3936            if ($t instanceof DimensionToken && self::isResolutionUnit($t->unit)) {
3937                $dppx = self::resolutionToDppx((float) $t->value, $t->unit);
3938                if ($dppx === null) {
3939                    return null;
3940                }
3941                $resolution = $dppx;
3942                $i++;
3943                continue;
3944            }
3945            if ($t instanceof NumberToken && $image !== null) {
3946                // Bare numbers are NOT valid resolution per spec;
3947                // fail to parse.
3948                return null;
3949            }
3950            if ($t instanceof FunctionToken && strtolower($t->name) === 'type') {
3951                // Collect the matching close paren.
3952                $depth = 1;
3953                $argStart = $i + 1;
3954                $j = $argStart;
3955                while ($j < $count) {
3956                    if ($tokens[$j] instanceof LeftParenToken || $tokens[$j] instanceof FunctionToken) {
3957                        $depth++;
3958                    } elseif ($tokens[$j] instanceof RightParenToken) {
3959                        $depth--;
3960                        if ($depth === 0) {
3961                            break;
3962                        }
3963                    }
3964                    $j++;
3965                }
3966                $inner = self::trimWhitespace(array_slice($tokens, $argStart, $j - $argStart));
3967                if (count($inner) !== 1 || !($inner[0] instanceof StringToken)) {
3968                    return null;
3969                }
3970                $mime = $inner[0]->value;
3971                $i = $j + 1;
3972                continue;
3973            }
3974            if ($image === null) {
3975                // Fast paths for the common cases â€” saves a
3976                // re-tokenise + re-parse round trip:
3977                //   - UrlToken    â†’ Url value directly
3978                //   - StringToken â†’ StringValue directly
3979                if ($t instanceof UrlToken) {
3980                    $image = new Url($t->value);
3981                    $i++;
3982                    continue;
3983                }
3984                if ($t instanceof StringToken) {
3985                    $image = new \Phpdftk\Css\Value\StringValue($t->value);
3986                    $i++;
3987                    continue;
3988                }
3989                // Fallback: treat the token (and any function /
3990                // paren-balanced run starting here) as the image
3991                // and let the dispatch parse it. Used for nested
3992                // image-set / linear-gradient / etc.
3993                $consumed = self::collectFunctionLikeRun($tokens, $i);
3994                $imageTokens = array_slice($tokens, $i, $consumed);
3995                $css = self::serializeTokens($imageTokens);
3996                $image = $this->parseFromString($css);
3997                $i += $consumed;
3998                continue;
3999            }
4000            return null;
4001        }
4002        if ($image === null) {
4003            return null;
4004        }
4005        return new ImageSetOption($image, $resolution, $mime);
4006    }
4007
4008    /**
4009     * Collect a function call (and any nested function calls in
4010     * its arguments) starting at $start, OR a single non-function
4011     * token. Returns the count of tokens consumed.
4012     *
4013     * @param list<Token> $tokens
4014     */
4015    private static function collectFunctionLikeRun(array $tokens, int $start): int
4016    {
4017        $head = $tokens[$start] ?? null;
4018        if (!($head instanceof FunctionToken)) {
4019            return 1;
4020        }
4021        $depth = 1;
4022        $i = $start + 1;
4023        $count = count($tokens);
4024        while ($i < $count && $depth > 0) {
4025            if ($tokens[$i] instanceof LeftParenToken || $tokens[$i] instanceof FunctionToken) {
4026                $depth++;
4027            } elseif ($tokens[$i] instanceof RightParenToken) {
4028                $depth--;
4029            }
4030            $i++;
4031        }
4032        return $i - $start;
4033    }
4034
4035    private static function isResolutionUnit(string $unit): bool
4036    {
4037        return in_array(strtolower($unit), ['x', 'dppx', 'dpcm', 'dpi'], true);
4038    }
4039
4040    private static function resolutionToDppx(float $value, string $unit): ?float
4041    {
4042        return match (strtolower($unit)) {
4043            'x', 'dppx' => $value,
4044            'dpi' => $value / 96.0,                        // 96 dpi = 1 dppx
4045            'dpcm' => $value * 2.54 / 96.0,                // 1 dpcm = 2.54 dpi â†’ /96
4046            default => null,
4047        };
4048    }
4049
4050    /** @param list<Token> $tokens */
4051    private function isRadialHeader(array $tokens): bool
4052    {
4053        foreach ($tokens as $t) {
4054            if ($t instanceof IdentToken
4055                && in_array(strtolower($t->value), ['circle', 'ellipse', 'at', 'closest-side', 'closest-corner', 'farthest-side', 'farthest-corner'], true)
4056            ) {
4057                return true;
4058            }
4059        }
4060        return false;
4061    }
4062
4063    /**
4064     * @param list<Token> $tokens
4065     * @return array{0: GradientShape, 1: ?Length, 2: ?Length, 3: ?Length, 4: ?Length}
4066     */
4067    private function parseRadialHeader(array $tokens): array
4068    {
4069        $shape = GradientShape::Ellipse;
4070        $sizeX = null;
4071        $sizeY = null;
4072        $centerX = null;
4073        $centerY = null;
4074        $atIdx = null;
4075        foreach ($tokens as $i => $t) {
4076            if ($t instanceof IdentToken && strtolower($t->value) === 'at') {
4077                $atIdx = $i;
4078                break;
4079            }
4080        }
4081        $headerPart = $atIdx !== null ? array_slice($tokens, 0, $atIdx) : $tokens;
4082        foreach (self::splitOnWhitespace($headerPart) as $piece) {
4083            $piece = self::trimWhitespace($piece);
4084            if ($piece === []) {
4085                continue;
4086            }
4087            $head = $piece[0];
4088            if ($head instanceof IdentToken) {
4089                $name = strtolower($head->value);
4090                if ($name === 'circle') {
4091                    $shape = GradientShape::Circle;
4092                } elseif ($name === 'ellipse') {
4093                    $shape = GradientShape::Ellipse;
4094                }
4095                // closest-side etc. are sizing keywords; ignored for now.
4096            } elseif ($head instanceof DimensionToken) {
4097                $val = $this->parseSingle($piece);
4098                if ($val instanceof Length) {
4099                    if ($sizeX === null) {
4100                        $sizeX = $val;
4101                    } else {
4102                        $sizeY = $val;
4103                    }
4104                }
4105            }
4106        }
4107        if ($atIdx !== null) {
4108            $positionPart = array_slice($tokens, $atIdx + 1);
4109            $positions = [];
4110            foreach (self::splitOnWhitespace($positionPart) as $piece) {
4111                $piece = self::trimWhitespace($piece);
4112                if ($piece === []) {
4113                    continue;
4114                }
4115                $val = $this->parseSingle($piece);
4116                if ($val instanceof Length) {
4117                    $positions[] = $val;
4118                }
4119            }
4120            $centerX = $positions[0] ?? null;
4121            $centerY = $positions[1] ?? null;
4122        }
4123        return [$shape, $sizeX, $sizeY, $centerX, $centerY];
4124    }
4125
4126    // ============================================================
4127    // Token-stream utilities
4128    // ============================================================
4129
4130    /** @param list<Token> $tokens
4131     *  @return list<Token>
4132     */
4133    private static function trimWhitespace(array $tokens): array
4134    {
4135        $start = 0;
4136        $end = count($tokens) - 1;
4137        while ($start <= $end && ($tokens[$start] instanceof WhitespaceToken || $tokens[$start] instanceof EofToken)) {
4138            $start++;
4139        }
4140        while ($end >= $start && ($tokens[$end] instanceof WhitespaceToken || $tokens[$end] instanceof EofToken)) {
4141            $end--;
4142        }
4143        return array_slice($tokens, $start, $end - $start + 1);
4144    }
4145
4146    /**
4147     * Split a flat token list at top-level instances of $separator (i.e.
4148     * outside any nested parens / brackets).
4149     *
4150     * @param list<Token> $tokens
4151     * @param class-string<Token> $separator
4152     * @return list<list<Token>>
4153     */
4154    private static function splitTopLevel(array $tokens, string $separator): array
4155    {
4156        $groups = [];
4157        $current = [];
4158        $depth = 0;
4159        foreach ($tokens as $t) {
4160            $cls = $t::class;
4161            if ($depth === 0 && $cls === $separator) {
4162                $groups[] = $current;
4163                $current = [];
4164                continue;
4165            }
4166            if (str_ends_with($cls, '\\LeftParenToken')
4167                || str_ends_with($cls, '\\LeftBracketToken')
4168                || str_ends_with($cls, '\\LeftBraceToken')
4169                || $t instanceof FunctionToken
4170            ) {
4171                $depth++;
4172            } elseif (str_ends_with($cls, '\\RightParenToken')
4173                || str_ends_with($cls, '\\RightBracketToken')
4174                || str_ends_with($cls, '\\RightBraceToken')
4175            ) {
4176                if ($depth > 0) {
4177                    $depth--;
4178                }
4179            }
4180            $current[] = $t;
4181        }
4182        $groups[] = $current;
4183        return $groups;
4184    }
4185
4186    /**
4187     * Like {@see splitTopLevel} but splits on a specific `DelimToken` value
4188     * (typically `/` for the slash-shorthand pattern).
4189     *
4190     * @param list<Token> $tokens
4191     * @return list<list<Token>>
4192     */
4193    private static function splitTopLevelDelim(array $tokens, string $delim): array
4194    {
4195        $groups = [];
4196        $current = [];
4197        $depth = 0;
4198        foreach ($tokens as $t) {
4199            if ($depth === 0 && $t instanceof DelimToken && $t->value === $delim) {
4200                $groups[] = $current;
4201                $current = [];
4202                continue;
4203            }
4204            if ($t instanceof LeftParenToken
4205                || $t instanceof LeftBracketToken
4206                || $t instanceof LeftBraceToken
4207                || $t instanceof FunctionToken
4208            ) {
4209                $depth++;
4210            } elseif ($t instanceof RightParenToken
4211                || $t instanceof RightBracketToken
4212                || $t instanceof RightBraceToken
4213            ) {
4214                if ($depth > 0) {
4215                    $depth--;
4216                }
4217            }
4218            $current[] = $t;
4219        }
4220        $groups[] = $current;
4221        return $groups;
4222    }
4223
4224    /** @param list<Token> $tokens
4225     *  @return list<list<Token>>
4226     */
4227    private static function splitOnWhitespace(array $tokens): array
4228    {
4229        $groups = [];
4230        $current = [];
4231        $depth = 0;
4232        foreach ($tokens as $t) {
4233            if ($depth === 0 && $t instanceof WhitespaceToken) {
4234                if ($current !== []) {
4235                    $groups[] = $current;
4236                    $current = [];
4237                }
4238                continue;
4239            }
4240            $cls = $t::class;
4241            if (str_ends_with($cls, '\\LeftParenToken')
4242                || str_ends_with($cls, '\\LeftBracketToken')
4243                || str_ends_with($cls, '\\LeftBraceToken')
4244                || $t instanceof FunctionToken
4245            ) {
4246                $depth++;
4247            } elseif (str_ends_with($cls, '\\RightParenToken')
4248                || str_ends_with($cls, '\\RightBracketToken')
4249                || str_ends_with($cls, '\\RightBraceToken')
4250            ) {
4251                if ($depth > 0) {
4252                    $depth--;
4253                }
4254            }
4255            $current[] = $t;
4256        }
4257        if ($current !== []) {
4258            $groups[] = $current;
4259        }
4260        return $groups;
4261    }
4262}