TypeScript · 0 Dependencies · ESM

Generate PDFs directly.
No Chromium, no detours.

PDFs direkt erzeugen.
Ohne Chromium, ohne Umwege.

直接生成 PDF。
无需 Chromium,不绕弯路。

fast-pdf writes PDF syntax itself — no HTML rendering, no headless browser, no native binaries. Runs unchanged on Node ≥ 18, Bun, Deno, in the browser and on edge runtimes. This page explains every feature with code that works exactly as shown.

  • 0runtime dependencies
  • ~1.6 ms3-page document
  • 245tests · 94 % coverage
  • ~187 KBESM, tree-shakeable

Compared to the usual approaches:

fast-pdf Component-framework renderers HTML → headless browser
Runtime deps 0 10+, incl. a UI framework as peer dependency a ~300 MB browser binary
Runs on Node, Bun, Deno, browser, edge Node + browser; edge runtimes are tricky servers that can run a browser
3-page document ~1.6 ms element tree → flexbox layout → PDF lib ~1000 ms+ (browser startup + render)
Tables built-in: repeating headers, spans, JSON → table hand-built from flexbox views HTML/CSS
Signature form fields ✓ AcroForm ✗ printed output has no form fields
TOC, outlines, watermark ✓ built-in partly
Bundle ~187 KB ESM, tree-shakeable several 100 KB + the framework n/a

01Quick start

Create a PDFDocument, add content with calls like text(), table() and image(), then save — that’s the whole workflow. Content flows down the page like in a word processor, and a new page starts automatically when space runs out. Positions are measured from the top-left corner, in points (1 pt = 1/72″). The one-pager below is the complete program — vector graphics, type, an image, a watermark and a table, no other library involved:

npm install fast-pdf     // Node ≥ 18
bun add fast-pdf         // Bun
deno add npm:fast-pdf    // Deno ≥ 2 — or import directly from "npm:fast-pdf"
brief.ts — the complete program, 83 linesimport { readFile } from "node:fs/promises"; import { PDFDocument } from "fast-pdf"; const pdf = new PDFDocument({ format: "A4", margins: 64 });
import { readFile } from "node:fs/promises";
import { PDFDocument } from "fast-pdf";

const INK = "#101828", MUTED = "#667085", FAINT = "#e4e7ec", ACCENT = "#4f46e5";

const pdf = new PDFDocument({
  format: "A4",            // A3 | A4 | A5 | Letter | Legal | { width, height }
  margins: 64,
  font: "helvetica",       // standard 14: helvetica | times | courier
  metadata: { title: "Product Brief", author: "Example Software" },
});
const { width, height } = pdf.pageSize;

// Vector graphics — points, measured from the page's top-left corner. A page
// is transparent until painted, so lay down a white ground first.
pdf.rect(0, 0, width, height, { fill: "#ffffff" });
pdf.rect(0, 0, width, 5, { fill: ACCENT });                // full-bleed accent bar
pdf.watermark("DRAFT", { color: ACCENT, opacity: 0.05 });   // on every page

// Image, clipped to a circle — real vector clipping, no Canvas needed
pdf.image(await readFile("logo.png"), { x: width - 112, y: 62, width: 48, shape: "circle" });

// Title block: eyebrow label, display type, meta line, hairline
pdf.text("PRODUCT BRIEF", { x: 64, y: 66, size: 9.5, bold: true, color: ACCENT, letterSpacing: 3 });
pdf.text("Nimbus Workstation", { x: 62, y: 88, size: 33, bold: true, color: INK });
pdf.text("Q3 2026 · Example Software", { x: 64, y: 132, size: 10, color: MUTED });
pdf.line(64, 168, width - 64, 168, { color: FAINT, width: 0.5 });

// From here content flows down the page and breaks pages on its own
pdf.y = 196;
pdf.text("A workstation configured for build farms: sixteen cores, 64 GB of " +
         "memory and a chassis that stays quiet under sustained load.",
  { width: 360, size: 11, color: "#334155", lineHeight: 1.6, spacingAfter: 34 });

// Three stats — an accent tick, a label, a number. No charting library.
pdf.grid([
  ["CORES", "16"], ["MEMORY", "64 GB"], ["WARRANTY", "36 mo"],
].map(([label, value]) => (d) => {
  d.rect(d.x, d.y, 26, 2, { fill: ACCENT });
  d.y += 16;
  d.text(label, { size: 8.5, bold: true, color: MUTED, letterSpacing: 1.6 });
  d.text(value, { size: 21, bold: true, color: INK });
}), { columns: 3, gap: 18 });

pdf.moveDown(1.5);
pdf.table([
  ["Item", "Qty", "Unit", "Amount"],
  ["Nimbus Workstation", "1", "999 €", "999 €"],
  ["Docking station", "2", "149 €", "298 €"],
  ["USB-C cable, 2 m", "3", "19 €", "57 €"],
], {
  widths: [227, 50, 90, 100], aligns: ["left", "right", "right", "right"],
  headerFill: "#f8f9fc", headerColor: MUTED, borderColor: FAINT, borderWidth: 0.5,
  padding: 9,
});
pdf.table([["Total, net", "1.354 €"]], {          // totals block: a borderless table
  header: false, widths: [367, 100], aligns: ["right", "right"],
  borderWidth: 0, fontSize: 12,
});

