A server-side HTML/CSS renderer that produces a pixel buffer matching Chrome — no DOM, no browser process, no Playwright or Puppeteer anywhere in the product.
Feed it an HTML string, get back the same four quantities a browser would give you: text widths, computed styles, element rects, and a painted buffer. The point is to take the browser out of the equation entirely, so a program — or an AI agent — can get layout and measurement in-process, deterministically, and ~3× faster than Chrome's own render.
npm install
npm run verify # engine vs real Chrome, four layers, sub-pixel tolerances
At a glance (every number below is reproduced by a verify:<feature> script; npm run verify:all runs the full gate)
| Quantity | Result | What it means |
|---|---|---|
| Text measurement | 96/96 strings, mean Δ 0.0025px, worst 0.0300px | engine measureText ≈ Chrome's, sub-pixel |
| Documented text-measure gaps | 7 → 0 | every known divergence closed and reclassified |
| Box geometry (spine) | max Δ 0.0000px | getBoundingClientRect-identical to Chrome |
| Box geometry (110-fixture flex/grid sweep) | 110/110 pass, rect max Δ 0.0104px | every swept combo within the ≤ 0.5px band |
| Computed style | exact string equality, 0 mismatches | getComputedStyle identical |
| Screenshot, non-text pixels | 0 exceeding on spine + layout/paint suites (ΔE ≤ 2, ≤ 1%) | paint matches Chrome on everything that isn't glyph-ink |
| Stress corpus (page-scale, multi-viewport) | 6 fixtures, 13 fixture×viewport renders, four layers PASS | corpus/stress packed kitchen-sink pages hold 100% parity at mobile + desktop |
| Render speed | 2.1–3.6× faster than Chrome's own render (engine takes 0.28–0.48× of Chrome's time) | engine beats Chrome rendering the same HTML |
Every number above is a real measurement against headless Chrome, not an estimate. The last section tells you exactly what they do not prove.
The engine's parity against Chrome is not claimed — it is measured on every
verify:all run, per fixture, per layer, against live headless Chrome. The
latest full run (2026-08-17) wrote a report per verify script — spine,
sweep, cross-family, UA styles, and page-scale stress — covering every corpus
fixture, and exited 0 (all green). Collapsed to one number per layer:
| Layer | Fixtures | Result | Parity |
|---|---|---|---|
| 1 · Text measurement | 96 strings pass-corpus | mean Δ 0.0025px, worst 0.0300px (≤ 0.5px) | 100% |
| 2 · Computed style | all fixture props | exact string equality, 0 mismatches | 100% |
| 3 · Box geometry (rect) | every box in every fixture | 0 FAIL rows; spine/sweep/layout/paint max Δ 0.0000–0.0104px | 100% |
| 4 · Paint, non-text pixels | every screenshot | 0 exceeding above ΔE 2 / 1%, all suites (spine + sweep + layout + stress) PASS | 100% |
| Stress corpus | 6 fixtures, 13 fixture×viewport renders | 0.0000–0.0231px rect Δ; non-text screenshot ≤ 1% exceeding; text under tiered 97% tier | 100% |
| Line breaking (breaker) | 22 fixtures | 21/22 at exact Chrome line-count parity (95.5%); 1 declared divergence | 95%* |
* the one breaker divergence (long-word-default, a Chrome/engine word-break
boundary difference) is a declared typed gap — asserted to still diverge by
the gate, on the record in docs/ledgers/breakers.md, not hidden.
So the honest headline: on the verified corpus the engine is 100% Chrome-parity
for text measurement, computed style, box geometry, and non-text paint, and 95%
for exact line-count breaking. The caveats that keep the whole number off
100% are the breaker divergence above (95%), @container size/block-size
containment (inline-size is implemented; full two-axis containment is deferred),
and the type-level arbitrary-HTML gap — parity is proven for what the corpus
exercises. There is exactly one
intentional FAIL in the run: the harness's own regression-divergence fixture,
which must fail to prove the screenshot gate catches divergence.
Getting layout from a browser means launching one: a heavyweight process, a serialization round-trip for the DOM, another for computed style, another for rects, another for a screenshot — each non-deterministic, each slow, each feeding an agent a sprawling API surface instead of an answer.
This library replaces that whole pipeline with a function call:
import { renderHtml } from 'non-browser';
const out = renderHtml(html, {
width: 800, // viewport — required, never inferred from content (§3)
height: 600,
fontFamily: 'Noto Sans',
fontFile: '/path/to/NotoSans-Regular.ttf',
// browserConfig: getBrowserConfig('chrome'), // target browser (§4); default: chrome
});
out.rgba // layer 4: the painted page (PNG-encoded buffer)
out.rects // layer 3: per-id border-box rects (getBoundingClientRect; unrendered ids -> all-zero)
out.computedStyles // layer 2: computed-style strings (getComputedStyle)
// layer 1: text widths come from the same measurement engine the paint usedA runnable version of this exact flow — input, the four layers' output, and
a written PNG — lives in examples/basic-render.mjs
(node examples/basic-render.mjs).
src/index.ts is the entire public surface (dist/index.js in the built
package; exports allows no other path for a package consumer). Everything
else under src/ is internal.
- Input (
§5) — one HTML string that carries its own CSS, either generic corpus HTML or@ace-code/shastrenderComponentoutput. Both are plain strings through the identical code path; no DOM objects, no document state. - Viewport (
§3) —widthandheightare required inputs. DPR is fixed at 1; the engine draws into exactly the viewport you pass. - Browser config (
§4) — pass abrowserConfig(getBrowserConfig('chrome' | 'firefox' | 'safari'), orchromeConfig/firefoxConfig/safariConfig) to select the target browser's font-fallback tables and font-registration set. Omitted, the engine builds achromeconfig fromfontFamily/fontFile, which name the font single-face measure/paint use. Chrome is the default and the primary golden corpus. - Output —
rgbais a PNG-encodedBufferof the painted viewport;rectsmaps everyidpresent in the input DOM to that element's border-box rect (getBoundingClientRectsemantics, fractional-safe); elements that are not rendered —display:noneand their descendants, and id'dscript/styleelements — map to an all-zero rect, matchinggetBoundingClientRect;computedStylesholds layer-2 strings for thecomputedStylespecs you passed;generatedTextRects,textFragments, andlistMarkersare the layer-1/3/4 extras the parity harness compares against the oracle. Passingmediadrives@mediaresolution (prefers-color-scheme,prefers-reduced-motion,dppx).
The four-layer parity claim is the §2 table in "Engine vs Chrome" below: for the corpus, every output quantity above matches the oracle within those tolerances.
For an agent that needs to answer "does this card overflow?", "what is the width
of #header?", "how tall is this paragraph at 640px?" — you call a function
that returns the answer in milliseconds. No process to spawn, no page to wait
for, nothing to parse.
The project is honest about the difference between "the engine matches Chrome" and "Chrome and Firefox happen to agree". They are measured separately.
Playwright drives real headless Chrome only as a test oracle (a
devDependency; the library never imports it). Both sides render the same HTML
and the harness diffs four independent quantities:
| Layer | Oracle quantity | Tolerance | Current result |
|---|---|---|---|
| 1. Text measurement | canvas.measureText width |
mean ≤ 0.01px, no string > 0.5px | mean 0.0025px, 96/96 pass |
| 2. Style resolution | getComputedStyle |
exact string equality | 0 mismatches |
| 3. Geometry | getBoundingClientRect |
≤ 0.5px per box dimension | max Δ 0.0000px |
| 4. Paint | screenshot pixels | ΔE ≤ 2, ≤ 1% exceeding (non-text) | 0 exceeding |
This is the claim behind every number above: for the corpus, the engine and
Chrome agree within sub-pixel on layout and metrics, and pixel-identically on
non-text paint. The corpus spans block/inline, floats, positioning, flexbox,
grid, text scripts (Latin/CJK/Thai/Arabic/emoji), white-space modes, text
breaking (Pretext breaker, corpus/breaker), RTL box layout, value functions
(calc()/min()/max()/clamp()), box-shadows, opacity compositing, lists,
pseudo-elements, media queries incl. @container (inline-size), overflow
clipping, a 110-fixture generated sweep, and a page-scale stress corpus
(corpus/stress: kitchen-sink, navbar, card-grid, form, article, RTL/LTR mix)
verified at small + desktop viewports.
A separate probe renders the same HTML in Playwright Chrome and Playwright Firefox, with no engine in the loop, and diffs the four layers directly. It answers a different question: do the browsers already agree?
Result: layout and measurement are byte-identical across browsers
(rect max Δ 0.0000px, computedStyle 0 mismatches); only font resolution and
glyph rasterization differ. That is why one target (Chrome) is enough — the
engine only has to reproduce the layout every browser shares, plus Chrome's
font fallback decisions, which is handled by per-browser fallback tables
(src/config/, charter §4).
The bench reports three separate times so "fast" is never conflated with "the harness is fast":
- Engine — wall-clock of
renderHtmlin-process (parse → cascade → layout → paint). - Chrome render — Chrome's own cost to first paint for the same HTML, measured inside the page via
PerformanceObserver, not harness wall-clock. - Harness — the full Playwright oracle path (page setup + per-quantity
evaluateround-trips + screenshot).
Honest reading of the warm run: the engine renders the same HTML 2.1–3.6× faster than Chrome itself (Chrome's render time ÷ engine time; the engine takes 0.28–0.48× of Chrome's time). The full Playwright oracle path is ~5.8× Chrome's render wall-clock, and 83% of that is harness overhead — page setup and round-trips, not Chrome rendering. Earlier "28×" headlines had billed the harness's round-trips against the engine; this bench splits them so nobody can repeat that mistake.
The ledger docs/ledgers/parity.md maintains this list explicitly. The honest
reading:
- Text pixels are compared under a tiered tolerance, not a strict pass.
The two Skia rasterizers (Chrome's compositor vs
@napi-rs/canvas) hint glyphs differently — 60–74% of glyph-interior pixels exceed ΔE 2 even though Chrome's own canvas is 73% divergent from its own DOM text. So text pixels are reported and compared under a documented 97%-within-region tier, not silently excluded and not claimed as pixel-identical. Non-text pixels are strict. - The green run proves parity for the verified corpus, not arbitrary HTML.
The corpus is authored alongside the engine; the coverage matrix and
tasks/are the ledger of what is and isn't claimed.calc(), opacity compositing, box-shadow/text-shadow paint,direction: rtlbox layout, and@containerqueries (inline-size) are implemented and corpus-verified (corpus/calc,corpus/opacity,corpus/box-shadow,corpus/rtl-layout,corpus/media-queries, and the page-scalecorpus/stress; seeverify:calc,verify:opacity,verify:shadow,verify:rtl,verify:media-queries,verify:stress). The total rect map — every input id yields a rect, with all-zero rects for unrendered ids — is itself gated byverify:rect-contract. A property-coverage audit (src/layout/property-coverage.ts) reports which declared CSS properties the engine recognizes vs silently ignores, so unsupported properties can't slip past unseen. Tables and image decoding are out of v1 (charter §3). - The engine's shipped text layout is the Pretext breaker.
layoutTextLinesbreaks through@chenglou/pretext(breakNextLine) for every wrapping mode; the greedy wrapper survives only as theCASCADE_BREAKER=greedyfallback that the drift gate (verify:breaker) proves agrees with Pretext on the spine. The inline-piece walker still owns mixed inline content (atomics, foreign-style spans) andjustifylines (docs/ledgers/breakers.md). - Parity is font-set-bound. The engine reproduces Chrome's registered
font fallback; numbers reproduce where the same fonts resolve the same way.
Font registration is
src/config/; a machine-independent font bundle is open work. - Two typed gaps remain by design (each declared, asserted to still diverge,
on the record): the breaker's
long-word-defaultline-count divergence, and a fixture that deliberately diverges to prove the screenshot gate fails as designed. Plus@containersize/block-sizecontainment (full two-axis container sizing) is deferred — inline-size@containeris implemented. None are hidden.
Everything here is generated, not hand-written: npm run verify writes the
per-layer reports and the ledgers under docs/ledgers/ (parity.md,
text-measure.md, sweep.md, …), and check-charter.mjs fails the build if
the charter's claims drift from the engine or the corpus. The charter
(docs/charter.md) is the source of truth for scope and tolerances; the
orchestration that keeps the work honest lives in .orchestration/.