Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.83% covered (success)
96.83%
183 / 189
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cascade
96.83% covered (success)
96.83%
183 / 189
76.92% covered (warning)
76.92%
10 / 13
94
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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 computeFor
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
1 / 1
23
 activeStyleRules
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 mediaPreludeMatches
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 selectorPseudoElementName
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 shouldReplace
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 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
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
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
77    /**
78     * Run the cascade for one element. `$parentValues` is the already-
79     * computed result for the element's parent — used for inheritance.
80     * Pass `null` for the root element.
81     *
82     * @param list<Stylesheet> $sheets
83     */
84    public function computeFor(
85        array $sheets,
86        MatchableElement $element,
87        ?CascadedValues $parentValues = null,
88        ?string $pseudoElement = null,
89    ): CascadedValues {
90        // 1. Collect every (declaration, specificity, origin, source-order)
91        //    tuple for declarations that match this element. When
92        //    `$pseudoElement` is set (e.g. "before" / "after"), only rules
93        //    whose selector ends in `::$pseudoElement` are included; when
94        //    null, the inverse — rules ending in any pseudo-element are
95        //    excluded so the host's cascade doesn't pick up content meant
96        //    for a generated box.
97        $candidates = [];
98        $order = 0;
99        foreach ($sheets as $sheet) {
100            foreach ($this->activeStyleRules($sheet->rules) as $rule) {
101                $matchedSpec = null;
102                foreach ($rule->selectors->selectors as $sel) {
103                    $selPseudo = $this->selectorPseudoElementName($sel);
104                    if ($pseudoElement === null) {
105                        if ($selPseudo !== null) {
106                            continue;
107                        }
108                    } else {
109                        if ($selPseudo !== $pseudoElement) {
110                            continue;
111                        }
112                    }
113                    if (!$this->matcher->complexMatches($sel, $element)) {
114                        continue;
115                    }
116                    $spec = $sel->specificity();
117                    if ($matchedSpec === null || $spec->compare($matchedSpec) > 0) {
118                        $matchedSpec = $spec;
119                    }
120                }
121                if ($matchedSpec === null) {
122                    continue;
123                }
124                foreach ($rule->declarations as $decl) {
125                    foreach ($this->shorthands->expand($decl->property, $decl->value) as $longhand => $value) {
126                        $candidates[] = [
127                            'declaration' => new Declaration($longhand, $value, $decl->important),
128                            'specificity' => $matchedSpec,
129                            'origin' => $sheet->origin,
130                            'order' => $order++,
131                        ];
132                    }
133                }
134            }
135        }
136
137        // 1b. HTML `style="…"` attribute declarations cascade as author rules
138        // with elevated specificity per CSS Cascade 5 §6.4.4 — they beat any
139        // realistic selector. Use Specificity(1024, 0, 0) so authors aren't
140        // hitting a tie against id-laden selectors in practice. Pseudo-
141        // elements never inherit inline style — `style="..."` always targets
142        // the host element.
143        $inlineCss = $pseudoElement === null
144            ? $element->getAttributeValue('style')
145            : null;
146        if ($inlineCss !== null && $inlineCss !== '') {
147            $inlineSpec = new Specificity(1024, 0, 0);
148            $inlineRule = $this->parser->parseInlineStyle($inlineCss);
149            foreach ($inlineRule->declarations as $decl) {
150                foreach ($this->shorthands->expand($decl->property, $decl->value) as $longhand => $value) {
151                    $candidates[] = [
152                        'declaration' => new Declaration($longhand, $value, $decl->important),
153                        'specificity' => $inlineSpec,
154                        'origin' => Origin::Author,
155                        'order' => $order++,
156                    ];
157                }
158            }
159        }
160
161        // 2. Pick winning declaration per property.
162        /** @var array<string, array{declaration: Declaration, tier: int, specificity: Specificity, order: int}> $byProperty */
163        $byProperty = [];
164        foreach ($candidates as $c) {
165            $name = $c['declaration']->property;
166            $tier = self::tierFor($c['origin'], $c['declaration']->important);
167            $existing = $byProperty[$name] ?? null;
168            if ($existing === null || $this->shouldReplace($existing, $tier, $c['specificity'], $c['order'])) {
169                $byProperty[$name] = [
170                    'declaration' => $c['declaration'],
171                    'tier' => $tier,
172                    'specificity' => $c['specificity'],
173                    'order' => $c['order'],
174                ];
175            }
176        }
177
178        // 3. Materialise CascadedValues, then apply inheritance.
179        $result = new CascadedValues($this->registry);
180        foreach ($byProperty as $name => $winner) {
181            $value = $this->resolveSpecialKeywords(
182                $name,
183                $winner['declaration']->value,
184                $parentValues,
185            );
186            if ($value !== null) {
187                $result->set($name, $value);
188            }
189        }
190        $this->applyInheritance($result, $parentValues);
191        $this->inheritCustomProperties($result, $parentValues);
192        $this->substituteCustomProperties($result);
193        return $result;
194    }
195
196    /**
197     * Return the name of the pseudo-element targeted by `$sel` (the last
198     * compound's terminating `::name`), or null when the selector is a
199     * regular host-element selector. Used to gate cascade matching so
200     * `p::before` rules don't pollute `<p>`'s style and vice versa.
201     */
202    /**
203     * Yield every `StyleRule` reachable from the given rule list,
204     * recursing into `@media`-style conditional at-rules whose prelude
205     * matches the current rendering context. Phase-1 simplification:
206     * matches `@media print`, `@media all`, and any `@media` list that
207     * mentions `print` or `all` (CSS Media Queries 4 §2.3 media types).
208     * `@media screen` / `@media speech` / unrecognised media features
209     * are skipped, so screen-only rules don't leak into print output.
210     *
211     * `@supports` blocks are always entered (we treat every supports()
212     * condition as matching at Phase 1; full evaluation lands later
213     * alongside `@supports` query parsing).
214     *
215     * @param list<\Phpdftk\Css\Sheet\Rule> $rules
216     * @return iterable<StyleRule>
217     */
218    private function activeStyleRules(array $rules): iterable
219    {
220        foreach ($rules as $rule) {
221            if ($rule instanceof StyleRule) {
222                yield $rule;
223                continue;
224            }
225            if ($rule instanceof \Phpdftk\Css\Sheet\AtRule && $rule->block !== null) {
226                $name = strtolower($rule->name);
227                if ($name === 'media') {
228                    if (!$this->mediaPreludeMatches($rule->prelude)) {
229                        continue;
230                    }
231                } elseif ($name !== 'supports') {
232                    continue;
233                }
234                $nested = [];
235                foreach ($rule->block->contents as $item) {
236                    if ($item instanceof \Phpdftk\Css\Sheet\Rule) {
237                        $nested[] = $item;
238                    }
239                }
240                yield from $this->activeStyleRules($nested);
241            }
242        }
243    }
244
245    /**
246     * `@media <prelude>` matches when the media list mentions `print` or
247     * `all` (the renderer's intrinsic media). Logical `not`/`only` and
248     * media features (`(min-width: ...)`) are not honoured at Phase 1 —
249     * a `not print` query incorrectly matches; this is a follow-up.
250     */
251    private function mediaPreludeMatches(string $prelude): bool
252    {
253        $lower = strtolower($prelude);
254        if ($lower === '' || $lower === 'all') {
255            return true;
256        }
257        foreach (explode(',', $lower) as $part) {
258            $tokens = preg_split('/\s+/', trim($part)) ?: [];
259            foreach ($tokens as $tok) {
260                if ($tok === 'print' || $tok === 'all') {
261                    return true;
262                }
263            }
264        }
265        return false;
266    }
267
268    private function selectorPseudoElementName(\Phpdftk\Css\Selector\ComplexSelector $sel): ?string
269    {
270        $compounds = $sel->compounds;
271        if ($compounds === []) {
272            return null;
273        }
274        $last = $compounds[array_key_last($compounds)]->compound;
275        foreach ($last->components as $simple) {
276            if ($simple instanceof \Phpdftk\Css\Selector\PseudoElementSelector) {
277                return strtolower($simple->name);
278            }
279        }
280        return null;
281    }
282
283    /**
284     * @param array{tier: int, specificity: Specificity, order: int} $existing
285     */
286    private function shouldReplace(array $existing, int $tier, Specificity $spec, int $order): bool
287    {
288        if ($tier !== $existing['tier']) {
289            return $tier > $existing['tier'];
290        }
291        $cmp = $spec->compare($existing['specificity']);
292        if ($cmp !== 0) {
293            return $cmp > 0;
294        }
295        return $order > $existing['order'];
296    }
297
298    /**
299     * Handle the `inherit` / `initial` / `unset` / `revert` keywords. Returns
300     * the resolved value (or null when the cascade should leave the property
301     * to fall through to inheritance / initial).
302     */
303    private function resolveSpecialKeywords(
304        string $name,
305        Value $value,
306        ?CascadedValues $parent,
307    ): ?Value {
308        if (!$value instanceof Keyword) {
309            return $value;
310        }
311        $lower = strtolower($value->name);
312        $def = $this->registry->get($name);
313        return match ($lower) {
314            'inherit' => $parent?->get($name) ?? $def?->initial,
315            'initial' => $def?->initial,
316            'unset' => $def !== null && $def->inherits
317                ? ($parent?->get($name) ?? $def->initial)
318                : $def?->initial,
319            'revert', 'revert-layer' => $def?->initial,
320            default => $value,
321        };
322    }
323
324    private function applyInheritance(CascadedValues $values, ?CascadedValues $parent): void
325    {
326        if ($parent === null) {
327            return;
328        }
329        foreach ($this->registry->all() as $name => $def) {
330            if (!$def->inherits) {
331                continue;
332            }
333            if ($values->has($name)) {
334                continue;
335            }
336            $inheritedValue = $parent->get($name);
337            if ($inheritedValue !== null) {
338                $values->set($name, $inheritedValue);
339            }
340        }
341    }
342
343    /**
344     * CSS Custom Properties §3: custom properties always inherit. Copy any
345     * not-locally-declared property down from the parent so later `var()`
346     * substitution sees the inherited values.
347     */
348    private function inheritCustomProperties(CascadedValues $values, ?CascadedValues $parent): void
349    {
350        if ($parent === null) {
351            return;
352        }
353        foreach ($parent->customProperties() as $name => $value) {
354            if (!$values->has($name)) {
355                $values->set($name, $value);
356            }
357        }
358    }
359
360    /**
361     * Walk every cascaded value and substitute `var(--name[, fallback])`
362     * references with the resolved custom-property value. Per the spec
363     * (CSS Variables §3.2), a missing variable and no fallback leaves the
364     * property invalid at computed-value time — the cascade then falls back
365     * to the property's initial value.
366     *
367     * Substitution depth is capped at 100 to match the project's
368     * configurable defaults (see Security section in `html-and-svg.md`).
369     */
370    private function substituteCustomProperties(CascadedValues $values): void
371    {
372        foreach ($values->all() as $name => $value) {
373            $resolved = $this->substituteValue($value, $values, 0);
374            if ($resolved === null) {
375                // Invalid at computed-value time → revert to initial.
376                $def = $this->registry->get($name);
377                if ($def !== null) {
378                    $values->set($name, $def->initial);
379                }
380                continue;
381            }
382            if ($resolved !== $value) {
383                $values->set($name, $resolved);
384            }
385        }
386    }
387
388    private function substituteValue(Value $value, CascadedValues $values, int $depth): ?Value
389    {
390        if ($depth > 100) {
391            return null;
392        }
393        if ($value instanceof CustomProperty) {
394            $referenced = $values->get($value->name);
395            if ($referenced !== null) {
396                return $this->substituteValue($referenced, $values, $depth + 1);
397            }
398            if ($value->fallback !== null) {
399                return $this->substituteValue($value->fallback, $values, $depth + 1);
400            }
401            return null;
402        }
403        if ($value instanceof ValueList) {
404            $newChildren = [];
405            foreach ($value->values as $child) {
406                $resolved = $this->substituteValue($child, $values, $depth + 1);
407                if ($resolved === null) {
408                    return null;
409                }
410                $newChildren[] = $resolved;
411            }
412            return new ValueList($newChildren, $value->separator);
413        }
414        return $value;
415    }
416
417    /**
418     * Resolve relative-unit lengths to absolute pixels. Two-pass: font-size
419     * resolves first against `$context->parentFontSize`, then every other
420     * length resolves against the resulting font-size (passed in
421     * `currentFontSize`).
422     *
423     * Mutates `$values` in place and returns it for chaining. Idempotent —
424     * already-px lengths pass through unchanged.
425     */
426    public function resolveLengths(CascadedValues $values, LengthContext $context): CascadedValues
427    {
428        // Resolve font-size first, using the parent's font-size as the em basis.
429        $fontSize = $values->get('font-size');
430        $currentFontSize = $context->parentFontSize;
431        if ($fontSize instanceof Length) {
432            $emCtx = new LengthContext(
433                parentFontSize: $context->parentFontSize,
434                currentFontSize: $context->parentFontSize,
435                rootFontSize: $context->rootFontSize,
436                viewportWidth: $context->viewportWidth,
437                viewportHeight: $context->viewportHeight,
438            );
439            $currentFontSize = LengthResolver::toPx($fontSize, $emCtx);
440            $values->set('font-size', new Length($currentFontSize, LengthUnit::Px));
441        }
442        $bodyCtx = $context->withCurrentFontSize($currentFontSize);
443
444        foreach ($values->all() as $name => $value) {
445            if ($name === 'font-size') {
446                continue;
447            }
448            if ($value instanceof Length) {
449                $values->set(
450                    $name,
451                    new Length(LengthResolver::toPx($value, $bodyCtx), LengthUnit::Px),
452                );
453            }
454        }
455        return $values;
456    }
457}