// A short list — bullets centred from real font metrics, not a magic constant
pdf.moveDown(1.4);
pdf.text("IN THE BOX", { size: 8.5, bold: true, color: MUTED, letterSpacing: 1.6, spacingAfter: 9 });
for (const item of ["Workstation, assembled and burned in for 48 h",
                    "Two docking stations, firmware pre-flashed",
                    "Three USB-C cables, 2 m, e-marked",
                    "On-site swap within one business day, 36 months"]) {
  const m = pdf.fontMetrics({ size: 10 });
  pdf.circle(pdf.x + 2, pdf.y + m.baseline - m.capHeight / 2, 1.8, { fill: ACCENT });
  pdf.text(item, { x: 13, size: 10, color: "#334155", spacingAfter: 6 });
}

// Full-bleed call-to-action strip and a footer, both absolute
pdf.rect(0, height - 188, width, 68, { fill: INK });
pdf.text("Ready to order?", { x: 64, y: height - 168, size: 13.5, bold: true, color: "#ffffff" });
pdf.text("Reply to this brief by 30 Sep 2026 · sales@example-software.com",
  { x: 64, y: height - 146, size: 9.5, color: "#98a2b3" });

pdf.line(64, height - 84, width - 64, height - 84, { color: FAINT, width: 0.5 });
pdf.text("Prices excluding VAT · valid for 30 days · example-software.com",
  { x: 64, y: height - 74, width: width - 128, size: 8.5, color: MUTED, align: "center" });

await pdf.save("brief.pdf");
Rendered A4 one-pager: indigo accent bar, circular logo, Nimbus Workstation display headline, three stat columns, a hairline price table, a bulleted list, a dark call-to-action strip and a faint diagonal DRAFT watermark
The output of exactly this snippet — brief.pdf, page 1, rendered with macOS Quartz.

02Output formats

The right output for every platform — the core stays identical everywhere:

const bytes  = await pdf.render();     // Uint8Array — works everywhere
await pdf.save("invoice.pdf");         // Node/Bun/Deno: file · browser: download
const buffer = await pdf.toBuffer();   // Node Buffer (Uint8Array elsewhere)
const blob   = await pdf.toBlob();     // Blob for FormData, object URLs, …
const stream = pdf.toStream();         // ReadableStream, 64 KiB chunks

// API route (Next.js, Hono, Workers, …):
return new Response(pdf.toStream(), {
  headers: { "Content-Type": "application/pdf" },
});

03Pages & breaks

Text and tables break pages automatically. What most libraries are missing: deciding yourself where the next page starts. That’s exactly what pageBreak() is for — an explicit break at precisely the spot you choose, optionally with a custom start position and per-page setup:

pdf.text("Chapter 1 …");

pdf.pageBreak();                       // new page, cursor at the top margin
pdf.pageBreak({ y: 200 });             // new page, content starts at y = 200 pt
pdf.pageBreak({ landscape: true });    // new page in landscape
pdf.pageBreak({ format: "A5", margins: 30, y: 120 });   // all combinable

There’s manual cursor control as well:

pdf.addPage();          // like pageBreak() without options
pdf.moveDown(2);        // move down two line heights
pdf.y = 300;            // set the cursor directly
console.log(pdf.y);     // … or read it
Good to know: Inside container(), columns() and grid(), pageBreak() is deliberately forbidden — those blocks guarantee their content stays on one page. The call throws a FastPDFError there instead of tearing the layout apart.

04Text & typography

Line wrapping against real font metrics, justified text, decorations and letter spacing:

pdf.text("Wrapped automatically — äöüß € “curly quotes” – dashes.", {
  size: 12, bold: true,
  color: "#334155",              // "#rgb" | "#rrggbb" | { r, g, b }
  align: "justify",              // left | center | right | justify
  underline: true,               // also: strikethrough
  letterSpacing: 0.5,            // pt between characters
  lineHeight: 1.4,
  width: 300,                    // wrap width (default: content width)
  spacingAfter: 8,
});

pdf.text("Header", { y: 20, align: "right" });      // absolute: no flow, no break
pdf.widthOfText("How wide am I?", { size: 12 });    // measure without drawing

Hyphenation

Mark preferred break points with a soft hyphen (U+00AD). Words break only where needed — with a visible hyphen at the break:

pdf.text("Donau­dampf­schiff­fahrts­gesellschaft", { width: 100 });
// → "Donaudampf-" ⏎ "schifffahrts-" ⏎ "gesellschaft"

05Custom fonts

TrueType fonts are embedded as Type0/Identity-H — with subsetting (only the glyphs you use end up in the PDF) and a ToUnicode CMap (copy & search keep working). That makes the font’s full Unicode range available:

const inter = await fetch("/fonts/Inter.ttf").then(r => r.arrayBuffer());

pdf.registerFont(inter,     { family: "inter" });
pdf.registerFont(interBold, { family: "inter", bold: true });

pdf.text("Full Unicode — Ελληνικά, кириллица, 中文", { font: "inter" });
pdf.text("Set in bold", { font: "inter", bold: true });
Fallback rule: If a variant is missing (e.g. italic), fast-pdf falls back to the regular cut. The standard 14 fonts (Helvetica, Times, Courier) need no embedding — zero extra bytes in the PDF.

