HTML to PDF without a browser: the @formepdf/html input path
Render the HTML and print CSS you already have — @page rules, margin boxes, page counters — through a Rust/WASM engine. No Chromium, a documented subset, and warnings for everything else.
Since 0.14.0, Forme has had a second front door: @formepdf/html takes HTML and print CSS and renders paginated PDFs through the same Rust engine that renders JSX components. No headless browser anywhere in the pipeline.
npx @formepdf/html invoice.html -o invoice.pdf
That's the whole integration. No Chromium download, no --no-sandbox flags, no 500MB Docker layer, no cold start.
The pitch: Satori for paginated documents
If you know Satori — Vercel's HTML/CSS-subset-to-SVG renderer — the shape is familiar: a deliberately documented subset of HTML/CSS rather than a whole browser. The difference is what the output is for. Satori renders one fixed-size frame; Forme's engine is page-native. Content flows into pages: tables repeat their headers on every page, widows and orphans are controlled, and page breaks land where a human would put them.
That page-native engine is what makes the interesting print CSS work — the parts browsers never really finished:
@page {
margin: 72pt 54pt;
@top-center { content: "ACME WIDGET CO. — CONFIDENTIAL"; color: #888 }
@bottom-right { content: counter(page); color: #888 }
}
@page :first {
@top-center { content: none } /* no letterhead on the title page */
}
h2 { break-before: page }
Running letterhead via margin boxes. Page counters. A title page that suppresses both. Break control. This is the letterhead every business document wants, in eleven lines of CSS that Chrome's print pipeline can't fully express (Chrome has no margin boxes at all) and wkhtmltopdf's 2012 WebKit half-supports.
A subset with a spine
The supported subset is documented as a constitution: stylesheets with real cascade semantics (type/class/id selectors, compounds, combinators, the :nth-child and :nth-of-type families, !important), tables with border-collapse emulation and <thead> repetition, justified text with real Knuth-Plass distribution, text-transform, letter-spacing, flexbox, relative and absolute positioning.
And the load-bearing rule: anything outside the subset warns by name.
$ npx @formepdf/html letterhead.html -o letterhead.pdf
warning: unsupported property: text-transform-origin
Skipped stylesheet links, @imports, @font-face families that weren't fetched, properties the mapper doesn't know — every one lands in the warnings list with its name. If a render is silent, the document used only the subset, and what you got is what the subset defines. Run your existing template once and the warnings list is your migration checklist.
Deterministic, everywhere JavaScript runs
The engine ships as WASM for Node, bundlers/browser (@formepdf/html/browser), and Cloudflare Workers/edge (@formepdf/html/worker) — the web targets landed in 0.15.0, verified in real headless Chromium and real workerd, not just Node. CI enforces byte-identical output across the native binary and the WASM builds: the same HTML produces the same bytes on your laptop, your CI runner, and a Workers isolate.
import { init, renderHtml } from '@formepdf/html/worker';
import wasm from '@formepdf/html/pkg-web/forme_pdf_html_bg.wasm';
export default {
async fetch(request: Request): Promise<Response> {
await init(wasm);
const { html } = await request.json();
const { pdf, warnings } = renderHtml(html);
return new Response(pdf, { headers: { 'Content-Type': 'application/pdf' } });
},
};
Honest sizing: the HTML engine WASM is 7.66 MB uncompressed, 3.41 MB gzipped (measured at 0.20.0) — it fits the Workers free plan since Cloudflare's September 2026 limit change (the paid plan is still needed for CPU time on real workloads), and it is lazy-loadable in the browser. (The playground on our homepage loads it only when you click the HTML tab.)
Migrating
- Replacing Puppeteer — you keep the HTML, lose the browser
- From wkhtmltopdf — the legacy
page-break-*spellings work as-is - From DomPDF — real print CSS and text shaping
Or start from zero: paste any HTML into the live playground and watch the warnings panel tell you exactly where you stand.