Skip to content

Browserless PDF

@broadpaper/pdf drives headless Chromium. That gives the highest fidelity and keeps the editor and the PDF in exact agreement, but it needs a ~700 MB browser on the machine that renders, which rules out edge and most serverless runtimes.

Either backend can be put behind HTTP with @broadpaper/server, so a .NET, Java, Python or Go application can render without a JavaScript runtime of its own.

@broadpaper/forme is an alternative backend built on Forme, an MIT-licensed layout engine written in Rust and compiled to WebAssembly. No browser, no native dependencies, about 7 MB, and it runs anywhere JavaScript does — Node, Deno, Bun, Cloudflare Workers, Vercel Edge, AWS Lambda and the browser itself.

ts
import { renderPdfPaginated } from "@broadpaper/forme";
import { createRegistry } from "@broadpaper/blocks";

const { pdf, pages, paged, warnings } = await renderPdfPaginated({
  template,
  registry: createRegistry([riskProfileBlock]),
  data,
  theme,
  dataSources,
  metadata: { title: "Investment review" },
  fonts: [{ family: "Inter", src: interRegularTtfBytes, weight: 400 }]
});

Two entry points

renderPdfPaginated()recommended. BroadPaper owns pagination exactly as it does with Chromium; Forme only measures and draws. Two passes through the engine: one onto a page tall enough that nothing overflows, which yields a box for every node, line and table row, and one that draws the pages our paginator decided. Every break rule stays ours — keep together, keep with next, orphans and widows, repeated table headers, explicit page breaks, splitting multi-column rows — and page numbers are resolved per page, so {{ page.number }} of {{ page.total }} is exact.

renderPdf() — the engine paginates. One pass, so it is faster, and it uses Forme's own Knuth-Plass line breaking and Fixed running regions. Page breaks will not match the editor, and page-number tokens resolve once rather than per page. Use it for throughput when neither matters.

@broadpaper/pdf (Chromium)renderPdfPaginated()renderPdf()
Who paginatesBroadPaperBroadPaperForme
Page count on the demo445
Page numbersExactExactNot resolved per page
Dependency~707 MB browser~7 MB WASM~7 MB WASM
Demo report~800 ms + launch~400 ms~250 ms
Edge / serverlessNoYesYes
PDF/UA, PDF/ANoYesYes

Neither engine promises to draw the same pixels as the other — they shape text with different code. What each one guarantees is the detail, including the conditions under which output is byte-for-byte reproducible.

What is translated

Every block renderer returns a VNode tree, so the translation happens once at that seam and covers custom blocks automatically — a block never knows which backend is running.

  • Containers, rows and columns → View with flexbox / grid
  • Headings and text → Heading / Text
  • Tables → Table with is_header rows, column widths, and headers repeated per page
  • Charts → Svg, taking the vector markup our chart renderer already produces
  • Images and logos → Image
  • Header and footer regions → Fixed running regions, so page numbers repeat
  • Page breaks → PageBreak

CSS with no equivalent in the engine is reported in warnings rather than dropped silently, so you can see exactly what a given template loses.

Fonts

Forme embeds and subsets the fonts you register. Without any, it falls back to its standard faces, which changes metrics and therefore line breaks:

ts
fonts: [
  { family: "Inter", src: "./fonts/Inter-Regular.ttf", weight: 400 },
  { family: "Inter", src: "./fonts/Inter-Bold.ttf", weight: 700 }
]

src accepts a path (Node), a data: URI or raw bytes.

Accessibility and archival conformance

ts
await renderPdf({ ..., tagged: true, pdfUa: true, pdfA: "2b", metadata: { lang: "en-GB" } });

Both need an embeddable font registered. Anything missing is reported in warnings.

Measuring the canvas with the engine

By default the editor measures with the browser, which is instant and needs nothing extra. That leaves the canvas and the PDF laid out by two different engines, so a paragraph can break a line — and occasionally a page — in one place on screen and another in the file.

Pass createMeasurer and the canvas measures with the engine instead. Then a break you see is a break you get:

tsx
import { init, renderSerializedDocWithLayout } from "@formepdf/core/browser";
import { FormeEditorMeasurer } from "@broadpaper/forme";

let ready: Promise<void> | null = null;

<ReportDesigner
  createMeasurer={() =>
    new FormeEditorMeasurer({
      render: async (doc) => {
        ready ??= init();
        await ready;
        return await renderSerializedDocWithLayout(doc);
      }
    })
  }

/>

Design mode keeps measuring with the browser whatever you pass. Its badges, dashed outlines and drop targets are styled by the document stylesheet, which an engine outside the browser never sees and so cannot measure — and a canvas paginated against heights it does not draw clips its own content. Preview mode draws what the PDF draws, so that is where the engine measures and where the agreement matters.

What it costs: the WebAssembly module has to load before the first layout, and engine measurement is slower than reading the DOM. FormeEditorMeasurer caches by section content and sends only what changed, in one call rather than one per section, so an edit re-measures the section you touched and nothing else.

Give it the same fonts you give the renderer. Without font bytes the engine measures against its own standard faces while the canvas draws with yours, and the two will disagree about where lines wrap.

Rendering in the browser

The same module can produce the file, with no server at all. Supply onExportPdf and the editor's Export button hands the user a real PDF paginated by exactly the code that paginated the canvas:

tsx
<ReportDesigner
  onExportPdf={async (ctx) => {
    const { pdf } = await renderPdfPaginated({
      template: ctx.template,
      registry,
      data: ctx.data,
      theme: ctx.theme,
      dataSources: ctx.dataSources,
      renderer: async (doc) => {
        ready ??= init();
        await ready;
        return await renderSerializedDocWithLayout(doc);
      }
    });
    downloadBlob(new Blob([pdf.slice()], { type: "application/pdf" }), "report.pdf");
  }}

/>

This is what the demo does. It suits evaluation, internal tools and anywhere the data should not leave the browser. Render on a server when you need scheduled or bulk output, or when the data is not in the browser to begin with — the same call works in Node, Workers or Lambda.

Bundler setup

The engine ships as a WebAssembly module built for bundlers, so your bundler needs to handle a .wasm import. For Vite:

ts
import wasm from "vite-plugin-wasm";

export default defineConfig({
  plugins: [react(), wasm()],
  // The module initialises with a top-level await, which every browser we
  // target supports natively.
  build: { target: "esnext" },
  optimizeDeps: { exclude: ["@formepdf/core"] }
});

Webpack needs experiments.asyncWebAssembly. Next.js needs the same in its webpack config, and the import kept out of any component that renders on the server.

Current status

Supported and tested. Both backends agree on page counts across the test corpus — to within one page on a long document, which is the bound the parity test enforces — and the editor, when it measures with the engine, places every section on the same page as the PDF does. Chromium remains the fidelity reference the parity tests measure against, but nothing in the product needs a browser to lay out a page. See docs/ARCHITECTURE.md §15 for the decision record and the translation defects that had to be fixed to get there.