Introduction to takumi-pdf
Render JSX to paged, selectable-text PDF with takumi-pdf.
takumi-pdf renders the same JSX, Tailwind classes, and node trees as image output. It writes a paged vector PDF instead. It ships as a WebAssembly module. It runs on Node.js, Bun, and Cloudflare Workers without Chromium.
- Page breaks honor
break-before: page,break-after: page, andbreak-inside: avoid. - Headers and footers repeat on every page, with page counters.
- Text stays selectable and searchable. Fonts embed as subsets.
- Links and metadata carry into the output.
outline: truebuilds bookmarks from headings. - Attachments embed files, including Factur-X e-invoice XML.
- Tagged PDF is on by default. PDF/A-2, A-3, A-4, and PDF/UA-1 pass veraPDF.
- Arabic, bidi text, CJK, Devanagari, and emoji shape correctly.
- Gradients,
box-shadow, transforms, and color filters stay vector, conic gradients included. - 1.5 MB of gzip wasm fits the Cloudflare Workers free plan.
npm i takumi-pdfRender a document
import { } from "takumi-pdf";
import { } from "@takumi-rs/helpers";
import { } from "node:fs/promises";
const = await (< ={} />, {
// A4 portrait with a 48px margin is the default
: "a4",
: await (["Inter"]),
: (
< ="flex w-full justify-center text-[10px] text-gray-500">
Page < ="pageNumber" /> of < ="totalPages" />
</>
),
});
await ("invoice.pdf", );render() accepts JSX, HTML strings, or JSON node trees. It returns Uint8Array PDF bytes. Content lays out at the page's content width. It flows onto as many pages as needed.
HTML strings use the same parser as takumi-js. A <style> tag in the string applies only to that render:
import { } from "takumi-pdf";
const = await (`
<style>.total { font-weight: 700 }</style>
<div><span class="total">Total: $1,290</span></div>
`);Headers, footers, and measure() accept the same inputs.
Page setup
import { } from "takumi-pdf";
const = await (, {
: "letter", // a page keyword, or { width, height } in CSS px at 96 dpi
: true,
: { : 48, : 32, : 48, : 32 },
});| Option | Type | Default | Description |
|---|---|---|---|
size | a page keyword or { width, height } | "a4" | Page size in CSS px at 96 dpi. Keywords ignore case. |
landscape | boolean | false | Swaps page width and height, including explicit sizes. |
margin | number, "auto", or { top?, right?, bottom?, left? } | "auto" | "auto" fits the band on that side, and a side left out of the object is "auto" too. |
backgroundColor | CSS color | unset | Fills the page box, margins included, under everything. |
size takes the page keywords CSS Paged Media defines, portrait as that module writes them:
| Keyword | Size | Keyword | Size |
|---|---|---|---|
"a3" | 297 × 420 mm | "jis-b4" | 257 × 364 mm |
"a4" | 210 × 297 mm | "jis-b5" | 182 × 257 mm |
"a5" | 148 × 210 mm | "ledger" | 11 × 17 in |
"b4" | 250 × 353 mm | "legal" | 8.5 × 14 in |
"b5" | 176 × 250 mm | "letter" | 8.5 × 11 in |
Set landscape to turn any of them, or pass { width, height } for a size with no keyword.
These options are the structured form of @page, which takumi does not parse as CSS.
size and margin map to the descriptors of the same name, header and footer to its margin boxes.
A margin left at "auto" takes the space its band needs: the band's measured
height plus the 20px it is inset from the paper edge, and never less than 48.
Without a band on that side it stays at 48, which is where a page starts.
Leave backgroundColor unset for white paper.
A viewer draws white by default, and the file stays free of a full-page rectangle.
Reuse a renderer
render() keeps one shared renderer alive. Construct PdfRenderer directly for applications that manage several font sets:
import { } from "takumi-pdf";
const = new ();
await .("https://example.com/Inter-Regular.woff2");
const = await .();Registered fonts deduplicate across calls. Rendering many documents pays the font cost once.
Runtimes
takumi-pdf ships as WebAssembly. It runs on Node.js, Bun, and Cloudflare Workers.
The module fits the Workers free-tier size limit.
Import takumi-pdf/no-init to instantiate the wasm yourself.
Next.js
Import takumi-pdf/next from a route handler. It hands the binary to Turbopack in the form
Turbopack emits, on the Node runtime and the Edge runtime alike, so the package needs no
serverExternalPackages entry.
import { render } from "takumi-pdf/next";In the browser
The default entry picks its loader from the export conditions the bundler sets.
Vite, webpack, and Turbopack set the same conditions for a browser build.
All three land on the Vite entry, and its ?url import only works in Vite.
Load the binary yourself instead:
import init, { render } from "takumi-pdf/no-init";
import wasmUrl from "takumi-pdf/wasm-url";
await init({ module_or_path: wasmUrl });takumi-pdf/wasm-url resolves the binary with new URL(specifier, import.meta.url).
Vite, webpack, and Turbopack rewrite that call to the asset they emit.
The module has no top-level await, so it also works in a worker bundled as an IIFE.
Server code should keep the default entry. A Vite SSR build leaves the URL pointing at the server chunk, where the asset never lands.
On Turbopack, drop any turbopack.rules entry mapping *.wasm to type: "wasm".
That rule makes Turbopack instantiate the binary and look for wasm-bindgen glue the
package does not ship.
Last updated on