Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.14% covered (success)
93.14%
1249 / 1341
53.73% covered (warning)
53.73%
36 / 67
CRAP
0.00% covered (danger)
0.00%
0 / 1
ShorthandExpander
93.14% covered (success)
93.14%
1249 / 1341
53.73% covered (warning)
53.73%
36 / 67
701.02
0.00% covered (danger)
0.00%
0 / 1
 expand
94.38% covered (success)
94.38%
84 / 89
0.00% covered (danger)
0.00%
0 / 1
67.80
 isCssWideKeyword
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 shorthandLonghands
66.67% covered (warning)
66.67%
16 / 24
0.00% covered (danger)
0.00%
0 / 1
19.26
 isShorthand
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
3
 expandLogicalPair
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 expandPlaceShorthand
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 expandGridArea
76.00% covered (warning)
76.00%
19 / 25
0.00% covered (danger)
0.00%
0 / 1
6.50
 expandGridTemplate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 expandGrid
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 gridTrackListIsDefinite
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 gridHasAutoFlow
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 expandGridLine
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 expandFourSided
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 expandBorderSide
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 expandBorder
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 expandOutline
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
14
 classifyBorderComponents
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
12
 toComponents
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 stripSlashTail
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 looksLikeBorderWidth
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isZeroNumber
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 asZeroLength
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 looksLikeBorderStyle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 looksLikeColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 expandFont
96.97% covered (success)
96.97%
64 / 66
0.00% covered (danger)
0.00%
0 / 1
29
 looksLikeFontStyle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 looksLikeFontVariant
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 looksLikeFontWeight
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 looksLikeFontStretch
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 expandBackground
97.33% covered (success)
97.33%
73 / 75
0.00% covered (danger)
0.00%
0 / 1
34
 expandListStyle
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
13.17
 expandTextDecoration
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
16
 expandFlex
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
24
 expandFlexFlow
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 expandOverflow
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 expandInset
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 expandGap
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 expandColumns
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
16.12
 expandContainer
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 expandColumnRule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 expandBothAxisRule
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 expandBothAxisRuleLonghand
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 expandRuleInset
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
11.04
 expandRowRule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 expandRuleShorthand
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
11
 expandPositionTry
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
7.03
 expandMask
79.78% covered (warning)
79.78%
71 / 89
0.00% covered (danger)
0.00%
0 / 1
34.49
 expandBorderImage
