Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
82.43% |
948 / 1150 |
|
39.34% |
24 / 61 |
CRAP | |
0.00% |
0 / 1 |
| Cascade | |
82.43% |
948 / 1150 |
|
39.34% |
24 / 61 |
1810.21 | |
0.00% |
0 / 1 |
| tierFor | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
8 | |||
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| expandDeclaration | |
57.14% |
8 / 14 |
|
0.00% |
0 / 1 |
10.86 | |||
| withViewport | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| withMatchingMediaTypes | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| anonymousFromParent | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| computeFor | |
98.65% |
73 / 74 |
|
0.00% |
0 / 1 |
24 | |||
| forceTableInternalWritingMode | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
| resolveLogicalProperties | |
90.00% |
54 / 60 |
|
0.00% |
0 / 1 |
20.40 | |||
| applyFontSizeAdjustZero | |
50.00% |
6 / 12 |
|
0.00% |
0 / 1 |
6.00 | |||
| resolveLightDarkValues | |
70.59% |
12 / 17 |
|
0.00% |
0 / 1 |
14.08 | |||
| activeStyleRules | |
95.45% |
42 / 44 |
|
0.00% |
0 / 1 |
25 | |||
| resolveLayerIndex | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| mediaPreludeMatches | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| containerPreludeMatches | |
70.59% |
12 / 17 |
|
0.00% |
0 / 1 |
9.63 | |||
| containerQueryUsesOnlySupportedFeatures | |
89.29% |
25 / 28 |
|
0.00% |
0 / 1 |
10.12 | |||
| matchSingleMediaQuery | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
10.20 | |||
| evaluateMediaQueryBody | |
85.71% |
18 / 21 |
|
0.00% |
0 / 1 |
12.42 | |||
| evaluateMediaCondition | |
96.67% |
29 / 30 |
|
0.00% |
0 / 1 |
20 | |||
| isSingleParenExpression | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
9.04 | |||
| splitMediaConditionAt | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
9 | |||
| rewriteRangeFeature | |
86.49% |
32 / 37 |
|
0.00% |
0 / 1 |
12.36 | |||
| rangeToLegacy | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
5.20 | |||
| reverseOp | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
16.14 | |||
| matchFeatureQuery | |
75.00% |
54 / 72 |
|
0.00% |
0 / 1 |
84.00 | |||
| matchRatioFeature | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
156 | |||
| matchIntegerFeature | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
| supportsPreludeMatches | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
| parseSupportsOr | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
8 | |||
| peekSupportsKeyword | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| parseSupportsPrimary | |
94.55% |
52 / 55 |
|
0.00% |
0 / 1 |
20.06 | |||
| skipSupportsWs | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| consumeSupportsKeyword | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| evaluateSupportsFeature | |
80.43% |
37 / 46 |
|
0.00% |
0 / 1 |
31.06 | |||
| supportsValueIsAcceptable | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
| valueOnlyUsesKnownFunctions | |
94.59% |
35 / 37 |
|
0.00% |
0 / 1 |
5.00 | |||
| isColorTypedProperty | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
3 | |||
| isAcceptableColorValue | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
7.23 | |||
| evaluateSupportsSelector | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
| selectorIsFullySupported | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
5.39 | |||
| simpleSelectorIsSupported | |
54.55% |
6 / 11 |
|
0.00% |
0 / 1 |
11.60 | |||
| allSelectorsSupported | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| isKnownPseudoElement | |
0.00% |
0 / 56 |
|
0.00% |
0 / 1 |
2 | |||
| isKnownPseudoClass | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
1 | |||
| evaluateSupportsFontFormat | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| evaluateSupportsFontTech | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| matchDimensionFeature | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
7.04 | |||
| resolveMediaDimensionValue | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| parseMediaLength | |
55.56% |
10 / 18 |
|
0.00% |
0 / 1 |
27.84 | |||
| evaluateMediaCalcSum | |
82.35% |
14 / 17 |
|
0.00% |
0 / 1 |
8.35 | |||
| selectorPseudoElementName | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| pickCascadeWinner | |
98.08% |
51 / 52 |
|
0.00% |
0 / 1 |
13 | |||
| layerRank | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
| beats | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| resolveSpecialKeywords | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
12.00 | |||
| applyInheritance | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| inheritCustomProperties | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| substituteCustomProperties | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| substituteValue | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
8.01 | |||
| resolveLengths | |
82.14% |
23 / 28 |
|
0.00% |
0 / 1 |
8.36 | |||
| resolveValueLengths | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
7.02 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Css\Cascade; |
| 6 | |
| 7 | use Phpdftk\Css\Parser; |
| 8 | use Phpdftk\Css\Selector\MatchableElement; |
| 9 | use Phpdftk\Css\Selector\Matcher; |
| 10 | use Phpdftk\Css\Selector\Specificity; |
| 11 | use Phpdftk\Css\Sheet\Declaration; |
| 12 | use Phpdftk\Css\Sheet\Origin; |
| 13 | use Phpdftk\Css\Sheet\StyleRule; |
| 14 | use Phpdftk\Css\Sheet\Stylesheet; |
| 15 | use Phpdftk\Css\Value\CustomProperty; |
| 16 | use Phpdftk\Css\Value\Keyword; |
| 17 | use Phpdftk\Css\Value\Length; |
| 18 | use Phpdftk\Css\Value\LengthUnit; |
| 19 | use Phpdftk\Css\Value\Value; |
| 20 | use 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 | */ |
| 41 | final 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 | } |