CSS resolver: cascade and specificity
At a glance
Section titled “At a glance”The CssResolver matches selectors against the token stream, orders matched rules by cascade layer, specificity, and document order, then applies !important in a second pass.
Install
Section titled “Install”composer require nextpdf/core:^3Conceptual overview
Section titled “Conceptual overview”CssResolver is the Layer 1 component (per ADR-010). It owns the parsed Cascading Style Sheets (CSS) rules and decides which declarations apply to each element. The class was extracted from HtmlParser to keep the structure clear, and it is internal rather than a public application programming interface (API).
The resolver does not need a document tree. Selector matching reads the flat token stream and uses the index maps that HtmlChildScanner builds in pipeline Stage 3: child counts, same-tag counts, and emptiness. Those maps answer structural pseudo-classes. The relational :has() selector uses the bounded pre-scan described in streaming constraints.
Cascade resolution runs in two passes inside CssResolver::resolveMatchingProperties(). Pass 1 applies normal declarations in cascade order: cascade-layer weight first, then specificity, then document order. Pass 2 applies !important declarations in specificity order. An !important declaration overrides any normal declaration, regardless of specificity. This two-pass split is the implementation strategy, and it produces the resolved property set that the layout layer consumes.
The cascade order the resolver implements aligns with the World Wide Web Consortium (W3C) CSS Cascading and Inheritance specification. Declarations are sorted first by origin and importance, then by selector specificity. For equal specificity, the last declaration in document order wins (CSS Cascade 5 §6.4; see Conformance). The in-source comment in CssResolver cites the same clause, so you have a third way to verify this behavior, alongside the specification and the glossary.
Specificity is computed as an (A, B, C) triple from counts of ID, class, and type components, and the triples are compared component by component (Selectors Level 4 §16). NextPDF computes specificity for each matched rule before it sorts the cascade.
One constraint matters. The §6.4.3 layer-inversion rule applies to !important declarations across cascade layers, and the source records it as outstanding for the cascade-layer work cluster. When cascade layers are declared and !important crosses layers, the resolved order can differ from full specification behavior. The CSS support matrix is the authority for per-feature support state, and this page does not restate per-property support.
API surface
Section titled “API surface”| Symbol | Location | Role |
|---|---|---|
CssResolver::parseStyleBlock(string $css, bool $nestingEnabled = false): void | src/Html/CssResolver.php | Parse a <style> block into rules. |
CssResolver::resolveMatchingProperties(...) | src/Html/CssResolver.php | Match selectors and resolve the two-pass cascade. |
CssResolver::resolveHasSelectors(array $tokens): array | src/Html/CssResolver.php | Bounded :has() pre-scan (gated). |
CssResolver::resolveFirstLetterProperties(...) | src/Html/CssResolver.php | Resolve ::first-letter properties. |
CssResolver::resolvePseudoElementProperties(...) | src/Html/CssResolver.php | Resolve ::before / ::after properties. |
CssResolver::getLayerRegistry(): LayerRegistry | src/Html/CssResolver.php | Declared cascade layers. |
Code sample — Quick start
Section titled “Code sample — Quick start”You do not call the resolver directly. You write CSS, and the resolver runs inside writeHtml(). In the cascade below, p resolves to red because the class rule has higher specificity than the type rule.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();$doc->addPage();$doc->writeHtml( '<style>p { color: blue; } .lead { color: red; }</style>' . '<p class="lead">Higher-specificity class wins.</p>');$doc->save(__DIR__ . '/output/cascade.pdf');Code sample — Production
Section titled “Code sample — Production”This example shows the !important second pass. The !important type declaration overrides the inline-equivalent class declaration, even though the class selector has higher specificity.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();$doc->addPage();$doc->writeHtml( '<style>p { color: green !important; } .lead { color: red; }</style>' . '<p class="lead">!important overrides higher specificity.</p>');$doc->save(__DIR__ . '/output/important.pdf');Edge cases & gotchas
Section titled “Edge cases & gotchas”!importantignores specificity. Pass 2 applies!importantdeclarations in specificity order, and those declarations always override normal declarations.- Cascade layers +
!importantacross layers. The source records the §6.4.3 layer-inversion rule for important declarations as outstanding. Verify behavior against the CSS support matrix before relying on it. - No layers declared is the fast path. With no
@layer, ordering reduces to specificity-only behavior and is bit-identical to pre-layer behavior. :has()is gated. The relational pre-scan runs only when thecss.hasexperimental feature is enabled.- Selector matching is stream-based. Structural selectors use index maps, not a tree walk. A selector that would need arbitrary tree navigation beyond the index maps is not resolvable in this model.
Performance
Section titled “Performance”In the worst case, selector matching is O(rules × elements), bounded by the streaming caps. The two cascade sorts are O(matched rules · log matched rules) per element. The unlayered path skips layer resolution entirely. The per-page performance_budget (wall_ms: 1500, peak_mb: 64) sets the operational ceiling. The HTML render-pipeline benchmark guards regressions (merged work, PR #564).
Security notes
Section titled “Security notes”The resolver sees only CSS that DefaultHtmlSecurityPolicy::isCssPropertyAllowed() admits. The allowlist sets the security ceiling, and the runtime support table sets a separate capability ceiling. A policy-blocked property never reaches the cascade. See the HTML module security model.
Conformance
Section titled “Conformance”| Behavior | Specification | Clause | reference_id |
|---|---|---|---|
| Cascade sort: origin/importance → specificity → order of appearance | W3C CSS Cascading and Inheritance Level 5 | §6.4 (css_cascade_5#x1.x7.x1.p21) | |
| Specificity as an (A,B,C) triple from ID/class/type counts | W3C Selectors Level 4 | §16 (selectors_4#x1.x16.p2) | |
| Deterministic parsing and parse-error recovery | W3C CSS Syntax Level 3 | §4 (css_syntax_3#x1.x4.p2) |
W3C material is CC-BY 4.0. The claims above are paraphrased. Clause and chunk identifiers are provided for verification. NextPDF does not claim full conformance to these modules; see the CSS support matrix for verified per-module status.
Commercial context
Section titled “Commercial context”Enterprise capability. Premium widens the matched and applied property set. The cascade algorithm and the two-pass
!importantmodel are identical across editions. See the CSS support matrix.