Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.43% covered (warning)
82.43%
948 / 1150
39.34% covered (danger)
39.34%
24 / 61
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cascade
82.43% covered (warning)
82.43%
948 / 1150
39.34% covered (danger)
39.34%
24 / 61
1810.21
0.00% covered (danger)
0.00%
0 / 1
 tierFor
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
8
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 expandDeclaration
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
10.86
 withViewport
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 withMatchingMediaTypes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 anonymousFromParent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 computeFor
98.65% covered (success)
98.65%
73 / 74
0.00% covered (danger)
0.00%
0 / 1
24
 forceTableInternalWritingMode
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 resolveLogicalProperties
90.00% covered (success)
90.00%
54 / 60
0.00% covered (danger)
0.00%
0 / 1
20.40
 applyFontSizeAdjustZero
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
6.00
 resolveLightDarkValues
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
14.08
 activeStyleRules
95.45% covered (success)
95.45%
42 / 44
0.00% covered (danger)
0.00%
0 / 1
25
 resolveLayerIndex
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 mediaPreludeMatches
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 containerPreludeMatches
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
9.63
 containerQueryUsesOnlySupportedFeatures
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
10.12
 matchSingleMediaQuery
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
10.20
 evaluateMediaQueryBody
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
12.42
 evaluateMediaCondition
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
20
 isSingleParenExpression
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
9.04
 splitMediaConditionAt
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
9
 rewriteRangeFeature
86.49% covered (warning)
86.49%
32 / 37
0.00% covered (danger)
0.00%
0 / 1
12.36
 rangeToLegacy
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
 reverseOp
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
16.14
 matchFeatureQuery