06Layout engine

Boxes, columns and grids with padding, borders and relative widths — height grows with the content:

pdf.container(
  {
    width: "80%", align: "center",        // points or "%" of the available width
    padding: 12, margin: { top: 8 },
    background: "#eef4ff",
    border: { color: "#4a7dff", width: 1 },
    radius: 8, minHeight: 60,
  },
  (d) => d.text("A box that grows with its content."),
);

pdf.columns(
  [(d) => d.text("Left column"), (d) => d.text("Right column")],
  { widths: ["35%", "65%"], gap: 16 },
);

pdf.grid(
  cards.map((c) => (d) => d.text(c.title)),
  { columns: 3, gap: 10 },     // breaks between rows, never inside cells
);

After columns() the cursor sits below the tallest column. Containers and columns nest freely.

07Tables

The header repeats on every page, the footer is drawn once at the end. Cells can span columns and rows — span groups never straddle page breaks:

pdf.table(
  [
    [{ text: "Invoice Q3", colSpan: 3, align: "center" }],
    ["Item", "Qty", "Price"],
    [{ text: "Consulting", rowSpan: 2 }, "8 h", "960.00 €"],
    ["4 h", "480.00 €"],
    [{ text: "Total", colSpan: 2, bold: true }, "1,440.00 €"],
  ],
  {
    widths: [220, 90, 110],
    aligns: ["left", "right", "right"],
    header: true,                  // first row: repeats on every page
    footer: true,                  // last row: styled, never repeated
    zebraFill: "#f8fafc",
    headerFill: "#0f172a", headerColor: "#ffffff",
    padding: 6, borderWidth: 0.5,
  },
);

Style individual cells inline: { text, bold, italic, color, fill, align, colSpan, rowSpan }.

Straight from a REST/JSON response

Got an array of records from your API? objectTable() turns it into a table with no manual row mapping — columns default to the keys of the first record, or you pick order, headers, widths and formatting:

const orders = await fetch("/api/orders").then((r) => r.json());

pdf.objectTable(orders);            // columns = keys of the first record

pdf.objectTable(orders, {           // …or take full control
  columns: [
    { key: "id",       header: "No.",    align: "right", width: 60 },
    { key: "customer", header: "Customer" },
    { key: "total",    header: "Amount", align: "right",
      format: (v) => `${v} €` },   // format(value, record) — computed cells too
  ],
  zebraFill: "#f8fafc",             // every table() option works here as well
});
Download on button click: wire it to a button and let the user save the PDF — all client-side. save() triggers a browser download; on the server it writes a file (same call).
async function onClick() {
  const rows = await fetch("/api/orders").then((r) => r.json());
  const pdf = new PDFDocument();
  pdf.objectTable(rows);
  await pdf.save("orders.pdf");   // → browser download
}

08ImagesNew: GIF & WebP

pdf.image(bytes, { width: 200 });                        // flows, keeps aspect ratio
pdf.image(logo,  { x: 400, y: 30, width: 120 });         // absolute position
pdf.image(photo, { width: 200, height: 200, fit: "cover" });  // fill | contain | cover
pdf.image(photo, { width: 100, crop: { x: 50, y: 50, width: 400, height: 400 } });
pdf.image(stamp, { width: 80, rotate: -15, align: "center" });
Format Behavior
JPEG Embedded as-is (DCTDecode), no re-encoding — grayscale, RGB, CMYK
PNG Gray/RGB/indexed without re-encoding; the alpha channel becomes a real SMask (transparency)
GIF First frame; palette decoded to RGB, a transparent index becomes an SMask (dependency-free LZW)
WebP Lossless (VP8L) decoded to RGB + SMask — full transform/colour-cache/LZ77 set; lossy (VP8) is rejected
Cache Identical image bytes are embedded once, no matter how many pages use them

09Shapes

pdf.line(50, 100, 545, 100, { color: "#e2e8f0", width: 0.5 });
pdf.rect(50, 120, 100, 40, { fill: "#3b82f6", radius: 8 });      // rounded
pdf.circle(100, 300, 40, { fill: "#ffd166", stroke: "#c79000" });
pdf.ellipse(300, 300, 80, 40, { stroke: "#0f172a", lineWidth: 2 });

Circles and ellipses are exact Bézier approximations; fill and stroke combine freely.

10SVG graphicsNew: svg()

svg() renders a practical SVG subset as native PDF vector graphics — no rasterization, crisp at every zoom level. Ideal for logos and icons. Supported: rect, circle, ellipse, line, polyline, polygon, path, text and groups (g) with fills, strokes, opacity and the full transform list (translate, rotate, scale, skewX/Y, matrix):

const logo = await fetch("/logo.svg").then((r) => r.text());

pdf.svg(logo, { width: 160, align: "center" });     // flows, keeps aspect ratio
pdf.svg(logo, { x: 430, y: 30, width: 110 });       // absolute: letterhead logo
pdf.svg(logo, { width: 200, height: 100, fit: "cover" });   // contain | fill | cover

