HTML to PDF
Render HTML and CSS to PDF — full browser print-stylesheet parity, no JavaScript.
composer require phpdftk/html-to-pdfStatus
Section titled “Status”HTML WPT in-scope pass: 95.72% (559 / 584). Full audit at HTML WPT substrate. MathML inline content reached 100% in-scope pass via the inline-MathML adapter (detail).
Phase 1 (MVP) is complete and Phase 2 is most of the way through. The renderer ships a working HTML/CSS-to-PDF pipeline with the following surface in place:
CSS basics — tokenizer, value parsing, selector matching, full cascade with !important, custom properties / var(), calc(), color-mix(in <space>, …) resolved to a concrete sRGB colour during the cascade — interpolating the two colours in the named space (polar spaces travel the hue direction shorter / longer / increasing / decreasing) at the percentage-weighted point, with a sub-100 %-sum scaling the result alpha (CSS Color 5 §3); the same resolution runs on a color-mix() used as a gradient stop; modern color-function syntax completeness — a bare <number> for hsl() / hwb() saturation / lightness / whiteness / blackness (hsl(60deg 0 50%), 50 ≡ 50%) and a none channel in rgb() / alpha (rgb(none 255 0), resolving to 0 outside interpolation, CSS Color 4 §7 / §10.5), shorthand expansion, @media (with feature queries: (min-width: N), (max-width: N), (orientation: ...), plus MQ5 range syntax (width > 400px) / (100px < width <= 500px)), @supports (full condition evaluation), @container [name?] (<size-query>) evaluated against the viewport as a Phase-1 proxy (full per-element container sizing lands later) with inline-size / block-size aliasing to width / height, @import, @font-face, @page with margin boxes and named pages, cqw / cqh / cqi / cqb / cqmin / cqmax container-relative length units (CSS Containment 3 §6) resolving against ancestors with container-type: inline-size or size.
Layout — block formatting context, inline / line boxes with shaping, floats + clear (a floated element’s margins never collapse, CSS 2.1 §8.3.1 — a float with margin-top as a block’s first child does not push the parent or the following in-flow content up), shape-outside basic shapes (circle() / ellipse() / inset() / polygon()) contracting a left float’s exclusion contour so inline content wraps the shape’s edge, position: relative / absolute / fixed / sticky, CSS Anchor Positioning 1 (anchor-name registering an anchor, position-anchor selecting one, anchor() resolving an inset to an edge of the anchor’s border box and anchor-size() a size to its extent — each with the function’s fallback when the reference cannot be resolved — and position-area placing the box in one region of the 3x3 grid the anchor cuts out of the containing block, honouring align-self / justify-self stretch / start / end / center and defaulting, per §3.3, to the alignment that draws the box toward the anchor; an anchor must precede the positioned box in tree order per §3.1, so sibling containers reusing one anchor-name each resolve to their own. @position-try fallbacks, anchor-scope, scroll-linked anchoring and position-visibility are not implemented), min/max sizing, aspect-ratio in both directions (including a definite min-height / max-height transferred through the ratio to bound the inline size, per CSS Sizing 4 §5.1), box-sizing: border-box, an in-flow height: % resolved against the containing block only when that block’s height is definite (CSS 2.1 §10.5 — an auto-height <html> / <body> ancestor is indefinite for in-flow percentage heights, kept separate from the viewport-definite chain abspos offsets use), replaced-element intrinsic sizing (CSS 2.1 §10.3.2 / §10.4 — a <canvas> / <img> with an intrinsic ratio and one definite axis derives the other axis from the ratio, including a percentage / max-height against a definite containing-block height, so <canvas width=1 height=1 style="height:100px"> is a 100×100 square), multi-column with column-span: all, paged media with break-before / break-after / break-inside + orphans / widows / box-decoration-break (pagination measured against the true page height, so a nested definite-height block is not treated as a fragmentainer), tables (with the HTML §15.3.8 defaults — cells at padding: 1px and tables at border-spacing: 2px, each overridable by the cellpadding / cellspacing presentational attributes; CSS 2.1 §17.2.1 anonymous table-object generation — bare table-cell children of a table / row-group are swept into a synthesised anonymous table-row, and a non-cell child of a row into an anonymous cell, so <div style="display:table"><div style="display:table-cell"> builds a real grid instead of stacking as bare blocks; border-collapse: collapse running full conflict resolution, the separated-borders model’s border-spacing — one or two lengths reserving horizontal / vertical gaps before, between, and after the cells and folded into the table’s intrinsic width — caption-side, rowspan / colspan, <col> / <colgroup> widths, visibility: collapse removing a table row from layout per CSS 2.1 §17.5.1, position: relative / sticky offsets applied to boxes laid out through specialised paths that bypass the shared block tail — a <tr> shifting the row and its cells + abspos descendants, and a flex / grid CONTAINER shifting its whole subtree, by the resolved offset (CSS 2.1 §9.4.3), auto-width algorithm scaling columns to cell max-content, cells stretched to the row height filling it exactly — a bordered/padded cell’s content grows to the row height minus its own border + padding rather than overflowing), flexbox (display: flex and inline-flex — both establish a flex formatting context per CSS Display 3 §2.6, the inline form shrink-to-fit per Flexbox 1 §9.7 — row + column with reverse, wrap, all justify-content / align-items / align-content / flex-grow / flex-shrink / flex-basis / order, column-gap / row-gap including percentage gaps, flex-basis: auto + auto-width items measured at max-content per §9.2, min-{width,height}: auto floored at min-content per §4.5, abspos children removed from the flex flow and positioned against the container as positioned ancestor, item percentage heights resolved against the flex container), grid (grid-template-{columns,rows} accepting <length>+, fr units, repeat(N, ...), and auto / min-content / max-content content-sized tracks; grid-template-areas string-grid with grid-area: <name> placement; grid-column / grid-row shorthand with span N and negative line indices; grid-auto-flow row / column / dense with implicit-track growth via grid-auto-{rows,columns}; column-gap / row-gap; justify-self / align-self with start / end / center / stretch), writing modes (CSS WM 4 §3 — writing-mode: vertical-rl / vertical-lr / sideways-rl / sideways-lr stack block children along the physical x-axis: rightward for *-lr / leftward for *-rl; auto-width shrinks to children’s outer-width sum (block-axis shrink-to-content per Sizing 4 §6.4); auto-height falls back to max child outer-height (orthogonal max-content approximation); adjacent sibling margins collapse along the block axis per §8.3.1 with the axes transposed; the resolver also maps the full logical-property set — margin-block-*, inset-inline-*, border-*-{start,end}, inline-size / block-size — to physical longhands per the active mode + direction).
Painter — backgrounds (including N-stop linear + radial gradients via PDF Type-3 stitching functions, with length-positioned stops; radial-gradient() paints a ShadingType-3 (concentric-circle) pattern anchored in absolute page coordinates — a PDF shading pattern places itself via its own /Matrix and ignores the current transform — with a single authored <length> treated as a circle radius, an elliptical ending shape carried by the pattern /Matrix, and the shading extended past the outer circle only when the last stop is opaque (a translucent final stop fades out rather than flooding the box, since a colour shading has no alpha); a gradient or url wrapped in image-set() paints the selected (1×, print-target) option’s image, CSS Images 4 §6; a linear-gradient(to <corner>, …) direction resolves its angle from the box’s aspect ratio at paint time (the gradient line is perpendicular to the diagonal joining the other two corners, CSS Images 3 §3.1), so a to right bottom gradient on a 200×100 box lands at ~153° rather than the square-box 135°; a single-color-stop gradient — linear-gradient(green) — renders as a solid fill per CSS Images 3 §3.5.1; a stop position carrying a font-/container-relative unit — linear-gradient(blue 2em, yellow) or linear-gradient(blue calc(2em), yellow) — resolves against the element during the cascade length pass before paint (CSS Values 4 §10), so a calc(2em) stop at font-size: 30px lands at 60px rather than dropping its offset; a gradient carrying an explicit interpolation method — linear-gradient(in oklch, red, blue), radial-gradient(in lab, …), or a polar space with a hue direction like in hsl longer hue — interpolates its stop colours in that space (CSS Color 4 §12 / CSS Images 4 §3.1) rather than sRGB: since a PDF DeviceRGB shading only interpolates linearly, each segment is densely resampled in the named space (OKLab / OKLCH / Lab / LCH / HSL / HWB / sRGB-linear / XYZ / Display-P3) — the polar spaces travelling the hue channel shorter / longer / increasing / decreasing, with a powerless (achromatic) endpoint adopting its neighbour’s hue — and the samples emitted as sRGB so the shading tracks the true curve; the forward sRGB→space matrices are exact runtime inverses of the backward matrices, so a round-trip is identity; stop positions are clamped monotonically and the resolved list is padded with flat end stops so a gradient whose stops don’t reach 0 %/100 % (e.g. …, green 70%, or a backwards …, green 0 that clamps up) holds a solid terminal band instead of stretching the final segment; a linear-gradient with translucent stops — rgba(c,0) → rgba(c,1) — respects per-stop alpha by installing a PDF Luminosity soft mask built from the alpha channel as a DeviceGray shading, since a colour shading carries no alpha of its own, ISO 32000-2 §11.6.5.2; conic-gradient() — including from <angle>, at <position>, and repeating-conic-gradient() — rendered through a function-based ShadingType-1 whose FunctionType-4 PostScript calculator maps each point to its sweep angle and interpolates the stops, since PDF has no native angular shading, CSS Images 4 §3.5; a linear gradient with a non-default background-size is tiled across the box per background-repeat — the same repeat / round / space geometry as a raster background — painting one axial shading per cell clipped to the box, with a sub-pixel tile size falling back to a single box fill, CSS Backgrounds 3 §3.9), background-clip + background-origin + background-size + background-position + background-repeat (including per-axis round, which rescales the tile so a whole number fits, and space, which fits as many whole tiles as possible and distributes the leftover as even inter-tile gaps with the first and last flush to the edges, CSS Backgrounds 3 §3.7), borders (all 8 styles), corner-shape (CSS Borders 4 §5 — each border-radius corner drawn as round (the default elliptical arc), bevel (a straight chamfer), square (filled out to the box corner), notch (an inward square), or scoop (a concave arc), with the 1-4 value shorthand and superellipse(<n>) mapped to the nearest of those by exponent; applied to the background fill and the rounded overflow / clip-path clip, so a corner-shape: bevel box with overflow: clip clips its content to the chamfered outline), border-image (raster source sliced into the 9-region grid per border-image-slice, corners stretched and the four edges tiled per border-image-repeat — stretch / repeat (natural size, centred, partial ends clipped) / round (rescaled so a whole number fits) / space (whole tiles with equal gaps between and around them, so a lone tile centres), with the one- or two-keyword form applying per axis; border-image-width sizes the destination slices — a <number> multiple of the border-width, a <length> / <percentage>, or auto — and may exceed the border, extending inward into the padding (opposite pairs that overflow are reduced together, §6.5); border-image-outset grows the area outward past the border edge into the margin (§6.6); the border-image shorthand resets omitted sub-properties to their initials, CSS Backgrounds 3 §6.2), box-shadow + inset box-shadow, filter: drop-shadow(), outline, multi-column column-rule + CSS Gaps 1 grid and flex gap decorations (column-rule / row-rule -width / -style / -color painted in grid gaps — and, for a flex container, in the gaps between items and between wrapped flex lines — clipped to the padding box under overflow other than visible; a flex container paints main-axis rules per line, each centred in that line’s own item gap and spanning between the surrounding cross-gap centres, so an irregular line (e.g. one wide item) simply shifts its rule positions, while inter-line rules span the whole main content extent; a flex container also honours column-rule-break / row-rule-break: intersection (CSS Gaps 1 §3.2), which breaks the rules at the perpendicular gaps — main-axis rules then span only their line’s item extent, and a cross-axis rule is drawn only where both adjacent lines have an item at that main position (the pairwise intersection of the two lines’ item coverage), so overlapping translucent rules don’t double up at intersections; the simple spanning-item / intersection cases with zero inset only — non-horizontal-tb writing modes, per-gap multi-value rule lists, non-zero *-rule-inset, subgrid, and fragmentation are skipped rather than mis-drawn), clip-path basic shapes (inset / circle / ellipse / polygon / rect / xywh) resolved against a <geometry-box> reference box (content-box / padding-box / border-box / margin-box, with the SVG fill-box / stroke-box / view-box mapping to their nearest CSS box) — including the bare clip-path: <geometry-box> form — and clip-path: url(#id) referencing an SVG <clipPath> element, whether it lives in an inline <svg> in the same document (a fragment-only URL is always document-local per CSS Values 4 §4.5, so a <base href> never redirects it) or in an external SVG file (url(icons.svg#clip)); the clipPath’s children are resolved in the referencing element’s own user coordinate system — origin at the top-left of its border box, y running down — and clipPathUnits="objectBoundingBox" measures them against that same box. A clip-path on the ROOT element also clips the background it propagates to the canvas. Also in the masking family: a masked element is painted as a single composited GROUP — the whole subtree goes into a transparency-group Form XObject that is drawn once under the soft-mask gs, which is both the grouping CSS Masking specifies and the only form real PDF consumers honour (Ghostscript drops a soft mask at the first Q inside the masked scope, so a box painted step-by-step under gs kept only its first drawing block masked) — mask-image: url(#id) referencing an SVG <mask> element — document-local or in an external SVG file — masking the box by that element’s children, resolved in the box’s own user coordinate system and honouring the mask’s maskUnits / maskContentUnits / x / y / width / height (the CSS mask box model does not apply to a <mask> reference; only mask-mode does, overriding the element’s mask-type), an unresolvable mask-image: url(...) masking its element out (CSS Masking 1 §4 — a failed mask layer is transparent, so the element and its subtree paint nothing), a mask-image: <linear-gradient> in the default match-source (alpha) mode masking its element (and subtree) by the gradient’s alpha — the box paint is wrapped in a PDF Luminosity soft mask built from that alpha as a DeviceGray shading, for the single-layer default-geometry case (non-default mask-mode / mask-size / mask-position / mask-repeat / mask-clip / mask-origin / mask-composite, multi-layer, and non-linear-gradient masks fall back to unmasked paint rather than mis-masking), text decorations (underline / overline / line-through with text-decoration-style: wavy), 2D + 3D CSS transforms (rotateX / rotateY / rotateZ / rotate3d / matrix3d / perspective, with backface-visibility: hidden suppressing back-facing rotations), and z-index stacking order (CSS 2.1 §9.9.1 / Appendix E) — a positioned child with a negative z-index paints behind the box’s in-flow descendants, a positive z-index paints in front, and everything with z-index: auto / 0 keeps document order, so a position: absolute; z-index: -1 backdrop renders under the content it sits behind rather than on top of it.
Inputs — full WHATWG HTML5 parser (100% pass on html5lib-tests tree-construction: 1586 / 1586 — full quirks-mode DOCTYPE table, MathML/SVG integration points as scope boundaries, foreign-content </br> and </p> breakout, <applet> / <marquee> / <object> AFE-marker handlers, InTable <form> pointer, stray-html / stray-body attribute merge, adoption-agency + foster parenting (cloned formatting elements lift out of tables, into template content), customizable-<select> (<div>/<button>/<datalist>/<keygen>/<menuitem>/<plaintext>/<svg>/<math> allowed, <selectedcontent> mirrors the selected option), scripted <noscript> RAWTEXT, full named-character-reference table), <picture> / <source> with media-attribute selection and srcset DPR picking, <select> + <select multiple> + <optgroup>, <details> / <summary>, <input type=button|submit|reset|checkbox|radio> static rendering.
Remaining Phase 2 work (Grid last-mile — auto-fill / auto-fit, minmax() / fit-content(), row-axis intrinsic sizing, named lines, subgrid; Writing Modes 4; Universal Shaping Engine for Arabic / Indic; remote url() fetching with SSRF gates; AcroForm widget annotations; raster filter primitives; full auto-width table algorithm; true perspective for 3D transforms) lives in docs/plans/html-and-svg.md. The cross-package contracts document the locked public surface.