98.25% covered (success)
98.25%
56 / 57
0.00% covered (danger)
0.00%
0 / 1
25
 expandBorderAxis
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 expandBorderLogicalSide
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 alias
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 expandMaskBorder
88.33% covered (warning)
88.33%
53 / 60
0.00% covered (danger)
0.00%
0 / 1
32.53
 joinSpace
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 expandFontVariant
89.23% covered (warning)
89.23%
58 / 65
0.00% covered (danger)
0.00%
0 / 1
20.50
 expandFontSynthesis
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
10
 expandCaret
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 expandWhiteSpace
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
14
 expandTextWrap
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
10.08
 looksLikeMaskImage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 expandTextEmphasis
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
7.01
 isColorComponent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 looksLikeFontSize
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 expandTransition
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
8
 expandAnimation
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
1 / 1
18
 toCommaLayers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 joinComma
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 isEasingValue
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
8.12
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css\Cascade;
6
7use Phpdftk\Css\Value\Color;
8use Phpdftk\Css\Value\Integer;
9use Phpdftk\Css\Value\Keyword;
10use Phpdftk\Css\Value\Length;
11use Phpdftk\Css\Value\ListSeparator;
12use Phpdftk\Css\Value\Number;
13use Phpdftk\Css\Value\Percentage;
14use Phpdftk\Css\Value\StringValue;
15use Phpdftk\Css\Value\Value;
16use Phpdftk\Css\Value\ValueList;
17
18/**
19 * Per CSS Cascade 5 §3.2, shorthand declarations expand into their longhand
20 * components before the cascade picks winners. This class implements the
21 * structural box-edge shorthands needed by Phase 1F layout:
22 *
23 *  - `margin` / `padding` / `border-width` / `border-style` /
24 *    `border-color` — four-sided variants.
25 *  - `border-top` / `border-right` / `border-bottom` / `border-left` —
26 *    composite per-side shorthand (width / style / color in any order).
27 *  - `border` — combines `border-{width,style,color}` for all four sides.
28 *
29 * Shorthand value-list rules (CSS Backgrounds 3 §3.1 / CSS Box 3):
30 *  - 1 value: applied to all four sides.
31 *  - 2 values: top/bottom, left/right.
32 *  - 3 values: top, left/right, bottom.
33 *  - 4 values: top, right, bottom, left (clockwise from top).
34 *
35 * Unknown shorthands fall through unchanged so the cascade can still match
36 * declarations the registry doesn't know about — they just don't decompose.
37 */
38final class ShorthandExpander
39{
40    /**
41     * Expand `$property = $value`. Returns the resulting longhand map; the
42     * shorthand name is intentionally omitted so the cascade doesn't keep
43     * tracking it alongside its longhands.
44     *
45     * @return array<string, Value>
46     */
47    public function expand(string $property, Value $value): array
48    {
49        $name = strtolower($property);
50        // CSS Cascade 5 §3.2 — a CSS-wide keyword (`inherit` / `initial` /
51        // `unset` / `revert` / `revert-layer`) on a shorthand sets EVERY
52        // one of its longhands to that keyword. The component-parsing
53        // expanders below would otherwise find no width/style/colour etc.
54        // and drop the declaration (e.g. `border: inherit` losing the
55        // border entirely). Distribute the keyword to the longhands; fall
56        // through for shorthands not in the map so their own handling (or
57        // the cascade's direct application) is preserved.
58        if ($this->isCssWideKeyword($value)) {
59            $longhands = $this->shorthandLonghands($name);
60            if ($longhands !== []) {
61                return array_fill_keys($longhands, $value);
62            }
63        }
64        return match ($name) {
65            'margin' => $this->expandFourSided('margin', $value, ['top', 'right', 'bottom', 'left']),
66            'padding' => $this->expandFourSided('padding', $value, ['top', 'right', 'bottom', 'left']),
67            'border-width' => $this->expandFourSided('border', $value, ['top-width', 'right-width', 'bottom-width', 'left-width']),
68            'border-style' => $this->expandFourSided('border', $value, ['top-style', 'right-style', 'bottom-style', 'left-style']),
69            'border-color' => $this->expandFourSided('border', $value, ['top-color', 'right-color', 'bottom-color', 'left-color']),
70            // CSS Backgrounds 3 §6: `border-radius` expands like `margin`
71            // but the corner suffix order is TL TR BR BL (clockwise from
72            // top-left), and the horizontal/vertical pair `/` form is
73            // ignored — Phase 1 only honours the symmetrical value.
74            'border-radius' => $this->expandFourSided(
75                'border',
76                $this->stripSlashTail($value),
77                ['top-left-radius', 'top-right-radius', 'bottom-right-radius', 'bottom-left-radius'],
78            ),
79            'border-top', 'border-right', 'border-bottom', 'border-left'
80                => $this->expandBorderSide($name, $value),
81            // CSS Logical Properties 1 §7 — `border-block` / `-inline`
82            // shorthand both sides of the axis, each a three-slot
83            // border (width || style || color) shorthand.
84            'border-block' => $this->expandBorderAxis($value, 'block'),
85            'border-inline' => $this->expandBorderAxis($value, 'inline'),
86            'border-block-start', 'border-block-end',
87            'border-inline-start', 'border-inline-end'
88                => $this->expandBorderLogicalSide($name, $value),
89            'border' => $this->expandBorder($value),
90            'outline' => $this->expandOutline($value),
91            'font' => $this->expandFont($value),
92            'text-decoration' => $this->expandTextDecoration($value),
93            'background' => $this->expandBackground($value),
94            'list-style' => $this->expandListStyle($value),
95            'columns' => $this->expandColumns($value),
96            'column-rule' => $this->expandColumnRule($value),
97            'row-rule' => $this->expandRowRule($value),
98            // CSS Gaps 1 §3 — the axis-agnostic `rule*` shorthands set the
99            // matching `column-rule-*` AND `row-rule-*` longhands at once.
100            'rule' => $this->expandBothAxisRule($value),
101            'rule-width', 'rule-style', 'rule-color',
102            'rule-break', 'rule-visibility-items'
103                => $this->expandBothAxisRuleLonghand($name, $value),
104            // CSS Gaps 1 §4 — the `*-rule-inset*` shorthand family.
105            'rule-inset', 'rule-inset-cap', 'rule-inset-junction',
106            'rule-inset-start', 'rule-inset-end',
107            'column-rule-inset', 'column-rule-inset-cap', 'column-rule-inset-junction',
108            'column-rule-inset-start', 'column-rule-inset-end',
109            'row-rule-inset', 'row-rule-inset-cap', 'row-rule-inset-junction',
110            'row-rule-inset-start', 'row-rule-inset-end'
111                => $this->expandRuleInset($name, $value),
112            'gap' => $this->expandGap($value),
113            'inset' => $this->expandInset($value),
114            // CSS Logical Properties 1 §5 / §6 — axis pair
115            // shorthands.
116            'inset-block' => $this->expandLogicalPair('inset-block', $value, ['start', 'end']),
117            'inset-inline' => $this->expandLogicalPair('inset-inline', $value, ['start', 'end']),
118            'margin-block' => $this->expandLogicalPair('margin-block', $value, ['start', 'end']),
119            'margin-inline' => $this->expandLogicalPair('margin-inline', $value, ['start', 'end']),
120            'padding-block' => $this->expandLogicalPair('padding-block', $value, ['start', 'end']),
121            'padding-inline' => $this->expandLogicalPair('padding-inline', $value, ['start', 'end']),
122            'overflow' => $this->expandOverflow($value),
123            'flex' => $this->expandFlex($value),
124            'flex-flow' => $this->expandFlexFlow($value),
125            'grid-column' => $this->expandGridLine($value, 'column'),
126            'grid-row' => $this->expandGridLine($value, 'row'),
127            'grid-area' => $this->expandGridArea($value),
128            'grid-template' => $this->expandGridTemplate($value),
129            'grid' => $this->expandGrid($value),
130            // CSS Box Alignment 3 §8 — `place-*` shorthands. Single
131            // value applies to both axes; two values map first →
132            // align (block-axis), second → justify (inline-axis).
133            'place-items' => $this->expandPlaceShorthand($value, 'align-items', 'justify-items'),
134            'place-content' => $this->expandPlaceShorthand($value, 'align-content', 'justify-content'),
135            'place-self' => $this->expandPlaceShorthand($value, 'align-self', 'justify-self'),
136            'transition' => $this->expandTransition($value),
137            'animation' => $this->expandAnimation($value),
138            'position-try' => $this->expandPositionTry($value),
139            'text-emphasis' => $this->expandTextEmphasis($value),
140            'mask' => $this->expandMask($value),
141            'border-image' => $this->expandBorderImage($value),
142            'mask-border' => $this->expandMaskBorder($value),
143            'container' => $this->expandContainer($value),
144            // Legacy property aliases — these write to BOTH the
145            // alias and the modern target so author CSS that still
146            // uses the old name keeps working alongside the new one.
147            'page-break-before' => $this->alias($property, $value, 'break-before'),
148            'page-break-after' => $this->alias($property, $value, 'break-after'),
149            'page-break-inside' => $this->alias($property, $value, 'break-inside'),
150            'inset-area' => $this->alias($property, $value, 'position-area'),
151            'word-wrap' => $this->alias($property, $value, 'overflow-wrap'),
152            'grid-gap' => [...$this->expandGap($value), 'grid-gap' => $value],
153            'grid-row-gap' => $this->alias($property, $value, 'row-gap'),
154            'grid-column-gap' => $this->alias($property, $value, 'column-gap'),
155            'text-wrap' => $this->expandTextWrap($value),
156            'white-space' => $this->expandWhiteSpace($value),
157            'caret' => $this->expandCaret($value),
158            // SVG 2 §11.6.2 — `marker` sets all three marker
159            // longhands to the same value. It is a shorthand, not an
160            // inherited property of its own: the longhands are what
161            // inherit, so `marker-end` declared on a child has to be
162            // able to beat a `marker` inherited from its parent.
163            'marker' => [
164                'marker-start' => $value,
165                'marker-mid' => $value,
166                'marker-end' => $value,
167            ],
168            'font-synthesis' => $this->expandFontSynthesis($value),
169            'font-variant' => $this->expandFontVariant($value),
170            default => [$property => $value],
171        };
172    }
173
174    private function isCssWideKeyword(Value $value): bool
175    {
176        return $value instanceof Keyword
177            && in_array(
178                strtolower($value->name),
179                ['inherit', 'initial', 'unset', 'revert', 'revert-layer'],
180                true,
181            );
182    }
183
184    /**
185     * Longhand property names a shorthand expands to, for distributing a
186     * CSS-wide keyword. Only the shorthands where component-parsing would
187     * otherwise drop the keyword need be listed; anything absent falls
188     * through to the per-property expander.
189     *
190     * @return list<string>
191     */
192    private function shorthandLonghands(string $name): array
193    {
194        $sides = ['top', 'right', 'bottom', 'left'];
195        return match ($name) {
196            'border' => [
197                'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
198                'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
199                'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
200            ],
201            'border-top', 'border-right', 'border-bottom', 'border-left' => [
202                "$name-width", "$name-style", "$name-color",
203            ],
204            'border-width' => array_map(static fn($s) => "border-$s-width", $sides),
205            'border-style' => array_map(static fn($s) => "border-$s-style", $sides),
206            'border-color' => array_map(static fn($s) => "border-$s-color", $sides),
207            'margin' => array_map(static fn($s) => "margin-$s", $sides),
208            'padding' => array_map(static fn($s) => "padding-$s", $sides),
209            'outline' => ['outline-width', 'outline-style', 'outline-color'],
210            'background' => [
211                'background-image', 'background-position', 'background-size',
212                'background-repeat', 'background-origin', 'background-clip',
213                'background-attachment', 'background-color',
214            ],
215            'list-style' => ['list-style-type', 'list-style-position', 'list-style-image'],
216            'marker' => ['marker-start', 'marker-mid', 'marker-end'],
217            default => [],
218        };
219    }
220
221    /**
222     * Test whether a property name is a known shorthand we expand.
223     * Used by `@supports (shorthand: value)` feature detection where
224     * the cascade only registers the longhand initial values, but the
225     * shorthand name itself is still "supported" if we can expand it.
226     */
227    public function isShorthand(string $property): bool
228    {
229        return match (strtolower($property)) {
230            'margin', 'padding',
231            'border-width', 'border-style', 'border-color', 'border-radius',
232            'border-top', 'border-right', 'border-bottom', 'border-left',
233            'border-block', 'border-inline',
234            'border-block-start', 'border-block-end',
235            'border-inline-start', 'border-inline-end',
236            'border', 'outline', 'font', 'text-decoration', 'background',
237            'list-style', 'columns', 'column-rule', 'row-rule', 'gap', 'inset',
238            'rule', 'rule-width', 'rule-style', 'rule-color',
239            'rule-break', 'rule-visibility-items',
240            'rule-inset', 'rule-inset-cap', 'rule-inset-junction',
241            'rule-inset-start', 'rule-inset-end',
242            'column-rule-inset', 'column-rule-inset-cap', 'column-rule-inset-junction',
243            'column-rule-inset-start', 'column-rule-inset-end',
244            'row-rule-inset', 'row-rule-inset-cap', 'row-rule-inset-junction',
245            'row-rule-inset-start', 'row-rule-inset-end',
246            'inset-block', 'inset-inline',
247            'margin-block', 'margin-inline', 'padding-block', 'padding-inline',
248            'overflow', 'flex', 'flex-flow',
249            'grid-column', 'grid-row', 'grid-area',
250            'place-items', 'place-content', 'place-self',
251            'transition', 'animation', 'position-try',
252            'text-emphasis', 'mask', 'border-image', 'mask-border',
253            'page-break-before', 'page-break-after', 'page-break-inside',
254            'inset-area', 'word-wrap', 'grid-gap', 'grid-row-gap', 'grid-column-gap',
255            'text-wrap', 'white-space', 'caret',
256            'font-synthesis', 'font-variant', 'container', 'marker' => true,
257            default => false,
258        };
259    }
260
261    /**
262     * Expand a `<prefix>: <start> [<end>]` logical-pair shorthand
263     * into `<prefix>-start` / `<prefix>-end`. One value applies
264     * to both sides; two values map first → start, second → end
265     * per CSS Logical Properties 1 §5.
266     *
267     * @param list<string> $suffixes
268     * @return array<string, Value>
269     */
270    private function expandLogicalPair(string $prefix, Value $value, array $suffixes): array
271    {
272        $components = $this->toComponents($value);
273        if ($components === []) {
274            return [];
275        }
276        $start = $components[0];
277        $end = $components[1] ?? $start;
278        return [
279            $prefix . '-' . $suffixes[0] => $start,
280            $prefix . '-' . $suffixes[1] => $end,
281        ];
282    }
283
284    /**
285     * CSS Box Alignment 3 §8.3 — `place-items` / `place-content` /
286     * `place-self`. One value applies to both axes; two values map
287     * first → block-axis (`align-*`), second → inline-axis
288     * (`justify-*`).
289     *
290     * @return array<string, Value>
291     */
292    private function expandPlaceShorthand(Value $value, string $alignProp, string $justifyProp): array
293    {
294        $components = $this->toComponents($value);
295        if ($components === []) {
296            return [];
297        }
298        $align = $components[0];
299        $justify = $components[1] ?? $align;
300        return [
301            $alignProp => $align,
302            $justifyProp => $justify,
303        ];
304    }
305
306    /**
307     * CSS Grid Layout 2 §8.5 — `grid-area` shorthand. Three forms:
308     *  - Single `<custom-ident>` (any non-`auto` keyword) — propagates
309     *    to all four longhands as a name reference. Layout resolves
310     *    the name against the grid container's `grid-template-areas`
311     *    map; falls back to `auto` when the name doesn't exist.
312     *  - Single `<integer>` — applies to `grid-row-start` only; the
313     *    other three default to `auto`.
314     *  - Slash-separated 2-, 3-, or 4-value form — fills row-start /
315     *    column-start / row-end / column-end. Omitted positions
316     *    mirror the spec's "missing → matching opposite or auto"
317     *    rule (Phase-2 simplification: missing → `auto`).
318     *
319     * @return array<string, Value>
320     */
321    private function expandGridArea(Value $value): array
322    {
323        $autoKey = new \Phpdftk\Css\Value\Keyword('auto');
324        // Single-value forms.
325        if (!($value instanceof \Phpdftk\Css\Value\ValueList)
326            || $value->separator !== \Phpdftk\Css\Value\ListSeparator::Slash
327        ) {
328            if ($value instanceof \Phpdftk\Css\Value\Keyword
329                && strtolower($value->name) !== 'auto'
330                && strtolower($value->name) !== 'none'
331            ) {
332                // Custom-ident name — propagate to all four sides.
333                return [
334                    'grid-row-start' => $value,
335                    'grid-column-start' => $value,
336                    'grid-row-end' => $value,
337                    'grid-column-end' => $value,
338                ];
339            }
340            // Bare integer / `auto` — only row-start gets the value.
341            return [
342                'grid-row-start' => $value,
343                'grid-column-start' => $autoKey,
344                'grid-row-end' => $autoKey,
345                'grid-column-end' => $autoKey,
346            ];
347        }
348        // Slash-separated list — positional mapping.
349        $vs = $value->values;
350        return [
351            'grid-row-start' => $vs[0] ?? $autoKey,
352            'grid-column-start' => $vs[1] ?? $autoKey,
353            'grid-row-end' => $vs[2] ?? $autoKey,
354            'grid-column-end' => $vs[3] ?? $autoKey,
355        ];
356    }
357
358    /**
359     * CSS Grid Layout 2 §7.4 — `grid-template` shorthand. Handles the
360     * `none` and `<'grid-template-rows'> / <'grid-template-columns'>`
361     * forms (the common track-list case). The `<line-names>? <string>
362     * …` template-areas form is left unexpanded (returns `[]`) so the
363     * cascade ignores it rather than dropping the columns silently —
364     * area-string layout isn't modelled yet.
365     *
366     * @return array<string, Value>
367     */
368    private function expandGridTemplate(Value $value): array
369    {
370        $none = new \Phpdftk\Css\Value\Keyword('none');
371        if ($value instanceof \Phpdftk\Css\Value\Keyword
372            && strtolower($value->name) === 'none'
373        ) {
374            return [
375                'grid-template-rows' => $none,
376                'grid-template-columns' => $none,
377                'grid-template-areas' => $none,
378            ];
379        }
380        if ($value instanceof \Phpdftk\Css\Value\ValueList
381            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Slash
382            && count($value->values) === 2
383            && $this->gridTrackListIsDefinite($value->values[0])
384            && $this->gridTrackListIsDefinite($value->values[1])
385        ) {
386            return [
387                'grid-template-rows' => $value->values[0],
388                'grid-template-columns' => $value->values[1],
389                'grid-template-areas' => $none,
390            ];
391        }
392        return [];
393    }
394
395    /**
396     * CSS Grid Layout 2 §7.8 — `grid` shorthand. Delegates the plain
397     * `<'grid-template'>` forms to {@see expandGridTemplate()} and, per
398     * spec, resets the implicit-grid longhands (`grid-auto-flow` /
399     * `grid-auto-rows` / `grid-auto-columns`) to their initial values.
400     * The `[ auto-flow && dense? ] …` forms are deferred (returns `[]`).
401     *
402     * @return array<string, Value>
403     */
404    private function expandGrid(Value $value): array
405    {
406        if ($value instanceof \Phpdftk\Css\Value\ValueList
407            && $this->gridHasAutoFlow($value)
408        ) {
409            return [];
410        }
411        $template = $this->expandGridTemplate($value);
412        if ($template === []) {
413            return [];
414        }
415        $template['grid-auto-flow'] = new \Phpdftk\Css\Value\Keyword('row');
416        $template['grid-auto-rows'] = new \Phpdftk\Css\Value\Keyword('auto');
417        $template['grid-auto-columns'] = new \Phpdftk\Css\Value\Keyword('auto');
418        return $template;
419    }
420
421    /**
422     * Whether one axis of a grid template is a list of only DEFINITE
423     * track sizes — `<length>`, `<percentage>`, or `<flex>` (`fr`).
424     * These size the same whether or not the grid container has a
425     * definite size, so expanding them to the longhands is always safe.
426     *
427     * Intrinsic forms (`auto` / `min-content` / `max-content` /
428     * `minmax()` / `fit-content()` / `repeat()`), `subgrid`, `masonry`,
429     * and template-area `<string>`s are NOT definite: they need the
430     * content-sizing / auto-flow track machinery that the current grid
431     * layout only partly models, so the shorthand is left unexpanded
432     * (the grid keeps its working auto-placement fallback) rather than
433     * pinned to a track list that would collapse.
434     */
435    private function gridTrackListIsDefinite(Value $value): bool
436    {
437        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
438            foreach ($value->values as $inner) {
439                if (!$this->gridTrackListIsDefinite($inner)) {
440                    return false;
441                }
442            }
443            return true;
444        }
445        if ($value instanceof \Phpdftk\Css\Value\Length
446            || $value instanceof \Phpdftk\Css\Value\Percentage
447        ) {
448            return true;
449        }
450        return $value instanceof \Phpdftk\Css\Value\CssFunction
451            && strtolower($value->name) === 'fr';
452    }
453
454    /**
455     * Whether a `grid` shorthand value uses the `auto-flow` form on
456     * either axis (deferred — needs auto-flow direction modelling).
457     */
458    private function gridHasAutoFlow(\Phpdftk\Css\Value\ValueList $value): bool
459    {
460        foreach ($value->values as $operand) {
461            if ($operand instanceof \Phpdftk\Css\Value\Keyword
462                && strtolower($operand->name) === 'auto-flow'
463            ) {
464                return true;
465            }
466            if ($operand instanceof \Phpdftk\Css\Value\ValueList) {
467                foreach ($operand->values as $inner) {
468                    if ($inner instanceof \Phpdftk\Css\Value\Keyword
469                        && strtolower($inner->name) === 'auto-flow'
470                    ) {
471                        return true;
472                    }
473                }
474            }
475        }
476        return false;
477    }
478
479    /**
480     * CSS Grid Layout 2 §8.3.1 — `grid-column` / `grid-row` shorthand.
481     * Accepts `<start>` (end omitted, defaults to auto) or
482     * `<start> / <end>`. Numeric start/end are kept as `Integer`;
483     * `auto` falls through as a Keyword. The `span N` syntax is
484     * deferred (Phase-2 follow-up — span support requires the layout
485     * to grow the implicit grid).
486     *
487     * @return array<string, Value>
488     */
489    private function expandGridLine(Value $value, string $axis): array
490    {
491        $startKey = "grid-{$axis}-start";
492        $endKey = "grid-{$axis}-end";
493        if ($value instanceof \Phpdftk\Css\Value\ValueList
494            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Slash
495            && count($value->values) >= 2
496        ) {
497            return [
498                $startKey => $value->values[0],
499                $endKey => $value->values[1],
500            ];
501        }
502        return [
503            $startKey => $value,
504            $endKey => new \Phpdftk\Css\Value\Keyword('auto'),
505        ];
506    }
507
508    /**
509     * `margin: 10px;` → margin-top/right/bottom/left = 10px
510     * `margin: 10px 5px;` → top/bottom = 10px, left/right = 5px
511     * `margin: 10px 5px 20px;` → top = 10, right/left = 5, bottom = 20
512     * `margin: 10px 5px 20px 0;` → t r b l
513     *
514     * @param array{0:string, 1:string, 2:string, 3:string} $suffixes
515     * @return array<string, Value>
516     */
517    private function expandFourSided(string $prefix, Value $value, array $suffixes): array
518    {
519        $components = $this->toComponents($value);
520        $count = count($components);
521        if ($count === 0) {
522            return [];
523        }
524        [$top, $right, $bottom, $left] = match ($count) {
525            1 => [$components[0], $components[0], $components[0], $components[0]],
526            2 => [$components[0], $components[1], $components[0], $components[1]],
527            3 => [$components[0], $components[1], $components[2], $components[1]],
528            default => [$components[0], $components[1], $components[2], $components[3]],
529        };
530        return [
531            $prefix . '-' . $suffixes[0] => $top,
532            $prefix . '-' . $suffixes[1] => $right,
533            $prefix . '-' . $suffixes[2] => $bottom,
534            $prefix . '-' . $suffixes[3] => $left,
535        ];
536    }
537
538    /**
539     * `border-top: 1px solid red` → expands to border-top-width, -style, -color.
540     * Components can appear in any order; missing fields default to the
541     * spec's initial value (`medium` for width, `none` for style, `currentcolor`
542     * for color) but we leave the omitted longhands out so the registry's
543     * initial values apply.
544     *
545     * @return array<string, Value>
546     */
547    private function expandBorderSide(string $shorthand, Value $value): array
548    {
549        // shorthand is e.g. "border-top".
550        $side = substr($shorthand, strlen('border-'));
551        $components = $this->toComponents($value);
552        return $this->classifyBorderComponents($components, [$side]);
553    }
554
555    /** @return array<string, Value> */
556    private function expandBorder(Value $value): array
557    {
558        $components = $this->toComponents($value);
559        return $this->classifyBorderComponents($components, ['top', 'right', 'bottom', 'left']);
560    }
561
562    /**
563     * `outline: 2px solid red` → outline-width, outline-style, outline-color.
564     * Free-order components like border-side.
565     *
566     * @return array<string, Value>
567     */
568    private function expandOutline(Value $value): array
569    {
570        $components = $this->toComponents($value);
571        $width = null;
572        $style = null;
573        $color = null;
574        foreach ($components as $c) {
575            if ($style === null && $this->looksLikeBorderStyle($c)) {
576                $style = $c;
577                continue;
578            }
579            if ($width === null && $this->looksLikeBorderWidth($c)) {
580                $width = self::asZeroLength($c);
581                continue;
582            }
583            if ($color === null && $this->isColorComponent($c)) {
584                $color = $c;
585                continue;
586            }
587            // CSS Basic UI 4 §3.3 — `outline-color: invert` is the
588            // legacy CSS 2.1 keyword that requests xor-blending
589            // against the underlying pixels. Print medium can't
590            // implement it; the cascade preserves it so author CSS
591            // round-trips.
592            if ($color === null && $c instanceof Keyword
593                && strtolower($c->name) === 'invert'
594            ) {
595                $color = $c;
596            }
597        }
598        $out = [];
599        if ($width !== null) {
600            $out['outline-width'] = $width;
601        }
602        if ($style !== null) {
603            $out['outline-style'] = $style;
604        }
605        if ($color !== null) {
606            $out['outline-color'] = $color;
607        }
608        return $out;
609    }
610
611    /**
612     * Map free-order `<width> <style> <color>` components onto every side
613     * listed in `$sides`. Order-agnostic per CSS Backgrounds 3 §3.1.
614     *
615     * @param list<Value> $components
616     * @param list<string> $sides
617     * @return array<string, Value>
618     */
619    private function classifyBorderComponents(array $components, array $sides): array
620    {
621        $width = null;
622        $style = null;
623        $color = null;
624        foreach ($components as $c) {
625            if ($style === null && $this->looksLikeBorderStyle($c)) {
626                $style = $c;
627                continue;
628            }
629            if ($width === null && $this->looksLikeBorderWidth($c)) {
630                $width = self::asZeroLength($c);
631                continue;
632            }
633            if ($color === null && $this->looksLikeColor($c)) {
634                $color = $c;
635                continue;
636            }
637        }
638        $out = [];
639        foreach ($sides as $side) {
640            if ($width !== null) {
641                $out["border-$side-width"] = $width;
642            }
643            if ($style !== null) {
644                $out["border-$side-style"] = $style;
645            }
646            if ($color !== null) {
647                $out["border-$side-color"] = $color;
648            }
649        }
650        return $out;
651    }
652
653    /** @return list<Value> */
654    private function toComponents(Value $value): array
655    {
656        if ($value instanceof ValueList) {
657            return $value->values;
658        }
659        return [$value];
660    }
661
662    /**
663     * `border-radius: 5px 10px / 8px 12px` — Phase 1 ignores the second
664     * (vertical) radius set after `/`; drop everything past the slash so
665     * the horizontal radii feed `expandFourSided`.
666     */
667    private function stripSlashTail(Value $value): Value
668    {
669        if (!($value instanceof ValueList)) {
670            return $value;
671        }
672        if ($value->separator !== \Phpdftk\Css\Value\ListSeparator::Slash) {
673            return $value;
674        }
675        // The slash-separated outer list has the horizontal radii as its
676        // first item; keep only that. The horizontal-radii item may itself
677        // be a Space ValueList.
678        $first = $value->values[0] ?? $value;
679        return $first;
680    }
681
682    private function looksLikeBorderWidth(Value $v): bool
683    {
684        if ($v instanceof \Phpdftk\Css\Value\Length) {
685            return true;
686        }
687        if ($v instanceof \Phpdftk\Css\Value\Keyword) {
688            return in_array(strtolower($v->name), ['thin', 'medium', 'thick'], true);
689        }
690        // CSS Values 4 §5.2 — zero is the one <length> that may be written
691        // without a unit, so a bare `0` tokenises as a number, not a Length.
692        // `border: 0` is the overwhelmingly common way to switch a UA border
693        // off; rejecting it here left the shorthand unclassifiable, so the
694        // whole declaration was silently dropped and the UA border stayed.
695        return self::isZeroNumber($v);
696    }
697
698    /**
699     * A unitless numeric zero, as written in `border: 0` / `outline: 0`.
700     */
701    private static function isZeroNumber(Value $v): bool
702    {
703        if ($v instanceof \Phpdftk\Css\Value\Integer) {
704            return $v->value === 0;
705        }
706        return $v instanceof \Phpdftk\Css\Value\Number && $v->value === 0.0;
707    }
708
709    /**
710     * Normalise a matched unitless zero to a Length. Downstream consumers
711     * almost all gate on `instanceof Length`, so emitting a bare Integer
712     * would trade a dropped declaration for one parsed and then ignored.
713     */
714    private static function asZeroLength(Value $v): Value
715    {
716        return self::isZeroNumber($v)
717            ? new \Phpdftk\Css\Value\Length(0.0, \Phpdftk\Css\Value\LengthUnit::Px)
718            : $v;
719    }
720
721    private function looksLikeBorderStyle(Value $v): bool
722    {
723        if (!$v instanceof \Phpdftk\Css\Value\Keyword) {
724            return false;
725        }
726        return in_array(strtolower($v->name), [
727            'none', 'hidden', 'dotted', 'dashed', 'solid',
728            'double', 'groove', 'ridge', 'inset', 'outset',
729        ], true);
730    }
731
732    private function looksLikeColor(Value $v): bool
733    {
734        // Accept typed Color values plus the keyword forms the
735        // shorthand grammar permits everywhere a `<color>` appears
736        // (`currentcolor`, `transparent`). Other Keyword tokens
737        // remain candidates for their own slots (border-style,
738        // -width, etc.) — `isColorComponent` whitelists the two
739        // color-bearing keywords specifically.
740        return $this->isColorComponent($v);
741    }
742
743    /**
744     * CSS Fonts 4 §6.7: `font: [<style> || <variant> || <weight> || <stretch>]?
745     * <size> [/ <line-height>]? <family>`.
746     *
747     * The input value can carry three nested structures depending on whether
748     * comma-separated families and a slash-separated line-height are present:
749     *
750     *  - bare Space list: `bold 16px Arial`
751     *  - Slash wrapping Space lists: `bold 16px/1.5 Arial`
752     *  - Comma wrapping the above + extra family items: `bold 16px Arial, sans-serif`
753     *  - Comma + Slash combo: `bold 16px/1.5 Arial, sans-serif`
754     *
755     * @return array<string, Value>
756     */
757    private function expandFont(Value $value): array
758    {
759        $head = $value;
760        $extraFamilies = [];
761        if ($value instanceof ValueList && $value->separator === ListSeparator::Comma) {
762            $head = $value->values[0] ?? $value;
763            $extraFamilies = array_slice($value->values, 1);
764        }
765
766        $lineHeight = null;
767        $tail = [];
768        if ($head instanceof ValueList && $head->separator === ListSeparator::Slash) {
769            $sizeSegment = $head->values[0] ?? $head;
770            $afterSlash = $head->values[1] ?? null;
771            if ($afterSlash !== null) {
772                $afterItems = $afterSlash instanceof ValueList && $afterSlash->separator === ListSeparator::Space
773                    ? $afterSlash->values
774                    : [$afterSlash];
775                $lineHeight = $afterItems[0] ?? null;
776                $tail = array_slice($afterItems, 1);
777            }
778            $head = $sizeSegment;
779        }
780
781        $items = $head instanceof ValueList && $head->separator === ListSeparator::Space
782            ? $head->values
783            : [$head];
784
785        $style = $variant = $weight = $stretch = $size = null;
786        $familyHead = [];
787        foreach ($items as $item) {
788            if ($size === null) {
789                if ($style === null && $this->looksLikeFontStyle($item)) {
790                    $style = $item;
791                    continue;
792                }
793                if ($variant === null && $this->looksLikeFontVariant($item)) {
794                    $variant = $item;
795                    continue;
796                }
797                if ($weight === null && $this->looksLikeFontWeight($item)) {
798                    $weight = $item;
799                    continue;
800                }
801                if ($stretch === null && $this->looksLikeFontStretch($item)) {
802                    $stretch = $item;
803                    continue;
804                }
805                if ($this->looksLikeFontSize($item)) {
806                    $size = self::asZeroLength($item);
807                    continue;
808                }
809                continue;
810            }
811            $familyHead[] = $item;
812        }
813
814        $allFamilies = array_merge($familyHead, $tail, $extraFamilies);
815
816        $out = [];
817        if ($size !== null) {
818            $out['font-size'] = $size;
819        }
820        if ($style !== null) {
821            $out['font-style'] = $style;
822        }
823        if ($variant !== null) {
824            // CSS Fonts 4 §6.7 — the `font` shorthand only accepts the
825            // CSS 2.1 `small-caps` token for the variant slot. Route it
826            // straight to `font-variant-caps`; the second-pass expansion
827            // of `font-variant` wouldn't run here (the cascade calls
828            // shorthand expansion once per declaration).
829            $out['font-variant-caps'] = $variant;
830            // Reset the other `font-variant-*` longhands to `normal`
831            // per CSS Fonts 4 §6.11 — the `font` shorthand resets ALL
832            // `font-variant-*` longhands, not just `font-variant-caps`.
833            $out['font-variant-ligatures'] = new Keyword('normal');
834            $out['font-variant-numeric'] = new Keyword('normal');
835            $out['font-variant-east-asian'] = new Keyword('normal');
836            $out['font-variant-position'] = new Keyword('normal');
837            $out['font-variant-alternates'] = new Keyword('normal');
838            $out['font-variant-emoji'] = new Keyword('normal');
839        }
840        if ($weight !== null) {
841            $out['font-weight'] = $weight;
842        }
843        if ($stretch !== null) {
844            $out['font-stretch'] = $stretch;
845        }
846        if ($lineHeight !== null) {
847            $out['line-height'] = $lineHeight;
848        }
849        if ($allFamilies !== []) {
850            $out['font-family'] = count($allFamilies) === 1
851                ? $allFamilies[0]
852                : new ValueList(array_values($allFamilies), ListSeparator::Comma);
853        }
854        return $out;
855    }
856
857    private function looksLikeFontStyle(Value $v): bool
858    {
859        if (!$v instanceof Keyword) {
860            return false;
861        }
862        return in_array(strtolower($v->name), ['italic', 'oblique'], true);
863    }
864
865    private function looksLikeFontVariant(Value $v): bool
866    {
867        return $v instanceof Keyword && strtolower($v->name) === 'small-caps';
868    }
869
870    private function looksLikeFontWeight(Value $v): bool
871    {
872        if ($v instanceof Keyword
873            && in_array(strtolower($v->name), ['bold', 'bolder', 'lighter'], true)
874        ) {
875            return true;
876        }
877        if ($v instanceof Integer || $v instanceof Number) {
878            $n = $v instanceof Integer ? $v->value : (int) $v->value;
879            return $n >= 1 && $n <= 1000;
880        }
881        return false;
882    }
883
884    private function looksLikeFontStretch(Value $v): bool
885    {
886        if (!$v instanceof Keyword) {
887            return false;
888        }
889        return in_array(strtolower($v->name), [
890            'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed',
891            'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded',
892        ], true);
893    }
894
895    /**
896     * `background: <bg-image> || <position> [ / <bg-size> ]? || <repeat> ||
897     * <attachment> || <box> || <box> || <color>` (CSS Backgrounds 3 §3.10),
898     * with `<box>` reused for both `background-origin` and `background-clip`.
899     *
900     * Phase-1 implementation classifies components by their parsed type and
901     * keyword vocabulary rather than tracking the per-component grammar
902     * precisely — sufficient for the common author-CSS patterns
903     * (`background: red`, `background: url(x.png)`, `background: #fff
904     * no-repeat`, `background: url(bg.jpg) center / cover`). Components the
905     * classifier doesn't recognise are dropped, which is forgiving but
906     * sometimes lossy — matches browser behaviour for malformed inputs.
907     *
908     * @return array<string, Value>
909     */
910    private function expandBackground(Value $value): array
911    {
912        // Slash separates position from size: `background: <pos> / <size>`.
913        $position = null;
914        $size = null;
915        $body = $value;
916        if ($value instanceof ValueList && $value->separator === ListSeparator::Slash) {
917            $body = $value->values[0] ?? $value;
918            $size = $value->values[1] ?? null;
919        }
920
921        $components = $this->toComponents($body);
922        $color = null;
923        $image = null;
924        $repeat = null;
925        $attachment = null;
926        $origin = null;
927        $clip = null;
928        $positionParts = [];
929        $geoBoxKw = ['border-box', 'padding-box', 'content-box', 'text'];
930        foreach ($components as $c) {
931            if ($this->looksLikeColor($c)) {
932                $color = $c;
933                continue;
934            }
935            // CSS Values 5 §11 — a typed `attr()` is still unresolved at
936            // cascade time (it resolves per-element in BoxGenerator), so it
937            // matches none of the concrete classifiers below and would be
938            // dropped. Route it to the right longhand by its declared type so
939            // the later attr() resolution lands on background-color /
940            // -image. An untyped attr() is ambiguous — leave it dropped.
941            if ($c instanceof \Phpdftk\Css\Value\AttrFunction) {
942                $type = $c->typeOrUnit !== null ? strtolower($c->typeOrUnit) : '';
943                if (str_contains($type, 'color')) {
944                    $color = $c;
945                } elseif (str_contains($type, 'url') || str_contains($type, 'image')) {
946                    $image = $c;
947                }
948                continue;
949            }
950            // CSS Backgrounds 3 §3.1 / CSS Images 4 — `background-image`
951            // accepts any <image>: url, image-set, gradient, cross-fade, and
952            // the `image()` functional notation (a CssFunction the painter
953            // resolves to its first supported url or its fallback colour).
954            if ($c instanceof \Phpdftk\Css\Value\Url
955                || $c instanceof \Phpdftk\Css\Value\Gradient
956                || $c instanceof \Phpdftk\Css\Value\ImageSet
957                || $c instanceof \Phpdftk\Css\Value\CrossFade
958                || ($c instanceof \Phpdftk\Css\Value\CssFunction
959                    && strtolower($c->name) === 'image')
960            ) {
961                $image = $c;
962                continue;
963            }
964            if ($c instanceof Keyword) {
965                $lower = strtolower($c->name);
966                if (in_array($lower, ['repeat', 'no-repeat', 'repeat-x', 'repeat-y', 'round', 'space'], true)) {
967                    $repeat = $c;
968                    continue;
969                }
970                if (in_array($lower, ['scroll', 'fixed', 'local'], true)) {
971                    $attachment = $c;
972                    continue;
973                }
974                if (in_array($lower, $geoBoxKw, true)) {
975                    // First geometry-box → origin AND clip; second → clip only.
976                    if ($origin === null) {
977                        $origin = $c;
978                        $clip = $c;
979                    } else {
980                        $clip = $c;
981                    }
982                    continue;
983                }
984                if (in_array($lower, ['top', 'bottom', 'left', 'right', 'center'], true)) {
985                    $positionParts[] = $c;
986                    continue;
987                }
988            }
989            if ($c instanceof Length || $c instanceof Percentage) {
990                $positionParts[] = $c;
991            }
992        }
993        if ($positionParts !== []) {
994            $position = count($positionParts) === 1
995                ? $positionParts[0]
996                : new ValueList($positionParts, ListSeparator::Space);
997        }
998
999        $out = [];
1000        if ($color !== null) {
1001            $out['background-color'] = $color;
1002        }
1003        if ($image !== null) {
1004            $out['background-image'] = $image;
1005        }
1006        if ($repeat !== null) {
1007            $out['background-repeat'] = $repeat;
1008        }
1009        if ($attachment !== null) {
1010            $out['background-attachment'] = $attachment;
1011        }
1012        if ($origin !== null) {
1013            $out['background-origin'] = $origin;
1014        }
1015        if ($clip !== null) {
1016            $out['background-clip'] = $clip;
1017        }
1018        if ($position !== null) {
1019            $out['background-position'] = $position;
1020        }
1021        if ($size !== null) {
1022            $out['background-size'] = $size;
1023        }
1024        return $out;
1025    }
1026
1027    /**
1028     * `list-style: <list-style-type> || <list-style-position> ||
1029     * <list-style-image>` (CSS Lists 3 §1.4). Free order; the `none`
1030     * keyword is genuinely ambiguous between type and image — per spec we
1031     * apply it to whichever side hasn't been set yet, defaulting to type
1032     * when both are still free.
1033     *
1034     * @return array<string, Value>
1035     */
1036    private function expandListStyle(Value $value): array
1037    {
1038        $type = null;
1039        $position = null;
1040        $image = null;
1041        $components = $this->toComponents($value);
1042
1043        foreach ($components as $c) {
1044            if ($c instanceof \Phpdftk\Css\Value\Url) {
1045                $image = $c;
1046                continue;
1047            }
1048            if (!$c instanceof Keyword) {
1049                continue;
1050            }
1051            $lower = strtolower($c->name);
1052            if ($lower === 'inside' || $lower === 'outside') {
1053                $position = $c;
1054                continue;
1055            }
1056            if ($lower === 'none') {
1057                if ($type === null) {
1058                    $type = $c;
1059                } elseif ($image === null) {
1060                    $image = $c;
1061                }
1062                continue;
1063            }
1064            // CSS Lists 3 §1.4 — the type slot is a `<counter-style>`,
1065            // i.e. a `<counter-style-name>`, i.e. any custom-ident other
1066            // than `none`. It is NOT a closed set: `@counter-style` lets
1067            // authors name their own, and the predefined set keeps
1068            // growing (`disclosure-closed`, the kana and CJK families,
1069            // `ethiopic-numeric`, …). Matching against a hard-coded
1070            // allowlist silently DROPPED every style outside it — which
1071            // is why the UA sheet's `list-style: disclosure-closed
1072            // inside` on `<summary>` fell back to a `disc` bullet.
1073            if ($type === null) {
1074                $type = $c;
1075            }
1076        }
1077
1078        $out = [];
1079        if ($type !== null) {
1080            $out['list-style-type'] = $type;
1081        }
1082        if ($position !== null) {
1083            $out['list-style-position'] = $position;
1084        }
1085        if ($image !== null) {
1086            $out['list-style-image'] = $image;
1087        }
1088        return $out;
1089    }
1090
1091    /**
1092     * `text-decoration: <line> || <style> || <color>` (CSS Text Decoration 3
1093     * §2). `<line>` itself can be a space-list of `underline` / `overline` /
1094     * `line-through` / `blink`, or `none`. Order is free.
1095     *
1096     * @return array<string, Value>
1097     */
1098    private function expandTextDecoration(Value $value): array
1099    {
1100        $components = $this->toComponents($value);
1101        $lineParts = [];
1102        $style = null;
1103        $color = null;
1104        $thickness = null;
1105        foreach ($components as $c) {
1106            if ($c instanceof Keyword) {
1107                $lower = strtolower($c->name);
1108                if (in_array($lower, [
1109                    'underline', 'overline', 'line-through', 'blink', 'none',
1110                    // CSS Text Decoration 4 §2.1 — spelling-error /
1111                    // grammar-error are decoration-line keywords drawn
1112                    // with the UA's spell/grammar squiggly style.
1113                    'spelling-error', 'grammar-error',
1114                ], true)) {
1115                    $lineParts[] = $c;
1116                    continue;
1117                }
1118                if (in_array($lower, ['solid', 'double', 'dotted', 'dashed', 'wavy'], true)) {
1119                    $style = $c;
1120                    continue;
1121                }
1122                // CSS Text Decoration 4 §1.5 — `auto` / `from-font`
1123                // are the named thickness forms.
1124                if ($thickness === null && in_array($lower, ['auto', 'from-font'], true)) {
1125                    $thickness = $c;
1126                    continue;
1127                }
1128            }
1129            // Length / Percentage at this slot are thickness values
1130            // per §1.5; the only other place a Length appears in the
1131            // shorthand is the color slot, which Length isn't.
1132            if ($thickness === null && ($c instanceof Length || $c instanceof Percentage)) {
1133                $thickness = $c;
1134                continue;
1135            }
1136            if ($this->looksLikeColor($c)) {
1137                $color = $c;
1138            }
1139        }
1140        $out = [];
1141        if ($lineParts !== []) {
1142            $out['text-decoration-line'] = count($lineParts) === 1
1143                ? $lineParts[0]
1144                : new ValueList($lineParts, ListSeparator::Space);
1145        }
1146        if ($style !== null) {
1147            $out['text-decoration-style'] = $style;
1148        }
1149        if ($color !== null) {
1150            $out['text-decoration-color'] = $color;
1151        }
1152        if ($thickness !== null) {
1153            $out['text-decoration-thickness'] = $thickness;
1154        }
1155        return $out;
1156    }
1157
1158    /**
1159     * `flex: <flex-grow> <flex-shrink>? <flex-basis>?` (CSS Flex 1
1160     * §7.2). Common forms:
1161     *  - `flex: <number>` → grow with shrink=1, basis=0%.
1162     *  - `flex: <number> <number>` → grow + shrink, basis=0%.
1163     *  - `flex: <number> <number> <length>` → all three explicit.
1164     *  - `flex: none` → 0 0 auto.
1165     *  - `flex: auto` → 1 1 auto.
1166     *  - `flex: initial` → 0 1 auto (the spec initial).
1167     *
1168     * @return array<string, Value>
1169     */
1170    private function expandFlex(Value $value): array
1171    {
1172        if ($value instanceof Keyword) {
1173            return match (strtolower($value->name)) {
1174                'none' => [
1175                    'flex-grow' => new Number(0),
1176                    'flex-shrink' => new Number(0),
1177                    'flex-basis' => new Keyword('auto'),
1178                ],
1179                'auto' => [
1180                    'flex-grow' => new Number(1),
1181                    'flex-shrink' => new Number(1),
1182                    'flex-basis' => new Keyword('auto'),
1183                ],
1184                'initial' => [
1185                    'flex-grow' => new Number(0),
1186                    'flex-shrink' => new Number(1),
1187                    'flex-basis' => new Keyword('auto'),
1188                ],
1189                default => [],
1190            };
1191        }
1192        $components = $this->toComponents($value);
1193        $grow = null;
1194        $shrink = null;
1195        $basis = null;
1196        $numericCount = 0;
1197        foreach ($components as $c) {
1198            if (($c instanceof Number || $c instanceof Integer) && $numericCount < 2) {
1199                if ($numericCount === 0) {
1200                    $grow = new Number($c instanceof Number ? $c->value : (float) $c->value);
1201                } else {
1202                    $shrink = new Number($c instanceof Number ? $c->value : (float) $c->value);
1203                }
1204                $numericCount++;
1205            } elseif ($basis === null) {
1206                $basis = $c;
1207            }
1208        }
1209        $out = [];
1210        if ($grow !== null) {
1211            $out['flex-grow'] = $grow;
1212        }
1213        if ($shrink !== null) {
1214            $out['flex-shrink'] = $shrink;
1215        }
1216        if ($basis !== null) {
1217            $out['flex-basis'] = $basis;
1218        }
1219        // Per spec, omitted basis defaults to 0% when grow is set
1220        // (so `flex: 2` → grow:2, shrink:1, basis:0%). Use Length 0
1221        // as the closest approximation.
1222        if ($grow !== null && $basis === null) {
1223            $out['flex-basis'] = new \Phpdftk\Css\Value\Length(0.0, \Phpdftk\Css\Value\LengthUnit::Px);
1224        }
1225        // Omitted shrink defaults to 1.
1226        if ($grow !== null && $shrink === null) {
1227            $out['flex-shrink'] = new Number(1.0);
1228        }
1229        // Per CSS Flexbox 1 §7.2, `flex: <length|percentage>` (a
1230        // single non-number value) is shorthand for `flex: 1 1
1231        // <value>` — grow and shrink default to 1 when only a basis
1232        // is supplied, not the property initial values.
1233        if ($grow === null && $basis !== null) {
1234            $out['flex-grow'] = new Number(1.0);
1235            if ($shrink === null) {
1236                $out['flex-shrink'] = new Number(1.0);
1237            }
1238        }
1239        return $out;
1240    }
1241
1242    /**
1243     * `flex-flow: <flex-direction> || <flex-wrap>` (CSS Flex 1 §6.2).
1244     *
1245     * @return array<string, Value>
1246     */
1247    private function expandFlexFlow(Value $value): array
1248    {
1249        $components = $this->toComponents($value);
1250        $directions = ['row', 'row-reverse', 'column', 'column-reverse'];
1251        $wraps = ['nowrap', 'wrap', 'wrap-reverse'];
1252        $out = [];
1253        foreach ($components as $c) {
1254            if (!($c instanceof Keyword)) {
1255                continue;
1256            }
1257            $name = strtolower($c->name);
1258            if (in_array($name, $directions, true) && !isset($out['flex-direction'])) {
1259                $out['flex-direction'] = $c;
1260            } elseif (in_array($name, $wraps, true) && !isset($out['flex-wrap'])) {
1261                $out['flex-wrap'] = $c;
1262            }
1263        }
1264        return $out;
1265    }
1266
1267    /**
1268     * `overflow: <visible|hidden|clip|scroll|auto>{1,2}` (CSS
1269     * Overflow 3 §3.2). Single value applies to both axes;
1270     * two values are `overflow-x overflow-y`. Also keeps the legacy
1271     * `overflow` longhand so existing painter code keeps reading
1272     * the un-prefixed value.
1273     *
1274     * @return array<string, Value>
1275     */
1276    private function expandOverflow(Value $value): array
1277    {
1278        $components = $this->toComponents($value);
1279        if ($components === []) {
1280            return [];
1281        }
1282        [$x, $y] = count($components) === 1
1283            ? [$components[0], $components[0]]
1284            : [$components[0], $components[1]];
1285        return [
1286            'overflow-x' => $x,
1287            'overflow-y' => $y,
1288            // Keep the legacy direct property in sync so any reader
1289            // that still reaches for `overflow` sees a sensible value
1290            // (the X axis for asymmetric splits).
1291            'overflow' => $x,
1292        ];
1293    }
1294
1295    /**
1296     * `inset: <length> [<length>{1,3}]?` (CSS Position 3 §3.3).
1297     * Shorthand for `top` / `right` / `bottom` / `left` using the
1298     * standard 1-to-4-value clockwise pattern (TRBL).
1299     *
1300     * @return array<string, Value>
1301     */
1302    private function expandInset(Value $value): array
1303    {
1304        $components = $this->toComponents($value);
1305        $count = count($components);
1306        if ($count === 0) {
1307            return [];
1308        }
1309        [$top, $right, $bottom, $left] = match ($count) {
1310            1 => [$components[0], $components[0], $components[0], $components[0]],
1311            2 => [$components[0], $components[1], $components[0], $components[1]],
1312            3 => [$components[0], $components[1], $components[2], $components[1]],
1313            default => [$components[0], $components[1], $components[2], $components[3]],
1314        };
1315        return [
1316            'top' => $top,
1317            'right' => $right,
1318            'bottom' => $bottom,
1319            'left' => $left,
1320        ];
1321    }
1322
1323    /**
1324     * `gap: <row-gap> [<column-gap>]?` (CSS Box Alignment 3 §8.3).
1325     * One value: applies to both axes; two values: row first, column
1326     * second.
1327     *
1328     * @return array<string, Value>
1329     */
1330    private function expandGap(Value $value): array
1331    {
1332        $components = $this->toComponents($value);
1333        if ($components === []) {
1334            return [];
1335        }
1336        [$row, $col] = match (count($components)) {
1337            1 => [$components[0], $components[0]],
1338            default => [$components[0], $components[1]],
1339        };
1340        return [
1341            'row-gap' => $row,
1342            'column-gap' => $col,
1343        ];
1344    }
1345
1346    /**
1347     * `columns: <'column-width'> || <'column-count'>` (CSS Multi-column 1
1348     * §10.1). Either side may be omitted; `auto` may appear as either side
1349     * and is assigned to whichever slot is still free (width first).
1350     *
1351     * @return array<string, Value>
1352     */
1353    private function expandColumns(Value $value): array
1354    {
1355        $components = $this->toComponents($value);
1356        // CSS Multi-column 1 §3.3 — `columns` takes at most a
1357        // `<column-width>` and a `<column-count>`. More components than
1358        // that, or a component matching neither, makes the whole
1359        // declaration invalid; it must be DROPPED so an earlier valid
1360        // `column-count` keeps applying, rather than silently ignoring
1361        // the stray part and letting the rest win the cascade.
1362        if (count($components) > 2) {
1363            return [];
1364        }
1365        $width = null;
1366        $count = null;
1367        foreach ($components as $c) {
1368            if ($count === null && ($c instanceof Integer
1369                || ($c instanceof Number && floor($c->value) === $c->value))
1370            ) {
1371                $count = $c;
1372                continue;
1373            }
1374            if ($width === null && ($c instanceof Length || $c instanceof Percentage)) {
1375                $width = $c;
1376                continue;
1377            }
1378            if ($c instanceof Keyword && strtolower($c->name) === 'auto') {
1379                if ($width === null) {
1380                    $width = $c;
1381                } elseif ($count === null) {
1382                    $count = $c;
1383                }
1384                continue;
1385            }
1386            // A component that is neither a count, a width nor `auto`
1387            // (e.g. `columns: 8 normal`) invalidates the declaration.
1388            return [];
1389        }
1390        $out = [];
1391        if ($width !== null) {
1392            $out['column-width'] = $width;
1393        }
1394        if ($count !== null) {
1395            $out['column-count'] = $count;
1396        }
1397        return $out;
1398    }
1399
1400    /**
1401     * `column-rule: <'column-rule-width'> || <'column-rule-style'> ||
1402     * <'column-rule-color'>` (CSS Multi-column 1 §3.2). Free order; reuses
1403     * the border-width / border-style / color classifiers because the value
1404     * grammars match.
1405     *
1406     * @return array<string, Value>
1407     */
1408    /**
1409     * `container: <container-name> [ / <container-type> ]?` (CSS
1410     * Containment 3 §4.5). Either side may be omitted (defaults to
1411     * the property initial — `none` for name, `normal` for type).
1412     * The author CSS `container: foo` sets the name; `container: foo
1413     * / size` sets both; `container: / size` sets just the type.
1414     *
1415     * @return array<string, Value>
1416     */
1417    private function expandContainer(Value $value): array
1418    {
1419        $out = [
1420            'container-name' => new Keyword('none'),
1421            'container-type' => new Keyword('normal'),
1422        ];
1423        // Split on the `/` separator. ValueList with Slash separator
1424        // is the parsed shape; bare values without `/` show up as a
1425        // single Value (Keyword or ValueList of names).
1426        if ($value instanceof \Phpdftk\Css\Value\ValueList
1427            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Slash
1428        ) {
1429            $namePart = $value->values[0] ?? null;
1430            $typePart = $value->values[1] ?? null;
1431            if ($namePart !== null) {
1432                $out['container-name'] = $namePart;
1433            }
1434            if ($typePart !== null) {
1435                $out['container-type'] = $typePart;
1436            }
1437            return $out;
1438        }
1439        // Single value before any `/` — that's the name.
1440        $out['container-name'] = $value;
1441        return $out;
1442    }
1443
1444    /**
1445     * `column-rule: <width> || <style> || <color>` (CSS Multi-column
1446     * 1 §3). Composes the three properties from a single declaration.
1447     *
1448     * @return array<string, Value>
1449     */
1450    private function expandColumnRule(Value $value): array
1451    {
1452        return $this->expandRuleShorthand($value, 'column-rule');
1453    }
1454
1455    /**
1456     * CSS Gaps 1 §3 — `rule: <width> || <style> || <color>` sets the
1457     * `column-rule-*` AND `row-rule-*` longhands at once, so authors can
1458     * decorate both axes with one declaration.
1459     *
1460     * @return array<string, Value>
1461     */
1462    private function expandBothAxisRule(Value $value): array
1463    {
1464        return $this->expandRuleShorthand($value, 'column-rule')
1465            + $this->expandRuleShorthand($value, 'row-rule');
1466    }
1467
1468    /**
1469     * CSS Gaps 1 §3 — the axis-agnostic single-longhand shorthands
1470     * (`rule-width`, `rule-style`, `rule-color`, `rule-break`,
1471     * `rule-visibility-items`): copy the declared value onto the
1472     * `column-rule-` and `row-rule-` longhand of the same suffix.
1473     *
1474     * @return array<string, Value>
1475     */
1476    private function expandBothAxisRuleLonghand(string $name, Value $value): array
1477    {
1478        $suffix = substr(strtolower($name), strlen('rule-'));
1479
1480        return [
1481            'column-rule-' . $suffix => $value,
1482            'row-rule-' . $suffix => $value,
1483        ];
1484    }
1485
1486    /**
1487     * CSS Gaps 1 §4 — the `*-rule-inset*` shorthand family. Every form
1488     * resolves onto the four per-axis longhands
1489     * `{cap,junction}-{start,end}`:
1490     *
1491     *   `-inset`           all four          (1 value = all, 2 = start/end)
1492     *   `-inset-cap`       cap-start/-end
1493     *   `-inset-junction`  junction-start/-end
1494     *   `-inset-start`     cap-start + junction-start
1495     *   `-inset-end`       cap-end + junction-end
1496     *
1497     * A `rule-` (axis-agnostic) prefix writes both axes; `column-rule-` /
1498     * `row-rule-` write only their own.
1499     *
1500     * @return array<string, Value>
1501     */
1502    private function expandRuleInset(string $name, Value $value): array
1503    {
1504        $lower = strtolower($name);
1505        if (str_starts_with($lower, 'column-rule-inset')) {
1506            $axes = ['column-rule'];
1507            $form = substr($lower, strlen('column-rule-inset'));
1508        } elseif (str_starts_with($lower, 'row-rule-inset')) {
1509            $axes = ['row-rule'];
1510            $form = substr($lower, strlen('row-rule-inset'));
1511        } else {
1512            $axes = ['column-rule', 'row-rule'];
1513            $form = substr($lower, strlen('rule-inset'));
1514        }
1515
1516        $components = $this->toComponents($value);
1517        if ($components === []) {
1518            return [];
1519        }
1520        $first = $components[0];
1521        $second = $components[1] ?? $first;
1522
1523        // Which longhand suffixes this form writes, and with which of the
1524        // (up to two) declared values.
1525        $targets = match ($form) {
1526            '-cap' => ['inset-cap-start' => $first, 'inset-cap-end' => $second],
1527            '-junction' => ['inset-junction-start' => $first, 'inset-junction-end' => $second],
1528            '-start' => ['inset-cap-start' => $first, 'inset-junction-start' => $first],
1529            '-end' => ['inset-cap-end' => $first, 'inset-junction-end' => $first],
1530            default => [
1531                'inset-cap-start' => $first,
1532                'inset-junction-start' => $first,
1533                'inset-cap-end' => $second,
1534                'inset-junction-end' => $second,
1535            ],
1536        };
1537
1538        $out = [];
1539        foreach ($axes as $axis) {
1540            foreach ($targets as $suffix => $component) {
1541                $out[$axis . '-' . $suffix] = $component;
1542            }
1543        }
1544
1545        return $out;
1546    }
1547
1548    /**
1549     * CSS Gap Decorations 1 §3.2 — `row-rule` shorthand for
1550     * `row-rule-{width,style,color}`. Mirrors `column-rule` exactly,
1551     * only the target longhand prefix differs.
1552     *
1553     * @return array<string, Value>
1554     */
1555    private function expandRowRule(Value $value): array
1556    {
1557        return $this->expandRuleShorthand($value, 'row-rule');
1558    }
1559
1560    /**
1561     * Shared `<line-width> || <line-style> || <color>` expander for the
1562     * `column-rule` (CSS Multicol 1) and `row-rule` (CSS Gap Decorations
1563     * 1) shorthands. The two grammars are identical; only the longhand
1564     * prefix differs.
1565     *
1566     * @return array<string, Value>
1567     */
1568    private function expandRuleShorthand(Value $value, string $prefix): array
1569    {
1570        $components = $this->toComponents($value);
1571        $width = null;
1572        $style = null;
1573        $color = null;
1574        foreach ($components as $c) {
1575            if ($style === null && $this->looksLikeBorderStyle($c)) {
1576                $style = $c;
1577                continue;
1578            }
1579            if ($width === null && $this->looksLikeBorderWidth($c)) {
1580                $width = self::asZeroLength($c);
1581                continue;
1582            }
1583            if ($color === null && $this->looksLikeColor($c)) {
1584                $color = $c;
1585            }
1586        }
1587        $out = [];
1588        if ($width !== null) {
1589            $out[$prefix . '-width'] = $width;
1590        }
1591        if ($style !== null) {
1592            $out[$prefix . '-style'] = $style;
1593        }
1594        if ($color !== null) {
1595            $out[$prefix . '-color'] = $color;
1596        }
1597        return $out;
1598    }
1599
1600    /**
1601     * CSS Anchor Positioning 1 §8 — `position-try` shorthand for
1602     * `position-try-order` + `position-try-fallbacks`. Two-form
1603     * grammar:
1604     *
1605     *   - Single value (no order keyword): all components feed the
1606     *     fallbacks list; order defaults to `normal`.
1607     *   - Leading order keyword (`normal | most-width | most-height
1608     *     | most-block-size | most-inline-size`) sets the order
1609     *     before the rest of the components feed the fallbacks list.
1610     *
1611     * @return array<string, Value>
1612     */
1613    private function expandPositionTry(Value $value): array
1614    {
1615        $components = $this->toComponents($value);
1616        if ($components === []) {
1617            return [];
1618        }
1619        $orderKeywords = [
1620            'normal', 'most-width', 'most-height',
1621            'most-block-size', 'most-inline-size',
1622        ];
1623        $order = new Keyword('normal');
1624        $fallbackComponents = $components;
1625        $head = $components[0];
1626        if ($head instanceof Keyword
1627            && in_array(strtolower($head->name), $orderKeywords, true)
1628        ) {
1629            $order = $head;
1630            $fallbackComponents = array_slice($components, 1);
1631        }
1632        $fallbacks = match (count($fallbackComponents)) {
1633            0 => new Keyword('none'),
1634            1 => $fallbackComponents[0],
1635            default => new \Phpdftk\Css\Value\ValueList(
1636                $fallbackComponents,
1637                \Phpdftk\Css\Value\ListSeparator::Comma,
1638            ),
1639        };
1640        return [
1641            'position-try-order' => $order,
1642            'position-try-fallbacks' => $fallbacks,
1643        ];
1644    }
1645
1646    /**
1647     * CSS Masking 1 §17 — `mask` shorthand. Per the spec each comma-
1648     * separated layer carries up to 8 components:
1649     *
1650     *   <mask-image> || <position> [/ <size>]? || <repeat-style>
1651     *     || <geometry-box> [<geometry-box> | no-clip]?
1652     *     || <compositing-operator> || <masking-mode>
1653     *
1654     * This expander handles the practical single-layer subset
1655     * authors actually write (the only one any browser ships
1656     * end-to-end painting for): pick out the image source (Url /
1657     * ImageSet / gradient typed values), the position+size pair,
1658     * the repeat keyword, the geometry-box keywords (which assign
1659     * to both mask-origin and mask-clip), the compositing operator,
1660     * and the masking mode. Anything not recognised at a slot is
1661     * left at its registry default.
1662     *
1663     * @return array<string, Value>
1664     */
1665    private function expandMask(Value $value): array
1666    {
1667        $layers = $this->toCommaLayers($value);
1668        $images = [];
1669        $positions = [];
1670        $sizes = [];
1671        $repeats = [];
1672        $origins = [];
1673        $clips = [];
1674        $composites = [];
1675        $modes = [];
1676        $repeatKw = ['repeat', 'repeat-x', 'repeat-y', 'space', 'round', 'no-repeat'];
1677        $geoBoxKw = [
1678            'border-box', 'padding-box', 'content-box',
1679            'margin-box', 'fill-box', 'stroke-box', 'view-box',
1680        ];
1681        $compositeKw = ['add', 'subtract', 'intersect', 'exclude'];
1682        $modeKw = ['match-source', 'alpha', 'luminance'];
1683        foreach ($layers as $layer) {
1684            $componentsAll = $this->toComponents($layer);
1685            $image = null;
1686            $position = null;
1687            $size = null;
1688            $repeat = null;
1689            $origin = null;
1690            $clip = null;
1691            $composite = null;
1692            $mode = null;
1693
1694            // Pull out `<position> / <size>` first if there's a slash
1695            // anywhere in the layer. The slash form arrives as a
1696            // ValueList(Slash). Other components remain in $rest.
1697            $slashLayer = null;
1698            $afterSlash = null;
1699            $rest = $componentsAll;
1700            if ($layer instanceof ValueList && $layer->separator === ListSeparator::Slash) {
1701                $slashLayer = $layer->values[0] ?? null;
1702                $afterSlash = $layer->values[1] ?? null;
1703                $rest = $slashLayer instanceof ValueList
1704                    && $slashLayer->separator === ListSeparator::Space
1705                        ? $slashLayer->values
1706                        : ($slashLayer !== null ? [$slashLayer] : []);
1707                $size = $afterSlash;
1708            }
1709            foreach ($rest as $c) {
1710                if ($image === null && $this->looksLikeMaskImage($c)) {
1711                    $image = $c;
1712                    continue;
1713                }
1714                if ($repeat === null && $c instanceof Keyword
1715                    && in_array(strtolower($c->name), $repeatKw, true)
1716                ) {
1717                    $repeat = $c;
1718                    continue;
1719                }
1720                if ($c instanceof Keyword
1721                    && in_array(strtolower($c->name), $geoBoxKw, true)
1722                ) {
1723                    // Per spec, first geometry-box → mask-origin AND
1724                    // mask-clip; second → mask-clip only.
1725                    if ($origin === null) {
1726                        $origin = $c;
1727                        $clip = $c;
1728                    } else {
1729                        $clip = $c;
1730                    }
1731                    continue;
1732                }
1733                if ($clip === null && $c instanceof Keyword && strtolower($c->name) === 'no-clip') {
1734                    $clip = $c;
1735                    continue;
1736                }
1737                if ($composite === null && $c instanceof Keyword
1738                    && in_array(strtolower($c->name), $compositeKw, true)
1739                ) {
1740                    $composite = $c;
1741                    continue;
1742                }
1743                if ($mode === null && $c instanceof Keyword
1744                    && in_array(strtolower($c->name), $modeKw, true)
1745                ) {
1746                    $mode = $c;
1747                    continue;
1748                }
1749                // Anything left is treated as part of the position.
1750                if ($position === null) {
1751                    $position = $c;
1752                } elseif ($position instanceof ValueList && $position->separator === ListSeparator::Space) {
1753                    $position = new ValueList(
1754                        [...$position->values, $c],
1755                        ListSeparator::Space,
1756                    );
1757                } else {
1758                    $position = new ValueList([$position, $c], ListSeparator::Space);
1759                }
1760            }
1761            $images[] = $image ?? new Keyword('none');
1762            $positions[] = $position ?? new Keyword('0%');
1763            $sizes[] = $size ?? new Keyword('auto');
1764            $repeats[] = $repeat ?? new Keyword('repeat');
1765            $origins[] = $origin ?? new Keyword('border-box');
1766            $clips[] = $clip ?? new Keyword('border-box');
1767            $composites[] = $composite ?? new Keyword('add');
1768            $modes[] = $mode ?? new Keyword('match-source');
1769        }
1770        return [
1771            'mask-image' => $this->joinComma($images),
1772            'mask-position' => $this->joinComma($positions),
1773            'mask-size' => $this->joinComma($sizes),
1774            'mask-repeat' => $this->joinComma($repeats),
1775            'mask-origin' => $this->joinComma($origins),
1776            'mask-clip' => $this->joinComma($clips),
1777            'mask-composite' => $this->joinComma($composites),
1778            'mask-mode' => $this->joinComma($modes),
1779        ];
1780    }
1781
1782    /**
1783     * CSS Backgrounds 3 §6.1 — `border-image` shorthand. Per the
1784     * spec:
1785     *
1786     *   border-image: <source> || <slice> [/ <width> | / <width>?
1787     *                 / <outset>]? || <repeat>
1788     *
1789     * Picks out the typed image source first, the repeat keyword(s)
1790     * (1 or 2 of stretch | repeat | round | space), and the
1791     * slash-split <slice> / <width> / <outset> chain. The slice /
1792     * width / outset components may each be 1-4 numbers, a single
1793     * value, or `fill` (slice only).
1794     *
1795     * @return array<string, Value>
1796     */
1797    private function expandBorderImage(Value $value): array
1798    {
1799        $repeatKw = ['stretch', 'repeat', 'round', 'space'];
1800        // The shorthand uses `/` to separate slice / width / outset
1801        // chunks. Authors usually write them on a single layer (no
1802        // top-level commas), so flatten to a flat component list and
1803        // then peel slash groups.
1804        $components = $this->toComponents($value);
1805        if ($components === []) {
1806            return [];
1807        }
1808        $source = null;
1809        $repeats = [];
1810        $slashChunks = [[]];
1811        foreach ($components as $c) {
1812            if ($source === null && $this->looksLikeMaskImage($c)) {
1813                $source = $c;
1814                continue;
1815            }
1816            if ($c instanceof Keyword
1817                && in_array(strtolower($c->name), $repeatKw, true)
1818            ) {
1819                $repeats[] = $c;
1820                continue;
1821            }
1822            $slashChunks[count($slashChunks) - 1][] = $c;
1823        }
1824        // If the input was a ValueList(Slash), explode it.
1825        if ($value instanceof ValueList && $value->separator === ListSeparator::Slash) {
1826            $slashChunks = array_map(
1827                fn(Value $g) => $g instanceof ValueList && $g->separator === ListSeparator::Space
1828                    ? $g->values
1829                    : [$g],
1830                $value->values,
1831            );
1832            // Re-scan EVERY chunk for the source and repeat keywords so
1833            // they don't pollute the slice / width / outset lists. The
1834            // repeat keywords may trail the LAST slash group (e.g.
1835            // `… 30 / 4 / 2 round stretch`, where `round stretch` sits in
1836            // the outset chunk), so scanning chunk[0] alone would drop them.
1837            $source = null;
1838            $repeats = [];
1839            foreach ($slashChunks as $idx => $chunk) {
1840                $reScanned = [];
1841                foreach ($chunk as $c) {
1842                    if ($source === null && $this->looksLikeMaskImage($c)) {
1843                        $source = $c;
1844                        continue;
1845                    }
1846                    if ($c instanceof Keyword
1847                        && in_array(strtolower($c->name), $repeatKw, true)
1848                    ) {
1849                        $repeats[] = $c;
1850                        continue;
1851                    }
1852                    $reScanned[] = $c;
1853                }
1854                $slashChunks[$idx] = $reScanned;
1855            }
1856        }
1857        // A shorthand sets ALL its longhands: any omitted sub-property is
1858        // reset to its initial value, not left at a previous declaration
1859        // (CSS Cascade 4 §2.1). Seed the initials, then override.
1860        $out = [
1861            'border-image-source' => new Keyword('none'),
1862            'border-image-slice' => new Percentage(100.0),
1863            'border-image-width' => new Number(1.0),
1864            'border-image-outset' => new Number(0.0),
1865            'border-image-repeat' => new Keyword('stretch'),
1866        ];
1867        if ($source !== null) {
1868            $out['border-image-source'] = $source;
1869        }
1870        if ($slashChunks[0] !== []) {
1871            $out['border-image-slice'] = $this->joinSpace($slashChunks[0]);
1872        }
1873        if (isset($slashChunks[1]) && $slashChunks[1] !== []) {
1874            $out['border-image-width'] = $this->joinSpace($slashChunks[1]);
1875        }
1876        if (isset($slashChunks[2]) && $slashChunks[2] !== []) {
1877            $out['border-image-outset'] = $this->joinSpace($slashChunks[2]);
1878        }
1879        if ($repeats !== []) {
1880            $out['border-image-repeat'] = count($repeats) === 1
1881                ? $repeats[0]
1882                : new ValueList($repeats, ListSeparator::Space);
1883        }
1884        return $out;
1885    }
1886
1887    /**
1888     * CSS Logical Properties 1 §7 — `border-block` / `border-inline`
1889     * fan out the same width/style/color tuple to both sides of
1890     * the chosen axis.
1891     *
1892     * @return array<string, Value>
1893     */
1894    private function expandBorderAxis(Value $value, string $axis): array
1895    {
1896        $components = $this->toComponents($value);
1897        $sides = $axis === 'block'
1898            ? ['block-start', 'block-end']
1899            : ['inline-start', 'inline-end'];
1900        return $this->classifyBorderComponents($components, $sides);
1901    }
1902
1903    /**
1904     * CSS Logical Properties 1 §7 — single-side logical shorthand
1905     * (e.g. `border-block-start: 1px solid red`). Same width /
1906     * style / color slots, but on one side instead of four.
1907     *
1908     * @return array<string, Value>
1909     */
1910    private function expandBorderLogicalSide(string $shorthand, Value $value): array
1911    {
1912        $components = $this->toComponents($value);
1913        // `border-block-start` → side suffix is `block-start`.
1914        $side = substr($shorthand, strlen('border-'));
1915        return $this->classifyBorderComponents($components, [$side]);
1916    }
1917
1918    /**
1919     * Write the value to BOTH the legacy alias and the modern
1920     * canonical property name. The modern property is the one the
1921     * renderer reads; the legacy name is also retained so any
1922     * downstream code reading the original property still sees it.
1923     *
1924     * @return array<string, Value>
1925     */
1926    private function alias(string $legacy, Value $value, string $modern): array
1927    {
1928        return [
1929            $legacy => $value,
1930            $modern => $value,
1931        ];
1932    }
1933
1934    /**
1935     * CSS Masking 1 §13 — `mask-border` shorthand, the masking
1936     * analogue of `border-image`. Same grammar shape, with an
1937     * additional optional `<mask-border-mode>` keyword:
1938     *
1939     *   mask-border = <source> || <slice> [/ <width> [/ <outset>]?]?
1940     *                 || <repeat> || <mode>
1941     *
1942     *   <mode>: luminance | alpha
1943     *
1944     * @return array<string, Value>
1945     */
1946    private function expandMaskBorder(Value $value): array
1947    {
1948        $repeatKw = ['stretch', 'repeat', 'round', 'space'];
1949        $modeKw = ['luminance', 'alpha'];
1950        $components = $this->toComponents($value);
1951        if ($components === []) {
1952            return [];
1953        }
1954        $source = null;
1955        $repeats = [];
1956        $mode = null;
1957        $slashChunks = [[]];
1958        foreach ($components as $c) {
1959            if ($source === null && $this->looksLikeMaskImage($c)) {
1960                $source = $c;
1961                continue;
1962            }
1963            if ($mode === null && $c instanceof Keyword
1964                && in_array(strtolower($c->name), $modeKw, true)
1965            ) {
1966                $mode = $c;
1967                continue;
1968            }
1969            if ($c instanceof Keyword
1970                && in_array(strtolower($c->name), $repeatKw, true)
1971            ) {
1972                $repeats[] = $c;
1973                continue;
1974            }
1975            $slashChunks[count($slashChunks) - 1][] = $c;
1976        }
1977        if ($value instanceof ValueList && $value->separator === ListSeparator::Slash) {
1978            // Same slash-chunk explosion border-image uses, then
1979            // re-scan the first chunk for source / mode / repeat.
1980            $slashChunks = array_map(
1981                fn(Value $g) => $g instanceof ValueList && $g->separator === ListSeparator::Space
1982                    ? $g->values
1983                    : [$g],
1984                $value->values,
1985            );
1986            $reScanned = [];
1987            foreach ($slashChunks[0] ?? [] as $c) {
1988                if ($source === null && $this->looksLikeMaskImage($c)) {
1989                    $source = $c;
1990                    continue;
1991                }
1992                if ($mode === null && $c instanceof Keyword
1993                    && in_array(strtolower($c->name), $modeKw, true)
1994                ) {
1995                    $mode = $c;
1996                    continue;
1997                }
1998                if ($c instanceof Keyword
1999                    && in_array(strtolower($c->name), $repeatKw, true)
2000                ) {
2001                    $repeats[] = $c;
2002                    continue;
2003                }
2004                $reScanned[] = $c;
2005            }
2006            $slashChunks[0] = $reScanned;
2007        }
2008        $out = [];
2009        if ($source !== null) {
2010            $out['mask-border-source'] = $source;
2011        }
2012        if ($slashChunks[0] !== []) {
2013            $out['mask-border-slice'] = $this->joinSpace($slashChunks[0]);
2014        }
2015        if (isset($slashChunks[1]) && $slashChunks[1] !== []) {
2016            $out['mask-border-width'] = $this->joinSpace($slashChunks[1]);
2017        }
2018        if (isset($slashChunks[2]) && $slashChunks[2] !== []) {
2019            $out['mask-border-outset'] = $this->joinSpace($slashChunks[2]);
2020        }
2021        if ($repeats !== []) {
2022            $out['mask-border-repeat'] = count($repeats) === 1
2023                ? $repeats[0]
2024                : new ValueList($repeats, ListSeparator::Space);
2025        }
2026        if ($mode !== null) {
2027            $out['mask-border-mode'] = $mode;
2028        }
2029        return $out;
2030    }
2031
2032    /**
2033     * Join a list of space-separated component values into a single
2034     * Value, collapsing the trivial cases.
2035     *
2036     * @param list<Value> $values
2037     */
2038    private function joinSpace(array $values): Value
2039    {
2040        if (count($values) === 1) {
2041            return $values[0];
2042        }
2043        return new ValueList(array_values($values), ListSeparator::Space);
2044    }
2045
2046    /**
2047     * CSS Fonts 4 §6.11 — `font-variant` shorthand.
2048     *
2049     *   font-variant = normal | none | [ <common-lig-values> ||
2050     *                  <discretionary-lig-values> || <historical-
2051     *                  lig-values> || <contextual-alt-values> ||
2052     *                  stylistic(<ident>) || historical-forms ||
2053     *                  styleset(<ident>+) || character-variant(...) ||
2054     *                  swash(<ident>) || ornaments(<ident>) ||
2055     *                  annotation(<ident>) || [ small-caps |
2056     *                  all-small-caps | petite-caps | all-petite-caps |
2057     *                  unicase | titling-caps ] || <numeric-figure-values>
2058     *                  || <numeric-spacing-values> ||
2059     *                  <numeric-fraction-values> || ordinal || slashed-zero
2060     *                  || <east-asian-variant-values> ||
2061     *                  <east-asian-width-values> || ruby || sub | super ]
2062     *
2063     * Each component routes to its respective longhand by keyword.
2064     * `normal` / `none` shortcuts each set all longhands to that
2065     * keyword.
2066     *
2067     * @return array<string, Value>
2068     */
2069    private function expandFontVariant(Value $value): array
2070    {
2071        $allLonghands = [
2072            'font-variant-ligatures',
2073            'font-variant-caps',
2074            'font-variant-numeric',
2075            'font-variant-east-asian',
2076            'font-variant-position',
2077            'font-variant-alternates',
2078            'font-variant-emoji',
2079        ];
2080        $components = $this->toComponents($value);
2081        // `normal` / `none` shortcuts.
2082        if (count($components) === 1 && $components[0] instanceof Keyword) {
2083            $kw = strtolower($components[0]->name);
2084            if ($kw === 'normal' || $kw === 'none') {
2085                $out = [];
2086                $target = $kw === 'none'
2087                    ? new Keyword('none')
2088                    : new Keyword('normal');
2089                foreach ($allLonghands as $prop) {
2090                    $out[$prop] = $target;
2091                }
2092                return $out;
2093            }
2094        }
2095        // Per-component routing by keyword vocabulary.
2096        $capsKw = [
2097            'small-caps', 'all-small-caps', 'petite-caps',
2098            'all-petite-caps', 'unicase', 'titling-caps',
2099        ];
2100        $positionKw = ['sub', 'super'];
2101        $ligKw = [
2102            'common-ligatures', 'no-common-ligatures',
2103            'discretionary-ligatures', 'no-discretionary-ligatures',
2104            'historical-ligatures', 'no-historical-ligatures',
2105            'contextual', 'no-contextual',
2106        ];
2107        $numericKw = [
2108            'lining-nums', 'oldstyle-nums', 'proportional-nums', 'tabular-nums',
2109            'diagonal-fractions', 'stacked-fractions',
2110            'ordinal', 'slashed-zero',
2111        ];
2112        $eastAsianKw = [
2113            'jis78', 'jis83', 'jis90', 'jis04',
2114            'simplified', 'traditional',
2115            'full-width', 'proportional-width', 'ruby',
2116        ];
2117        $emojiKw = ['text', 'emoji', 'unicode'];
2118        $alternatesKw = ['historical-forms'];
2119
2120        $buckets = [];
2121        foreach ($components as $c) {
2122            if (!($c instanceof Keyword)) {
2123                continue;
2124            }
2125            $kw = strtolower($c->name);
2126            $prop = match (true) {
2127                in_array($kw, $capsKw, true) => 'font-variant-caps',
2128                in_array($kw, $positionKw, true) => 'font-variant-position',
2129                in_array($kw, $ligKw, true) => 'font-variant-ligatures',
2130                in_array($kw, $numericKw, true) => 'font-variant-numeric',
2131                in_array($kw, $eastAsianKw, true) => 'font-variant-east-asian',
2132                in_array($kw, $emojiKw, true) => 'font-variant-emoji',
2133                in_array($kw, $alternatesKw, true) => 'font-variant-alternates',
2134                default => null,
2135            };
2136            if ($prop === null) {
2137                continue;
2138            }
2139            $buckets[$prop][] = $c;
2140        }
2141        $out = [];
2142        foreach ($buckets as $prop => $entries) {
2143            $out[$prop] = count($entries) === 1
2144                ? $entries[0]
2145                : new ValueList($entries, ListSeparator::Space);
2146        }
2147        return $out;
2148    }
2149
2150    /**
2151     * CSS Fonts 4 §6.7 — `font-synthesis` shorthand for the four
2152     * synthesis axis longhands. Two grammar shapes:
2153     *
2154     *   - `none` → all four longhands become `none` (UA must not
2155     *     synthesise anything; respect the font as-shipped).
2156     *   - `[ weight || style || small-caps || position ]` → each
2157     *     listed axis sets its longhand to `auto`, unlisted axes
2158     *     fall to `none`.
2159     *
2160     * Default for each longhand is `auto`; this shorthand only
2161     * fires when authors explicitly opt out via `none` or restrict
2162     * the active set.
2163     *
2164     * @return array<string, Value>
2165     */
2166    private function expandFontSynthesis(Value $value): array
2167    {
2168        $longhands = [
2169            'weight' => 'font-synthesis-weight',
2170            'style' => 'font-synthesis-style',
2171            'small-caps' => 'font-synthesis-small-caps',
2172            'position' => 'font-synthesis-position',
2173        ];
2174        $components = $this->toComponents($value);
2175        $none = new Keyword('none');
2176        $auto = new Keyword('auto');
2177        // Single `none` sets every longhand to none.
2178        if (count($components) === 1
2179            && $components[0] instanceof Keyword
2180            && strtolower($components[0]->name) === 'none'
2181        ) {
2182            $out = [];
2183            foreach ($longhands as $prop) {
2184                $out[$prop] = $none;
2185            }
2186            return $out;
2187        }
2188        $on = [];
2189        foreach ($components as $c) {
2190            if (!($c instanceof Keyword)) {
2191                continue;
2192            }
2193            $kw = strtolower($c->name);
2194            if (isset($longhands[$kw])) {
2195                $on[$kw] = true;
2196            }
2197        }
2198        $out = [];
2199        foreach ($longhands as $kw => $prop) {
2200            $out[$prop] = isset($on[$kw]) ? $auto : $none;
2201        }
2202        return $out;
2203    }
2204
2205    /**
2206     * CSS UI 4 §5.3 — `caret` shorthand for `caret-color` +
2207     * `caret-shape`. Any-order: typed color → caret-color,
2208     * recognised shape keyword → caret-shape.
2209     *
2210     *   caret-shape: auto | bar | block | underscore
2211     *
2212     * @return array<string, Value>
2213     */
2214    private function expandCaret(Value $value): array
2215    {
2216        $shapeKw = ['auto', 'bar', 'block', 'underscore'];
2217        $components = $this->toComponents($value);
2218        $color = null;
2219        $shape = null;
2220        foreach ($components as $c) {
2221            if ($color === null && $this->isColorComponent($c)) {
2222                $color = $c;
2223                continue;
2224            }
2225            if ($shape === null && $c instanceof Keyword
2226                && in_array(strtolower($c->name), $shapeKw, true)
2227            ) {
2228                $shape = $c;
2229            }
2230        }
2231        $out = [];
2232        if ($color !== null) {
2233            $out['caret-color'] = $color;
2234        }
2235        if ($shape !== null) {
2236            $out['caret-shape'] = $shape;
2237        }
2238        return $out;
2239    }
2240
2241    /**
2242     * CSS Text 4 §3.1 — `white-space` shorthand for
2243     * `white-space-collapse` + `text-wrap-mode`. Two shapes:
2244     *
2245     * 1. Legacy single-keyword forms (CSS 2.1):
2246     *      normal       → collapse + wrap
2247     *      pre          → preserve + nowrap
2248     *      pre-wrap     → preserve + wrap
2249     *      pre-line     → preserve-breaks + wrap
2250     *      nowrap       → collapse + nowrap
2251     *      break-spaces → break-spaces + wrap
2252     *
2253     * 2. New two-keyword form (CSS Text 4):
2254     *      <white-space-collapse> || <text-wrap-mode>
2255     *
2256     * The cascade also keeps `white-space` as a longhand itself
2257     * (so reading back the original declaration still works);
2258     * downstream layout reads through the new longhands.
2259     *
2260     * @return array<string, Value>
2261     */
2262    private function expandWhiteSpace(Value $value): array
2263    {
2264        $legacyMap = [
2265            'normal' => ['collapse', 'wrap'],
2266            'pre' => ['preserve', 'nowrap'],
2267            'pre-wrap' => ['preserve', 'wrap'],
2268            'pre-line' => ['preserve-breaks', 'wrap'],
2269            'nowrap' => ['collapse', 'nowrap'],
2270            'break-spaces' => ['break-spaces', 'wrap'],
2271        ];
2272        $collapseKw = ['collapse', 'preserve', 'preserve-breaks', 'preserve-spaces', 'break-spaces'];
2273        $modeKw = ['wrap', 'nowrap'];
2274
2275        $components = $this->toComponents($value);
2276        $collapse = null;
2277        $mode = null;
2278        if (count($components) === 1 && $components[0] instanceof Keyword) {
2279            $name = strtolower($components[0]->name);
2280            if (isset($legacyMap[$name])) {
2281                [$collapseName, $modeName] = $legacyMap[$name];
2282                $collapse = new Keyword($collapseName);
2283                $mode = new Keyword($modeName);
2284            }
2285        }
2286        if ($collapse === null && $mode === null) {
2287            // Two-keyword path; pick one of each by membership.
2288            foreach ($components as $c) {
2289                if (!($c instanceof Keyword)) {
2290                    continue;
2291                }
2292                $lc = strtolower($c->name);
2293                if ($collapse === null && in_array($lc, $collapseKw, true)) {
2294                    $collapse = $c;
2295                    continue;
2296                }
2297                if ($mode === null && in_array($lc, $modeKw, true)) {
2298                    $mode = $c;
2299                }
2300            }
2301        }
2302        $out = [
2303            // Preserve the original shorthand value too — some
2304            // downstream code reads `white-space` directly.
2305            'white-space' => $value,
2306        ];
2307        if ($collapse !== null) {
2308            $out['white-space-collapse'] = $collapse;
2309        }
2310        if ($mode !== null) {
2311            $out['text-wrap-mode'] = $mode;
2312        }
2313        return $out;
2314    }
2315
2316    /**
2317     * CSS Text 4 §11 — `text-wrap` shorthand for
2318     * `text-wrap-mode` + `text-wrap-style`. Components may appear
2319     * in any order; each routes to its own longhand by keyword.
2320     *
2321     *   text-wrap-mode:  wrap | nowrap
2322     *   text-wrap-style: auto | balance | pretty | stable
2323     *
2324     * @return array<string, Value>
2325     */
2326    private function expandTextWrap(Value $value): array
2327    {
2328        $modeKw = ['wrap', 'nowrap'];
2329        $styleKw = ['auto', 'balance', 'pretty', 'stable'];
2330        $components = $this->toComponents($value);
2331        if ($components === []) {
2332            return [];
2333        }
2334        $mode = null;
2335        $style = null;
2336        foreach ($components as $c) {
2337            if (!($c instanceof Keyword)) {
2338                continue;
2339            }
2340            $lc = strtolower($c->name);
2341            if ($mode === null && in_array($lc, $modeKw, true)) {
2342                $mode = $c;
2343                continue;
2344            }
2345            if ($style === null && in_array($lc, $styleKw, true)) {
2346                $style = $c;
2347            }
2348        }
2349        $out = [];
2350        if ($mode !== null) {
2351            $out['text-wrap-mode'] = $mode;
2352        }
2353        if ($style !== null) {
2354            $out['text-wrap-style'] = $style;
2355        }
2356        return $out;
2357    }
2358
2359    /**
2360     * Recognise a value that can serve as a mask source: a URL,
2361     * an image-set, a gradient (any typed Gradient subclass), or
2362     * the `none` keyword (which clears any earlier source).
2363     */
2364    private function looksLikeMaskImage(Value $v): bool
2365    {
2366        return $v instanceof \Phpdftk\Css\Value\Url
2367            || $v instanceof \Phpdftk\Css\Value\ImageSet
2368            || $v instanceof \Phpdftk\Css\Value\Gradient
2369            || ($v instanceof Keyword && strtolower($v->name) === 'none');
2370    }
2371
2372    /**
2373     * CSS Text Decoration 4 §8.6 — `text-emphasis` shorthand for
2374     * `text-emphasis-style` + `text-emphasis-color`. Components
2375     * may appear in any order; the color component is distinguished
2376     * by being a Color value (typed) or `currentcolor` keyword.
2377     *
2378     * @return array<string, Value>
2379     */
2380    private function expandTextEmphasis(Value $value): array
2381    {
2382        $components = $this->toComponents($value);
2383        if ($components === []) {
2384            return [];
2385        }
2386        $style = null;
2387        $color = null;
2388        foreach ($components as $c) {
2389            if ($color === null && $this->isColorComponent($c)) {
2390                $color = $c;
2391                continue;
2392            }
2393            $style ??= $c;
2394        }
2395        $out = [];
2396        if ($style !== null) {
2397            $out['text-emphasis-style'] = $style;
2398        }
2399        if ($color !== null) {
2400            $out['text-emphasis-color'] = $color;
2401        }
2402        return $out;
2403    }
2404
2405    private function isColorComponent(Value $v): bool
2406    {
2407        if ($v instanceof Color) {
2408            return true;
2409        }
2410        if ($v instanceof Keyword) {
2411            $lc = strtolower($v->name);
2412            return $lc === 'currentcolor' || $lc === 'transparent';
2413        }
2414        return false;
2415    }
2416
2417    private function looksLikeFontSize(Value $v): bool
2418    {
2419        if ($v instanceof Length || $v instanceof Percentage) {
2420            return true;
2421        }
2422        // CSS Values 4 §5.2 — a unitless zero is a <length>, so `font: 0 Ahem`
2423        // (and the `font: 0/0 a` idiom) carries a real font-size. Rejecting it
2424        // left the shorthand unclassifiable and dropped the declaration whole.
2425        if (self::isZeroNumber($v)) {
2426            return true;
2427        }
2428        if ($v instanceof Keyword) {
2429            return in_array(strtolower($v->name), [
2430                'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large',
2431                'larger', 'smaller',
2432            ], true);
2433        }
2434        return false;
2435    }
2436
2437    /**
2438     * CSS Transitions 1 §3.2 — `transition` is per-property:
2439     *
2440     *   transition: <property> <duration> <timing-function> <delay>
2441     *
2442     * Components may appear in any order. The first time-valued
2443     * component sets `transition-duration`, the second sets
2444     * `transition-delay`; any easing keyword/function sets
2445     * `transition-timing-function`; any non-time, non-easing
2446     * non-keyword ident sets `transition-property` (or `all` as
2447     * default). Multiple comma-separated layers are supported —
2448     * each layer's longhands form a comma-joined list per spec.
2449     *
2450     * @return array<string, Value>
2451     */
2452    private function expandTransition(Value $value): array
2453    {
2454        $layers = $this->toCommaLayers($value);
2455        $properties = [];
2456        $durations = [];
2457        $timings = [];
2458        $delays = [];
2459        foreach ($layers as $layer) {
2460            $components = $this->toComponents($layer);
2461            $property = null;
2462            $duration = null;
2463            $timing = null;
2464            $delay = null;
2465            foreach ($components as $c) {
2466                if ($c instanceof \Phpdftk\Css\Value\Time) {
2467                    if ($duration === null) {
2468                        $duration = $c;
2469                    } elseif ($delay === null) {
2470                        $delay = $c;
2471                    }
2472                    continue;
2473                }
2474                if ($this->isEasingValue($c)) {
2475                    $timing ??= $c;
2476                    continue;
2477                }
2478                if ($c instanceof Keyword) {
2479                    $property ??= $c;
2480                }
2481            }
2482            $properties[] = $property ?? new Keyword('all');
2483            $durations[] = $duration ?? new Keyword('0s');
2484            $timings[] = $timing ?? new Keyword('ease');
2485            $delays[] = $delay ?? new Keyword('0s');
2486        }
2487        return [
2488            'transition-property' => $this->joinComma($properties),
2489            'transition-duration' => $this->joinComma($durations),
2490            'transition-timing-function' => $this->joinComma($timings),
2491            'transition-delay' => $this->joinComma($delays),
2492        ];
2493    }
2494
2495    /**
2496     * CSS Animations 1 §4.10 — `animation` is per-instance:
2497     *
2498     *   animation: <duration> <timing-function> <delay>
2499     *              <iteration-count> <direction> <fill-mode>
2500     *              <play-state> <name>
2501     *
2502     * Same any-order policy as `transition`: first time → duration,
2503     * second time → delay, easing → timing-function, number →
2504     * iteration-count, recognised keywords → direction / fill-mode /
2505     * play-state, remaining ident → name. Multi-layer (comma)
2506     * support.
2507     *
2508     * @return array<string, Value>
2509     */
2510    private function expandAnimation(Value $value): array
2511    {
2512        $layers = $this->toCommaLayers($value);
2513        $names = [];
2514        $durations = [];
2515        $timings = [];
2516        $delays = [];
2517        $iterations = [];
2518        $directions = [];
2519        $fillModes = [];
2520        $playStates = [];
2521        $directionKw = ['normal', 'reverse', 'alternate', 'alternate-reverse'];
2522        $fillModeKw = ['none', 'forwards', 'backwards', 'both'];
2523        $playStateKw = ['running', 'paused'];
2524        foreach ($layers as $layer) {
2525            $components = $this->toComponents($layer);
2526            $name = null;
2527            $duration = null;
2528            $timing = null;
2529            $delay = null;
2530            $iter = null;
2531            $direction = null;
2532            $fillMode = null;
2533            $playState = null;
2534            foreach ($components as $c) {
2535                if ($c instanceof \Phpdftk\Css\Value\Time) {
2536                    if ($duration === null) {
2537                        $duration = $c;
2538                    } elseif ($delay === null) {
2539                        $delay = $c;
2540                    }
2541                    continue;
2542                }
2543                if ($c instanceof \Phpdftk\Css\Value\Number
2544                    || $c instanceof \Phpdftk\Css\Value\Integer
2545                ) {
2546                    $iter ??= $c;
2547                    continue;
2548                }
2549                if ($c instanceof Keyword && strtolower($c->name) === 'infinite') {
2550                    $iter ??= $c;
2551                    continue;
2552                }
2553                if ($this->isEasingValue($c)) {
2554                    $timing ??= $c;
2555                    continue;
2556                }
2557                if ($c instanceof Keyword) {
2558                    $lc = strtolower($c->name);
2559                    if ($direction === null && in_array($lc, $directionKw, true)) {
2560                        $direction = $c;
2561                        continue;
2562                    }
2563                    if ($fillMode === null && in_array($lc, $fillModeKw, true)) {
2564                        $fillMode = $c;
2565                        continue;
2566                    }
2567                    if ($playState === null && in_array($lc, $playStateKw, true)) {
2568                        $playState = $c;
2569                        continue;
2570                    }
2571                    $name ??= $c;
2572                }
2573            }
2574            $names[] = $name ?? new Keyword('none');
2575            $durations[] = $duration ?? new Keyword('0s');
2576            $timings[] = $timing ?? new Keyword('ease');
2577            $delays[] = $delay ?? new Keyword('0s');
2578            $iterations[] = $iter ?? new \Phpdftk\Css\Value\Number(1);
2579            $directions[] = $direction ?? new Keyword('normal');
2580            $fillModes[] = $fillMode ?? new Keyword('none');
2581            $playStates[] = $playState ?? new Keyword('running');
2582        }
2583        return [
2584            'animation-name' => $this->joinComma($names),
2585            'animation-duration' => $this->joinComma($durations),
2586            'animation-timing-function' => $this->joinComma($timings),
2587            'animation-delay' => $this->joinComma($delays),
2588            'animation-iteration-count' => $this->joinComma($iterations),
2589            'animation-direction' => $this->joinComma($directions),
2590            'animation-fill-mode' => $this->joinComma($fillModes),
2591            'animation-play-state' => $this->joinComma($playStates),
2592        ];
2593    }
2594
2595    /**
2596     * Split a top-level comma list into per-layer values. A single
2597     * non-list value yields one layer.
2598     *
2599     * @return list<Value>
2600     */
2601    private function toCommaLayers(Value $value): array
2602    {
2603        if ($value instanceof \Phpdftk\Css\Value\ValueList
2604            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
2605        ) {
2606            return $value->values;
2607        }
2608        return [$value];
2609    }
2610
2611    /**
2612     * Join a list of per-layer longhand values into a comma-
2613     * separated ValueList (or pass through the single value).
2614     *
2615     * @param list<Value> $values
2616     */
2617    private function joinComma(array $values): Value
2618    {
2619        if (count($values) === 1) {
2620            return $values[0];
2621        }
2622        return new \Phpdftk\Css\Value\ValueList(
2623            $values,
2624            \Phpdftk\Css\Value\ListSeparator::Comma,
2625        );
2626    }
2627
2628    /**
2629     * Recognise easing forms — both the named keywords and the
2630     * typed function-value classes that the value parser lifts
2631     * from cubic-bezier() / steps() / linear().
2632     */
2633    private function isEasingValue(Value $v): bool
2634    {
2635        if ($v instanceof Keyword) {
2636            return in_array(strtolower($v->name), [
2637                'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out',
2638                'step-start', 'step-end',
2639            ], true);
2640        }
2641        if ($v instanceof \Phpdftk\Css\Value\CubicBezier
2642            || $v instanceof \Phpdftk\Css\Value\StepsEasing
2643            || $v instanceof \Phpdftk\Css\Value\LinearEasing
2644        ) {
2645            return true;
2646        }
2647        return false;
2648    }
2649}