// currentColor is substituted — one icon set, any theme color:
pdf.svg(icon, { width: 14, color: "#2e5ce6" });
Not supported: gradients, patterns, filters, clip-paths, masks and CSS stylesheets — shapes using them fall back to a flat fill. Input can be a string or Uint8Array; a zero-sized viewBox throws INVALID_ARGUMENT.

11MarkdownNew: markdown()

markdown() renders a CommonMark/GFM subset straight into the flow — headings, paragraphs, nested ordered/unordered lists, pipe tables, fenced code blocks, blockquotes, horizontal rules, plus inline bold, italic, code, links and images. Perfect for turning LLM output, release notes or CMS content into a document without writing layout code:

pdf.markdown(`
# Quarterly Report

Revenue grew **12 %** — details in the [appendix](#appendix).

| Region | Revenue |
| ------ | ------: |
| EMEA   | 4.2 M€  |
| APAC   | 3.1 M€  |

- automatic page breaks
- nested lists
  1. work too
`);

Tables go through table() — repeating headers included. Since the core does no I/O, you resolve image references yourself:

pdf.markdown(readme, {
  resolveImage: (src) => imageBytes.get(src),   // ![alt](src) → raw bytes
});
// without a resolver, images render as their alt text
Agent-friendly: pipe a model's Markdown answer straight into a PDF — pdf.markdown(completion), done. Headings scale from your base font size, code blocks are set in Courier on a tinted background, and everything breaks pages like regular content.

12Document features

Recurring elements are drawn onto every page at render time — so they know the final page number and count:

pdf.header("Annual Report 2026", { align: "right" });
pdf.footer("© 2026 Example Software");
pdf.pageNumbers({ format: (n, t) => `Page ${n} of ${t}`, startAt: 2 });
pdf.watermark("DRAFT", { opacity: 0.1 });     // diagonal, transparent

Bookmarks, links & table of contents

pdf.outline("Chapter 1");                    // PDF bookmark (viewer sidebar)
pdf.outline("Section 1.1", { level: 1 });    // nested

pdf.anchor("details");                                   // named jump target
pdf.text("Read more …", { link: "#details" });           // internal link
pdf.text("Website", { link: "https://example.com" });    // external link

pdf.toc({ title: "Contents" });   // call last: builds linked TOC pages
                                  // and inserts them at the front —
                                  // page numbers shift correctly

Clickable buttons0.7.0

A link does not have to be underlined text. button() draws a filled, optionally bordered box with an optically centred label and puts the link annotation on top. It flows like text and breaks the page when it no longer fits; passing y places it absolutely. Without width the box sizes itself to its label:

pdf.button("Open the demo", {
  link: "https://kevinci.github.io/fast-pdf/",  // URL or "#anchor"
  fill: "#4f46e5",           // button colour
  borderColor: "#3730a3",    // borderWidth defaults to 1 once set
  color: "#ffffff",          // label colour
  width: 200,                // points or "60%" — omit to fit the label
  radius: 6,
  align: "center",           // placement in the flow area
});

pdf.button("Ghost", {        // outline style: light box, dark label
  link: "#details", fill: "#ffffff", borderColor: "#e4e7ec", color: "#101828",
});

pdf.button("Sidebar CTA", { link: "…", x: 40, y: 700, width: 120 });  // absolute

pdf.link(50, 50, 200, 20, "#details");   // bare clickable area, draws nothing
Under the hood: a rectangle plus a link annotation — deliberately not an AcroForm /Btn widget. It needs no form support, renders in every viewer and can do nothing but follow its target. A label wider than the box is truncated with an ellipsis instead of spilling out, and unsafe schemes (javascript:, vbscript:, data:, file:) are rejected with UNSAFE_LINK — the same check link() and text({ link }) use.

Availability: button() ships with version 0.7.0 at the end of August 2026. link(), anchor() and text({ link }) work today in 0.7.0.

Signature fields

signature() places an empty AcroForm /Sig field — the clickable area recipients use to digitally sign the document in their PDF viewer (e.g. Adobe Acrobat) and send it back. A signature line and an optional label are drawn automatically; in flow mode the field moves the cursor and breaks pages like any other content:

pdf.signature({ label: "Client · place, date" });   // flow mode, auto-named "Signature1"

// Absolute positioning + custom names: two fields side by side
pdf.signature({ name: "client",     x: 60,  y: 600, width: 210, height: 56, label: "Client" });
pdf.signature({ name: "contractor", x: 325, y: 600, width: 210, height: 56, label: "Contractor" });

pdf.signature({ name: "initials-p1", width: 86, height: 32, line: false });  // initials, no line
Good to know: field names must be unique per document (auto-numbered Signature1, Signature2, … if omitted). fast-pdf creates the fields to be signed by the recipient — it does not cryptographically sign the document itself. A complete, ready-to-sign contract lives in examples/signature.ts (npx tsx examples/signature.ts, then open the PDF in Acrobat and click a field).

13Appending PDFsNew: append()

append() attaches the pages of an existing PDF to the document you just generated — a certificate, a reference letter, a scan somebody uploaded. The classic case: a CV builder that lets applicants add their references to the résumé it produces. Until now that was the one job that needed a second library:

const pdf = new PDFDocument();
pdf.text("Curriculum Vitae", { size: 24 });

