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