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