await pdf.append(certificateBytes);                 // original size, 1:1
await pdf.append(letterBytes, { fit: "page" });     // scaled onto A4
await pdf.append(scanBytes, { pages: [1, 3] });     // a selection

await pdf.save("application.pdf");

Pages are copied, not re-rendered: content streams, fonts and images move over byte-for-byte with their filters intact. An appended page therefore looks exactly like the original and stays as small as it was, and objects shared by several pages of one file are written once. append() is async — the only page-producing call that is, because the source has to be parsed before its page count is known.

Option Meaning
pages 1-based page numbers to take, in the order given (2 or [3, 1]). Default: all
fit "keep" (default) keeps the original page size; "page" scales onto this document's format
overlay Allow header()/footer()/pageNumbers()/watermark() and your own drawing on the appended pages. Default: off for "keep", always on for "page"
padding fit: "page" only — inset from the page edge, in points
autoRotate fit: "page" only — give the target page the source's orientation. Default: true

Check an upload before you take it

pdfInfo() reads page count, page sizes and whether a file is encrypted — without importing anything. Exactly what an upload form needs to reject a 500-page file or tell the user their PDF is password-protected:

import { pdfInfo } from "fast-pdf";

const info = await pdfInfo(uploadBytes);
// { version: "1.7", pageCount: 3, encrypted: false, pageSizes: [ … ] }

if (info.encrypted) return "Please remove the password protection first.";
if (info.pageCount > 20) return "At most 20 pages, please.";
await pdf.append(uploadBytes);

Drawing on appended pages

By default an appended page is left alone — no page number gets stamped over a document somebody else signed. With overlay: true the imported content stays underneath and everything you draw lands on top, upright even on a page that was scanned sideways. Without it, the flow cursor continues on a fresh page, so the next text() never ends up on the attachment:

pdf.pageNumbers({ format: (n, total) => `Page ${n} of ${total}` });

await pdf.append(letterBytes, { overlay: true });   // numbered too
pdf.text("Attachment 1", { y: 30, size: 9 });       // stamped on top

await pdf.append(contractBytes);                    // untouched
pdf.text("Cover note");                             // → on a new page

Drawing on an appended page without overlay is a typed error rather than silently dropped content. Reading covers classic cross-reference tables, cross-reference streams and object streams (PDF 1.5+), page rotation and crop boxes; a file whose cross-reference table is damaged or stale is recovered by scanning it. Encrypted sources are rejected with ENCRYPTED_PDF.

Uploads are untrusted input: the page dictionary and the annotation types that come along are whitelists. Form fields, bookmarks and tagged structure are not imported, and neither is any annotation holding more than a plain web link or a jump inside the imported pages — a file attachment or a /JavaScript action cannot travel out of an upload and into your output. Try it yourself: the repository ships a browser test page at docs/append-playground.html — run npm run build, then python3 -m http.server 8080 and open it; drop PDFs in and watch the result. The runnable Node example is examples/append.ts (npm run append).

14Error handling

All user-facing failures throw FastPDFError with a stable, machine-readable code — messages may be reworded, codes never change:

import { FastPDFError } from "fast-pdf";

try {
  pdf.text("Hello", { font: "doesNotExist" });
} catch (e) {
  if (e instanceof FastPDFError && e.code === "UNKNOWN_FONT") {
    // "UNKNOWN_FONT" | "INVALID_COLOR" | "UNSUPPORTED_IMAGE"
    // "UNKNOWN_PAGE_FORMAT" | "INVALID_ARGUMENT" | …
  }
}

15SecurityNew: encryption & signatures

Security is a first-class design goal, not an afterthought. The long-term aim is a build that regulated industries — banks and insurers — can rely on. That bar isn’t fully cleared yet, so here is the honest split: what is hardened today, and what is still on the roadmap.

Hardened today

