Skip to content

HTML engine layer contracts (ADR-010)

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.

Terminal window
composer require nextpdf/core:^3

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.

LayerFiles (representative)WritesReadsMust not
1 — CSS parsing & applicatorsCssValueParser, CssResolver, HtmlCssApplicator, src/Html/Applicator/*HtmlStyleState CSS fieldsRaw CSS textCompute geometry; emit operators
2 — Style stateHtmlStyleState, State/ComputedStyle, State/LayoutState— (passive value bag)Parse CSS; decide layout; emit operators
3 — Layout & formattingFormattingContextFactory, HtmlBlockHandler, FlexLayoutEngine, TableParser, FloatContextCursor geometryHtmlStyleState fieldsRead raw $css[...]; emit paint operators
4 — Paint & renderingBorderRenderer, BackgroundImageRenderer, src/Html/Paint/*, src/Html/Gradient/*PDF operator streamComputedStyle (immutable) + geometryCompute geometry; parse CSS; decide page breaks
LayerFiles (representative)Role
5 — Paged mediaPageBreakController, PageBorderPainter, PageRule, PageRuleParser, ParserConfiguratorResolve @page rules; evaluate break and orphan/widow constraints; delegate page decoration to paint.
6 — Measurement & harnessWeb Platform Tests (WPT) classifier scripts, tests/Support/*Classify test outcomes; produce regression snapshots; provide assertion helpers. Carries no rendering logic.

The contract is enforced by class placement and the HtmlStyleState docblock. Verify it against src/Html/.

SymbolLayerContract role
PropertyApplicatorInterface1Strategy interface; the only place that writes CSS computed fields.
ParserConfigurator::buildCssApplicator()1 (wiring)Registers every applicator. A new CSS property registers here.
HtmlStyleState2Dual-group bag; the class docblock states the per-field owning layer.
HtmlStyleState::toComputedStyle()2Produces the immutable ComputedStyle for the paint layer.
FormattingContextFactory::dispatchOpenTag()3Single routing point for new layout behavior.
PageBorderPainter::buildStream()4Page decoration; called from Layer 5, not inlined in HtmlParser.

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');

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.
  • FormattingContextFactory::startTable() reads raw CSS. This is the only documented contract exception, deferred to a future TableApplicator. 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.
  • HtmlStyleState is dual-group. It carries CSS computed fields and layout-tracking fields. Only applicators write the CSS group. Paint reads ComputedStyle, never the layout-tracking fields.
  • HtmlParser has no layer. It is the orchestrator. CSS parsing, geometry math, and paint emission must not live in it.

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.

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.

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.

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.