HTML engine layer contracts (ADR-010)
At a glance
Section titled “At a glance”The Hypertext Markup Language (HTML) subsystem separates Cascading Style Sheets (CSS) parsing, style state, layout, and paint into four layers. Data moves in one direction across those layers. Architecture Decision Record 010 (ADR-010) defines the boundaries and the extension rules.
Install
Section titled “Install”composer require nextpdf/core:^3Conceptual overview
Section titled “Conceptual overview”Architecture Decision Record 010 (ADR-010) (“Engine Layer Contracts, Hot Path Ownership, and Extension Rules”, accepted 2026-04-12) formalizes how the HTML subsystem is layered. The core rendering contract has four layers: CSS parsing and applicators, style state, layout and formatting, and paint. ADR-010 also documents two adjunct layers: paged media and the measurement harness. They wrap the four-layer core without changing its data flow. The canonical glossary term for the core is “HTML pipeline”, a four-layer pipeline.
Data flows in one direction. CSS text becomes typed values in Layer 1. Layer 1 writes those values into HtmlStyleState fields in Layer 2. Layer 3 reads style-state fields and computes geometry. Layer 4 reads an immutable ComputedStyle snapshot plus geometry and emits Portable Document Format (PDF) operators. No layer reads from a later layer.
The four-layer separation is more than documentation. ADR-010 records two bounded refactors applied in v1.2.0 that moved code to the correct layer. PageBorderPainter was extracted from HtmlParser, so paint operators no longer live in the orchestrator. The HtmlStyleState class docblock now carries the formal layer contract and states which fields each layer may write or read.
One boundary is explicit. FormattingContextFactory::startTable() still reads five raw CSS keys directly. ADR-010 records this as known, deferred technical debt for a future TableApplicator, not as the intended contract. Documenting the exception is part of the contract.
The four core layers
Section titled “The four core layers”| Layer | Files (representative) | Writes | Reads | Must not |
|---|---|---|---|---|
| 1 — CSS parsing & applicators | CssValueParser, CssResolver, HtmlCssApplicator, src/Html/Applicator/* | HtmlStyleState CSS fields | Raw CSS text | Compute geometry; emit operators |
| 2 — Style state | HtmlStyleState, State/ComputedStyle, State/LayoutState | — (passive value bag) | — | Parse CSS; decide layout; emit operators |
| 3 — Layout & formatting | FormattingContextFactory, HtmlBlockHandler, FlexLayoutEngine, TableParser, FloatContext | Cursor geometry | HtmlStyleState fields | Read raw $css[...]; emit paint operators |
| 4 — Paint & rendering | BorderRenderer, BackgroundImageRenderer, src/Html/Paint/*, src/Html/Gradient/* | PDF operator stream | ComputedStyle (immutable) + geometry | Compute geometry; parse CSS; decide page breaks |
The two adjunct layers
Section titled “The two adjunct layers”| Layer | Files (representative) | Role |
|---|---|---|
| 5 — Paged media | PageBreakController, PageBorderPainter, PageRule, PageRuleParser, ParserConfigurator | Resolve @page rules; evaluate break and orphan/widow constraints; delegate page decoration to paint. |
| 6 — Measurement & harness | Web Platform Tests (WPT) classifier scripts, tests/Support/* | Classify test outcomes; produce regression snapshots; provide assertion helpers. Carries no rendering logic. |
API surface
Section titled “API surface”The contract is enforced by class placement and the HtmlStyleState docblock. Verify it against src/Html/.
| Symbol | Layer | Contract role |
|---|---|---|
PropertyApplicatorInterface | 1 | Strategy interface; the only place that writes CSS computed fields. |
ParserConfigurator::buildCssApplicator() | 1 (wiring) | Registers every applicator. A new CSS property registers here. |
HtmlStyleState | 2 | Dual-group bag; the class docblock states the per-field owning layer. |
HtmlStyleState::toComputedStyle() | 2 | Produces the immutable ComputedStyle for the paint layer. |
FormattingContextFactory::dispatchOpenTag() | 3 | Single routing point for new layout behavior. |
PageBorderPainter::buildStream() | 4 | Page decoration; called from Layer 5, not inlined in HtmlParser. |
Code sample — Quick start
Section titled “Code sample — Quick start”You never touch the layers directly. The four-layer flow runs inside one call.
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();$doc->addPage();$doc->writeHtml('<p style="color:#1E3A8A;border:1px solid #999;">Layered render.</p>');$doc->save(__DIR__ . '/output/layers.pdf');Code sample — Production
Section titled “Code sample — Production”The contract matters when you contribute, not when you call the library. To add a CSS property, use the Layer 1 extension point: create an applicator, add a typed HtmlStyleState field with a layer docblock, and register the applicator in ParserConfigurator. The illustration below shows the applicator contract shape. Use src/Html/Applicator/ as the model for a concrete class.
<?php
declare(strict_types=1);
// Layer 1 extension contract (see ADR-010 §C "New CSS property").// A new property group ships as a PropertyApplicatorInterface// implementation registered in ParserConfigurator::buildCssApplicator().// It writes a typed HtmlStyleState field and never computes geometry// or emits PDF operators — those belong to Layers 3 and 4.Edge cases & gotchas
Section titled “Edge cases & gotchas”FormattingContextFactory::startTable()reads raw CSS. This is the only documented contract exception, deferred to a futureTableApplicator. Do not copy the pattern.- Six layers, four-layer core. ADR-010 numbers six layers. The data-flow contract is the four-layer core; paged media and measurement are adjuncts.
HtmlStyleStateis dual-group. It carries CSS computed fields and layout-tracking fields. Only applicators write the CSS group. Paint readsComputedStyle, never the layout-tracking fields.HtmlParserhas no layer. It is the orchestrator. CSS parsing, geometry math, and paint emission must not live in it.
Performance
Section titled “Performance”The layer contract is structural, so it adds no runtime cost. HtmlStyleState::toComputedStyle() produces one immutable snapshot for each element that needs paint. That snapshot lets paint code avoid the mutable state bag. Render cost is governed by the streaming model, not by layering. The per-page performance_budget (wall_ms: 1500, peak_mb: 64) remains the operational ceiling.
Security notes
Section titled “Security notes”Layer separation supports the security model. Layer 1 parses and policy-filters CSS values before layout or paint code sees them, so DefaultHtmlSecurityPolicy::isCssPropertyAllowed() remains the single gate. Paint never reads attacker-controlled raw CSS. See the HTML module security model.
Conformance
Section titled “Conformance”This page cites no external standard. Layer boundaries come from ADR-010 and the HtmlStyleState class docblock, which encodes the contract in source. CSS behavioral conformance is documented on css-resolver.
Commercial context
Section titled “Commercial context”Enterprise capability. Premium CSS features use these same four layers through the documented extension points. There is no separate Premium pipeline. See the CSS support matrix.