Area Protection
String & name injection Every literal and name is byte-escaped (escapeString, name #XX) — content can’t break out of the object stream
Link schemes javascript:, vbscript:, data: and file: targets are rejected → UNSAFE_LINK
Null & control bytes Escaped octally, never written raw
Numeric input NaN, ±Infinity and out-of-range magnitudes are rejected at the call site → INVALID_NUMBER
Memory exhaustion Decompression is size-bounded (zlib-bomb guard); oversized images are refused → IMAGE_TOO_LARGE
Failure surface Every user-facing error is a typed FastPDFError with a stable code
Document encryption AES-256 (R6 / AESV3) with user/owner passwords and granular permissions via encrypt — strings and streams are both encrypted
Reproducible output deterministic mode emits byte-identical PDFs (no wall-clock timestamp) with a content-derived file /ID
Digital signatures Detached PAdES-B (CAdES) signing via signature({ sign }) — RSA + SHA-256, embedded certificate, verifiable in Acrobat and with openssl

On the roadmap

Still toward banks & insurers: PDF/A-1/2/3 archival profiles, tagged/accessible PDF, and signature timestamps (RFC 3161). These are planned, not shipped — tracked in the enterprise roadmap.

16Performance

Direct PDF synthesis instead of a browser detour. Compression uses the native CompressionStream API (zlib) — zero bundle bytes for it. Measured with npm run bench (Apple Silicon, Node 22):

Document Time Throughput Size
Text, ~3 pages 1.6 ms 621 docs/s 1.9 KB
Text, ~30 pages 12.6 ms 79 docs/s 13.0 KB
Table, 500 rows (~17 p.) 26.4 ms 38 docs/s 55.4 KB
Table, 5000 rows (~170 p.) 232 ms 4 docs/s 549 KB
Mixed (text/tables/vector) 0.9 ms 1071 docs/s 2.8 KB

17See it rendered

A design-forward report from examples/report.ts (npx tsx examples/report.ts), rendered with macOS Quartz — full-bleed color, a vector bar chart and big type, all from the same primitives shown above. Proof that "fast" doesn't mean "plain":

Dark report cover with oversized Growth Report headline, coral and mint decorative rings
Cover — full-bleed background, oversized type and decorative rings from rect() and circle().
Dark metrics dashboard with KPI cards, a gradient vector bar chart and a mint progress ring
Dashboard — KPI cards, a bar chart built from rounded rect()s and a progress ring, no charting library.

18Templates & AI skill

The fastest way to a good-looking document is copying a finished one. Five complete, designed templates ship with the npm package (node_modules/fast-pdf/examples/) — copy one into your project and change the import from "../src/index" to "fast-pdf":

Template What you get
invoice.ts Invoice with letterhead, item table, totals block, footer
report.ts Design-forward report: full-bleed cover, KPI cards, vector bar chart
signature.ts Contract with clause sections and clickable AcroForm signature fields
showcase.ts Feature tour: TOC, outlines, watermark, cell spans, columns, links
basic.ts Minimal text + table starting point

Working with Claude Code or another coding agent? fast-pdf ships a design skill — curated palettes, layout recipes (letterhead, totals block, signature area), typography rules and a render-preview-iterate loop. One command installs it into your project:

npm install fast-pdf
npx fast-pdf-skill     // copies the skill to ./.claude/skills/fast-pdf-designer/
Then just ask: "an invoice with fast-pdf" — the agent picks up the design rules automatically, starts from a template and validates the result visually before handing it over.

19Changelog

Release notes, newest first — kept in English so they match CHANGELOG.md in the repository one to one. The format follows Keep a Changelog, versioning follows SemVer.

0.7.12026-08-05Clarity fix around appended annotations, plus a technical report and a dependency-free audit tool for verifying the filter.

Changed

  • A /Link left without a destination by the annotation filter is now dropped instead of copied as an inert rectangle. Its behaviour had sat in /AA (mouse-enter JavaScript), which is never copied — so the action was already gone in 0.7.0 and clicking did nothing. But the rectangle survived, and viewers still show a hand cursor over it, which reads as “the filter did not work”. Purely a clarity fix: no security-relevant behaviour changed between 0.7.0 and 0.7.1.

Added

  • docs/APPEND-SECURITY.md — technical report on what append() reads, copies and discards, why it is a whitelist rather than a blocklist, and how to verify it independently. Covers the three observations that regularly look like a broken filter and are not: visible page text spelling out payload names (page content is copied verbatim by design), a deliberately harmless control link, and an in-document /GoTo jump that is retargeted rather than removed.
  • scripts/audit-pdf.mjs — lists the security-relevant structures of any PDF. Dependency-free and independent of fast-pdf, so it works as a second opinion. Strips stream payloads before scanning, so visible page text cannot produce false hits.
0.7.02026-08-05Appending the pages of an existing PDF, and clickable buttons: a filled, optionally bordered box with a centred label.

Added

  • append(pdfBytes, options) — attach the pages of an existing PDF. The most common reason to run fast-pdf next to a second library: a CV builder whose applicants upload a reference letter or a certificate and want it attached to the generated résumé. Pages are copied, not re-rendered — content streams, fonts and images move over byte-for-byte with their filters intact, so an appended page looks exactly like the original, stays as small as it was, and no filter beyond /FlateDecode has to be understood. Objects shared by several pages of one file are written once. fit: "keep" (default) copies the page dictionary, so the page keeps its size, rotation and annotations; fit: "page" scales it onto this document's format (padding, autoRotate). overlay opens imported pages to header(), footer(), pageNumbers(), watermark() and direct drawing — via a form XObject whose /Matrix undoes the source page's rotation and box offset, so a stamp lands upright even on a page scanned sideways. Without overlay the flow continues on a fresh page, and drawing on an appended page is a typed error rather than silently dropped content. Reading covers classic cross-reference tables, cross-reference streams and object streams (PDF 1.5+) with PNG/TIFF predictors, inherited page attributes, /Rotate and /CropBox; a damaged or stale cross-reference table is recovered by scanning the file. Encrypted sources are rejected with ENCRYPTED_PDF; source form fields, bookmarks and tagged structure are not carried over, and neither are annotations holding an action other than a plain web link or a jump inside the imported pages, so a /Launch or /JavaScript action cannot travel out of an upload and into the output. Decompression is bounded at 64 MB per stream.
  • pdfInfo(pdfBytes){ version, pageCount, pageSizes, encrypted }. Inspect an upload — reject a 500-page file, show “3 pages”, detect a password-protected file — without importing anything.
  • New error codes: INVALID_PDF_FILE, ENCRYPTED_PDF, UNSUPPORTED_PDF, DECOMPRESSION_UNSUPPORTED.
  • button(label, options) — a clickable button: a filled (optionally bordered) rounded box with an optically centred label, covered by a link annotation. link, fill, borderColor, borderWidth, color, width (points or "60%"), height, radius, paddingX/paddingY, font, size, bold, letterSpacing, textAlign, align, opacity, x/y, spacingBefore/spacingAfter. Flows by default and breaks the page when it no longer fits; y switches to absolute placement. Without width the box sizes itself to the label; a label wider than the box is truncated with an ellipsis rather than allowed to spill out. Deliberately a link annotation and not an AcroForm /Btn widget: no form support needed, renders in every viewer, and it can do nothing but follow its target. Targets go through the same UNSAFE_LINK check as link().
0.6.02026-08-01Driven almost entirely by a field report from a production application that builds six CV designs, a skill matrix, invoices and CLI documents with fast-pdf — in the browser, on a Node server and in scripts. Every item removes something that project had to build or work around by hand.

Driven almost entirely by a field report from a production application that builds six CV designs, a skill matrix, invoices and CLI documents with fast-pdf — in the browser, on a Node server and in scripts. Every item removes something that project had to build or work around by hand. No breaking changes: existing documents render as before.

Added

  • Measurement — measureText(), measureBlock(), lastBlockHeight. The single largest gap: absolutely positioned designs had to predict their own line counts, which meant reimplementing the line breaker. measureText() wraps through the same engine text() draws with; measureBlock(fn, { width }) lays arbitrary flow content out on a throwaway page and reports its height.
  • fontMetrics(){ baseline, ascent, descent, capHeight, lineGap, lineHeight } in points, so optical alignment no longer needs a reverse-engineered constant.
  • Browser build behind the browser export condition. dist/index.browser.js contains no fs reference at all and is selected automatically — no more resolve aliases and stub modules in Vite, webpack or Turbopack. Also reachable as fast-pdf/browser.
  • Clipping in the public API — clip(), image({ radius, shape }). A round avatar used to require punching alpha through a <canvas>; it is now a real vector path in every runtime.
  • opacity on every primitiveline, rect, circle, ellipse, text, image, svg and container backgrounds.
  • Flow control for absolute layoutsensureSpace(), remainingHeight, keepTogether(), plus spacingBefore and keepWithNext on text().
  • region({ x, y, width, height, clip }, fn) — flow content with its own cursor inside any rectangle, including inside onPage() decorators. Returns { usedHeight, remaining, overflow }, so a sidebar that no longer fits says so.
  • flowColumns(items, options) — newspaper-style multi-column flow that runs from column 1 into column 2 and onto the next page, with balance: true and a dropped report instead of silent overflow.
  • pdf.x and pdf.width — the active flow area's left edge and width; the documented bridge between flow offsets and absolute page coordinates.
  • Rotated texttext({ rotate }) for marginalia, turned column heads and spine labels.
  • Table valign and self-drawing cells. Cells take valign: "top" | "middle" | "bottom" and a render: (doc, box) => … callback for progress bars, badges or logos.
  • language document option → the catalog's /Lang, plus ViewerPreferences /DisplayDocTitle when a title is set.
  • Synthetic italic. A family registered without an italic cut is slanted by the standard 12° oblique shear instead of rendering silently upright.
  • Permissions-only encryptionencrypt: { permissions: {…} } without inventing a dummy owner password, and encrypt.onUnsupported: "throw" | "skip" for runtimes without Web Crypto.

Fixed

  • widthOfText() ignored letterSpacing — every letterspaced heading had to be corrected by hand at the call site. It now shares one measurement function with the renderer.
  • SVG arc flags were mis-parsed. Minifiers run large-arc-flag and sweep-flag into the following number (a5 5 0 0150 0 means 0, 1, 50, 0), which corrupted practically every icon set using a/A — Lucide, Feather, Heroicons.
  • Zero-length SVG arcs emitted NaN and poisoned the rest of the path; they are now dropped, per SVG 1.1 F.6.2.
  • Line breaking only considered spaces and soft hyphens. Real hyphens, dashes and slashes are break opportunities now (UAX #14 classes HY/BA); digit groups such as 2026-08-01 and 3/4 stay whole.
  • Table row heights are derived from measured content height rather than line count, so rows containing rendered cells size correctly.

Changed

  • wrapLines() is documented and enforced as the single line-breaking implementation: drawing and measuring cannot diverge by construction.
  • Font implementations expose capHeight and lineGap, read from OS/2 / hhea for embedded fonts.
  • Shapes emit colour and line-width operators before the path is constructed, matching PDF's graphics object model. Output is visually identical, but if you hash deterministic: true output, expect new digests for documents containing rect(), circle() or ellipse().
  • npm run build clears dist/ itself — tsup runs the two build configs concurrently, so its own clean would race them.

Documentation

  • README: measurement, regions, multi-column flow, clipping, opacity, self-drawing table cells, the browser condition, and a new Limitations table stating plainly what fast-pdf does not do (reading/merging PDFs, tagged PDF, non-signature form fields, WOFF2, shaping, gradients).
  • Encryption is documented for the first time, including the advisory nature of PDF permissions.
  • The font section names .ttf as the required format and gives a one-line WOFF2 conversion command.
0.5.02026-07-23

Added

  • WebP images (lossless). image() accepts the VP8L profile, decoded in-house to DeviceRGB plus an 8-bit /SMask when the picture has transparency. The decoder implements the complete VP8L feature set — the four inverse transforms, the colour cache, meta-Huffman code groups and LZ77 with 2-D distance mapping — with no dependency, validated bit-exact against libwebp. Lossy WebP (VP8) is rejected with UNSUPPORTED_IMAGE.
  • GIF images. First frame, composited onto the logical screen; the palette is decoded to DeviceRGB and a transparent colour index becomes an /SMask. Dependency-free LZW decoder.
  • SVG rendering. svg() renders a practical subset as native PDF vector graphics — rect, circle, ellipse, line, polyline, polygon, path, text, groups with fills, strokes and opacity, and the full transform list.
  • Markdown rendering. markdown() renders a CommonMark subset — headings, paragraphs, lists, tables, code blocks, blockquotes, rules, and inline emphasis, links and images.
  • Digital signatures. signature({ sign }) signs with a detached PAdES-B (CAdES) signature — RSA + SHA-256, ESS signing-certificate-v2, CMS SignedData built in-house and signed via Web Crypto. Validated against openssl cms -verify and macOS Quartz.
  • Document encryption. AES-256 standard security handler (revision 6 / AESV3, ISO 32000-2) with user password, owner password and granular permissions. No RC4/MD5, no dependency; both strings and streams are encrypted.
  • Deterministic output. The deterministic option produces byte-identical PDFs for identical input — no wall-clock timestamp unless metadata.creationDate is set.
  • Every document carries a file /ID derived from a 128-bit digest of the file body; ModDate is written alongside CreationDate.

Security

  • Numeric input hardening: line(), rect(), circle(), ellipse() and text() reject NaN, ±Infinity and magnitudes ≥ 1e21 at the call site with the stable INVALID_NUMBER code.
  • fmtNumber() throws a typed FastPDFError as a last-line-of-defence guard, so no code path can emit a corrupt PDF number.
0.4.02026-07-19

Added

  • The fast-pdf-designer Claude Code skill ships with the package: palettes, layout recipes and a visual validation loop. Install with npx fast-pdf-skill.
  • The example templates (examples/*.ts) are part of the npm package, linked from the README as copy-and-adapt starting points.
  • signature() — empty AcroForm signature fields (/FT /Sig) for contracts: draws a signature line and optional label, participates in the flow layout, auto-names fields with uniqueness enforced.

Security

  • Link targets reject javascript:, vbscript:, data: and file: schemes — including variants disguised with control characters — with UNSAFE_LINK.
  • The PNG alpha decode path is hardened against decompression bombs: IDAT size is capped at the size implied by the declared dimensions, pixel count at 2²⁷ (~134 MP).
  • Truncated or malformed PNG/JPEG files fail with typed FastPDFErrors (INVALID_IMAGE_FILE, IMAGE_TOO_LARGE) instead of RangeErrors deep in the parser; same for corrupt fonts (INVALID_FONT_FILE).
  • Numbers ≥ 1e21 are rejected instead of silently serializing in exponent notation; PDF names beyond U+00FF are escaped as UTF-8 byte sequences per ISO 32000-1.
  • Added SECURITY.md (threat model, reporting) and a README security section.
0.3.02026-07-18

Added

  • objectTable(records, { columns }) — render an array of records (e.g. a JSON REST response) straight into a table. Columns default to the keys of the first record, or you pick order, headers, widths, alignment and a per-column format().
  • examples/report.ts — a design-forward two-page report showing fast-pdf beyond invoices.
  • pageBreak({ y?, format?, landscape?, margins? }) — explicit page break with a controllable start position and per-page setup.
  • Trilingual demo page (English/German/Chinese) with a language selector.
  • The package builds itself on install from GitHub (prepare script).

Fixed

  • Stroke-only shapes were always drawn in black — the requested stroke colour was reset right before stroking.
0.2.02026-07-17

Added

  • Shapescircle(), ellipse(), rounded rectangles, plus Bézier/clip/transform operators in the content stream.
  • Layout enginecontainer() (padding, margin, background, border, radius, minHeight), columns(), grid(), relative sizes ("50%"), block alignment.
  • Typography — underline, strikethrough, letter spacing, justified text and soft-hyphen (U+00AD) hyphenation.
  • Tables — footer rows, colSpan/rowSpan (span groups never straddle page breaks).
  • Imagesfit: contain | cover, crop, rotate, align.
  • Document featuresheader(), footer(), pageNumbers(), watermark(), outline() bookmarks, link annotations and a linked table of contents via toc().
  • toStream() output, and FastPDFError with stable codes.
0.1.02026-07-14

Added

  • Initial engine: PDFDocument/Page, multi-page documents, page formats (A3–A5, Letter, Legal), landscape, margins.
  • Standard-14 fonts with real AFM metrics, WinAnsi encoding, TrueType embedding with subsetting.
  • Text with word wrap, alignment and colours; automatic page breaks; tables with header repetition and zebra rows; JPEG/PNG images; vector primitives.
  • Output as Uint8Array, toBuffer(), toBlob(), save() across Node/Bun/Deno/browser.