Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.21% covered (warning)
87.21%
382 / 438
51.85% covered (warning)
51.85%
14 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
ShorthandExpander
87.21% covered (warning)
87.21%
382 / 438
51.85% covered (warning)
51.85%
14 / 27
317.32
0.00% covered (danger)
0.00%
0 / 1
 expand
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
22.18
 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%
21 / 21
100.00% covered (success)
100.00%
1 / 1
11
 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.67% covered (success)
96.67%
58 / 60
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
95.45% covered (success)
95.45%
42 / 44
0.00% covered (danger)
0.00%
0 / 1
18
 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%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 expandFlex
48.84% covered (danger)
48.84%
21 / 43
0.00% covered (danger)
0.00%
0 / 1
80.06
 expandFlexFlow
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 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
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
15.17
 expandColumnRule
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
11
 looksLikeFontSize
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css\Cascade;
6
7use Phpdftk\Css\Value\Integer;
8use Phpdftk\Css\Value\Keyword;
9use Phpdftk\Css\Value\Length;
10use Phpdftk\Css\Value\ListSeparator;
11use Phpdftk\Css\Value\Number;
12use Phpdftk\Css\Value\Percentage;
13use Phpdftk\Css\Value\StringValue;
14use Phpdftk\Css\Value\Value;
15use Phpdftk\Css\Value\ValueList;
16
17/**
18 * Per CSS Cascade 5 §3.2, shorthand declarations expand into their longhand
19 * components before the cascade picks winners. This class implements the
20 * structural box-edge shorthands needed by Phase 1F layout:
21 *
22 *  - `margin` / `padding` / `border-width` / `border-style` /
23 *    `border-color` — four-sided variants.
24 *  - `border-top` / `border-right` / `border-bottom` / `border-left` —
25 *    composite per-side shorthand (width / style / color in any order).
26 *  - `border` — combines `border-{width,style,color}` for all four sides.
27 *
28 * Shorthand value-list rules (CSS Backgrounds 3 §3.1 / CSS Box 3):
29 *  - 1 value: applied to all four sides.
30 *  - 2 values: top/bottom, left/right.
31 *  - 3 values: top, left/right, bottom.
32 *  - 4 values: top, right, bottom, left (clockwise from top).
33 *
34 * Unknown shorthands fall through unchanged so the cascade can still match
35 * declarations the registry doesn't know about — they just don't decompose.
36 */
37final class ShorthandExpander
38{
39    /**
40     * Expand `$property = $value`. Returns the resulting longhand map; the
41     * shorthand name is intentionally omitted so the cascade doesn't keep
42     * tracking it alongside its longhands.
43     *
44     * @return array<string, Value>
45     */
46    public function expand(string $property, Value $value): array
47    {
48        $name = strtolower($property);
49        return match ($name) {
50            'margin' => $this->expandFourSided('margin', $value, ['top', 'right', 'bottom', 'left']),
51            'padding' => $this->expandFourSided('padding', $value, ['top', 'right', 'bottom', 'left']),
52            'border-width' => $this->expandFourSided('border', $value, ['top-width', 'right-width', 'bottom-width', 'left-width']),
53            'border-style' => $this->expandFourSided('border', $value, ['top-style', 'right-style', 'bottom-style', 'left-style']),
54            'border-color' => $this->expandFourSided('border', $value, ['top-color', 'right-color', 'bottom-color', 'left-color']),
55            // CSS Backgrounds 3 §6: `border-radius` expands like `margin`
56            // but the corner suffix order is TL TR BR BL (clockwise from
57            // top-left), and the horizontal/vertical pair `/` form is
58            // ignored — Phase 1 only honours the symmetrical value.
59            'border-radius' => $this->expandFourSided(
60                'border',
61                $this->stripSlashTail($value),
62                ['top-left-radius', 'top-right-radius', 'bottom-right-radius', 'bottom-left-radius'],
63            ),
64            'border-top', 'border-right', 'border-bottom', 'border-left'
65                => $this->expandBorderSide($name, $value),
66            'border' => $this->expandBorder($value),
67            'outline' => $this->expandOutline($value),
68            'font' => $this->expandFont($value),
69            'text-decoration' => $this->expandTextDecoration($value),
70            'background' => $this->expandBackground($value),
71            'list-style' => $this->expandListStyle($value),
72            'columns' => $this->expandColumns($value),
73            'column-rule' => $this->expandColumnRule($value),
74            'gap' => $this->expandGap($value),
75            'inset' => $this->expandInset($value),
76            'overflow' => $this->expandOverflow($value),
77            'flex' => $this->expandFlex($value),
78            'flex-flow' => $this->expandFlexFlow($value),
79            default => [$property => $value],
80        };
81    }
82
83    /**
84     * `margin: 10px;` → margin-top/right/bottom/left = 10px
85     * `margin: 10px 5px;` → top/bottom = 10px, left/right = 5px
86     * `margin: 10px 5px 20px;` → top = 10, right/left = 5, bottom = 20
87     * `margin: 10px 5px 20px 0;` → t r b l
88     *
89     * @param array{0:string, 1:string, 2:string, 3:string} $suffixes
90     * @return array<string, Value>
91     */
92    private function expandFourSided(string $prefix, Value $value, array $suffixes): array
93    {
94        $components = $this->toComponents($value);
95        $count = count($components);
96        if ($count === 0) {
97            return [];
98        }
99        [$top, $right, $bottom, $left] = match ($count) {
100            1 => [$components[0], $components[0], $components[0], $components[0]],
101            2 => [$components[0], $components[1], $components[0], $components[1]],
102            3 => [$components[0], $components[1], $components[2], $components[1]],
103            default => [$components[0], $components[1], $components[2], $components[3]],
104        };
105        return [
106            $prefix . '-' . $suffixes[0] => $top,
107            $prefix . '-' . $suffixes[1] => $right,
108            $prefix . '-' . $suffixes[2] => $bottom,
109            $prefix . '-' . $suffixes[3] => $left,
110        ];
111    }
112
113    /**
114     * `border-top: 1px solid red` → expands to border-top-width, -style, -color.
115     * Components can appear in any order; missing fields default to the
116     * spec's initial value (`medium` for width, `none` for style, `currentcolor`
117     * for color) but we leave the omitted longhands out so the registry's
118     * initial values apply.
119     *
120     * @return array<string, Value>
121     */
122    private function expandBorderSide(string $shorthand, Value $value): array
123    {
124        // shorthand is e.g. "border-top".
125        $side = substr($shorthand, strlen('border-'));
126        $components = $this->toComponents($value);
127        return $this->classifyBorderComponents($components, [$side]);
128    }
129
130    /** @return array<string, Value> */
131    private function expandBorder(Value $value): array
132    {
133        $components = $this->toComponents($value);
134        return $this->classifyBorderComponents($components, ['top', 'right', 'bottom', 'left']);
135    }
136
137    /**
138     * `outline: 2px solid red` → outline-width, outline-style, outline-color.
139     * Free-order components like border-side.
140     *
141     * @return array<string, Value>
142     */
143    private function expandOutline(Value $value): array
144    {
145        $components = $this->toComponents($value);
146        $width = null;
147        $style = null;
148        $color = null;
149        foreach ($components as $c) {
150            if ($style === null && $this->looksLikeBorderStyle($c)) {
151                $style = $c;
152                continue;
153            }
154            if ($width === null && $this->looksLikeBorderWidth($c)) {
155                $width = $c;
156                continue;
157            }
158            if ($color === null && $c instanceof \Phpdftk\Css\Value\Color) {
159                $color = $c;
160            }
161        }
162        $out = [];
163        if ($width !== null) {
164            $out['outline-width'] = $width;
165        }
166        if ($style !== null) {
167            $out['outline-style'] = $style;
168        }
169        if ($color !== null) {
170            $out['outline-color'] = $color;
171        }
172        return $out;
173    }
174
175    /**
176     * Map free-order `<width> <style> <color>` components onto every side
177     * listed in `$sides`. Order-agnostic per CSS Backgrounds 3 §3.1.
178     *
179     * @param list<Value> $components
180     * @param list<string> $sides
181     * @return array<string, Value>
182     */
183    private function classifyBorderComponents(array $components, array $sides): array
184    {
185        $width = null;
186        $style = null;
187        $color = null;
188        foreach ($components as $c) {
189            if ($style === null && $this->looksLikeBorderStyle($c)) {
190                $style = $c;
191                continue;
192            }
193            if ($width === null && $this->looksLikeBorderWidth($c)) {
194                $width = $c;
195                continue;
196            }
197            if ($color === null && $this->looksLikeColor($c)) {
198                $color = $c;
199                continue;
200            }
201        }
202        $out = [];
203        foreach ($sides as $side) {
204            if ($width !== null) {
205                $out["border-$side-width"] = $width;
206            }
207            if ($style !== null) {
208                $out["border-$side-style"] = $style;
209            }
210            if ($color !== null) {
211                $out["border-$side-color"] = $color;
212            }
213        }
214        return $out;
215    }
216
217    /** @return list<Value> */
218    private function toComponents(Value $value): array
219    {
220        if ($value instanceof ValueList) {
221            return $value->values;
222        }
223        return [$value];
224    }
225
226    /**
227     * `border-radius: 5px 10px / 8px 12px` — Phase 1 ignores the second
228     * (vertical) radius set after `/`; drop everything past the slash so
229     * the horizontal radii feed `expandFourSided`.
230     */
231    private function stripSlashTail(Value $value): Value
232    {
233        if (!($value instanceof ValueList)) {
234            return $value;
235        }
236        if ($value->separator !== \Phpdftk\Css\Value\ListSeparator::Slash) {
237            return $value;
238        }
239        // The slash-separated outer list has the horizontal radii as its
240        // first item; keep only that. The horizontal-radii item may itself
241        // be a Space ValueList.
242        $first = $value->values[0] ?? $value;
243        return $first;
244    }
245
246    private function looksLikeBorderWidth(Value $v): bool
247    {
248        if ($v instanceof \Phpdftk\Css\Value\Length) {
249            return true;
250        }
251        if ($v instanceof \Phpdftk\Css\Value\Keyword) {
252            return in_array(strtolower($v->name), ['thin', 'medium', 'thick'], true);
253        }
254        return false;
255    }
256
257    private function looksLikeBorderStyle(Value $v): bool
258    {
259        if (!$v instanceof \Phpdftk\Css\Value\Keyword) {
260            return false;
261        }
262        return in_array(strtolower($v->name), [
263            'none', 'hidden', 'dotted', 'dashed', 'solid',
264            'double', 'groove', 'ridge', 'inset', 'outset',
265        ], true);
266    }
267
268    private function looksLikeColor(Value $v): bool
269    {
270        return $v instanceof \Phpdftk\Css\Value\Color;
271    }
272
273    /**
274     * CSS Fonts 4 §6.7: `font: [<style> || <variant> || <weight> || <stretch>]?
275     * <size> [/ <line-height>]? <family>`.
276     *
277     * The input value can carry three nested structures depending on whether
278     * comma-separated families and a slash-separated line-height are present:
279     *
280     *  - bare Space list: `bold 16px Arial`
281     *  - Slash wrapping Space lists: `bold 16px/1.5 Arial`
282     *  - Comma wrapping the above + extra family items: `bold 16px Arial, sans-serif`
283     *  - Comma + Slash combo: `bold 16px/1.5 Arial, sans-serif`
284     *
285     * @return array<string, Value>
286     */
287    private function expandFont(Value $value): array
288    {
289        $head = $value;
290        $extraFamilies = [];
291        if ($value instanceof ValueList && $value->separator === ListSeparator::Comma) {
292            $head = $value->values[0] ?? $value;
293            $extraFamilies = array_slice($value->values, 1);
294        }
295
296        $lineHeight = null;
297        $tail = [];
298        if ($head instanceof ValueList && $head->separator === ListSeparator::Slash) {
299            $sizeSegment = $head->values[0] ?? $head;
300            $afterSlash = $head->values[1] ?? null;
301            if ($afterSlash !== null) {
302                $afterItems = $afterSlash instanceof ValueList && $afterSlash->separator === ListSeparator::Space
303                    ? $afterSlash->values
304                    : [$afterSlash];
305                $lineHeight = $afterItems[0] ?? null;
306                $tail = array_slice($afterItems, 1);
307            }
308            $head = $sizeSegment;
309        }
310
311        $items = $head instanceof ValueList && $head->separator === ListSeparator::Space
312            ? $head->values
313            : [$head];
314
315        $style = $variant = $weight = $stretch = $size = null;
316        $familyHead = [];
317        foreach ($items as $item) {
318            if ($size === null) {
319                if ($style === null && $this->looksLikeFontStyle($item)) {
320                    $style = $item;
321                    continue;
322                }
323                if ($variant === null && $this->looksLikeFontVariant($item)) {
324                    $variant = $item;
325                    continue;
326                }
327                if ($weight === null && $this->looksLikeFontWeight($item)) {
328                    $weight = $item;
329                    continue;
330                }
331                if ($stretch === null && $this->looksLikeFontStretch($item)) {
332                    $stretch = $item;
333                    continue;
334                }
335                if ($this->looksLikeFontSize($item)) {
336                    $size = $item;
337                    continue;
338                }
339                continue;
340            }
341            $familyHead[] = $item;
342        }
343
344        $allFamilies = array_merge($familyHead, $tail, $extraFamilies);
345
346        $out = [];
347        if ($size !== null) {
348            $out['font-size'] = $size;
349        }
350        if ($style !== null) {
351            $out['font-style'] = $style;
352        }
353        if ($variant !== null) {
354            $out['font-variant'] = $variant;
355        }
356        if ($weight !== null) {
357            $out['font-weight'] = $weight;
358        }
359        if ($stretch !== null) {
360            $out['font-stretch'] = $stretch;
361        }
362        if ($lineHeight !== null) {
363            $out['line-height'] = $lineHeight;
364        }
365        if ($allFamilies !== []) {
366            $out['font-family'] = count($allFamilies) === 1
367                ? $allFamilies[0]
368                : new ValueList(array_values($allFamilies), ListSeparator::Comma);
369        }
370        return $out;
371    }
372
373    private function looksLikeFontStyle(Value $v): bool
374    {
375        if (!$v instanceof Keyword) {
376            return false;
377        }
378        return in_array(strtolower($v->name), ['italic', 'oblique'], true);
379    }
380
381    private function looksLikeFontVariant(Value $v): bool
382    {
383        return $v instanceof Keyword && strtolower($v->name) === 'small-caps';
384    }
385
386    private function looksLikeFontWeight(Value $v): bool
387    {
388        if ($v instanceof Keyword
389            && in_array(strtolower($v->name), ['bold', 'bolder', 'lighter'], true)
390        ) {
391            return true;
392        }
393        if ($v instanceof Integer || $v instanceof Number) {
394            $n = $v instanceof Integer ? $v->value : (int) $v->value;
395            return $n >= 1 && $n <= 1000;
396        }
397        return false;
398    }
399
400    private function looksLikeFontStretch(Value $v): bool
401    {
402        if (!$v instanceof Keyword) {
403            return false;
404        }
405        return in_array(strtolower($v->name), [
406            'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed',
407            'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded',
408        ], true);
409    }
410
411    /**
412     * `background: <bg-image> || <position> [ / <bg-size> ]? || <repeat> ||
413     * <attachment> || <box> || <box> || <color>` (CSS Backgrounds 3 §3.10),
414     * with `<box>` reused for both `background-origin` and `background-clip`.
415     *
416     * Phase-1 implementation classifies components by their parsed type and
417     * keyword vocabulary rather than tracking the per-component grammar
418     * precisely — sufficient for the common author-CSS patterns
419     * (`background: red`, `background: url(x.png)`, `background: #fff
420     * no-repeat`, `background: url(bg.jpg) center / cover`). Components the
421     * classifier doesn't recognise are dropped, which is forgiving but
422     * sometimes lossy — matches browser behaviour for malformed inputs.
423     *
424     * @return array<string, Value>
425     */
426    private function expandBackground(Value $value): array
427    {
428        // Slash separates position from size: `background: <pos> / <size>`.
429        $position = null;
430        $size = null;
431        $body = $value;
432        if ($value instanceof ValueList && $value->separator === ListSeparator::Slash) {
433            $body = $value->values[0] ?? $value;
434            $size = $value->values[1] ?? null;
435        }
436
437        $components = $this->toComponents($body);
438        $color = null;
439        $image = null;
440        $repeat = null;
441        $positionParts = [];
442        foreach ($components as $c) {
443            if ($this->looksLikeColor($c)) {
444                $color = $c;
445                continue;
446            }
447            if ($c instanceof \Phpdftk\Css\Value\Url) {
448                $image = $c;
449                continue;
450            }
451            if ($c instanceof Keyword) {
452                $lower = strtolower($c->name);
453                if (in_array($lower, ['repeat', 'no-repeat', 'repeat-x', 'repeat-y', 'round', 'space'], true)) {
454                    $repeat = $c;
455                    continue;
456                }
457                if (in_array($lower, ['top', 'bottom', 'left', 'right', 'center'], true)) {
458                    $positionParts[] = $c;
459                    continue;
460                }
461            }
462            if ($c instanceof Length || $c instanceof Percentage) {
463                $positionParts[] = $c;
464            }
465        }
466        if ($positionParts !== []) {
467            $position = count($positionParts) === 1
468                ? $positionParts[0]
469                : new ValueList($positionParts, ListSeparator::Space);
470        }
471
472        $out = [];
473        if ($color !== null) {
474            $out['background-color'] = $color;
475        }
476        if ($image !== null) {
477            $out['background-image'] = $image;
478        }
479        if ($repeat !== null) {
480            $out['background-repeat'] = $repeat;
481        }
482        if ($position !== null) {
483            $out['background-position'] = $position;
484        }
485        if ($size !== null) {
486            $out['background-size'] = $size;
487        }
488        return $out;
489    }
490
491    /**
492     * `list-style: <list-style-type> || <list-style-position> ||
493     * <list-style-image>` (CSS Lists 3 §1.4). Free order; the `none`
494     * keyword is genuinely ambiguous between type and image — per spec we
495     * apply it to whichever side hasn't been set yet, defaulting to type
496     * when both are still free.
497     *
498     * @return array<string, Value>
499     */
500    private function expandListStyle(Value $value): array
501    {
502        $type = null;
503        $position = null;
504        $image = null;
505        $components = $this->toComponents($value);
506
507        $typeKeywords = [
508            'disc', 'circle', 'square', 'decimal', 'decimal-leading-zero',
509            'lower-alpha', 'upper-alpha', 'lower-roman', 'upper-roman',
510            'lower-greek', 'lower-latin', 'upper-latin', 'armenian', 'georgian',
511            'hebrew', 'cjk-decimal', 'simp-chinese-formal', 'simp-chinese-informal',
512            'trad-chinese-formal', 'trad-chinese-informal',
513        ];
514
515        foreach ($components as $c) {
516            if ($c instanceof \Phpdftk\Css\Value\Url) {
517                $image = $c;
518                continue;
519            }
520            if (!$c instanceof Keyword) {
521                continue;
522            }
523            $lower = strtolower($c->name);
524            if ($lower === 'inside' || $lower === 'outside') {
525                $position = $c;
526                continue;
527            }
528            if ($lower === 'none') {
529                if ($type === null) {
530                    $type = $c;
531                } elseif ($image === null) {
532                    $image = $c;
533                }
534                continue;
535            }
536            if (in_array($lower, $typeKeywords, true)) {
537                $type = $c;
538            }
539        }
540
541        $out = [];
542        if ($type !== null) {
543            $out['list-style-type'] = $type;
544        }
545        if ($position !== null) {
546            $out['list-style-position'] = $position;
547        }
548        if ($image !== null) {
549            $out['list-style-image'] = $image;
550        }
551        return $out;
552    }
553
554    /**
555     * `text-decoration: <line> || <style> || <color>` (CSS Text Decoration 3
556     * §2). `<line>` itself can be a space-list of `underline` / `overline` /
557     * `line-through` / `blink`, or `none`. Order is free.
558     *
559     * @return array<string, Value>
560     */
561    private function expandTextDecoration(Value $value): array
562    {
563        $components = $this->toComponents($value);
564        $lineParts = [];
565        $style = null;
566        $color = null;
567        foreach ($components as $c) {
568            if ($c instanceof Keyword) {
569                $lower = strtolower($c->name);
570                if (in_array($lower, ['underline', 'overline', 'line-through', 'blink', 'none'], true)) {
571                    $lineParts[] = $c;
572                    continue;
573                }
574                if (in_array($lower, ['solid', 'double', 'dotted', 'dashed', 'wavy'], true)) {
575                    $style = $c;
576                    continue;
577                }
578            }
579            if ($this->looksLikeColor($c)) {
580                $color = $c;
581            }
582        }
583        $out = [];
584        if ($lineParts !== []) {
585            $out['text-decoration-line'] = count($lineParts) === 1
586                ? $lineParts[0]
587                : new ValueList($lineParts, ListSeparator::Space);
588        }
589        if ($style !== null) {
590            $out['text-decoration-style'] = $style;
591        }
592        if ($color !== null) {
593            $out['text-decoration-color'] = $color;
594        }
595        return $out;
596    }
597
598    /**
599     * `flex: <flex-grow> <flex-shrink>? <flex-basis>?` (CSS Flex 1
600     * §7.2). Common forms:
601     *  - `flex: <number>` → grow with shrink=1, basis=0%.
602     *  - `flex: <number> <number>` → grow + shrink, basis=0%.
603     *  - `flex: <number> <number> <length>` → all three explicit.
604     *  - `flex: none` → 0 0 auto.
605     *  - `flex: auto` → 1 1 auto.
606     *  - `flex: initial` → 0 1 auto (the spec initial).
607     *
608     * @return array<string, Value>
609     */
610    private function expandFlex(Value $value): array
611    {
612        if ($value instanceof Keyword) {
613            return match (strtolower($value->name)) {
614                'none' => [
615                    'flex-grow' => new Number(0),
616                    'flex-shrink' => new Number(0),
617                    'flex-basis' => new Keyword('auto'),
618                ],
619                'auto' => [
620                    'flex-grow' => new Number(1),
621                    'flex-shrink' => new Number(1),
622                    'flex-basis' => new Keyword('auto'),
623                ],
624                'initial' => [
625                    'flex-grow' => new Number(0),
626                    'flex-shrink' => new Number(1),
627                    'flex-basis' => new Keyword('auto'),
628                ],
629                default => [],
630            };
631        }
632        $components = $this->toComponents($value);
633        $grow = null;
634        $shrink = null;
635        $basis = null;
636        $numericCount = 0;
637        foreach ($components as $c) {
638            if (($c instanceof Number || $c instanceof Integer) && $numericCount < 2) {
639                if ($numericCount === 0) {
640                    $grow = new Number($c instanceof Number ? $c->value : (float) $c->value);
641                } else {
642                    $shrink = new Number($c instanceof Number ? $c->value : (float) $c->value);
643                }
644                $numericCount++;
645            } elseif ($basis === null) {
646                $basis = $c;
647            }
648        }
649        $out = [];
650        if ($grow !== null) {
651            $out['flex-grow'] = $grow;
652        }
653        if ($shrink !== null) {
654            $out['flex-shrink'] = $shrink;
655        }
656        if ($basis !== null) {
657            $out['flex-basis'] = $basis;
658        }
659        // Per spec, omitted basis defaults to 0% when grow is set
660        // (so `flex: 2` → grow:2, shrink:1, basis:0%). Use Length 0
661        // as the closest approximation.
662        if ($grow !== null && $basis === null) {
663            $out['flex-basis'] = new \Phpdftk\Css\Value\Length(0.0, \Phpdftk\Css\Value\LengthUnit::Px);
664        }
665        // Omitted shrink defaults to 1.
666        if ($grow !== null && $shrink === null) {
667            $out['flex-shrink'] = new Number(1.0);
668        }
669        return $out;
670    }
671
672    /**
673     * `flex-flow: <flex-direction> || <flex-wrap>` (CSS Flex 1 §6.2).
674     *
675     * @return array<string, Value>
676     */
677    private function expandFlexFlow(Value $value): array
678    {
679        $components = $this->toComponents($value);
680        $directions = ['row', 'row-reverse', 'column', 'column-reverse'];
681        $wraps = ['nowrap', 'wrap', 'wrap-reverse'];
682        $out = [];
683        foreach ($components as $c) {
684            if (!($c instanceof Keyword)) {
685                continue;
686            }
687            $name = strtolower($c->name);
688            if (in_array($name, $directions, true) && !isset($out['flex-direction'])) {
689                $out['flex-direction'] = $c;
690            } elseif (in_array($name, $wraps, true) && !isset($out['flex-wrap'])) {
691                $out['flex-wrap'] = $c;
692            }
693        }
694        return $out;
695    }
696
697    /**
698     * `overflow: <visible|hidden|clip|scroll|auto>{1,2}` (CSS
699     * Overflow 3 §3.2). Single value applies to both axes;
700     * two values are `overflow-x overflow-y`. Also keeps the legacy
701     * `overflow` longhand so existing painter code keeps reading
702     * the un-prefixed value.
703     *
704     * @return array<string, Value>
705     */
706    private function expandOverflow(Value $value): array
707    {
708        $components = $this->toComponents($value);
709        if ($components === []) {
710            return [];
711        }
712        [$x, $y] = count($components) === 1
713            ? [$components[0], $components[0]]
714            : [$components[0], $components[1]];
715        return [
716            'overflow-x' => $x,
717            'overflow-y' => $y,
718            // Keep the legacy direct property in sync so any reader
719            // that still reaches for `overflow` sees a sensible value
720            // (the X axis for asymmetric splits).
721            'overflow' => $x,
722        ];
723    }
724
725    /**
726     * `inset: <length> [<length>{1,3}]?` (CSS Position 3 §3.3).
727     * Shorthand for `top` / `right` / `bottom` / `left` using the
728     * standard 1-to-4-value clockwise pattern (TRBL).
729     *
730     * @return array<string, Value>
731     */
732    private function expandInset(Value $value): array
733    {
734        $components = $this->toComponents($value);
735        $count = count($components);
736        if ($count === 0) {
737            return [];
738        }
739        [$top, $right, $bottom, $left] = match ($count) {
740            1 => [$components[0], $components[0], $components[0], $components[0]],
741            2 => [$components[0], $components[1], $components[0], $components[1]],
742            3 => [$components[0], $components[1], $components[2], $components[1]],
743            default => [$components[0], $components[1], $components[2], $components[3]],
744        };
745        return [
746            'top' => $top,
747            'right' => $right,
748            'bottom' => $bottom,
749            'left' => $left,
750        ];
751    }
752
753    /**
754     * `gap: <row-gap> [<column-gap>]?` (CSS Box Alignment 3 §8.3).
755     * One value: applies to both axes; two values: row first, column
756     * second.
757     *
758     * @return array<string, Value>
759     */
760    private function expandGap(Value $value): array
761    {
762        $components = $this->toComponents($value);
763        if ($components === []) {
764            return [];
765        }
766        [$row, $col] = match (count($components)) {
767            1 => [$components[0], $components[0]],
768            default => [$components[0], $components[1]],
769        };
770        return [
771            'row-gap' => $row,
772            'column-gap' => $col,
773        ];
774    }
775
776    /**
777     * `columns: <'column-width'> || <'column-count'>` (CSS Multi-column 1
778     * §10.1). Either side may be omitted; `auto` may appear as either side
779     * and is assigned to whichever slot is still free (width first).
780     *
781     * @return array<string, Value>
782     */
783    private function expandColumns(Value $value): array
784    {
785        $components = $this->toComponents($value);
786        $width = null;
787        $count = null;
788        foreach ($components as $c) {
789            if ($count === null && ($c instanceof Integer
790                || ($c instanceof Number && floor($c->value) === $c->value))
791            ) {
792                $count = $c;
793                continue;
794            }
795            if ($width === null && ($c instanceof Length || $c instanceof Percentage)) {
796                $width = $c;
797                continue;
798            }
799            if ($c instanceof Keyword && strtolower($c->name) === 'auto') {
800                if ($width === null) {
801                    $width = $c;
802                } elseif ($count === null) {
803                    $count = $c;
804                }
805            }
806        }
807        $out = [];
808        if ($width !== null) {
809            $out['column-width'] = $width;
810        }
811        if ($count !== null) {
812            $out['column-count'] = $count;
813        }
814        return $out;
815    }
816
817    /**
818     * `column-rule: <'column-rule-width'> || <'column-rule-style'> ||
819     * <'column-rule-color'>` (CSS Multi-column 1 §3.2). Free order; reuses
820     * the border-width / border-style / color classifiers because the value
821     * grammars match.
822     *
823     * @return array<string, Value>
824     */
825    private function expandColumnRule(Value $value): array
826    {
827        $components = $this->toComponents($value);
828        $width = null;
829        $style = null;
830        $color = null;
831        foreach ($components as $c) {
832            if ($style === null && $this->looksLikeBorderStyle($c)) {
833                $style = $c;
834                continue;
835            }
836            if ($width === null && $this->looksLikeBorderWidth($c)) {
837                $width = $c;
838                continue;
839            }
840            if ($color === null && $this->looksLikeColor($c)) {
841                $color = $c;
842            }
843        }
844        $out = [];
845        if ($width !== null) {
846            $out['column-rule-width'] = $width;
847        }
848        if ($style !== null) {
849            $out['column-rule-style'] = $style;
850        }
851        if ($color !== null) {
852            $out['column-rule-color'] = $color;
853        }
854        return $out;
855    }
856
857    private function looksLikeFontSize(Value $v): bool
858    {
859        if ($v instanceof Length || $v instanceof Percentage) {
860            return true;
861        }
862        if ($v instanceof Keyword) {
863            return in_array(strtolower($v->name), [
864                'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large',
865                'larger', 'smaller',
866            ], true);
867        }
868        return false;
869    }
870}