75.00% covered (warning)
75.00%
54 / 72
0.00% covered (danger)
0.00%
0 / 1
84.00
 matchRatioFeature
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
156
 matchIntegerFeature
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 supportsPreludeMatches
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 parseSupportsOr
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 peekSupportsKeyword
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parseSupportsPrimary
94.55% covered (success)
94.55%
52 / 55
0.00% covered (danger)
0.00%
0 / 1
20.06
 skipSupportsWs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 consumeSupportsKeyword
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 evaluateSupportsFeature
80.43% covered (warning)
80.43%
37 / 46
0.00% covered (danger)
0.00%
0 / 1
31.06
 supportsValueIsAcceptable
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 valueOnlyUsesKnownFunctions
94.59% covered (success)
94.59%
35 / 37
0.00% covered (danger)
0.00%
0 / 1
5.00
 isColorTypedProperty
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 isAcceptableColorValue
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 evaluateSupportsSelector
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 selectorIsFullySupported
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 simpleSelectorIsSupported
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
11.60
 allSelectorsSupported
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 isKnownPseudoElement
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
2
 isKnownPseudoClass
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 evaluateSupportsFontFormat
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 evaluateSupportsFontTech
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 matchDimensionFeature
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 resolveMediaDimensionValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parseMediaLength
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
27.84
 evaluateMediaCalcSum
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
8.35
 selectorPseudoElementName
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 pickCascadeWinner
98.08% covered (success)
98.08%
51 / 52
0.00% covered (danger)
0.00%
0 / 1
13
 layerRank
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 beats
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 resolveSpecialKeywords
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
12.00
 applyInheritance
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 inheritCustomProperties
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 substituteCustomProperties
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 substituteValue
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 resolveLengths
82.14% covered (warning)
82.14%
23 / 28
0.00% covered (danger)
0.00%
0 / 1
8.36
 resolveValueLengths
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css\Cascade;
6
7use Phpdftk\Css\Parser;
8use Phpdftk\Css\Selector\MatchableElement;
9use Phpdftk\Css\Selector\Matcher;
10use Phpdftk\Css\Selector\Specificity;
11use Phpdftk\Css\Sheet\Declaration;
12use Phpdftk\Css\Sheet\Origin;
13use Phpdftk\Css\Sheet\StyleRule;
14use Phpdftk\Css\Sheet\Stylesheet;
15use Phpdftk\Css\Value\CustomProperty;
16use Phpdftk\Css\Value\Keyword;
17use Phpdftk\Css\Value\Length;
18use Phpdftk\Css\Value\LengthUnit;
19use Phpdftk\Css\Value\Value;
20use Phpdftk\Css\Value\ValueList;
21
22/**
23 * CSS Cascade 5 + inheritance implementation. Given a set of stylesheets
24 * (with `Origin` tags) and a `MatchableElement`, produces a `CascadedValues`
25 * containing each property's resolved value.
26 *
27 * Cascade order, per CSS Cascade 5 Â§6:
28 *  1. Origin Ã— Importance: !important UA > !important User > !important
29 *     Author > Animation > Author > User > UA > rolled-in transitions
30 *  2. Specificity (a, b, c)
31 *  3. Source order (later wins)
32 *
33 * Inheritance per Â§7: properties marked `inherits=true` in the registry
34 * fall back to the parent element's cascaded value when the cascade
35 * produces no declaration for the property on this element.
36 *
37 * Phase 1D.3 ships the structural cascade. Custom-property substitution
38 * (`var()`) and shadow-scoped matching arrive in 1D.4 / 1D.5. The
39 * `inherit` / `initial` / `unset` / `revert` keywords are honoured here.
40 */
41final class Cascade
42{
43    /**
44     * Cascade-tier numbering per CSS Cascade 5 Â§6. Higher number wins.
45     *
46     *  0: UA normal       â€” lowest
47     *  1: User normal
48     *  2: Author normal
49     *  3: (animations â€” reserved for Phase 2)
50     *  4: Author !important
51     *  5: User !important
52     *  6: UA !important   â€” highest
53     */
54    private static function tierFor(Origin $origin, bool $important): int
55    {
56        if ($important) {
57            return match ($origin) {
58                Origin::UserAgent => 6,
59                Origin::User => 5,
60                Origin::Author => 4,
61            };
62        }
63        return match ($origin) {
64            Origin::UserAgent => 0,
65            Origin::User => 1,
66            Origin::Author => 2,
67        };
68    }
69
70    public function __construct(
71        public readonly PropertyRegistry $registry = new PropertyRegistry(),
72        private readonly Matcher $matcher = new Matcher(),
73        private readonly ShorthandExpander $shorthands = new ShorthandExpander(),
74        private readonly Parser $parser = new Parser(),
75        /**
76         * Viewport width in CSS pixels, used to evaluate `@media`
77         * feature queries (`(min-width: N)`, `(max-width: N)`, etc).
78         * Null = unknown (feature queries treated as matching so
79         * print stylesheets that gate on width never silently drop).
80         */
81        private readonly ?float $viewportWidth = null,
82        private readonly ?float $viewportHeight = null,
83        /**
84         * Media types that match this rendering context. Defaults to
85         * `print` for the PDF-output target; the WPT harness and other
86         * "browser-like" embedders can pass `screen` to match the
87         * countless tests that gate on `@media screen and (…)`. Any
88         * type IN this set (plus the universal `all`) matches.
89         *
90         * @var list<string>
91         */
92        private readonly array $matchingMediaTypes = ['print'],
93    ) {
94        $this->expandedCache = new \WeakMap();
95        $this->selPseudoCache = new \WeakMap();
96    }
97
98    /**
99     * Per-Declaration shorthand-expansion cache. Same Declaration
100     * applied against many elements only pays the expansion cost
101     * once. WeakMap so stylesheets can be GC'd cleanly.
102     *
103     * Value: `list<array{0: string, 1: \Phpdftk\Css\Value\Value}>`
104     *
105     * @var \WeakMap<object, mixed>
106     */
107    private \WeakMap $expandedCache;
108
109    /**
110     * Per-ComplexSelector pseudo-element-name cache. The same
111     * selector applied against many elements only walks its
112     * compounds once.
113     *
114     * Value: `array{0: ?string}` (tuple so we can distinguish
115     * "cached null" from "missing").
116     *
117     * @var \WeakMap<object, mixed>
118     */
119    private \WeakMap $selPseudoCache;
120
121    /**
122     * Layer name â†’ declaration-order index. Populated lazily per
123     * `computeFor` call as we descend into `@layer` blocks. Named
124     * layers reuse the same index across all of their occurrences,
125     * anonymous blocks each get a fresh index.
126     *
127     * Per CSS Cascade 5 Â§5.3.1 â€” for normal author declarations, a
128     * higher index (later-declared) wins; unlayered declarations
129     * (index `null` on a candidate) outrank all layered.
130     *
131     * @var array<string, int>
132     */
133    private array $layerIndices = [];
134    private int $nextLayerIndex = 0;
135
136    /**
137     * Expand a declaration's shorthand, memoised on the Declaration
138     * object itself. Returns `[longhandName, expandedValue]` tuples
139     * so the cascade can iterate without per-call array allocation.
140     *
141     * @return list<array{string, \Phpdftk\Css\Value\Value}>
142     */
143    private function expandDeclaration(\Phpdftk\Css\Sheet\Declaration $decl): array
144    {
145        if (isset($this->expandedCache[$decl])) {
146            return $this->expandedCache[$decl];
147        }
148        $pairs = [];
149        // CSS Cascade 5 Â§3.2 â€” the `all` shorthand applies its value
150        // to EVERY CSS property except `direction` and `unicode-bidi`
151        // (which deal with text direction and aren't reset). The
152        // value must be a CSS-wide keyword: `initial` / `inherit` /
153        // `unset` / `revert` / `revert-layer`. Fan out the
154        // declaration so each property cascades on its own.
155        if (strtolower($decl->property) === 'all') {
156            foreach ($this->registry->all() as $propName => $_def) {
157                if ($propName === 'direction' || $propName === 'unicode-bidi') {
158                    continue;
159                }
160                $pairs[] = [$propName, $decl->value];
161            }
162            $this->expandedCache[$decl] = $pairs;
163            return $pairs;
164        }
165        foreach ($this->shorthands->expand($decl->property, $decl->value) as $longhand => $value) {
166            $pairs[] = [$longhand, $value];
167        }
168        $this->expandedCache[$decl] = $pairs;
169        return $pairs;
170    }
171
172    /**
173     * Return a Cascade configured for a specific viewport so that
174     * `@media (min-width: N)`-style feature queries can evaluate.
175     * The other dependencies are inherited from this instance.
176     */
177    public function withViewport(float $width, float $height): self
178    {
179        return new self(
180            $this->registry,
181            $this->matcher,
182            $this->shorthands,
183            $this->parser,
184            $width,
185            $height,
186            $this->matchingMediaTypes,
187        );
188    }
189
190    /**
191     * Return a Cascade configured to match additional media types in
192     * `@media` queries â€” useful when the embedder isn't a pure print
193     * target. Pass `['print', 'screen']` to also honour author CSS
194     * gated on `@media screen` (the assumed default for browser-
195     * targeted WPT tests).
196     *
197     * @param list<string> $types
198     */
199    public function withMatchingMediaTypes(array $types): self
200    {
201        return new self(
202            $this->registry,
203            $this->matcher,
204            $this->shorthands,
205            $this->parser,
206            $this->viewportWidth,
207            $this->viewportHeight,
208            $types,
209        );
210    }
211
212    /**
213     * Build the cascaded-values bag for an anonymous box (CSS Display 3
214     * Â§3.4). The box has no element of its own, so it has no author
215     * rules to match. Per spec the box takes the parent's *inherited*
216     * properties (font, color, line-height, â€¦) and leaves every
217     * non-inherited property at its registry-defined initial value
218     * (so e.g. `background-color`, `width`, `height`, `border-*`,
219     * `padding-*`, `margin-*` come out at their initial values
220     * regardless of what the parent declared).
221     *
222     * Custom properties always inherit (CSS Custom Properties Â§3) and
223     * are copied straight across.
224     */
225    public function anonymousFromParent(?CascadedValues $parentValues): CascadedValues
226    {
227        $values = new CascadedValues($this->registry);
228        $this->applyInheritance($values, $parentValues);
229        $this->inheritCustomProperties($values, $parentValues);
230        return $values;
231    }
232
233    /**
234     * Run the cascade for one element. `$parentValues` is the already-
235     * computed result for the element's parent â€” used for inheritance.
236     * Pass `null` for the root element.
237     *
238     * @param list<Stylesheet> $sheets
239     */
240    public function computeFor(
241        array $sheets,
242        MatchableElement $element,
243        ?CascadedValues $parentValues = null,
244        ?string $pseudoElement = null,
245    ): CascadedValues {
246        // 1. Collect every (declaration, specificity, origin, source-order)
247        //    tuple for declarations that match this element. When
248        //    `$pseudoElement` is set (e.g. "before" / "after"), only rules
249        //    whose selector ends in `::$pseudoElement` are included; when
250        //    null, the inverse â€” rules ending in any pseudo-element are
251        //    excluded so the host's cascade doesn't pick up content meant
252        //    for a generated box.
253        // Per-call layer-state reset so two sequential computeFor
254        // calls don't accumulate stale layer indices.
255        $this->layerIndices = [];
256        $this->nextLayerIndex = 0;
257        $candidates = [];
258        $order = 0;
259        foreach ($sheets as $sheet) {
260            foreach ($this->activeStyleRules($sheet->rules) as [$rule, $layerIndex]) {
261                $matchedSpec = null;
262                foreach ($rule->selectors->selectors as $sel) {
263                    $selPseudo = $this->selectorPseudoElementName($sel);
264                    if ($pseudoElement === null) {
265                        if ($selPseudo !== null) {
266                            continue;
267                        }
268                    } else {
269                        if ($selPseudo !== $pseudoElement) {
270                            continue;
271                        }
272                    }
273                    if (!$this->matcher->complexMatches($sel, $element)) {
274                        continue;
275                    }
276                    $spec = $sel->specificity();
277                    if ($matchedSpec === null || $spec->compare($matchedSpec) > 0) {
278                        $matchedSpec = $spec;
279                    }
280                }
281                if ($matchedSpec === null) {
282                    continue;
283                }
284                foreach ($rule->declarations as $decl) {
285                    foreach ($this->expandDeclaration($decl) as [$longhand, $value]) {
286                        // Reuse the original Declaration when the
287                        // longhand is unchanged (the common case for
288                        // non-shorthand properties), skipping the
289                        // per-cascade allocation. Same Specificity
290                        // and origin can also be shared.
291                        $decl2 = ($longhand === $decl->property)
292                            ? $decl
293                            : new Declaration($longhand, $value, $decl->important);
294                        $candidates[] = [
295                            'declaration' => $decl2,
296                            'specificity' => $matchedSpec,
297                            'origin' => $sheet->origin,
298                            'layerIndex' => $layerIndex,
299                            'order' => $order++,
300                        ];
301                    }
302                }
303            }
304        }
305
306        // 1b. HTML `style="…"` attribute declarations cascade as author rules
307        // with elevated specificity per CSS Cascade 5 Â§6.4.4 â€” they beat any
308        // realistic selector. Use Specificity(1024, 0, 0) so authors aren't
309        // hitting a tie against id-laden selectors in practice. Pseudo-
310        // elements never inherit inline style â€” `style="..."` always targets
311        // the host element.
312        $inlineCss = $pseudoElement === null
313            ? $element->getAttributeValue('style')
314            : null;
315        if ($inlineCss !== null && $inlineCss !== '') {
316            $inlineSpec = new Specificity(1024, 0, 0);
317            $inlineRule = $this->parser->parseInlineStyle($inlineCss);
318            foreach ($inlineRule->declarations as $decl) {
319                foreach ($this->expandDeclaration($decl) as [$longhand, $value]) {
320                    $decl2 = ($longhand === $decl->property)
321                        ? $decl
322                        : new Declaration($longhand, $value, $decl->important);
323                    $candidates[] = [
324                        'declaration' => $decl2,
325                        'specificity' => $inlineSpec,
326                        'origin' => Origin::Author,
327                        'layerIndex' => null,
328                        'order' => $order++,
329                    ];
330                }
331            }
332        }
333
334        // 2. Group candidates by property and pick the cascade winner.
335        // We need the full per-property candidate list (not just a
336        // running maximum) so `revert-layer` can re-resolve against
337        // lower-priority declarations after excluding the winner's
338        // layer.
339        /** @var array<string, list<int>> $byProperty index into $candidates */
340        $byProperty = [];
341        foreach ($candidates as $idx => $c) {
342            $byProperty[$c['declaration']->property][] = $idx;
343        }
344
345        // 3. Materialise CascadedValues, then apply inheritance.
346        $result = new CascadedValues($this->registry);
347        foreach ($byProperty as $name => $indices) {
348            $winner = $this->pickCascadeWinner($name, $indices, $candidates);
349            if ($winner === null) {
350                continue;
351            }
352            $value = $this->resolveSpecialKeywords(
353                $name,
354                $winner['declaration']->value,
355                $parentValues,
356            );
357            if ($value !== null) {
358                $result->set($name, $value);
359            }
360        }
361        $this->applyInheritance($result, $parentValues);
362        $this->inheritCustomProperties($result, $parentValues);
363        $this->substituteCustomProperties($result);
364        // CSS Color 5 Â§5 â€” `light-dark(<light>, <dark>)` is resolved
365        // at COMPUTED-VALUE time using THIS element's `color-scheme`,
366        // so it inherits as the chosen arm (not the symbolic
367        // expression). Without this pass an inner element's
368        // `color-scheme` would re-evaluate the parent's `light-dark()`
369        // value, contradicting WPT light-dark-inheritance.
370        $this->resolveLightDarkValues($result);
371        $this->applyFontSizeAdjustZero($result);
372        // CSS Tables 3 Â§2.3 + CSS Writing Modes 4 Â§3 â€” the
373        // `writing-mode` and `direction` properties do not apply to
374        // internal table boxes (`table-row-group` / `table-header-
375        // group` / `table-footer-group` / `table-row` / `table-
376        // column` / `table-column-group`). When an author declares
377        // them on `<tr>`, `<thead>`, `<tbody>`, `<tfoot>`, `<col>`,
378        // or `<colgroup>` the cascade still computes a value but the
379        // table layout algorithm uses the table's writing-mode for
380        // row stacking and the cell-axis swap. Override the computed
381        // value back to the inherited (parent) one so downstream
382        // logical-property resolution and layout dispatch see the
383        // table's effective WM.
384        $this->forceTableInternalWritingMode($result, $element, $parentValues);
385        $this->resolveLogicalProperties($result);
386        return $result;
387    }
388
389    /**
390     * @see computeFor â€” call site explains the spec rule.
391     */
392    private function forceTableInternalWritingMode(
393        CascadedValues $result,
394        MatchableElement $element,
395        ?CascadedValues $parentValues,
396    ): void {
397        if ($parentValues === null) {
398            return;
399        }
400        $local = strtolower($element->localName());
401        if (!in_array($local, ['tr', 'thead', 'tbody', 'tfoot', 'col', 'colgroup'], true)) {
402            return;
403        }
404        $parentWm = $parentValues->get('writing-mode');
405        if ($parentWm !== null) {
406            $result->set('writing-mode', $parentWm);
407        }
408        $parentDir = $parentValues->get('direction');
409        if ($parentDir !== null) {
410            $result->set('direction', $parentDir);
411        }
412    }
413
414    /**
415     * CSS Logical Properties 1 Â§3 â€” every logical longhand maps to
416     * a physical longhand per the element's `writing-mode` +
417     * `direction`. The cascade preserves logical and physical as
418     * separate property entries; this pass collapses the logical
419     * ones into their physical equivalents so layout code can
420     * keep reading `margin-top`, `padding-left`, etc. without
421     * threading WritingMode through every site.
422     *
423     * Precedence rule: if both the logical and the physical entry
424     * were explicitly set, the physical one wins (we don't track
425     * per-property declaration order across the logical/physical
426     * pair). Most authoring uses one OR the other, so this matches
427     * the common case; the edge case of "set both, expect later
428     * wins" lands when we extend the cascade engine itself.
429     */
430    private function resolveLogicalProperties(CascadedValues $values): void
431    {
432        $wm = WritingMode::fromStyle($values);
433        // 1) sizing: block-size / inline-size â†’ height / width
434        $sizingPairs = [
435            'block-size' => $wm->isVertical() ? 'width' : 'height',
436            'inline-size' => $wm->isVertical() ? 'height' : 'width',
437            'min-block-size' => $wm->isVertical() ? 'min-width' : 'min-height',
438            'min-inline-size' => $wm->isVertical() ? 'min-height' : 'min-width',
439            'max-block-size' => $wm->isVertical() ? 'max-width' : 'max-height',
440            'max-inline-size' => $wm->isVertical() ? 'max-height' : 'max-width',
441        ];
442        foreach ($sizingPairs as $logical => $physical) {
443            if ($values->has($logical) && !$values->has($physical)) {
444                $logicalValue = $values->get($logical);
445                if ($logicalValue !== null) {
446                    $values->set($physical, $logicalValue);
447                }
448            }
449        }
450        // 2) edge longhands: margin/padding/inset/border *-block-start/end,
451        // *-inline-start/end â†’ top / right / bottom / left per WM.
452        $edgePrefixes = [
453            ['margin-block-start', 'margin-', 'block-start'],
454            ['margin-block-end', 'margin-', 'block-end'],
455            ['margin-inline-start', 'margin-', 'inline-start'],
456            ['margin-inline-end', 'margin-', 'inline-end'],
457            ['padding-block-start', 'padding-', 'block-start'],
458            ['padding-block-end', 'padding-', 'block-end'],
459            ['padding-inline-start', 'padding-', 'inline-start'],
460            ['padding-inline-end', 'padding-', 'inline-end'],
461            ['inset-block-start', '', 'block-start'],
462            ['inset-block-end', '', 'block-end'],
463            ['inset-inline-start', '', 'inline-start'],
464            ['inset-inline-end', '', 'inline-end'],
465        ];
466        foreach ($edgePrefixes as [$logical, $prefix, $logicalEdge]) {
467            if (!$values->has($logical)) {
468                continue;
469            }
470            $physicalEdge = $wm->physicalEdge($logicalEdge);
471            $physical = $prefix === '' ? $physicalEdge : $prefix . $physicalEdge;
472            if (!$values->has($physical)) {
473                $logicalValue = $values->get($logical);
474                if ($logicalValue !== null) {
475                    $values->set($physical, $logicalValue);
476                }
477            }
478        }
479        // 3) border logical longhands per CSS Logical Properties 1 Â§7.
480        // Each shape maps to border-<edge>-<sub>: border-block-start-color
481        // â†’ border-top-color under horizontal-tb.
482        $borderTriples = [
483            ['border-block-start-width', 'block-start', '-width'],
484            ['border-block-end-width', 'block-end', '-width'],
485            ['border-inline-start-width', 'inline-start', '-width'],
486            ['border-inline-end-width', 'inline-end', '-width'],
487            ['border-block-start-style', 'block-start', '-style'],
488            ['border-block-end-style', 'block-end', '-style'],
489            ['border-inline-start-style', 'inline-start', '-style'],
490            ['border-inline-end-style', 'inline-end', '-style'],
491            ['border-block-start-color', 'block-start', '-color'],
492            ['border-block-end-color', 'block-end', '-color'],
493            ['border-inline-start-color', 'inline-start', '-color'],
494            ['border-inline-end-color', 'inline-end', '-color'],
495        ];
496        foreach ($borderTriples as [$logical, $logicalEdge, $suffix]) {
497            if (!$values->has($logical)) {
498                continue;
499            }
500            $physicalEdge = $wm->physicalEdge($logicalEdge);
501            $physical = 'border-' . $physicalEdge . $suffix;
502            if (!$values->has($physical)) {
503                $logicalValue = $values->get($logical);
504                if ($logicalValue !== null) {
505                    $values->set($physical, $logicalValue);
506                }
507            }
508        }
509    }
510
511    /**
512     * CSS Fonts 4 Â§5 â€” `font-size-adjust: 0` makes the used font-size
513     * 0px regardless of the cascaded `font-size`. We don't compute
514     * the full x-height-ratio remap, but the zero special case
515     * (which intentionally hides text by collapsing its used size)
516     * is handled directly so WPT font-size-adjust-005 / -014 pass.
517     */
518    private function applyFontSizeAdjustZero(CascadedValues $values): void
519    {
520        $adjust = $values->get('font-size-adjust');
521        $isZero = false;
522        if ($adjust instanceof \Phpdftk\Css\Value\Number) {
523            $isZero = abs($adjust->value) < 1e-9;
524        } elseif ($adjust instanceof \Phpdftk\Css\Value\Integer) {
525            $isZero = $adjust->value === 0;
526        }
527        if (!$isZero) {
528            return;
529        }
530        $values->set(
531            'font-size',
532            new Length(0.0, LengthUnit::Px),
533        );
534    }
535
536    /**
537     * Walk every cascaded value in `$values` and replace any
538     * `LightDark` instance with its preferred arm â€” the dark side
539     * when `color-scheme` resolves to a list whose first preferred
540     * scheme is dark, the light side otherwise (matching the spec
541     * default).
542     */
543    private function resolveLightDarkValues(CascadedValues $values): void
544    {
545        $scheme = $values->get('color-scheme');
546        $isDark = false;
547        if ($scheme instanceof Keyword && strtolower($scheme->name) === 'dark') {
548            $isDark = true;
549        } elseif ($scheme instanceof ValueList) {
550            foreach ($scheme->values as $entry) {
551                if (!$entry instanceof Keyword) {
552                    continue;
553                }
554                $name = strtolower($entry->name);
555                if ($name === 'dark') {
556                    $isDark = true;
557                    break;
558                }
559                if ($name === 'light') {
560                    break;
561                }
562            }
563        }
564        foreach ($values->all() as $name => $value) {
565            if ($value instanceof \Phpdftk\Css\Value\LightDark) {
566                $values->set($name, $isDark ? $value->dark : $value->light);
567            }
568        }
569    }
570
571    /**
572     * Return the name of the pseudo-element targeted by `$sel` (the last
573     * compound's terminating `::name`), or null when the selector is a
574     * regular host-element selector. Used to gate cascade matching so
575     * `p::before` rules don't pollute `<p>`'s style and vice versa.
576     */
577    /**
578     * Yield every `StyleRule` reachable from the given rule list,
579     * recursing into `@media`-style conditional at-rules whose prelude
580     * matches the current rendering context. Phase-1 simplification:
581     * matches `@media print`, `@media all`, and any `@media` list that
582     * mentions `print` or `all` (CSS Media Queries 4 Â§2.3 media types).
583     * `@media screen` / `@media speech` / unrecognised media features
584     * are skipped, so screen-only rules don't leak into print output.
585     *
586     * `@supports` blocks are always entered (we treat every supports()
587     * condition as matching at Phase 1; full evaluation lands later
588     * alongside `@supports` query parsing).
589     *
590     * @param list<\Phpdftk\Css\Sheet\Rule> $rules
591     * @return iterable<array{0: StyleRule, 1: ?int}>
592     */
593    private function activeStyleRules(array $rules, ?int $layerIndex = null): iterable
594    {
595        foreach ($rules as $rule) {
596            if ($rule instanceof StyleRule) {
597                yield [$rule, $layerIndex];
598                continue;
599            }
600            if ($rule instanceof \Phpdftk\Css\Sheet\AtRule) {
601                $name = strtolower($rule->name);
602                // Statement-form `@layer name1, name2;` (no block).
603                // Just registers each name in declaration order so the
604                // priority ranking is locked in before later usages.
605                if ($name === 'layer' && $rule->block === null) {
606                    $parts = preg_split('/\s*,\s*/', trim($rule->prelude)) ?: [];
607                    foreach ($parts as $layerName) {
608                        if ($layerName === '') {
609                            continue;
610                        }
611                        $this->resolveLayerIndex($layerName);
612                    }
613                    continue;
614                }
615                if ($rule->block === null) {
616                    continue;
617                }
618                if ($name === 'media') {
619                    if (!$this->mediaPreludeMatches($rule->prelude)) {
620                        continue;
621                    }
622                } elseif ($name === 'supports') {
623                    if (!$this->supportsPreludeMatches($rule->prelude)) {
624                        continue;
625                    }
626                } elseif ($name === 'layer') {
627                    // CSS Cascade 5 Â§3.1 â€” `@layer <name>? { ... }`
628                    // block-form. Resolve the layer name (or assign a
629                    // fresh anonymous index) and pass it down so every
630                    // nested StyleRule remembers which layer it lives
631                    // in. Subsequent occurrences of the SAME named
632                    // layer reuse the previously-assigned index, so
633                    // splitting one layer across multiple `@layer foo
634                    // { ... }` blocks composes correctly. Nested
635                    // `@layer` inside another layer creates a "child"
636                    // layer; we flatten by assigning a fresh index
637                    // (full sub-layer priority lands later).
638                    $prelude = trim($rule->prelude);
639                    $childIndex = $prelude === ''
640                        ? $this->nextLayerIndex++
641                        : $this->resolveLayerIndex($prelude);
642                    $nested = [];
643                    foreach ($rule->block->contents as $item) {
644                        if ($item instanceof \Phpdftk\Css\Sheet\Rule) {
645                            $nested[] = $item;
646                        }
647                    }
648                    yield from $this->activeStyleRules($nested, $childIndex);
649                    continue;
650                } elseif ($name === 'scope') {
651                    // CSS Cascade 6 Â§3 â€” `@scope (root) [to limit] { ... }`
652                    // Same pass-through posture as @layer for now;
653                    // proper scope tree handling lands later.
654                } elseif ($name === 'starting-style') {
655                    // CSS Transitions 2 Â§3 â€” declares the entry
656                    // (from-) state for transitioning properties.
657                    // For static print render the starting state
658                    // IS the rendered state, so the inner rules
659                    // pass through.
660                } elseif ($name === 'container') {
661                    // CSS Containment 3 Â§4.4 â€” `@container [name?]
662                    // (query) { ... }`. Container queries resolve
663                    // against the nearest size-query container's
664                    // dimensions. We don't yet plumb per-element
665                    // container sizes through the cascade (that
666                    // would require a two-pass layout), so we
667                    // evaluate the query against the viewport as
668                    // a Phase-1 approximation: for top-level
669                    // containers (where the viewport IS the
670                    // container) the answer matches; for nested
671                    // containers this may be wrong. Trivially-
672                    // unsatisfiable queries (`(min-width: 9999999px)`)
673                    // drop correctly, and trivially-satisfiable ones
674                    // pass â€” the common opt-in case where the
675                    // author gates a section on a page-scale size.
676                    if (!$this->containerPreludeMatches($rule->prelude)) {
677                        continue;
678                    }
679                } elseif ($name === 'position-try') {
680                    // CSS Anchor Positioning 1 Â§8 â€” `@position-try
681                    // --fallback { ... }` declares positioning
682                    // fallbacks used when the primary position
683                    // overflows. For static print, primary wins;
684                    // pass through so rules cascade.
685                } else {
686                    continue;
687                }
688                $nested = [];
689                foreach ($rule->block->contents as $item) {
690                    if ($item instanceof \Phpdftk\Css\Sheet\Rule) {
691                        $nested[] = $item;
692                    }
693                }
694                yield from $this->activeStyleRules($nested, $layerIndex);
695            }
696        }
697    }
698
699    /**
700     * Look up (or assign) a stable index for a named layer. Named
701     * layers reuse the same index across all occurrences, so the
702     * priority ranking is locked in by the FIRST mention of the name â€”
703     * even when later styles add more rules to the same layer.
704     */
705    private function resolveLayerIndex(string $name): int
706    {
707        $key = strtolower($name);
708        if (!isset($this->layerIndices[$key])) {
709            $this->layerIndices[$key] = $this->nextLayerIndex++;
710        }
711        return $this->layerIndices[$key];
712    }
713
714    /**
715     * Evaluate a CSS Media Queries 4 prelude against the print
716     * rendering context. Supports:
717     *  - comma-separated media query list â€” true when ANY part matches
718     *  - bare media types: `print`, `all` match; `screen`, `speech`
719     *    don't
720     *  - logical `not` prefix â€” inverts the rest of the query
721     *  - logical `only` prefix â€” historical legacy keyword, treated
722     *    as no-op (the query must otherwise match)
723     *  - `and`-joined feature queries: `(min-width: N)`, `(max-width:
724     *    N)`, `(width: N)`, plus their `min-height` / `max-height` /
725     *    `height` siblings; resolves against the cascade's viewport
726     *    dimensions when set
727     *  - `(orientation: portrait | landscape)` â€” true when matching
728     *    the viewport's aspect ratio
729     * Unknown features evaluate to `false` per spec (CSS Media
730     * Queries 4 Â§3.1) so a query gated on something we don't model
731     * never accidentally matches.
732     */
733    private function mediaPreludeMatches(string $prelude): bool
734    {
735        $lower = strtolower($prelude);
736        if ($lower === '' || $lower === 'all') {
737            return true;
738        }
739        foreach (explode(',', $lower) as $part) {
740            if ($this->matchSingleMediaQuery(trim($part))) {
741                return true;
742            }
743        }
744        return false;
745    }
746
747    /**
748     * CSS Containment 3 Â§4.4 â€” evaluate an `@container [name?]
749     * (<query>)` prelude. We don't yet thread per-element container
750     * sizes through the cascade, so we evaluate the size query
751     * against the viewport as a Phase-1 proxy. The strict cascade-
752     * level evaluation is correct for top-level containers where the
753     * viewport IS the relevant container, conservatively drops
754     * unsatisfiable queries (e.g. `(min-width: 99999999px)`), and
755     * keeps trivially-satisfiable ones (`(min-width: 0)`).
756     *
757     * We only evaluate queries whose features map cleanly onto the
758     * media-query subset we already model (min-width / max-width /
759     * min-height / max-height plus orientation). Anything else â€”
760     * `inline-size` / `block-size`, MQ5 range syntax (`width >
761     * 400px`), `style()` / `scroll-state()` â€” falls through to true
762     * so author CSS doesn't silently drop until full support lands.
763     */
764    private function containerPreludeMatches(string $prelude): bool
765    {
766        $prelude = trim($prelude);
767        if ($prelude === '') {
768            return true;
769        }
770        // Strip an optional leading <container-name>. The name is a
771        // CSS identifier; it must appear BEFORE the first `(`.
772        $parenAt = strpos($prelude, '(');
773        if ($parenAt === false) {
774            return true;
775        }
776        $head = trim(substr($prelude, 0, $parenAt));
777        $body = trim(substr($prelude, $parenAt));
778        if ($body === '') {
779            return true;
780        }
781        // Style queries (`@container style(...)`) and scroll-state
782        // queries land later; accept them permissively.
783        if (stripos($body, 'style(') !== false || stripos($body, 'scroll-state(') !== false) {
784            return true;
785        }
786        // Conservative subset: only evaluate when every feature used
787        // in the body is one our media-query path already knows.
788        // Anything else (range syntax, inline-size / block-size,
789        // unknown feature) â†’ pass through.
790        if (!$this->containerQueryUsesOnlySupportedFeatures($body)) {
791            return true;
792        }
793        $negate = strtolower($head) === 'not';
794        $result = $this->evaluateMediaCondition($body);
795        return $negate ? !$result : $result;
796    }
797
798    /**
799     * Decide whether an `@container` query body uses only features
800     * the cascade-level evaluator can answer (min-width / max-width
801     * / min-height / max-height / width / height / orientation /
802     * aspect-ratio plus the MQ5 range-syntax rewrites on top of
803     * width / height). Returns true for queries we can evaluate;
804     * false for ones that should pass through unconditionally.
805     */
806    private function containerQueryUsesOnlySupportedFeatures(string $body): bool
807    {
808        // Walk parens-balanced `<name>: <value>` legacy form. Each
809        // feature name must be one we support.
810        $supported = [
811            'min-width', 'max-width', 'width',
812            'min-height', 'max-height', 'height',
813            'min-inline-size', 'max-inline-size', 'inline-size',
814            'min-block-size', 'max-block-size', 'block-size',
815            'orientation', 'aspect-ratio',
816            'min-aspect-ratio', 'max-aspect-ratio',
817        ];
818        if (preg_match_all('/\(\s*([a-z-]+)\s*:/i', $body, $matches) > 0) {
819            foreach ($matches[1] as $name) {
820                if (!in_array(strtolower($name), $supported, true)) {
821                    return false;
822                }
823            }
824        }
825        // MQ5 range syntax â€” `(width > 400px)` etc. Each parens-
826        // balanced range body must use a supported feature on either
827        // side of the comparison operator.
828        if (preg_match_all(
829            '/\(\s*([^()]*?[<>]=?[^()]*?)\s*\)/i',
830            $body,
831            $rangeMatches,
832        ) > 0) {
833            foreach ($rangeMatches[1] as $rangeBody) {
834                // Extract the identifier (a-z-) tokens; require at
835                // least one to be a supported feature.
836                if (preg_match_all('/[a-z][a-z0-9-]*/i', $rangeBody, $tokens) === 0) {
837                    return false;
838                }
839                $hit = false;
840                foreach ($tokens[0] as $tok) {
841                    if (in_array(strtolower($tok), $supported, true)) {
842                        $hit = true;
843                        break;
844                    }
845                }
846                if (!$hit) {
847                    return false;
848                }
849            }
850        }
851        return true;
852    }
853
854    /**
855     * Match a single comma-separated media query â€” `[not|only] type?
856     * [and (feature)]*` per CSS Media Queries 4 Â§2.1.
857     */
858    private function matchSingleMediaQuery(string $query): bool
859    {
860        if ($query === '') {
861            return false;
862        }
863        $negate = false;
864        if (str_starts_with($query, 'not ')) {
865            $negate = true;
866            $query = trim(substr($query, 4));
867        } elseif (str_starts_with($query, 'only ')) {
868            // `only` is a no-op gate for legacy browsers; the rest of
869            // the query must still match.
870            $query = trim(substr($query, 5));
871        }
872        if ($query === '') {
873            return false;
874        }
875        // Per CSS Media Queries 4 Â§2.1, `not` / `and` / `only` / `or`
876        // are reserved keywords and cannot be media types. A query
877        // whose type slot holds one of them is invalid syntax â†’ `not
878        // all` â†’ false. The outer `not` prefix does NOT flip this:
879        // invalid stays invalid.
880        $parts = preg_split('/\s+and\s+/', $query) ?: [];
881        $head = $parts[0] ?? '';
882        if ($head !== '' && $head[0] !== '(' && in_array($head, ['not', 'and', 'only', 'or', 'layer'], true)) {
883            return false;
884        }
885        $result = $this->evaluateMediaQueryBody($query);
886        return $negate ? !$result : $result;
887    }
888
889    /**
890     * Evaluate a media-query body â€” `<type>? [and (feature)]*`.
891     * Returns true when the type matches AND every feature query
892     * evaluates to true.
893     */
894    private function evaluateMediaQueryBody(string $query): bool
895    {
896        $query = trim($query);
897        if ($query === '') {
898            return true;
899        }
900        // Modern MQ4 syntax â€” the body IS a media-condition (no
901        // leading type token), with arbitrary `and` / `or` / `not`
902        // and grouped parens. Delegate to the condition evaluator
903        // which is paren-depth-aware.
904        if ($query[0] === '(') {
905            return $this->evaluateMediaCondition($query);
906        }
907        // Legacy syntax â€” `<type> [ and (feature) ]*`. Split on
908        // top-level ` and ` (features are parenthesised so they
909        // separate cleanly) and verify each.
910        $parts = preg_split('/\s+and\s+/', $query) ?: [];
911        $first = $parts[0] ?? '';
912        $typeMatches = $first === ''
913            || $first === 'all'
914            || in_array($first, $this->matchingMediaTypes, true);
915        if (!$typeMatches) {
916            return false;
917        }
918        array_shift($parts);
919        foreach ($parts as $featureRaw) {
920            $feature = trim($featureRaw);
921            if ($feature === '' || $feature[0] !== '(' || !str_ends_with($feature, ')')) {
922                return false;
923            }
924            $inside = trim(substr($feature, 1, -1));
925            if (!$this->evaluateMediaCondition($inside)) {
926                return false;
927            }
928        }
929        return true;
930    }
931
932    /**
933     * Evaluate the body of a parenthesised media condition. Handles
934     * the CSS Media Queries 4 `<media-condition>` shape:
935     *
936     *   `not (...)`               negation of a nested condition
937     *   `(...) and (...)`         conjunction inside a group
938     *   `(...) or (...)`          disjunction inside a group
939     *   `<name>[: <value>]`       a plain feature query
940     *
941     * Pure feature names (no leading `not` / no nested parens) defer
942     * to {@see matchFeatureQuery}.
943     */
944    private function evaluateMediaCondition(string $body): bool
945    {
946        $body = trim($body);
947        if (str_starts_with($body, 'not ') || str_starts_with($body, 'not(')) {
948            $rest = trim(substr($body, 3));
949            // CSS Media Queries 4 Â§3.3 â€” `not` is a unary operator
950            // over EXACTLY one media-in-parens. `not (X) and (Y)` is
951            // a syntax error: the `not` cannot pair with a trailing
952            // and/or chain unless the whole and/or is grouped
953            // (`not ((X) and (Y))`). Verify the rest is a single
954            // depth-balanced paren expression with nothing trailing.
955            if (!$this->isSingleParenExpression($rest)) {
956                return false;
957            }
958            return !$this->evaluateMediaCondition(trim(substr($rest, 1, -1)));
959        }
960        if ($body !== '' && $body[0] === '(') {
961            // Top-level looks like `(X) <op> (Y) ...` â€” split on top-
962            // level `and` / `or` between parenthesised groups.
963            $segments = $this->splitMediaConditionAt($body, ['and', 'or']);
964            if (count($segments) > 1) {
965                // CSS Media Queries 4 Â§3.3 â€” `and` and `or` cannot be
966                // mixed at the same level without explicit grouping
967                // parens: `(A) and (B) or (C)` is invalid syntax. Also
968                // a bare `not (X)` cannot appear as a segment of an
969                // and/or chain â€” it must be wrapped: `(not (X))`. Both
970                // shapes reject as `not all` per Â§3.1.
971                $opsSeen = [];
972                foreach ($segments as [$segOp, $segText]) {
973                    if ($segOp !== null) {
974                        $opsSeen[$segOp] = true;
975                    }
976                    $segText = trim($segText);
977                    if ($segText === '' || $segText[0] !== '(' || !str_ends_with($segText, ')')) {
978                        return false;
979                    }
980                }
981                if (isset($opsSeen['and']) && isset($opsSeen['or'])) {
982                    return false;
983                }
984                $result = null;
985                foreach ($segments as [$segOp, $segText]) {
986                    $segVal = $this->evaluateMediaCondition(trim($segText));
987                    if ($result === null) {
988                        $result = $segVal;
989                        continue;
990                    }
991                    $result = $segOp === 'and' ? ($result && $segVal) : ($result || $segVal);
992                }
993                return (bool) $result;
994            }
995            if (str_ends_with($body, ')')) {
996                return $this->evaluateMediaCondition(trim(substr($body, 1, -1)));
997            }
998            return false;
999        }
1000        return $this->matchFeatureQuery($body);
1001    }
1002
1003    /**
1004     * Test whether `$s` is a single depth-balanced paren expression
1005     * with no trailing content â€” that is, `(X)` where the opening
1006     * paren at index 0 only closes at the last character. Used to
1007     * enforce the CSS Media Queries 4 Â§3.3 rule that `not` takes
1008     * exactly one `<media-in-parens>` operand.
1009     */
1010    private function isSingleParenExpression(string $s): bool
1011    {
1012        $s = trim($s);
1013        if ($s === '' || $s[0] !== '(' || !str_ends_with($s, ')')) {
1014            return false;
1015        }
1016        $depth = 0;
1017        $n = strlen($s);
1018        for ($i = 0; $i < $n; $i++) {
1019            if ($s[$i] === '(') {
1020                $depth++;
1021            } elseif ($s[$i] === ')') {
1022                $depth--;
1023                // If the outermost `(` closes before the last char,
1024                // there's content after it â†’ not a single paren expr.
1025                if ($depth === 0 && $i !== $n - 1) {
1026                    return false;
1027                }
1028            }
1029        }
1030        return $depth === 0;
1031    }
1032
1033    /**
1034     * Split a `(A) and (B) and (C)` (or `or`-joined) string into
1035     * `[[op, segment], ...]` pairs, where `op` is the operator that
1036     * preceded the segment (`null` for the first). Honors paren depth
1037     * so nested `(not (X))` groups don't get torn apart.
1038     *
1039     * @param list<string> $operators
1040     * @return list<array{0: ?string, 1: string}>
1041     */
1042    private function splitMediaConditionAt(string $body, array $operators): array
1043    {
1044        $segments = [];
1045        $current = '';
1046        $currentOp = null;
1047        $depth = 0;
1048        $i = 0;
1049        $n = strlen($body);
1050        while ($i < $n) {
1051            $ch = $body[$i];
1052            if ($ch === '(') {
1053                $depth++;
1054                $current .= $ch;
1055                $i++;
1056                continue;
1057            }
1058            if ($ch === ')') {
1059                $depth--;
1060                $current .= $ch;
1061                $i++;
1062                continue;
1063            }
1064            if ($depth === 0 && ctype_space($ch)) {
1065                foreach ($operators as $op) {
1066                    $candidate = $op . ' ';
1067                    if (substr($body, $i + 1, strlen($candidate)) === $candidate) {
1068                        $segments[] = [$currentOp, trim($current)];
1069                        $current = '';
1070                        $currentOp = $op;
1071                        $i += 1 + strlen($candidate);
1072                        continue 2;
1073                    }
1074                }
1075            }
1076            $current .= $ch;
1077            $i++;
1078        }
1079        if ($current !== '') {
1080            $segments[] = [$currentOp, trim($current)];
1081        }
1082        return $segments;
1083    }
1084
1085    /**
1086     * CSS Media Queries 5 Â§2.5 â€” rewrite a range-syntax feature
1087     * (`width > 400px`, `400px <= width < 800px`) into one or more
1088     * legacy-prefix-form constraints (`min-width: 400.001px`, etc.)
1089     * that the existing evaluator already handles.
1090     *
1091     * Strict / non-strict inequality is approximated by the same
1092     * legacy form (which uses `>=` / `<=` semantics) since the
1093     * floating-point comparison difference is below display
1094     * resolution. Returns `null` when the body isn't range syntax,
1095     * `[]` when it parses but uses an unknown shape (drop).
1096     *
1097     * @return list<string>|null
1098     */
1099    private function rewriteRangeFeature(string $body): ?array
1100    {
1101        $body = trim($body);
1102        // Detect range operators outside string content. The simple
1103        // queries we model don't have nested parens or strings, so a
1104        // raw scan is fine.
1105        if (preg_match('/[<>]=?|=/', $body) !== 1) {
1106            // Check it's actually a range, not e.g. `color: rgb(0,0,0)`.
1107            if (preg_match('/[<>]/', $body) !== 1) {
1108                return null;
1109            }
1110        }
1111        // `<value> <op> <name> <op> <value>` (chained), or
1112        // `<name> <op> <value>` / `<value> <op> <name>` (simple).
1113        // Identifier match: `[a-z][a-z0-9-]*`.
1114        $ident = '[a-z][a-z0-9-]*';
1115        // Chained form: a <op> b <op> c, all three required.
1116        if (preg_match(
1117            '/^(.+?)\s*(<=?|>=?)\s*(' . $ident . ')\s*(<=?|>=?)\s*(.+?)$/i',
1118            $body,
1119            $m,
1120        ) === 1) {
1121            $left = trim($m[1]);
1122            $op1 = $m[2];
1123            $name = strtolower($m[3]);
1124            $op2 = $m[4];
1125            $right = trim($m[5]);
1126            // Rewrite `left op1 name op2 right` into two simple
1127            // comparisons against `name`: `name (op1-reversed) left`
1128            // AND `name op2 right`.
1129            $first = $this->rangeToLegacy($name, $this->reverseOp($op1), $left);
1130            $second = $this->rangeToLegacy($name, $op2, $right);
1131            if ($first === null || $second === null) {
1132                return [];
1133            }
1134            return [$first, $second];
1135        }
1136        // Simple form: `<name> <op> <value>` or `<value> <op> <name>`.
1137        if (preg_match(
1138            '/^(.+?)\s*(<=?|>=?|=)\s*(.+?)$/',
1139            $body,
1140            $m,
1141        ) === 1) {
1142            $left = trim($m[1]);
1143            $op = $m[2];
1144            $right = trim($m[3]);
1145            $leftIsName = preg_match('/^' . $ident . '$/i', $left) === 1;
1146            $rightIsName = preg_match('/^' . $ident . '$/i', $right) === 1;
1147            if ($leftIsName && !$rightIsName) {
1148                $rewrite = $this->rangeToLegacy(strtolower($left), $op, $right);
1149            } elseif ($rightIsName && !$leftIsName) {
1150                $rewrite = $this->rangeToLegacy(strtolower($right), $this->reverseOp($op), $left);
1151            } else {
1152                return [];
1153            }
1154            return $rewrite === null ? [] : [$rewrite];
1155        }
1156        return null;
1157    }
1158
1159    /**
1160     * Map a range comparison `(name op value)` to a legacy-prefix
1161     * `min-name: value` / `max-name: value` / `name: value`. Returns
1162     * null when the comparison can't be expressed in the legacy form.
1163     */
1164    private function rangeToLegacy(string $name, string $op, string $value): ?string
1165    {
1166        return match ($op) {
1167            '>', '>=' => 'min-' . $name . ': ' . $value,
1168            '<', '<=' => 'max-' . $name . ': ' . $value,
1169            '='       => $name . ': ' . $value,
1170            default   => null,
1171        };
1172    }
1173
1174    private function reverseOp(string $op): string
1175    {
1176        return match ($op) {
1177            '<' => '>',
1178            '<=' => '>=',
1179            '>' => '<',
1180            '>=' => '<=',
1181            '=' => '=',
1182            default => $op,
1183        };
1184    }
1185
1186    /**
1187     * Evaluate one feature query like `min-width: 600px` or
1188     * `orientation: portrait`. Returns false for any feature the
1189     * cascade doesn't model.
1190     */
1191    private function matchFeatureQuery(string $inside): bool
1192    {
1193        // CSS Media Queries 5 Â§2.5 â€” range syntax (`width > 400px`,
1194        // `400px < width <= 800px`). Rewrite to one or more
1195        // equivalent legacy-prefix-form constraints before falling
1196        // through to the existing `name: value` evaluator.
1197        $rangeRewrites = $this->rewriteRangeFeature($inside);
1198        if ($rangeRewrites !== null) {
1199            foreach ($rangeRewrites as $rewrite) {
1200                if (!$this->matchFeatureQuery($rewrite)) {
1201                    return false;
1202                }
1203            }
1204            return true;
1205        }
1206        if (!str_contains($inside, ':')) {
1207            // CSS Media Queries 4 Â§2.4.4 â€” boolean form: `(feature)`
1208            // matches when the feature's value is non-zero / not the
1209            // default "no" answer. We answer for the dimension features
1210            // we actually model; everything else stays false so an
1211            // unknown feature can never silently match.
1212            $name = strtolower(trim($inside));
1213            switch ($name) {
1214                case 'width':
1215                case 'device-width':
1216                case 'inline-size':  // CSS Containment 3 â€” alias of width in horizontal-tb
1217                    return $this->viewportWidth === null || $this->viewportWidth > 0;
1218                case 'height':
1219                case 'device-height':
1220                case 'block-size':   // alias of height in horizontal-tb
1221                    return $this->viewportHeight === null || $this->viewportHeight > 0;
1222                case 'aspect-ratio':
1223                case 'device-aspect-ratio':
1224                    return $this->viewportWidth !== null
1225                        && $this->viewportHeight !== null
1226                        && $this->viewportWidth > 0
1227                        && $this->viewportHeight > 0;
1228                case 'resolution':
1229                    return true;
1230                case 'color':
1231                    return true;
1232                case 'monochrome':
1233                case 'color-index':
1234                case 'grid':
1235                    return false;
1236                default:
1237                    return false;
1238            }
1239        }
1240        [$name, $valueRaw] = array_map('trim', explode(':', $inside, 2));
1241        $name = strtolower($name);
1242        $valueRaw = strtolower($valueRaw);
1243        if ($name === 'orientation') {
1244            if ($this->viewportWidth === null || $this->viewportHeight === null) {
1245                return true;
1246            }
1247            $isLandscape = $this->viewportWidth >= $this->viewportHeight;
1248            return ($valueRaw === 'landscape' && $isLandscape)
1249                || ($valueRaw === 'portrait' && !$isLandscape);
1250        }
1251        if (in_array($name, ['min-width', 'max-width', 'width'], true)) {
1252            return $this->matchDimensionFeature($name, $valueRaw, $this->viewportWidth);
1253        }
1254        if (in_array($name, ['min-height', 'max-height', 'height'], true)) {
1255            return $this->matchDimensionFeature($name, $valueRaw, $this->viewportHeight);
1256        }
1257        // CSS Containment 3 Â§4.4 â€” `inline-size` / `block-size`
1258        // container features (and their min-/max- prefix forms)
1259        // alias to width / height in horizontal writing modes (the
1260        // only mode we support for now).
1261        if (in_array($name, ['min-inline-size', 'max-inline-size', 'inline-size'], true)) {
1262            $bare = substr($name, 0, 3) === 'min' ? 'min-width'
1263                  : (substr($name, 0, 3) === 'max' ? 'max-width' : 'width');
1264            return $this->matchDimensionFeature($bare, $valueRaw, $this->viewportWidth);
1265        }
1266        if (in_array($name, ['min-block-size', 'max-block-size', 'block-size'], true)) {
1267            $bare = substr($name, 0, 3) === 'min' ? 'min-height'
1268                  : (substr($name, 0, 3) === 'max' ? 'max-height' : 'height');
1269            return $this->matchDimensionFeature($bare, $valueRaw, $this->viewportHeight);
1270        }
1271        // CSS Media Queries 4 Â§4.7 â€” device-* features mirror the
1272        // top-level dimensions for our print-target rendering context;
1273        // we don't model paged output devices separately from the
1274        // rendered viewport.
1275        if (in_array($name, ['min-device-width', 'max-device-width', 'device-width'], true)) {
1276            $bare = substr($name, 0, 3) === 'min' ? 'min-width'
1277                  : (substr($name, 0, 3) === 'max' ? 'max-width' : 'width');
1278            return $this->matchDimensionFeature($bare, $valueRaw, $this->viewportWidth);
1279        }
1280        if (in_array($name, ['min-device-height', 'max-device-height', 'device-height'], true)) {
1281            $bare = substr($name, 0, 3) === 'min' ? 'min-height'
1282                  : (substr($name, 0, 3) === 'max' ? 'max-height' : 'height');
1283            return $this->matchDimensionFeature($bare, $valueRaw, $this->viewportHeight);
1284        }
1285        // Â§4.4 â€” `color`, `color-index`, `monochrome` are integer
1286        // features (bits per channel; palette size; monochrome bits).
1287        // We model a color print device: 8-bit color, no palette,
1288        // no monochrome. Per Â§3 these features are "false in the
1289        // negative range" â€” the legacy min-/max- form clamps the
1290        // queried value to [0, âˆž) before comparison so negative
1291        // thresholds always satisfy a `min-` query and never satisfy
1292        // a `max-` query (max- against negative clamps to 0).
1293        if (in_array($name, ['min-color', 'max-color', 'color'], true)) {
1294            return $this->matchIntegerFeature($name, $valueRaw, 8);
1295        }
1296        if (in_array($name, ['min-color-index', 'max-color-index', 'color-index'], true)) {
1297            return $this->matchIntegerFeature($name, $valueRaw, 0);
1298        }
1299        if (in_array($name, ['min-monochrome', 'max-monochrome', 'monochrome'], true)) {
1300            return $this->matchIntegerFeature($name, $valueRaw, 0);
1301        }
1302        // CSS Media Queries 4 Â§4.6 â€” `aspect-ratio` / `device-aspect-
1303        // ratio` and their min-/max- prefix forms. Value is a
1304        // `<ratio>` (`<number> [ / <number> ]?`). Compares against
1305        // the viewport's width / height ratio; with no viewport we
1306        // can't decide â†’ return true permissively so author CSS
1307        // doesn't silently drop on print contexts.
1308        if (in_array($name, [
1309            'aspect-ratio', 'min-aspect-ratio', 'max-aspect-ratio',
1310            'device-aspect-ratio', 'min-device-aspect-ratio', 'max-device-aspect-ratio',
1311        ], true)) {
1312            return $this->matchRatioFeature($name, $valueRaw);
1313        }
1314        return false;
1315    }
1316
1317    /**
1318     * Evaluate an `(aspect-ratio: <ratio>)` style feature query.
1319     * The `<ratio>` may be `<num>` (treated as `<num>/1`) or
1320     * `<num>/<num>`. Compares against the viewport width/height
1321     * ratio.
1322     */
1323    private function matchRatioFeature(string $name, string $valueRaw): bool
1324    {
1325        if ($this->viewportWidth === null
1326            || $this->viewportHeight === null
1327            || $this->viewportWidth <= 0
1328            || $this->viewportHeight <= 0
1329        ) {
1330            return true;
1331        }
1332        $valueRaw = trim($valueRaw);
1333        // CSS Values 4 Â§10 â€” `calc(<num> / <num>)` evaluates to a
1334        // number that can stand in for the ratio. Handle the simple
1335        // single-division form here (WPT mq-calc-008) without falling
1336        // through to the full calc engine.
1337        if (preg_match('/^calc\s*\(\s*([\-+]?[0-9]*\.?[0-9]+)\s*\/\s*([\-+]?[0-9]*\.?[0-9]+)\s*\)$/i', $valueRaw, $cm) === 1) {
1338            $num = (float) $cm[1];
1339            $den = (float) $cm[2];
1340        } elseif (preg_match('/^([\-+]?[0-9]*\.?[0-9]+)\s*(?:\/\s*([\-+]?[0-9]*\.?[0-9]+))?$/', $valueRaw, $m) === 1) {
1341            $num = (float) $m[1];
1342            $den = isset($m[2]) ? (float) $m[2] : 1.0;
1343        } else {
1344            return false;
1345        }
1346        // Browsers treat any ratio with a zero numerator or
1347        // denominator as +∞ for `max-*` and 0 for `min-*` (so a
1348        // `0/N` ratio always satisfies `max-aspect-ratio` and never
1349        // satisfies `min-aspect-ratio` â€” WPT device-aspect-ratio-002
1350        // walks the `0/0` shape explicitly).
1351        if ($den === 0.0) {
1352            return str_starts_with($name, 'max-');
1353        }
1354        if ($num <= 0.0) {
1355            return str_starts_with($name, 'max-');
1356        }
1357        $queried = $num / $den;
1358        $viewport = $this->viewportWidth / $this->viewportHeight;
1359        if (str_starts_with($name, 'min-')) {
1360            return $viewport >= $queried;
1361        }
1362        if (str_starts_with($name, 'max-')) {
1363            return $viewport <= $queried;
1364        }
1365        return abs($viewport - $queried) < 1e-6;
1366    }
1367
1368    /**
1369     * Match an integer-valued media feature â€” `color`, `color-index`,
1370     * `monochrome`. Same shape as {@see matchDimensionFeature} but
1371     * parses bare integers instead of `<length>` values.
1372     *
1373     * Compares against the literal queried value (no clamping). The
1374     * device values we model are all non-negative; the natural
1375     * numeric comparison gives the same answer browsers do for
1376     * negative thresholds (WPT mq-negative-range-001/002): `min-color:
1377     * -10` always satisfies (8 â‰¥ -10) and `max-color-index: -10` never
1378     * does (0 â‰¤ -10 is false).
1379     */
1380    private function matchIntegerFeature(string $name, string $valueRaw, int $deviceValue): bool
1381    {
1382        $valueRaw = trim($valueRaw);
1383        if (!is_numeric($valueRaw)) {
1384            return false;
1385        }
1386        $queried = (int) $valueRaw;
1387        return match (true) {
1388            str_starts_with($name, 'min-') => $deviceValue >= $queried,
1389            str_starts_with($name, 'max-') => $deviceValue <= $queried,
1390            default => $deviceValue === $queried,
1391        };
1392    }
1393
1394    /**
1395     * Evaluate a CSS Conditional Rules 3 `@supports` prelude. Returns
1396     * true when the cascade can honour the queried feature. Supported:
1397     *  - `(property: value)` â€” true when the property is registered
1398     *    AND the value parses without errors
1399     *  - `(property)` boolean form â€” true when the property is
1400     *    registered
1401     *  - `not <cond>` â€” invert
1402     *  - `<a> and <b>` / `<a> or <b>` â€” combined conditions
1403     *  - parentheses for grouping
1404     *
1405     * `selector()`, `font-tech()`, `font-format()` and other extended
1406     * predicates evaluate to false (we don't model selector support
1407     * granularity).
1408     */
1409    public function supportsPreludeMatches(string $prelude): bool
1410    {
1411        $prelude = trim($prelude);
1412        if ($prelude === '') {
1413            return true;
1414        }
1415        $pos = 0;
1416        $result = $this->parseSupportsOr($prelude, $pos);
1417        // CSS Conditional Rules 3 Â§3.1 â€” the entire prelude must be a
1418        // single <supports-condition>. Leftover non-whitespace input
1419        // means the prelude is a syntax error â†’ drop the rule. WPT
1420        // css-supports-039 exercises this with `(color: green)
1421        // or(color: blue)` where `or(` is a function token; the OR
1422        // parser finishes after `(color: green)` and the trailing
1423        // function call shouldn't silently win.
1424        $this->skipSupportsWs($prelude, $pos);
1425        if ($pos < strlen($prelude)) {
1426            return false;
1427        }
1428        return $result;
1429    }
1430
1431    /**
1432     * Parse a top-level CSS Conditional Rules 3 Â§3.1
1433     * `<supports-condition>`:
1434     *
1435     *   not <supports-in-parens>
1436     *   <supports-in-parens> [ and <supports-in-parens> ]+
1437     *   <supports-in-parens> [ or  <supports-in-parens> ]+
1438     *   <supports-in-parens>
1439     *
1440     * The three forms are mutually exclusive: a `not` cannot be mixed
1441     * with `and` / `or` at the same level (`not X and Y` is invalid;
1442     * use `(not X) and Y` instead), and `and` cannot be mixed with
1443     * `or` at the same level (`X and Y or Z` is invalid; use
1444     * `(X and Y) or Z`). Each violation is a syntax error per Â§3.1,
1445     * which makes the at-rule drop entirely. `not`/`and`/`or` nested
1446     * INSIDE a `(...)` group are parsed via parseSupportsPrimary â†’
1447     * recursive parseSupportsOr, so grouping parens still combine
1448     * arbitrarily.
1449     */
1450    private function parseSupportsOr(string $s, int &$pos): bool
1451    {
1452        $this->skipSupportsWs($s, $pos);
1453        if ($this->consumeSupportsKeyword($s, $pos, 'not')) {
1454            $inner = $this->parseSupportsPrimary($s, $pos);
1455            // After `not <supports-in-parens>`, no further `and`/`or`
1456            // is allowed at this level. Anything trailing is a syntax
1457            // error; the supportsPreludeMatches caller already verifies
1458            // EOF, but we leave $pos at the first non-ws character so
1459            // it sees the leftover.
1460            return !$inner;
1461        }
1462        $left = $this->parseSupportsPrimary($s, $pos);
1463        $this->skipSupportsWs($s, $pos);
1464        // Look ahead for the FIRST operator after the initial primary
1465        // â€” that pins the operator type for the entire chain.
1466        $firstOp = null;
1467        if ($this->peekSupportsKeyword($s, $pos, 'and')) {
1468            $firstOp = 'and';
1469        } elseif ($this->peekSupportsKeyword($s, $pos, 'or')) {
1470            $firstOp = 'or';
1471        } else {
1472            return $left;
1473        }
1474        // Consume the chosen operator chain. The other operator can
1475        // not appear at the same level; if we hit it the rule drops.
1476        while ($this->consumeSupportsKeyword($s, $pos, $firstOp)) {
1477            $right = $this->parseSupportsPrimary($s, $pos);
1478            $left = $firstOp === 'and' ? ($left && $right) : ($left || $right);
1479            $this->skipSupportsWs($s, $pos);
1480        }
1481        return $left;
1482    }
1483
1484    /**
1485     * Peek whether a keyword token starts at $pos without consuming
1486     * it. Used by parseSupportsOr to pick the operator type before
1487     * committing.
1488     */
1489    private function peekSupportsKeyword(string $s, int $pos, string $kw): bool
1490    {
1491        $len = strlen($kw);
1492        if (strtolower(substr($s, $pos, $len)) !== $kw) {
1493            return false;
1494        }
1495        $next = $s[$pos + $len] ?? '';
1496        return $next === '' || ctype_space($next);
1497    }
1498
1499    /**
1500     * Recursive-descent: `(<expr>)` OR a bare feature function call
1501     * (`selector(...)`, `font-format(...)`, `font-tech(...)`) per
1502     * CSS Conditional Rules 4 Â§3 â€” these appear bare in the
1503     * prelude, NOT wrapped in another `(...)`.
1504     */
1505    private function parseSupportsPrimary(string $s, int &$pos): bool
1506    {
1507        $this->skipSupportsWs($s, $pos);
1508        if ($pos >= strlen($s)) {
1509            return false;
1510        }
1511        // Bare feature function form: `selector(...)`, `font-format(...)`,
1512        // `font-tech(...)`, plus the CSS Conditional Rules 3 Â§3.1
1513        // `<general-enclosed>` forwards-compat shape â€” any other
1514        // `<ident>(<any-value>)` consumes its tokens and evaluates
1515        // to false. This lets `unknown(...) or (color: green)` keep
1516        // parsing the `or` branch instead of stopping dead at the
1517        // unknown function (WPT css-supports-036).
1518        if (preg_match('/\G([A-Za-z][\w-]*)\(/A', $s, $m, 0, $pos) === 1) {
1519            $name = strtolower($m[1]);
1520            $pos += strlen($m[0]);
1521            $start = $pos;
1522            $depth = 1;
1523            while ($pos < strlen($s)) {
1524                $ch = $s[$pos];
1525                if ($ch === '(') {
1526                    $depth++;
1527                } elseif ($ch === ')') {
1528                    $depth--;
1529                    if ($depth === 0) {
1530                        break;
1531                    }
1532                }
1533                $pos++;
1534            }
1535            $arg = substr($s, $start, $pos - $start);
1536            if ($pos < strlen($s)) {
1537                $pos++; // skip closing ')'
1538            }
1539            if (in_array($name, ['selector', 'font-format', 'font-tech'], true)) {
1540                return $this->evaluateSupportsFeature($m[1] . '(' . $arg . ')');
1541            }
1542            // <general-enclosed> â€” unknown function notation. Consume
1543            // and evaluate to false per Â§3.1.
1544            return false;
1545        }
1546        if ($s[$pos] !== '(') {
1547            return false;
1548        }
1549        $pos++; // skip '('
1550        // Find the matching close paren, respecting nesting.
1551        $start = $pos;
1552        $depth = 1;
1553        while ($pos < strlen($s)) {
1554            $ch = $s[$pos];
1555            if ($ch === '(') {
1556                $depth++;
1557            } elseif ($ch === ')') {
1558                $depth--;
1559                if ($depth === 0) {
1560                    break;
1561                }
1562            }
1563            $pos++;
1564        }
1565        $body = trim(substr($s, $start, $pos - $start));
1566        if ($pos < strlen($s)) {
1567            $pos++; // skip ')'
1568        }
1569        // If the body itself contains `and`/`or`/`not` at the top
1570        // level, recurse â€” it's a logical group like `(A and B)`. The
1571        // recursive call must consume the WHOLE body; any trailing
1572        // tokens (e.g. `not X or Y` where `not` and `or` are mixed)
1573        // make the whole group invalid â†’ false per Â§3.1.
1574        if (preg_match('/^(not\s|.*\s(and|or)\s)/i', $body) === 1) {
1575            $sub = 0;
1576            $result = $this->parseSupportsOr($body, $sub);
1577            $this->skipSupportsWs($body, $sub);
1578            if ($sub < strlen($body)) {
1579                return false;
1580            }
1581            return $result;
1582        }
1583        // CSS Conditional Rules 3 Â§3 â€” extra parens around a single
1584        // sub-expression are allowed. `((color: green))` strips to
1585        // `(color: green)` as the body, which must recurse as a
1586        // primary itself rather than being misread as a malformed
1587        // `(property: value)` declaration. The recursive call must
1588        // consume the WHOLE body; trailing tokens like ` or(...)` in
1589        // WPT at-supports-043 make the whole prelude invalid.
1590        if ($body !== '' && $body[0] === '(') {
1591            $sub = 0;
1592            $result = $this->parseSupportsPrimary($body, $sub);
1593            $this->skipSupportsWs($body, $sub);
1594            if ($sub < strlen($body)) {
1595                return false;
1596            }
1597            return $result;
1598        }
1599        return $this->evaluateSupportsFeature($body);
1600    }
1601
1602    private function skipSupportsWs(string $s, int &$pos): void
1603    {
1604        while ($pos < strlen($s) && ctype_space($s[$pos])) {
1605            $pos++;
1606        }
1607    }
1608
1609    private function consumeSupportsKeyword(string $s, int &$pos, string $kw): bool
1610    {
1611        $this->skipSupportsWs($s, $pos);
1612        $len = strlen($kw);
1613        if (strtolower(substr($s, $pos, $len)) !== $kw) {
1614            return false;
1615        }
1616        // Must be followed by whitespace or end. Per CSS Conditional
1617        // Rules 3 Â§3.1 and CSS Syntax 3, `not(`, `and(`, `or(` are
1618        // FUNCTION tokens â€” they bind into a function call, NOT the
1619        // boolean operator keyword. WPT css-supports-038 / -039 fail
1620        // when we accept `not(unknown)` as the `not` operator instead
1621        // of as an unknown function. So whitespace (or EOF) is the
1622        // ONLY valid boundary after the keyword.
1623        $next = $s[$pos + $len] ?? '';
1624        if ($next !== '' && !ctype_space($next)) {
1625            return false;
1626        }
1627        $pos += $len;
1628        return true;
1629    }
1630
1631    private function evaluateSupportsFeature(string $body): bool
1632    {
1633        if ($body === '') {
1634            return false;
1635        }
1636        // CSS Conditional Rules 4 Â§3 â€” `selector(<sel>)`,
1637        // `font-format(<f>)`, `font-tech(<t>)`. Detect by leading
1638        // function name.
1639        if (preg_match('/^([A-Za-z][\w-]*)\s*\((.*)\)\s*$/s', $body, $m) === 1) {
1640            $name = strtolower($m[1]);
1641            $arg = trim($m[2]);
1642            return match ($name) {
1643                'selector' => $this->evaluateSupportsSelector($arg),
1644                'font-format' => self::evaluateSupportsFontFormat($arg),
1645                'font-tech' => self::evaluateSupportsFontTech($arg),
1646                default => false,
1647            };
1648        }
1649        if (str_contains($body, ':')) {
1650            // `(property: value)` form â€” property must be in the
1651            // registry AND the value must parse for the property's
1652            // known type. Full type validation is a larger lift, but
1653            // catching unambiguously-invalid values (e.g. `color:
1654            // rainbow` â€” `rainbow` is not a CSS color) fixes the
1655            // common @supports-condition-failure pattern used by
1656            // every browser-feature-detection stylesheet in the wild.
1657            $colonPos = strpos($body, ':');
1658            if ($colonPos === false) {
1659                return false;
1660            }
1661            $prop = strtolower(trim(substr($body, 0, $colonPos)));
1662            $value = trim(substr($body, $colonPos + 1));
1663            // CSS Conditional Rules 3 Â§3.1 â€” the body is exactly one
1664            // declaration. A top-level `;` separates declarations
1665            // (WPT at-supports-039); bare `]`, `}`, `[`, `{` are
1666            // token delimiters that never appear at the top level
1667            // of a single declaration's value (WPT at-supports-026).
1668            // Also balance must be preserved â€” a left bracket without
1669            // its right mate (or vice versa) is a parse error too.
1670            $parenDepth = 0;
1671            $bracketDepth = 0;
1672            $braceDepth = 0;
1673            $vlen = strlen($value);
1674            for ($i = 0; $i < $vlen; $i++) {
1675                $ch = $value[$i];
1676                if ($ch === '(') {
1677                    $parenDepth++;
1678                } elseif ($ch === ')') {
1679                    $parenDepth--;
1680                } elseif ($ch === '[') {
1681                    $bracketDepth++;
1682                } elseif ($ch === ']') {
1683                    $bracketDepth--;
1684                } elseif ($ch === '{') {
1685                    $braceDepth++;
1686                } elseif ($ch === '}') {
1687                    $braceDepth--;
1688                } elseif ($parenDepth + $bracketDepth + $braceDepth === 0 && $ch === ';') {
1689                    return false;
1690                }
1691                if ($parenDepth < 0 || $bracketDepth < 0 || $braceDepth < 0) {
1692                    return false;
1693                }
1694            }
1695            if ($parenDepth !== 0 || $bracketDepth !== 0 || $braceDepth !== 0) {
1696                return false;
1697            }
1698            // The declaration grammar permits a trailing `!important`;
1699            // the flag changes specificity, not parse validity, so
1700            // strip it before per-type acceptance.
1701            $value = preg_replace('/\s*!\s*important\s*$/i', '', $value) ?? $value;
1702            $value = trim($value);
1703            // Property is "supported" if it's in the registry (a
1704            // longhand we know about) OR a known shorthand we expand.
1705            // Shorthands aren't registered with initial values but
1706            // the @supports prelude still names them.
1707            if (!$this->registry->has($prop) && !$this->shorthands->isShorthand($prop)) {
1708                return false;
1709            }
1710            return $this->supportsValueIsAcceptable($prop, $value);
1711        }
1712        // Boolean form `(property)`.
1713        return $this->registry->has(strtolower($body));
1714    }
1715
1716    /**
1717     * Validate a `(property: value)` body's value against the property's
1718     * known type system, narrow enough to catch the common @supports
1719     * feature-detection patterns. Full type validation is a larger lift;
1720     * this helper catches:
1721     *
1722     *   - bareword identifiers that aren't named colours, for colour-typed
1723     *     properties (`color: rainbow` â†’ false)
1724     *   - empty / whitespace-only values
1725     *
1726     * Other property types currently fall through to "accept", matching
1727     * the previous behaviour. Tightening per-type acceptance is additive
1728     * and can land per-property.
1729     */
1730    private function supportsValueIsAcceptable(string $property, string $value): bool
1731    {
1732        $value = trim($value);
1733        if ($value === '') {
1734            return false;
1735        }
1736        // Colour-typed properties: `color`, `background-color`,
1737        // `border-*-color`, `outline-color`, `text-decoration-color`, etc.
1738        // A bareword that isn't a CSS named colour (or `currentcolor` /
1739        // `transparent`) fails the value-validity check.
1740        if ($this->isColorTypedProperty($property)) {
1741            return $this->isAcceptableColorValue($value);
1742        }
1743        // CSS Conditional Rules 3 Â§3.1 â€” at minimum, the value must
1744        // either be a bare keyword/length/percentage or use one of
1745        // the standard CSS function notations. An unknown `<ident>(…)`
1746        // shape (e.g. `compute(…)` in WPT at-supports-018) is not a
1747        // recognised value type and the gated rule must drop.
1748        if (!$this->valueOnlyUsesKnownFunctions($value)) {
1749            return false;
1750        }
1751        return true;
1752    }
1753
1754    /**
1755     * Scan a value string for any `<ident>(…)` callsites; allow only
1756     * the CSS Values 4 + Color 5 + Images 4 standard names. Anything
1757     * else (`compute(…)`, `xyz(…)`) is an unknown notation per
1758     * Â§3.1's `<general-enclosed>` definition and the surrounding
1759     * `(property: value)` shape evaluates as false.
1760     */
1761    private function valueOnlyUsesKnownFunctions(string $value): bool
1762    {
1763        if (!str_contains($value, '(')) {
1764            return true;
1765        }
1766        // CSS Values 4 Â§10 math functions + CSS Functions registry.
1767        $known = [
1768            'calc', 'min', 'max', 'clamp', 'round', 'mod', 'rem',
1769            'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2',
1770            'pow', 'sqrt', 'hypot', 'log', 'exp', 'abs', 'sign',
1771            // Color functions (Color 4/5).
1772            'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch',
1773            'oklab', 'oklch', 'color', 'color-mix', 'light-dark',
1774            'device-cmyk', 'contrast-color',
1775            // Value references.
1776            'var', 'attr', 'env', 'url',
1777            // Images / gradients.
1778            'linear-gradient', 'radial-gradient', 'conic-gradient',
1779            'repeating-linear-gradient', 'repeating-radial-gradient',
1780            'repeating-conic-gradient',
1781            'image', 'image-set', 'cross-fade', 'paint', 'element',
1782            // Counters / content / generated.
1783            'counter', 'counters', 'string', 'target-counter',
1784            'target-counters', 'target-text', 'leader',
1785            // Shapes / transforms / filters.
1786            'rect', 'inset', 'circle', 'ellipse', 'polygon', 'path',
1787            'shape', 'ray', 'xywh',
1788            'translate', 'translatex', 'translatey', 'translatez',
1789            'translate3d', 'scale', 'scalex', 'scaley', 'scalez',
1790            'scale3d', 'rotate', 'rotatex', 'rotatey', 'rotatez',
1791            'rotate3d', 'skew', 'skewx', 'skewy', 'matrix', 'matrix3d',
1792            'perspective',
1793            'blur', 'brightness', 'contrast', 'drop-shadow',
1794            'grayscale', 'hue-rotate', 'invert', 'opacity',
1795            'saturate', 'sepia',
1796            // Anchor positioning.
1797            'anchor', 'anchor-size',
1798            // Animation timing.
1799            'cubic-bezier', 'steps', 'linear',
1800            // Math / numeric.
1801            'progress',
1802            // Custom selectors / nesting.
1803            'fit-content', 'minmax', 'repeat', 'subgrid-line',
1804        ];
1805        // Find each `ident(` call. The ident must be one of the known
1806        // names; otherwise the value is invalid for @supports.
1807        if (preg_match_all('/(?<![A-Za-z0-9_-])([A-Za-z][\w-]*)\s*\(/', $value, $m) === false) {
1808            return true;
1809        }
1810        foreach ($m[1] as $name) {
1811            if (!in_array(strtolower($name), $known, true)) {
1812                return false;
1813            }
1814        }
1815        return true;
1816    }
1817
1818    private function isColorTypedProperty(string $property): bool
1819    {
1820        return match ($property) {
1821            'color',
1822            'background-color',
1823            'border-top-color',
1824            'border-right-color',
1825            'border-bottom-color',
1826            'border-left-color',
1827            'border-block-start-color',
1828            'border-block-end-color',
1829            'border-inline-start-color',
1830            'border-inline-end-color',
1831            'outline-color',
1832            'text-decoration-color',
1833            'text-emphasis-color',
1834            'caret-color',
1835            'column-rule-color',
1836            'fill',
1837            'stroke',
1838            'flood-color',
1839            'lighting-color',
1840            'stop-color' => true,
1841            default => false,
1842        };
1843    }
1844
1845    private function isAcceptableColorValue(string $value): bool
1846    {
1847        $lower = strtolower($value);
1848        // CSS-wide keywords (always acceptable per the cascade).
1849        if (in_array($lower, ['inherit', 'initial', 'unset', 'revert', 'revert-layer'], true)) {
1850            return true;
1851        }
1852        // Bare keyword forms: named colour, currentcolor, transparent.
1853        if (preg_match('/^[A-Za-z][A-Za-z0-9_-]*$/', $value) === 1) {
1854            if ($lower === 'currentcolor' || $lower === 'transparent') {
1855                return true;
1856            }
1857            return \Phpdftk\Css\Value\NamedColors::knows($lower);
1858        }
1859        // Hex notation.
1860        if (preg_match('/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $value) === 1) {
1861            return true;
1862        }
1863        // Functional notation (rgb / rgba / hsl / hwb / lab / lch / oklab /
1864        // oklch / color / color-mix) â€” accept on shape; the cascade's
1865        // ValueParser handles the argument validation when the rule actually
1866        // applies.
1867        if (preg_match('/^(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\s*\(/i', $value) === 1) {
1868            return true;
1869        }
1870        return false;
1871    }
1872
1873    /**
1874     * CSS Conditional Rules 4 â€” `selector(<sel>)` is true when the
1875     * inner selector parses cleanly. Selector support granularity
1876     * isn't modelled (we don't differentiate "supported but not
1877     * yet implemented" from "matches nothing"), so any well-formed
1878     * selector returns true.
1879     */
1880    private function evaluateSupportsSelector(string $selector): bool
1881    {
1882        try {
1883            $parsed = \Phpdftk\Css\Selector\SelectorParser::parse($selector);
1884        } catch (\Throwable) {
1885            return false;
1886        }
1887        // CSS Conditional Rules 4 Â§3 â€” `selector()` takes a SINGLE
1888        // `<complex-selector>`, NOT a `<selector-list>`. `selector(div,
1889        // div)` is invalid (WPT at-supports-selector-004) and must
1890        // evaluate false so the gated rule drops.
1891        if (count($parsed->selectors) !== 1) {
1892            return false;
1893        }
1894        // `selector()` reports whether the queried selector is actually
1895        // SUPPORTED â€” not merely parseable. A bare `<ident>(…)`
1896        // extension shape, an unknown pseudo-class or pseudo-element,
1897        // or any vendor-prefixed (`-webkit-`, `-moz-`) name we don't
1898        // implement all evaluate to false (WPT at-supports-selector-003).
1899        return $this->selectorIsFullySupported($parsed->selectors[0]);
1900    }
1901
1902    private function selectorIsFullySupported(\Phpdftk\Css\Selector\ComplexSelector $selector): bool
1903    {
1904        foreach ($selector->compounds as $compound) {
1905            // ComplexSelector's compounds carry a CompoundSelector
1906            // plus the combinator to the next compound; we only need
1907            // the inner one's simple-selector components.
1908            $inner = $compound instanceof \Phpdftk\Css\Selector\CompoundSelectorWithCombinator
1909                ? $compound->compound
1910                : $compound;
1911            foreach ($inner->components as $simple) {
1912                if (!$this->simpleSelectorIsSupported($simple)) {
1913                    return false;
1914                }
1915            }
1916        }
1917        return true;
1918    }
1919
1920    private function simpleSelectorIsSupported(\Phpdftk\Css\Selector\SimpleSelector $simple): bool
1921    {
1922        if ($simple instanceof \Phpdftk\Css\Selector\PseudoElementSelector) {
1923            return $this->isKnownPseudoElement($simple->name)
1924                && ($simple->arguments === null
1925                    || $this->allSelectorsSupported($simple->arguments));
1926        }
1927        if ($simple instanceof \Phpdftk\Css\Selector\PseudoClassSelector) {
1928            if (!$this->isKnownPseudoClass($simple->name)) {
1929                return false;
1930            }
1931            if ($simple->arguments !== null) {
1932                return $this->allSelectorsSupported($simple->arguments);
1933            }
1934            return true;
1935        }
1936        return true;
1937    }
1938
1939    private function allSelectorsSupported(\Phpdftk\Css\Selector\SelectorList $list): bool
1940    {
1941        foreach ($list->selectors as $sel) {
1942            if (!$this->selectorIsFullySupported($sel)) {
1943                return false;
1944            }
1945        }
1946        return true;
1947    }
1948
1949    /**
1950     * Whitelist of pseudo-elements we recognise â€” CSS Pseudo 4 Â§3 plus
1951     * a handful of widely-implemented extensions. Anything not on the
1952     * list (notably every vendor-prefixed name) reports unsupported.
1953     */
1954    private function isKnownPseudoElement(string $name): bool
1955    {
1956        return in_array(strtolower($name), [
1957            'after', 'before',
1958            'first-letter', 'first-line',
1959            'backdrop', 'marker', 'placeholder', 'file-selector-button',
1960            'selection', 'target-text', 'highlight',
1961            'spelling-error', 'grammar-error',
1962            'cue', 'cue-region',
1963            'slotted', 'part', 'theme',
1964            'view-transition', 'view-transition-group',
1965            'view-transition-image-pair', 'view-transition-old',
1966            'view-transition-new',
1967            'placeholder-shown', 'details-content',
1968            // HTML interactive controls.
1969            'picker', 'picker-icon',
1970            // Scroll markers.
1971            'scroll-marker', 'scroll-marker-group',
1972            'scroll-button',
1973            // Column boxes.
1974            'column',
1975            // Search.
1976            'search-text',
1977            // Form inputs.
1978            'first-letter', 'first-line',
1979            // Well-known WebKit / Mozilla vendor pseudo-elements
1980            // browsers expose as form-control internals. WPT
1981            // `selector(input::-webkit-slider-thumb)` gates rules on
1982            // the EXISTENCE of these names; we don't render their
1983            // shadow content but we report them as recognised so
1984            // author CSS that probes for them doesn't drop.
1985            '-webkit-slider-thumb',
1986            '-webkit-slider-runnable-track',
1987            '-webkit-progress-bar',
1988            '-webkit-progress-value',
1989            '-webkit-meter-bar',
1990            '-webkit-meter-optimum-value',
1991            '-webkit-meter-suboptimum-value',
1992            '-webkit-meter-even-less-good-value',
1993            '-webkit-scrollbar',
1994            '-webkit-scrollbar-thumb',
1995            '-webkit-scrollbar-track',
1996            '-webkit-scrollbar-corner',
1997            '-webkit-scrollbar-button',
1998            '-webkit-resizer',
1999            '-webkit-search-cancel-button',
2000            '-webkit-search-decoration',
2001            '-webkit-search-results-button',
2002            '-webkit-search-results-decoration',
2003            '-webkit-file-upload-button',
2004            '-webkit-inner-spin-button',
2005            '-webkit-outer-spin-button',
2006            '-webkit-calendar-picker-indicator',
2007            '-webkit-color-swatch-wrapper',
2008            '-webkit-color-swatch',
2009            '-webkit-details-marker',
2010            '-webkit-input-placeholder',
2011            '-webkit-textfield-decoration-container',
2012            '-moz-color-swatch',
2013            '-moz-focus-inner',
2014            '-moz-list-bullet',
2015            '-moz-list-number',
2016            '-moz-meter-bar',
2017            '-moz-progress-bar',
2018            '-moz-range-progress',
2019            '-moz-range-thumb',
2020            '-moz-range-track',
2021            '-moz-placeholder',
2022        ], true);
2023    }
2024
2025    /**
2026     * Whitelist of pseudo-classes we recognise. Same posture as pseudo-
2027     * elements â€” anything not on the list (notably vendor-prefixed
2028     * names like `-webkit-*`) reports unsupported.
2029     */
2030    private function isKnownPseudoClass(string $name): bool
2031    {
2032        return in_array(strtolower($name), [
2033            'hover', 'active', 'focus', 'focus-visible', 'focus-within',
2034            'link', 'visited', 'any-link', 'target', 'target-within',
2035            'scope', 'host', 'host-context',
2036            'root', 'empty', 'blank',
2037            'first-child', 'last-child', 'only-child',
2038            'first-of-type', 'last-of-type', 'only-of-type',
2039            'nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type',
2040            'nth-col', 'nth-last-col',
2041            'not', 'is', 'where', 'has',
2042            'lang', 'dir',
2043            'enabled', 'disabled', 'read-only', 'read-write',
2044            'required', 'optional', 'placeholder-shown',
2045            'checked', 'indeterminate', 'default',
2046            'valid', 'invalid', 'in-range', 'out-of-range',
2047            'user-invalid', 'user-valid',
2048            'autofill',
2049            'fullscreen', 'modal', 'picture-in-picture',
2050            'popover-open',
2051            'past', 'current', 'future',
2052            'state',
2053            'open', 'closed',
2054            'defined',
2055        ], true);
2056    }
2057
2058    /**
2059     * CSS Conditional Rules 4 / Fonts 4 Â§4.3 â€” `font-format()`.
2060     * True for the well-known font format keywords browsers
2061     * recognise. Without an actual font subsystem we accept the
2062     * standard formats and reject the rest.
2063     */
2064    private static function evaluateSupportsFontFormat(string $format): bool
2065    {
2066        $format = strtolower(trim($format, " \t\n\r\0\x0B\"'"));
2067        return in_array($format, [
2068            'collection',
2069            'embedded-opentype',
2070            'opentype',
2071            'svg',
2072            'truetype',
2073            'woff',
2074            'woff2',
2075        ], true);
2076    }
2077
2078    /**
2079     * CSS Conditional Rules 4 / Fonts 4 Â§4.4 â€” `font-tech()`. We
2080     * don't model OpenType variations / palettes / colorv0 / etc
2081     * granularity (rendering paths for those land with `phpdftk/text`
2082     * shaping), so this returns false for unknown techs. Accept the
2083     * baseline ones the renderer's font loader actually supports.
2084     */
2085    private static function evaluateSupportsFontTech(string $tech): bool
2086    {
2087        $tech = strtolower(trim($tech, " \t\n\r\0\x0B\"'"));
2088        return in_array($tech, [
2089            'variations',
2090            'palettes',
2091        ], true);
2092    }
2093
2094    /**
2095     * Compare a `<length>` value against the viewport extent.
2096     * Supports px (default), pt, in, cm, mm. Unknown units evaluate
2097     * to false. When the viewport extent is unknown (null), the
2098     * query is treated as matching so print stylesheets never
2099     * silently drop.
2100     */
2101    private function matchDimensionFeature(string $name, string $valueRaw, ?float $viewportExtent): bool
2102    {
2103        if ($viewportExtent === null) {
2104            return true;
2105        }
2106        $px = $this->resolveMediaDimensionValue($valueRaw);
2107        if ($px === null) {
2108            return false;
2109        }
2110        // CSS Values 4 Â§10 â€” `<length>` values used in `@media`
2111        // feature queries are clamped to their valid range. For
2112        // width / height that means `[0, âˆž)` â€” a negative `calc()`
2113        // result like `calc(-100px)` clamps to `0px`, so
2114        // `(min-width: calc(-100px))` matches any non-negative
2115        // viewport width.
2116        $px = max(0.0, $px);
2117        return match ($name) {
2118            'min-width', 'min-height' => $viewportExtent >= $px,
2119            'max-width', 'max-height' => $viewportExtent <= $px,
2120            'width', 'height' => abs($viewportExtent - $px) < 0.001,
2121            default => false,
2122        };
2123    }
2124
2125    /**
2126     * Resolve a `<length>` value used in an `@media` feature query
2127     * down to absolute pixels. Supports bare unit-suffixed lengths
2128     * (`100px`, `5cm`, `72pt`, `0`) plus simple `calc(<sum>)`
2129     * arithmetic over those â€” addition and subtraction at the top
2130     * level, sufficient for the canonical `calc(-100px)` clamping
2131     * case from CSS Values 4 Â§10. Returns null when the value
2132     * doesn't parse cleanly.
2133     */
2134    private function resolveMediaDimensionValue(string $raw): ?float
2135    {
2136        $raw = trim($raw);
2137        if (preg_match('/^calc\s*\((.*)\)\s*$/i', $raw, $m) === 1) {
2138            return $this->evaluateMediaCalcSum(trim($m[1]));
2139        }
2140        return $this->parseMediaLength($raw);
2141    }
2142
2143    private function parseMediaLength(string $value): ?float
2144    {
2145        $value = trim($value);
2146        if (preg_match('/^([\-+]?[0-9]*\.?[0-9]+)\s*([a-z]*)$/i', $value, $m) !== 1) {
2147            return null;
2148        }
2149        $n = (float) $m[1];
2150        // The `([a-z]*)` group always captures (empty when no unit),
2151        // and the `match` below folds '' into the `px` case.
2152        $unit = strtolower($m[2]);
2153        // CSS Media Queries 4 Â§1.3 â€” `rem` / `em` / `ex` / `ch` inside
2154        // an `@media` query use the INITIAL value of `font-size` on
2155        // the root element (16 CSS px in the absence of a UA
2156        // stylesheet override), NOT the cascaded value. This
2157        // intentionally diverges from property-context resolution so
2158        // authors can build feature-detection breakpoints that don't
2159        // shift when they bump root font-size on `:root`.
2160        $rootFontPx = 16.0;
2161        return match ($unit) {
2162            '', 'px' => $n,
2163            'pt' => $n * 96.0 / 72.0,
2164            'in' => $n * 96.0,
2165            'cm' => $n * 96.0 / 2.54,
2166            'mm' => $n * 96.0 / 25.4,
2167            'q'  => $n * 96.0 / 101.6,
2168            'pc' => $n * 16.0,
2169            'em', 'rem' => $n * $rootFontPx,
2170            'ex' => $n * $rootFontPx * 0.5,
2171            'ch' => $n * $rootFontPx * 0.5,
2172            default => null,
2173        };
2174    }
2175
2176    /**
2177     * Evaluate a sum of media-query lengths: each top-level `+` / `-`
2178     * separates a term, every term is a `parseMediaLength` value.
2179     * Returns null on any malformation so the caller fails the feature
2180     * query â€” partial evaluation would silently shift a real layout
2181     * decision off a spec-invalid value.
2182     */
2183    private function evaluateMediaCalcSum(string $body): ?float
2184    {
2185        $body = trim($body);
2186        if ($body === '') {
2187            return null;
2188        }
2189        // Split on `+` and `-` operators while keeping the operator
2190        // tokens. Whitespace is required around `+` / `-` (CSS Values 4
2191        // Â§10.4) to disambiguate from signed literals; we honour that.
2192        $tokens = preg_split('/\s+([+-])\s+/', $body, -1, PREG_SPLIT_DELIM_CAPTURE);
2193        if ($tokens === false || $tokens === []) {
2194            return null;
2195        }
2196        $sum = $this->parseMediaLength($tokens[0]);
2197        if ($sum === null) {
2198            return null;
2199        }
2200        $count = count($tokens);
2201        for ($i = 1; $i + 1 < $count; $i += 2) {
2202            $op = $tokens[$i];
2203            $value = $this->parseMediaLength($tokens[$i + 1]);
2204            if ($value === null) {
2205                return null;
2206            }
2207            $sum = $op === '-' ? $sum - $value : $sum + $value;
2208        }
2209        return $sum;
2210    }
2211
2212    private function selectorPseudoElementName(\Phpdftk\Css\Selector\ComplexSelector $sel): ?string
2213    {
2214        // Memoise by selector identity â€” the same ComplexSelector
2215        // is matched against every element of the cascade, but
2216        // its pseudo-element tail is a property of the selector
2217        // alone.
2218        if (isset($this->selPseudoCache[$sel])) {
2219            return $this->selPseudoCache[$sel][0];
2220        }
2221        $compounds = $sel->compounds;
2222        $result = null;
2223        if ($compounds !== []) {
2224            $last = $compounds[array_key_last($compounds)]->compound;
2225            foreach ($last->components as $simple) {
2226                if ($simple instanceof \Phpdftk\Css\Selector\PseudoElementSelector) {
2227                    $result = strtolower($simple->name);
2228                    break;
2229                }
2230            }
2231        }
2232        $this->selPseudoCache[$sel] = [$result];
2233        return $result;
2234    }
2235
2236    /**
2237     * Pick the cascade winner for one property, honouring layer
2238     * priority and `revert-layer` rollback per CSS Cascade 5 Â§5.3.
2239     *
2240     * Ranking, highest-priority first:
2241     *  1. Tier (origin Ã— importance) â€” already encoded by `tierFor`.
2242     *  2. Layer priority within the author tier â€” for NORMAL author
2243     *     declarations, unlayered outranks any layered candidate, and
2244     *     a later-declared layer outranks an earlier-declared one.
2245     *     For !IMPORTANT author the order reverses (any layered
2246     *     !important outranks unlayered, earlier-declared layer
2247     *     outranks later). Outside the author origin layers don't
2248     *     apply, so a single bucket suffices.
2249     *  3. Specificity (a, b, c).
2250     *  4. Source order â€” later wins.
2251     *
2252     * When the picked winner's cascaded value is the `revert-layer`
2253     * keyword, the spec says to recompute the cascade as if THIS
2254     * LAYER didn't exist (CSS Cascade 5 Â§5.4). We drop every
2255     * candidate that shares the winner's layer (including the
2256     * unlayered bucket itself when revert-layer appears unlayered)
2257     * and re-pick from the remainder, looping until we find a
2258     * non-`revert-layer` value or run out of candidates.
2259     *
2260     * @param list<int> $indices
2261     * @param list<array{declaration: Declaration, specificity: Specificity, origin: Origin, layerIndex: ?int, order: int}> $candidates
2262     * @return null|array{declaration: Declaration, tier: int, specificity: Specificity, order: int}
2263     */
2264    private function pickCascadeWinner(string $property, array $indices, array $candidates): ?array
2265    {
2266        // Two exclusion sets for the rollback loops:
2267        //  â€¢ `excludedLayers` â€” keyed on `origin:layer`; populated by
2268        //    `revert-layer` knock-outs (CSS Cascade 5 Â§5.4).
2269        //  â€¢ `excludedOrigins` â€” keyed on origin; populated by `revert`
2270        //    knock-outs (CSS Cascade 5 Â§5.3). `revert` drops the whole
2271        //    current origin's contribution, so on the next iteration
2272        //    we look at the next lower origin (Author â†’ User â†’ UA â†’
2273        //    initial fallback).
2274        $excludedLayers = [];
2275        $excludedOrigins = [];
2276        while (true) {
2277            $best = null;
2278            foreach ($indices as $idx) {
2279                $c = $candidates[$idx];
2280                $layer = $c['layerIndex'];
2281                if (isset($excludedOrigins[$c['origin']->name])) {
2282                    continue;
2283                }
2284                // Scope the exclusion key by origin so that an author
2285                // `revert-layer` only knocks out the author bucket
2286                // (not the UA stylesheet's matching candidates, which
2287                // live at origin=UserAgent with layerIndex=null too).
2288                $layerKey = $c['origin']->name . ':'
2289                    . ($layer === null ? 'unlayered' : 'L' . $layer);
2290                if (isset($excludedLayers[$layerKey])) {
2291                    continue;
2292                }
2293                $tier = self::tierFor($c['origin'], $c['declaration']->important);
2294                $layerRank = $this->layerRank($c['origin'], $c['declaration']->important, $layer);
2295                if ($best === null
2296                    || $this->beats(
2297                        $tier,
2298                        $layerRank,
2299                        $c['specificity'],
2300                        $c['order'],
2301                        $best['tier'],
2302                        $best['layerRank'],
2303                        $best['specificity'],
2304                        $best['order'],
2305                    )
2306                ) {
2307                    $best = [
2308                        'declaration' => $c['declaration'],
2309                        'tier' => $tier,
2310                        'layerRank' => $layerRank,
2311                        'layerKey' => $layerKey,
2312                        'origin' => $c['origin'],
2313                        'specificity' => $c['specificity'],
2314                        'order' => $c['order'],
2315                    ];
2316                }
2317            }
2318            if ($best === null) {
2319                return null;
2320            }
2321            $winnerValue = $best['declaration']->value;
2322            if ($winnerValue instanceof Keyword
2323                && strtolower($winnerValue->name) === 'revert-layer'
2324            ) {
2325                // Drop this layer (or the unlayered bucket) from
2326                // consideration and re-pick. Eventually we either
2327                // hit a concrete value in a lower-priority layer or
2328                // run dry, in which case the property falls through
2329                // to the registry's initial value via
2330                // `resolveSpecialKeywords` on `revert-layer`.
2331                $excludedLayers[$best['layerKey']] = true;
2332                continue;
2333            }
2334            if ($winnerValue instanceof Keyword
2335                && strtolower($winnerValue->name) === 'revert'
2336            ) {
2337                // CSS Cascade 5 Â§5.3 â€” `revert` rolls the cascade
2338                // back to the next lower origin. Drop every
2339                // candidate from the winner's origin and re-pick.
2340                // Eventually we either land on the UA stylesheet's
2341                // contribution (the next lower origin still in the
2342                // candidate list) or run dry â†’ registry initial.
2343                $excludedOrigins[$best['origin']->name] = true;
2344                continue;
2345            }
2346            return [
2347                'declaration' => $best['declaration'],
2348                'tier' => $best['tier'],
2349                'specificity' => $best['specificity'],
2350                'order' => $best['order'],
2351            ];
2352        }
2353    }
2354
2355    /**
2356     * Numeric layer priority within the author tier. Higher wins.
2357     *
2358     * Author normal:
2359     *   â€¢ unlayered â†’ PHP_INT_MAX (always beats any layered).
2360     *   â€¢ layered N â†’ N (later-declared layers got larger N at
2361     *     resolution time, so they outrank earlier-declared).
2362     *
2363     * Author !important:
2364     *   â€¢ unlayered â†’ PHP_INT_MIN (any layered !important wins).
2365     *   â€¢ layered N â†’ -N (first-declared layer's small N becomes
2366     *     large after negation, so it outranks later-declared).
2367     *
2368     * Non-author origins ignore layers â€” return 0 uniformly so the
2369     * ranking collapses back to tier + specificity + source order.
2370     */
2371    private function layerRank(Origin $origin, bool $important, ?int $layerIndex): int
2372    {
2373        if ($origin !== Origin::Author) {
2374            return 0;
2375        }
2376        if ($important) {
2377            return $layerIndex === null ? PHP_INT_MIN : -$layerIndex;
2378        }
2379        return $layerIndex === null ? PHP_INT_MAX : $layerIndex;
2380    }
2381
2382    /**
2383     * Strict "does A beat B" cascade comparison. Tier first, then
2384     * layer priority within the tier, then specificity, then source
2385     * order â€” matches CSS Cascade 5 Â§6.
2386     */
2387    private function beats(
2388        int $aTier,
2389        int $aLayer,
2390        Specificity $aSpec,
2391        int $aOrder,
2392        int $bTier,
2393        int $bLayer,
2394        Specificity $bSpec,
2395        int $bOrder,
2396    ): bool {
2397        if ($aTier !== $bTier) {
2398            return $aTier > $bTier;
2399        }
2400        if ($aLayer !== $bLayer) {
2401            return $aLayer > $bLayer;
2402        }
2403        $cmp = $aSpec->compare($bSpec);
2404        if ($cmp !== 0) {
2405            return $cmp > 0;
2406        }
2407        return $aOrder > $bOrder;
2408    }
2409
2410    /**
2411     * Handle the `inherit` / `initial` / `unset` / `revert` keywords. Returns
2412     * the resolved value (or null when the cascade should leave the property
2413     * to fall through to inheritance / initial).
2414     */
2415    private function resolveSpecialKeywords(
2416        string $name,
2417        Value $value,
2418        ?CascadedValues $parent,
2419    ): ?Value {
2420        if (!$value instanceof Keyword) {
2421            return $value;
2422        }
2423        $lower = strtolower($value->name);
2424        $def = $this->registry->get($name);
2425        return match ($lower) {
2426            'inherit' => $parent?->get($name) ?? $def?->initial,
2427            'initial' => $def?->initial,
2428            'unset' => $def !== null && $def->inherits
2429                ? ($parent?->get($name) ?? $def->initial)
2430                : $def?->initial,
2431            'revert', 'revert-layer' => $def?->initial,
2432            default => $value,
2433        };
2434    }
2435
2436    private function applyInheritance(CascadedValues $values, ?CascadedValues $parent): void
2437    {
2438        if ($parent === null) {
2439            return;
2440        }
2441        // Iterate only the inheriting subset instead of walking
2442        // every property. The registry caches the list internally
2443        // so this is constant time per cascade run.
2444        foreach ($this->registry->inheritingNames() as $name) {
2445            if ($values->has($name)) {
2446                continue;
2447            }
2448            $inheritedValue = $parent->get($name);
2449            if ($inheritedValue !== null) {
2450                $values->set($name, $inheritedValue);
2451            }
2452        }
2453    }
2454
2455    /**
2456     * CSS Custom Properties Â§3: custom properties always inherit. Copy any
2457     * not-locally-declared property down from the parent so later `var()`
2458     * substitution sees the inherited values.
2459     */
2460    private function inheritCustomProperties(CascadedValues $values, ?CascadedValues $parent): void
2461    {
2462        if ($parent === null) {
2463            return;
2464        }
2465        foreach ($parent->customProperties() as $name => $value) {
2466            if (!$values->has($name)) {
2467                $values->set($name, $value);
2468            }
2469        }
2470    }
2471
2472    /**
2473     * Walk every cascaded value and substitute `var(--name[, fallback])`
2474     * references with the resolved custom-property value. Per the spec
2475     * (CSS Variables Â§3.2), a missing variable and no fallback leaves the
2476     * property invalid at computed-value time â€” the cascade then falls back
2477     * to the property's initial value.
2478     *
2479     * Substitution depth is capped at 100 to match the project's
2480     * configurable defaults (see Security section in `html-and-svg.md`).
2481     */
2482    private function substituteCustomProperties(CascadedValues $values): void
2483    {
2484        foreach ($values->all() as $name => $value) {
2485            $resolved = $this->substituteValue($value, $values, 0);
2486            if ($resolved === null) {
2487                // Invalid at computed-value time â†’ revert to initial.
2488                $def = $this->registry->get($name);
2489                if ($def !== null) {
2490                    $values->set($name, $def->initial);
2491                }
2492                continue;
2493            }
2494            if ($resolved !== $value) {
2495                $values->set($name, $resolved);
2496            }
2497        }
2498    }
2499
2500    private function substituteValue(Value $value, CascadedValues $values, int $depth): ?Value
2501    {
2502        if ($depth > 100) {
2503            return null;
2504        }
2505        if ($value instanceof CustomProperty) {
2506            $referenced = $values->get($value->name);
2507            if ($referenced !== null) {
2508                return $this->substituteValue($referenced, $values, $depth + 1);
2509            }
2510            if ($value->fallback !== null) {
2511                return $this->substituteValue($value->fallback, $values, $depth + 1);
2512            }
2513            return null;
2514        }
2515        if ($value instanceof ValueList) {
2516            $newChildren = [];
2517            foreach ($value->values as $child) {
2518                $resolved = $this->substituteValue($child, $values, $depth + 1);
2519                if ($resolved === null) {
2520                    return null;
2521                }
2522                $newChildren[] = $resolved;
2523            }
2524            return new ValueList($newChildren, $value->separator);
2525        }
2526        return $value;
2527    }
2528
2529    /**
2530     * Resolve relative-unit lengths to absolute pixels. Two-pass: font-size
2531     * resolves first against `$context->parentFontSize`, then every other
2532     * length resolves against the resulting font-size (passed in
2533     * `currentFontSize`).
2534     *
2535     * Mutates `$values` in place and returns it for chaining. Idempotent â€”
2536     * already-px lengths pass through unchanged.
2537     */
2538    public function resolveLengths(CascadedValues $values, LengthContext $context): CascadedValues
2539    {
2540        // Resolve font-size first, using the parent's font-size as the em basis.
2541        $fontSize = $values->get('font-size');
2542        $currentFontSize = $context->parentFontSize;
2543        $emCtx = new LengthContext(
2544            parentFontSize: $context->parentFontSize,
2545            currentFontSize: $context->parentFontSize,
2546            rootFontSize: $context->rootFontSize,
2547            viewportWidth: $context->viewportWidth,
2548            viewportHeight: $context->viewportHeight,
2549        );
2550        if ($fontSize instanceof Length) {
2551            $currentFontSize = LengthResolver::toPx($fontSize, $emCtx);
2552            $values->set('font-size', new Length($currentFontSize, LengthUnit::Px));
2553        } elseif ($fontSize instanceof \Phpdftk\Css\Value\Percentage) {
2554            // CSS Fonts 3 Â§3.5 â€” `font-size: <percentage>` resolves
2555            // against the inherited (parent) font-size. Resolving here
2556            // turns the Percentage into a concrete Length so layout
2557            // doesn't fall back to the parent size verbatim.
2558            $currentFontSize = $context->parentFontSize * ($fontSize->value / 100.0);
2559            $values->set('font-size', new Length($currentFontSize, LengthUnit::Px));
2560        } elseif ($fontSize instanceof \Phpdftk\Css\Value\Calc) {
2561            $resolved = CalcEvaluator::resolveValue($fontSize, $emCtx);
2562            if ($resolved instanceof Length) {
2563                $currentFontSize = $resolved->value;
2564                $values->set('font-size', $resolved);
2565            }
2566        }
2567        $bodyCtx = $context->withCurrentFontSize($currentFontSize);
2568
2569        foreach ($values->all() as $name => $value) {
2570            if ($name === 'font-size') {
2571                continue;
2572            }
2573            $resolved = $this->resolveValueLengths($value, $bodyCtx);
2574            if ($resolved !== $value) {
2575                $values->set($name, $resolved);
2576            }
2577        }
2578        return $values;
2579    }
2580
2581    /**
2582     * Walk a property value tree and replace Length / Calc nodes with
2583     * absolute-pixel Lengths. ValueLists are rebuilt with their elements
2584     * resolved recursively (so e.g. `box-shadow: calc(1em + 10px)
2585     * calc(2em + 11px) 4px black` lands at the painter as a list of
2586     * pixel Lengths plus the colour).
2587     *
2588     * Leaves the value untouched when it isn't a Length / Calc / ValueList
2589     * â€” Keywords, Colors, Urls, etc. don't need length resolution.
2590     */
2591    private function resolveValueLengths(
2592        \Phpdftk\Css\Value\Value $value,
2593        LengthContext $ctx,
2594    ): \Phpdftk\Css\Value\Value {
2595        if ($value instanceof Length) {
2596            return new Length(LengthResolver::toPx($value, $ctx), LengthUnit::Px);
2597        }
2598        if ($value instanceof \Phpdftk\Css\Value\Calc) {
2599            return CalcEvaluator::resolveValue($value, $ctx);
2600        }
2601        if ($value instanceof \Phpdftk\Css\Value\ValueList) {
2602            $children = [];
2603            $changed = false;
2604            foreach ($value->values as $v) {
2605                $rv = $this->resolveValueLengths($v, $ctx);
2606                if ($rv !== $v) {
2607                    $changed = true;
2608                }
2609                $children[] = $rv;
2610            }
2611            return $changed ? new \Phpdftk\Css\Value\ValueList($children, $value->separator) : $value;
2612        }
2613        return $value;
2614    }
2615}