A Rust/WASM engine that lays out into pages. Runs anywhere JavaScript runs. MIT licensed.
React · Preact · Svelte · Vue · HTML/CSS
Byte-identical output across native, Node, and web builds — enforced in CI.
39/39 documents pass their claimed profiles — PDF/A-2 (2b/2u/2a), PDF/A-3 (3b/3u/3a), PDF/A-4 and A-4f, PDF/UA-1, and PDF/UA-2 — 139 veraPDF validations as a CI gate on every commit.
~53k weekly npm downloads across the package suite, led by @formepdf/core and @formepdf/react.measured 2026-09-30
The print CSS browsers never finished — working. Margin boxes, page counters, @page :first, break control. One command, no browser.
@page {
margin: 72pt 54pt;
@top-center {
content: "ACME WIDGET CO. — CONFIDENTIAL";
}
@bottom-right { content: counter(page); }
}
@page :first {
@top-center { content: none; } /* no letterhead on the title page */
}
h2 { break-before: page; }


A documented subset, not a silent one. Anything unsupported warns by name — skipped stylesheet links, unknown properties, unloaded @font-face families. And when the engine itself knowingly compromises — a table column clamped below its content, a split column row — that reports too, as a render defect: warning in the same stream. See the full subset table →
warning: unsupported property: text-transform-origin
Measured against 15 production templates from GitHub, graded against Chrome print: 14 render correctly, 1 degrades legibly with its cause named, 0 broken (0.20.0). How it was measured →
The usual Python HTML-to-PDF route wants cairo, pango, and their headers installed. Forme doesn't. pip install formepdf[local] pulls a single dependency and renders in-process — the same engine the JavaScript packages run.
from jinja2 import Template
from formepdf import render_html
# The HTML you already have — a Jinja template + your data.
html = Template(INVOICE_HTML).render(invoice=invoice)
# Same Rust engine as @formepdf/html, run in-process via wasmtime.
pdf = render_html(html, page_size="A4")
open("invoice.pdf", "wb").write(pdf)Also on PyPI. MIT licensed, Python 3.8+.
Not a re-implementation. The Python path runs the identical Rust engine as @formepdf/html, compiled to wasm32-wasip1. A CI job renders the same input and options through both and requires identical bytes on every commit — including the PDF/A-2b-with-embedded-fonts case. It's a regression if that ever stops being true.
No cairo, no pango, no system libraries, no browser. pip install formepdf[local] pulls one dependency (wasmtime) and runs in slim containers, on Lambda, and on Windows — the places a native-library toolchain makes painful.
Tagged PDFs, PDF/UA accessibility, and the PDF/A archival ladder come through the same engine paths the JavaScript packages use — pass pdf_ua=True or pdfa="2b" with an embeddable font. Local certify_pdf signs with PKCS#7 / X.509.
Prefer objects to markup? The component DSL — Document, Page, View, Text, Image, tables with headers that repeat across pages, charts, QR and barcodes, form fields — builds the same documents in pure Python.
One path from the JavaScript packages isn't on local Python yet: redact, merge, and extract. Everything above is.
Both engine builds are WASM all the way down — verified in real headless Chromium and real workerd, not just Node. Import paths by target:
Honest sizing: The HTML engine WASM is 7.75 MB uncompressed (3.45 MB gzipped) — since Cloudflare removed the compressed-size limit (64 MiB uncompressed on all plans, 2026-09-04), it fits the Workers free plan; real workloads need the paid plan for CPU time (free caps at 10 ms/request, a typical render is ~20 ms). Lazy-loadable in the browser. Better to know now than in a deploy error.
HTML input docs →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); // idempotent — warm isolates skip it
const { html } = await request.json();
const { pdf, warnings } = renderHtml(html);
return new Response(pdf, {
headers: { 'Content-Type': 'application/pdf' },
});
},
};The same component API in React, Svelte 5, Vue 3, and Preact. Forme compiles to WASM and renders in-process: no browser, no subprocess, no waiting.
import { Document, Page, View, Text } from '@formepdf/react';
import { renderDocument } from '@formepdf/core';
const pdf = await renderDocument(
<Document>
<Page size="Letter" margin={36}>
<Text style={{ fontSize: 24, fontWeight: 'bold' }}>
Invoice #2024-001
</Text>
<View style={{
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 24,
}}>
<Text>Widget Pro</Text>
<Text>$49.00</Text>
</View>
</Page>
</Document>
);
// pdf is a Uint8Array - save it, serve it, email itEdit the code below and watch the PDF render in real time. No sign-up, no server — everything runs in your browser.
Fight with CSS page breaks in a headless browser, or use an editor that can't handle dynamic data. Puppeteer boots a full Chrome instance per render. react-pdf lays out on an infinite canvas and slices it into pages after the fact, so layout can't react to the page boundary.
Content flows into pages, not onto an infinite canvas that gets sliced afterward. Page breaks happen at the right place every time. Flex calculations reflect actual page-constrained dimensions.
The rendering quality of a commercial CSS engine — open source, in-process, anywhere WASM runs.
See the difference in real code.
import puppeteer from 'puppeteer';
// Requires Chrome installed, Docker config,
// 200MB binary, sandbox flags...
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
executablePath: process.env.CHROME_PATH,
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({ format: 'A4' });
await browser.close();import { renderDocument } from '@formepdf/core';
import { Document, Page, View, Text } from '@formepdf/react';
const pdf = await renderDocument(
<Invoice data={data} />
);/* Guess and check. Sometimes works. */
.section { break-inside: avoid; }
.table-row { page-break-inside: avoid; }
/* Nested flex + page breaks = good luck */
@media print {
.header { position: fixed; top: 0; }
/* Hope the browser repeats it... */
}<Table>
<Row header>
<Cell>Item</Cell>
<Cell>Price</Cell>
</Row>
{items.map(item => (
<Row key={item.id}>
<Cell>{item.name}</Cell>
<Cell>{item.price}</Cell>
</Row>
))}
{/* Page breaks handled automatically */}
{/* Table headers repeat on every page */}
</Table>FROM node:20
# Install Chrome dependencies (500MB+)
RUN apt-get update && apt-get install -y \
chromium fonts-liberation \
--no-install-recommends
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# Memory limits, cold starts, Lambda layers...npm install @formepdf/core @formepdf/react
# That's it.
# WASM runs anywhere:
# - Cloudflare Workers
# - Vercel Edge
# - AWS Lambda
# - Browser
# Zero native dependencies.A complete PDF engine, from layout primitives to dev tooling. Everything here ships in the npm packages.
Content flows into pages, not onto an infinite canvas that gets sliced. Page breaks land in the right place, and no paragraph ever leaves a widow or orphan line at a boundary.
Header rows repeat on every page automatically. Cell content is preserved across page breaks, never silently dropped.
Knuth-Plass optimal line breaking from TeX, with automatic hyphenation in 35+ languages.
Real GSUB/GPOS shaping for ligatures, kerning, and contextual forms. TrueType embedding with automatic subsetting — only used glyphs ship.
Right-to-left text for Arabic and Hebrew in the component API. Mixed LTR/RTL with automatic direction detection. The HTML path honours dir="rtl" and CSS direction.
2D grid layout with fixed, fractional, and auto track sizing. Explicit and auto placement. Component API and display: grid in the HTML path — a documented subset, warned by name outside it.
Render existing HTML + print CSS through @formepdf/html: @page rules, margin boxes, page counters, break control. A documented subset — anything unsupported warns by name.
Document, Page, View, Text, Image, Table. Author documents as components in the framework you already use.
forme dev shows your PDF updating as you edit. Click any element to see its box model and computed styles — in the browser or the VS Code extension.
Structure trees, marked content, alt text, and tab order — built into the engine. Both generations verified end-to-end by veraPDF in CI; pdfUa2 emits the PDF 2.0 structure namespace.
The full ladder — 2b/2u/2a, 3b/3u/3a, 4, 4f — verified with veraPDF. Composes with PDF/UA at both generations. Refuses to emit non-conformant output.
Certify documents at render time with X.509/RSA certificates, or certify existing PDFs with certifyPdf(). PKCS#7, pure Rust — no external signing service.
redactText() removes content-stream text by literal or regex match — true removal, not a black box over selectable text. mergePdfs() combines documents.
Fillable text fields, checkboxes, dropdowns, and radio buttons. Full AcroForm support, with optional flattening to static content.
Attach structured JSON to any PDF and extract it back programmatically — no OCR needed. Works client-side too, with @formepdf/core/browser.
BarChart, LineChart, PieChart, AreaChart, DotPlot — rendered as vectors by the engine itself. No SVG hand-rolling, no chart library in your bundle.
Built-in <QrCode> and <Barcode> components. Code 128, Code 39, EAN-13, EAN-8, Codabar. Vector-based, crisp at any zoom.
Tagged PDF structure is on by default: structure trees, marked content, alt text, and keyboard tab order come out of every render. Strict pdfUa mode is one prop away — or pdfUa2 for the PDF 2.0 successor standard — with embedding via @formepdf/fonts-standard — metric-compatible fonts, so the accessible document lays out exactly like the one you previewed.
Scope claimed is scope verified: PDF/A-2 (2b/2u/2a), PDF/A-3 (3b/3u/3a), PDF/A-4 and A-4f, PDF/UA-1, and PDF/UA-2, every level verified with veraPDF. Archival composes with accessibility at both generations — PDF/A-2a + PDF/UA-1 on PDF 1.7, PDF/A-4 + PDF/UA-2 on PDF 2.0 — so one document can be both.
PDF/A needs embeddable fonts — install @formepdf/fonts-standard or register your own. If a font can't be embedded, pdfA refuses to render rather than emitting a file whose metadata claims conformance it doesn't have. The refusal is the feature.
documents pass their claimed profiles — the 9-file conformance corpus plus the 30 gallery templates
validates the full corpus as a CI gate on every commit — not a one-time audit
layout drift: accessible output is geometry-identical to normal output
the full archival ladder — 2b/2u/2a, 3b/3u/3a, 4, and 4f — CI renders the corpus as PDF/A + PDF/UA together and validates both profiles at every gated level
the archival-and-accessible combination is the configuration government, education, and healthcare buyers actually need — one document, both standards
A complete document system — invoices, contracts, reports, labels — built as one family. Copy the HTML and CSS, plug in your data, render. Every one passes PDF/UA-1 validation in CI.
Component tree, inspector, and PDF preview in a native VS Code panel. Click any element to jump to its source line.
Install from Marketplace
Side-by-side with the tools you're probably evaluating.
| Forme | react-pdf | Puppeteer | |
|---|---|---|---|
| Bring existing HTML/CSS | Yes (@formepdf/html) | No (JSX only) | Yes |
| Runs without a browser | Yes (WASM, in-process) | Yes | No (headless Chrome) |
| Print CSS: @page, margin boxes, counters | Yes, incl. @page :first | No | Partial (no margin boxes) |
| Page breaks | Page-native layout (widows/orphans) | Widows/orphans (recent rewrite) | CSS page-break (fragile) |
| Table headers | Automatic on every page | Not built in | Inconsistent <thead> |
| Line breaking | Knuth-Plass | Knuth-Plass | Browser engine |
| Hyphenation | Automatic, 35+ languages bundled | Automatic (en-US bundled) | Browser engine |
| Text shaping | OpenType GSUB/GPOS (rustybuzz) | OpenType GSUB/GPOS (fontkit) | Full browser shaping |
| BiDi text | RTL, mixed LTR/RTL (unicode-bidi) | RTL, mixed LTR/RTL (bidi-js) | Full browser BiDi |
| CSS Grid | Component API: display 'grid', fr/auto/fixed tracks | No | Full CSS Grid |
| Live preview | Built-in dev server | Render to file | Run script, open file |
| Click-to-inspect | VS Code, Cursor, WebStorm | No | No |
| Editor extension | VS Code sidebar panels | No | No |
| Render speed | ~20ms (6-page report) | ~100-500ms | ~1-5s (Chrome boot) |
| Memory | No browser process (WASM) | ~50-100MB | ~50-200MB |
| Links | href prop on Text/View | <Link> component | HTML <a> tags |
| Bookmarks | bookmark prop on any element | Yes | No |
| Charts | 5 built-in components, engine-native vectors | No (hand-rolled SVG) | Via JS chart libraries |
| QR codes | Built-in <QrCode> component | No | Via HTML/JS libraries |
| Barcodes | Built-in <Barcode> (5 formats) | No | Via HTML/JS libraries |
| Text overflow | textOverflow: 'ellipsis' | textOverflow + maxLines | CSS text-overflow |
| Font fallback | fontFamily array + per-glyph fallback | fontFamily array | Full CSS font stack |
| Custom fonts | TTF with OpenType shaping | Yes | Yes |
| Embedded data | Attach JSON, extract later | No | No |
| Browser rendering | Yes (same WASM engine) | Yes (client-side) | No (server only) |
| Dependencies | None (WASM) | yoga-layout | Chrome/Chromium |
| Runs in-process | Yes | Yes | No (subprocess) |
| PDF/A archival conformance | PDF/A-2, A-3, A-4/A-4f — veraPDF-verified, composes with PDF/UA-1 and UA-2 | No | No (Chrome print output isn't PDF/A) |
| True redaction | redactText() — literal/regex, content-stream removal | No | No |
| PDF merging | mergePdfs() in @formepdf/core | No | No |
| Digital certification | certifyPdf() — PKCS#7/RSA, pure Rust | No | No |
Render speed, cold start, and memory are measured per document and per runtime on a fixed corpus — including where Forme loses to a warm Puppeteer on very large tables. See the benchmarks →
Testing generated PDFs — from Forme or any of these producers? pdf-testkit diffs their structure on every commit. See pdf-testkit →
pip install formepdf[local] renders HTML and print CSS to PDF in-process through the engine compiled to wasm32-wasip1 and run via wasmtime — no cairo, no pango, no system libraries, no browser. It pulls a single dependency. It is the same Rust engine as the JavaScript packages: a CI job renders the same input through both and requires byte-identical output on every commit, including PDF/A with embedded fonts. PDF/UA and PDF/A conformance and local PKCS#7 signing are included, and there is a component DSL as well as the HTML path. Redact, merge, and extract are bound in the JavaScript packages but not yet on the local Python path.@page :first, running headers via @top-center, counter(page), and break-before. It is a documented subset rather than all of CSS — anything outside it warns by name at render time instead of failing silently.@formepdf/html/worker on the edge and @formepdf/html/browser in bundlers. The HTML engine WASM is 7.75 MB uncompressed (3.45 MB gzipped, measured at 0.20.0). Since Cloudflare removed the size limit (64 MiB uncompressed on all plans, 2026-09-04) it fits the Workers free plan; real workloads need the paid plan for CPU time — free caps at 10 ms per request and a typical render is ~20 ms. It can be lazy-loaded in the browser.@formepdf/fonts-standard (or your own registered fonts): if a font can't be embedded, pdfa refuses to render rather than emitting a file whose metadata claims conformance it doesn't have.I'd love to hear what you're generating, what you migrated from, and roughly how many documents you produce. It directly shapes what gets built next.