npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@reportwright/pdf

v1.1.1

Published

A streaming PDF writer for Node and browsers with no runtime dependencies: flat memory, PDF/A-1b to 3a, PDF/UA-1, Factur-X, embedded TrueType with Unicode text.

Readme

Pages are written to your sink as they end, so memory stays flat however many pages you write: 20,000 pages hold about the heap that 1,000 do. It is the writer inside ReportWright, extracted, plus a hardened reader for merging, splitting, filling and signing existing files.

It has an experimental renderer for page previews; for fidelity-critical rendering use pdf.js or PDFium. For heavy rewriting, repair or raw speed MuPDF, qpdf and pdfcpu are faster (see Benchmarks).

Why this library. Most PDF jobs are the same four: generate a report or invoice, make it compliant (PDF/A, PDF/UA, Factur-X), sign or protect it, and lightly edit someone else's file (merge, split, fill, stamp). This does all four with one maintained, dependency-free package, at flat memory however many pages you write, and it is conservative about untrusted input.

Contents

Install · Quick start · Coming from pdf-lib · Features · Screenshots · Guide · Bundle size · Examples · Rendering · Benchmarks · Options reference · Security model · Known limits · Public API and stability · Releasing · Changes

Install

npm i @reportwright/pdf

ESM only. Node 20.16 or later, and modern browsers (the same dist/index.js; crypto comes from WebCrypto). Types are included (index.d.ts). Optional, for complex scripts (Indic, Arabic) only: npm i harfbuzzjs.

Quick start (30 seconds)

// hello.mjs  →  node hello.mjs
import fs from 'node:fs';
import { createPdf } from '@reportwright/pdf';

const pdf = createPdf(fs.createWriteStream('hello.pdf'), { title: 'Hello' });
const page = pdf.addPage();                                       // A4, points, origin top-left
page.text('Hello, world', { x: 72, y: 72, font: pdf.standardFont('Helvetica-Bold'), size: 24, color: '#1f4e79' });
page.line(72, 104, 523, 104, { color: 0.6, width: 0.5 });
await page.end();                                                 // the page is written and freed
console.log(await pdf.end());                                     // { pages: 1, bytes: …, warnings: [] }

In memory instead of a stream: const bytes = await toBytes(async (pdf) => { … }, options).

Coming from pdf-lib

pdf-lib is a popular pure-JS PDF library. Its last npm release, 1.17.1, was published on 2021-11-06 (npm view pdf-lib time), so if you need current fixes, PDF/A, signing or accessible output, this is the same kind of API with those built in. The tasks below are the ones people reach for pdf-lib for. Two differences to know: our coordinates start at the top-left (pdf-lib's at the bottom-left), and a document is written to a sink as pages end instead of being built whole in memory.

Every @reportwright/pdf snippet here was run against the local 1.0.0 build and produced a valid PDF.

1. A page with text

// pdf-lib
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
const page = doc.addPage();
page.drawText('Hello, world', { x: 50, y: 780, size: 30, font, color: rgb(0.12, 0.31, 0.47) });
fs.writeFileSync('out.pdf', await doc.save());
// @reportwright/pdf
import { createPdf } from '@reportwright/pdf';
const pdf = createPdf(fs.createWriteStream('out.pdf'));
const page = pdf.addPage();                                     // A4, origin top-left
page.text('Hello, world', { x: 50, y: 50, size: 30, font: pdf.standardFont('Helvetica'), color: '#1f4e79' });
await page.end();
await pdf.end();

2. Embed a custom font

// pdf-lib (needs the separate @pdf-lib/fontkit package)
import fontkit from '@pdf-lib/fontkit';
doc.registerFontkit(fontkit);
const font = await doc.embedFont(fs.readFileSync('Inter-Regular.ttf'), { subset: true });
page.drawText('café, naïve', { x: 50, y: 780, size: 24, font });
// @reportwright/pdf (no extra package; subset, kerned, any Unicode)
const inter = await pdf.embedFont(fs.readFileSync('Inter-Regular.ttf'));
page.text('café, naïve', { x: 50, y: 50, size: 24, font: inter });

3. Embed a PNG or JPEG

// pdf-lib
const img = await doc.embedPng(fs.readFileSync('logo.png'));      // embedJpg for JPEG
page.drawImage(img, { x: 50, y: 500, width: 200, height: 200 * img.height / img.width });
// @reportwright/pdf (one call for both formats; height follows the aspect ratio)
const img = await pdf.embedImage(fs.readFileSync('logo.png'));
page.image(img, { x: 50, y: 50, width: 200 });

4. Fill a form field and flatten

// pdf-lib
const doc = await PDFDocument.load(fs.readFileSync('form.pdf'));
const form = doc.getForm();
form.getTextField('name').setText('Aanya Sharma');
form.flatten();
fs.writeFileSync('filled.pdf', await doc.save());
// @reportwright/pdf
import { loadPdf } from '@reportwright/pdf';
const doc = await loadPdf(fs.readFileSync('form.pdf'));
doc.fill({ name: 'Aanya Sharma' }).flatten();
fs.writeFileSync('filled.pdf', (await doc.save()).bytes);

5. Merge two PDFs

// pdf-lib
const merged = await PDFDocument.create();
for (const file of ['a.pdf', 'b.pdf']) {
  const src = await PDFDocument.load(fs.readFileSync(file));
  (await merged.copyPages(src, src.getPageIndices())).forEach((p) => merged.addPage(p));
}
fs.writeFileSync('merged.pdf', await merged.save());
// @reportwright/pdf
import { loadPdf, mergePdfs } from '@reportwright/pdf';
const a = await loadPdf(fs.readFileSync('a.pdf')), b = await loadPdf(fs.readFileSync('b.pdf'));
fs.writeFileSync('merged.pdf', (await mergePdfs([a, b])).bytes);

6. Set metadata

// pdf-lib
doc.setTitle('Report');
doc.setAuthor('Example Ltd');
doc.setSubject('Q3');
doc.setKeywords(['finance', 'q3']);
// @reportwright/pdf: when writing a new file
const pdf = createPdf(sink, { title: 'Report', author: 'Example Ltd', subject: 'Q3', keywords: 'finance, q3' });

// or on an existing one
const doc = await loadPdf(bytes);
doc.setInfo({ title: 'Report', author: 'Example Ltd', subject: 'Q3', keywords: 'finance, q3' });
const { bytes: out } = await doc.save();

What you get on top

  • Signing: PAdES B-B / B-T signatures, and incremental countersignatures that keep earlier ones valid.
  • PDF/A-1b to 3a and PDF/UA-1, veraPDF clean, plus Factur-X / ZUGFeRD / XRechnung invoices.
  • Encryption: AES-256 / AES-128 with permissions.
  • Streaming and bulk: flat memory for 20,000+ pages, and worker-thread batches with a per-file timeout.
  • Complex scripts (Devanagari, Bengali, Tamil, Arabic…) shaped through HarfBuzz, text still extractable.
  • Zero runtime dependencies, and a reader with a budget against hostile files.

Not supported yet

Existing pages are not editable at the content level: page.draw() overlays new text, images and paths on top of what is there, but you cannot remove, move or rewrite content already on a page, and there are no page edits in an incremental update (saveIncremental() fills, signs and changes info; use save() to rewrite). We also do not embed a page of another PDF as a reusable drawing (pdf-lib's embedPdf/embedPage); copyPages and mergePdfs copy whole pages instead. See Known limits.

Features

| Area | What you get | |---|---| | Write | Text in the 14 standard fonts or embedded TrueType/OpenType (subset, kerned, any Unicode); text boxes with wrapping and alignment; JPEG/PNG images (alpha as a soft mask); Canvas-style vector paths, gradients, patterns, layers | | Complex scripts | Devanagari, Bengali, Tamil, Telugu, Kannada, Arabic… through a HarfBuzz shaper you plug in (harfbuzzjs), text still extractable | | Streaming | Each page is written and freed when it ends; flat memory for 20,000+ pages; timeoutMs and AbortSignal | | Navigate | Links, named destinations, bookmarks, page labels, annotations | | Forms | Every AcroForm field type with appearance streams; fill and flatten existing forms | | Protect | AES-256 / AES-128 encryption with permissions | | Sign | PAdES B-B / B-T signatures while streaming; incremental countersignatures that keep earlier ones valid | | Comply | PDF/A-1b, 2b/2u/2a, 3b/3u/3a and PDF/UA-1 (veraPDF clean); Factur-X / ZUGFeRD / XRechnung invoices | | Read and edit | Load (also damaged or encrypted files), merge, split, reorder, extract text, fill, stamp, saveIncremental | | Bulk | One task over many files in worker threads, each with a heap cap and a per-file timeout; one bad file never stops the batch | | Safe by default | No JavaScript or launch actions can be written; active content is stripped on read; every input is budgeted | | Small | Zero runtime dependencies; one ESM file plus a lazily loaded fflate.js; TypeScript types included |

Screenshots

Page 1 of the examples, rendered by poppler (pdftoppm) on GitHub Actions straight from their output.

Guide

Every snippet below is cut from a file in examples/, each of which was run and produces a PDF. The examples import ../dist/index.js; in your code import @reportwright/pdf. They also ship in the package (node_modules/@reportwright/pdf/examples/).

Write: text, fonts, images, tables

const pdf = createPdf(fs.createWriteStream('report.pdf'), { title: 'Quarterly report', author: 'Example Ltd' });
const regular = pdf.standardFont('Helvetica'), bold = pdf.standardFont('Helvetica-Bold');
const inter = await pdf.embedFont(fs.readFileSync('Inter-Regular.ttf'));          // subset, any Unicode
const logo = await pdf.embedImage(fs.readFileSync('logo.png'));                   // alpha becomes a soft mask

const page = pdf.addPage();
page.image(logo, { x: 40, y: 40, width: 32 });
page.text('Quarterly report', { x: 84, y: 44, font: bold, size: 22, color: '#1f4e79' });
const box = page.textBox(longText, { x: 40, y: 90, width: 515, font: inter, size: 11, align: 'justify' });
// box = { height, lines, overflow }: continue `overflow` in the next box or page

Tables are paths and text: there is no table primitive, so you control every rule and alignment.

const rows = [['Region', 'Q1', 'Q2'], ['North', '1,204', '1,390'], ['South', '980', '1,122']];
const cols = [40, 300, 430], right = 555, rowH = 22, top = 100 + box.height;
page.fill(page.path().rect(40, top, right - 40, rowH), { fill: '#1f4e79' });       // header band
rows.forEach((r, i) => {
  const y = top + i * rowH;
  r.forEach((cell, c) => {
    const font = i === 0 ? bold : regular;
    const x = c === 0 ? cols[c] + 6 : (cols[c + 1] ?? right) - 6 - font.widthOfText(cell, 11);   // right-align numbers
    page.text(cell, { x, y: y + 5, font, size: 11, color: i === 0 ? '#ffffff' : 0 });
  });
  if (i > 0) page.stroke(page.path().moveTo(40, y + rowH).lineTo(right, y + rowH), { stroke: 0.75, width: 0.5 });
});
await page.end();
await pdf.end();

Full file: examples/write.mjs (it also generates its PNG, so it needs no input files).

Indic and other complex scripts (HarfBuzz)

Pass a shaper to embedFont and conjuncts, reordered matras, Arabic joining and marks are drawn correctly; copied and extracted text stays the source text, in logical order. The adapter for harfbuzzjs ships as examples/harfbuzz-shaper.mjs.

import { harfbuzzShaper } from '@reportwright/pdf/examples/harfbuzz-shaper.mjs';   // needs: npm i harfbuzzjs

const bytes = fs.readFileSync('NotoSansDevanagari-Regular.ttf');
const hindi = await pdf.embedFont(bytes, { shaper: harfbuzzShaper(bytes) });
page.text('किताब हिन्दी क्षत्रिय', { x: 40, y: 40, font: hindi, size: 24 });
page.textBox('इस किताब में हिन्दी और English दोनों हैं।', { x: 40, y: 90, width: 400, font: hindi, fallback: [latin] });

node examples/indic.mjs NotoSansDevanagari-Regular.ttf. Right-to-left: { direction: 'rtl' } (there is no bidi algorithm: split mixed-direction lines into runs yourself).

Links and bookmarks

page.link(40, 300, 160, 14, { url: 'https://example.com/report' });              // http, https, mailto by default
pdf.destination('appendix', 2, { top: 40 });                                      // a named destination
page.link(40, 320, 160, 14, { dest: 'appendix' }, { alt: 'Go to the appendix' });
page.link(40, 340, 160, 14, { page: 2, fit: 'FitH', top: 0 });
pdf.outline([
  { title: 'Quarterly report', page: 1 },
  { title: 'Appendix', page: 2, level: 1 },                                       // level nests under the previous entry
]);
pdf.pageLabels([{ start: 0, style: 'r' }, { start: 2, style: 'D' }]);             // i, ii, 1, 2, …

Forms: create, fill, flatten

import { toBytes, loadPdf } from '@reportwright/pdf';

const form = await toBytes(async (pdf) => {
  const font = pdf.standardFont('Helvetica'), page = pdf.addPage();
  pdf.form.textField('name', { page, x: 40, y: 80, width: 240, height: 22, font, tooltip: 'Full name', required: true });
  pdf.form.comboBox('type', { page, x: 40, y: 115, width: 160, height: 22, font, options: ['Annual', 'Sick', 'Unpaid'], value: 'Annual', tooltip: 'Leave type' });
  pdf.form.checkbox('approved', { page, x: 40, y: 150, width: 14, height: 14, tooltip: 'Approved by manager' });
  await page.end();
});

const doc = await loadPdf(form);
doc.fields;                                                        // [{ name: 'name', type: 'text', … }, …]
doc.fill({ name: 'Aanya Sharma', type: 'Sick', approved: true });  // appearances redrawn by the writer
const filled = (await doc.save()).bytes;

const flattened = (await (await loadPdf(form)).fill({ name: 'Aanya Sharma' }).flatten().save()).bytes;  // no AcroForm left

examples/fill-form.mjs writes form.pdf, filled.pdf and flattened.pdf. To flatten while writing: createPdf(sink, { form: { flatten: true } }).

Encryption

const bytes = await toBytes(build, {
  encrypt: {
    userPassword: 'open me', ownerPassword: 'full access',
    algorithm: 'aes-256',                                          // default; 'aes-128' for very old readers
    permissions: { print: true, copy: false, modify: false },      // each defaults to true
  },
});
const doc = await loadPdf(bytes, { password: 'open me' });         // doc.encryption.algorithm === 'aes-256'

examples/encrypt.mjs. RC4 is read, never written.

Signing (PAdES)

import { createPdf, loadPdf, nodeSigner } from '@reportwright/pdf';

const signer = nodeSigner({ key: fs.readFileSync('key.pem', 'utf8'), certs: fs.readFileSync('cert.pem', 'utf8') });
const pdf = createPdf(sink, { title: 'Offer letter', sign: true });
const page = pdf.addPage();
const hr = pdf.form.signature('hr', { page, x: 40, y: 700, width: 200, height: 40, tooltip: 'HR signature' });
pdf.form.signature('candidate', { page, x: 300, y: 700, width: 200, height: 40, tooltip: 'Candidate signature' });
await pdf.sign(hr, { signer, reason: 'Issued', location: 'Bengaluru', certify: 2 });
await page.end();
await pdf.end();                                                   // the signer runs here

// Countersign later: an incremental update, so the first signature stays valid.
const { bytes } = await (await loadPdf(signedBytes)).saveIncremental({ sign: { field: 'candidate', signer } });

examples/sign.mjs (make a test key with the openssl line at its top). Its output checks with pdfsig: both signatures "Signature is Valid", the second "Total document signed". signer can be any async function returning CMS bytes (an HSM, a cloud KMS: see cmsSigner); add timestamp for PAdES B-T.

PDF/A, PDF/UA and Factur-X

const pdf = createPdf(sink, {
  title: 'Leave policy',
  tagged: { lang: 'en-GB' },                // PDF/UA-1: structure tree, title, embedded fonts
  pdfa: { part: 2, conformance: 'a' },      // or true (2b), { part: 1, conformance: 'b' }, { part: 3, … }
});
const font = await pdf.embedFont(fs.readFileSync('Inter-Regular.ttf'));
const page = pdf.addPage();
page.artifact({ type: 'Pagination', subtype: 'Header' }, () => page.text('HR policies', { x: 40, y: 20, font, size: 8 }));
page.tag('H1', () => page.text('Leave policy', { x: 40, y: 50, font, size: 22 }));
page.figure({ alt: 'Bar chart: annual leave 18 days' }, () => page.fill(page.path().rect(40, 180, 180, 16), { fill: '#1f4e79' }));

A Factur-X / ZUGFeRD invoice is PDF/A-3 with the CII XML attached:

const pdf = createPdf(sink, { title: 'Invoice INV-2026-001', pdfa: { part: 3, conformance: 'a' }, tagged: { lang: 'en' } });
// … draw the invoice from the same data as the XML …
await pdf.facturX(xmlBytes, { profile: 'EN 16931' });   // MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED, XRECHNUNG

examples/accessible.mjs (PDF/A-2a + PDF/UA-1) and examples/invoice-facturx.mjs (self-contained PDF/A-3a invoice; factur-x.mjs does the same from your own XML) both pass veraPDF with 0 failed rules (2a + ua1, 3a + ua1).

Read: merge, split, extractText, saveIncremental

import { loadPdf, mergePdfs } from '@reportwright/pdf';

const a = await loadPdf(fs.readFileSync('contract.pdf')), b = await loadPdf(fs.readFileSync('annexure.pdf'));
const merged = await mergePdfs([a, b]);                            // { bytes, pages, stripped, warnings }

const all = await loadPdf(merged.bytes);
all.extractText();                                                 // every page, joined by \f
all.page(0).extractText();                                         // one page, in lines

for (let i = 0; i < all.pageCount; i++) {                          // split: one file per page
  fs.writeFileSync(`page-${i + 1}.pdf`, (await all.fork().reorder([i]).save()).bytes);
}

const doc = await loadPdf(merged.bytes);
doc.setInfo({ title: 'Contract (reviewed)' });
const { bytes } = await doc.saveIncremental();                     // the original bytes + an appended update

examples/merge-split.mjs. save() rewrites the file (and strips active content); saveIncremental() appends (and refuses files with active content): see Security model.

Bulk: many files in worker threads

import { bulk } from '@reportwright/pdf';

const files = fs.readdirSync('in').map((f) => fs.readFileSync(`in/${f}`));
const texts = await bulk(files, 'extractText', { workers: 4 });           // [{ ok: true, value } | { ok: false, error }]
const locked = await bulk(files, 'encrypt', { encrypt: { userPassword: 'u', ownerPassword: 'o' } });
const pages = await bulk(files, 'split');                                 // value: one Uint8Array per page
const joined = await bulk([[a, b], [c, d]], 'merge');                     // each input: the files to merge

Tasks are names (extractText, merge, split, encrypt; BULK_TASKS), never functions, so only bytes and plain options cross to a worker. Results come back in input order; a file that fails (malformed, wrong password, over the budget) gives { ok: false, error: { name, code, message } } and the batch goes on. Each worker's heap is capped (memoryMb, default 512): a file that exhausts it kills only that worker, its item fails with E_WORKER, and a new worker takes the next. workers defaults to os.availableParallelism() - 1. Node only: in browsers (no node:worker_threads), and with workers: 1, the same tasks run one at a time in the calling thread, without the heap cap. examples/bulk.mjs.

Workers stay warm (unref'd, so they never keep the process alive) for 2 s after a call, so the next call skips their start-up; small files are sent several at a time (up to 8, or 256 KB, a worker). Each item has itemTimeoutMs (default 120000) in a worker: past it the worker is ended and the item fails with E_TIMEOUT; a failed item is never retried, so a batch of stuck or worker-killing files ends with each failing alone (workers is 1–64, memoryMb 16–16384; with workers: 1 there is no item timeout or heap cap). Pass signal to stop a call (or AbortSignal.timeout(ms) for a time limit): its workers are ended and bulk rejects with the signal's reason. When to use it: CPU-heavy items (encrypt, the text of long files) gain from the first call; cheap items on small files (extractText or merge of 1–2 page files) gain only once the workers are warm: the first call pays ~25 ms a worker to start them, so a one-off batch of under ~100 ms of work is quicker with workers: 1.

Measured on an Apple M2 (4 performance + 4 efficiency cores, 8 GB, busy laptop), Node 22, files made with toBytes (small: 200 files of 2 pages, 0.4 MB in all; large: 20 files of 200 pages of text, 2.3 MB in all). Items/s from the best of 3 calls in one process (warm), and the first call's ms (cold); results equal to workers: 1's (encrypt: the same text once opened). Before: one worker started per call, one item a round trip.

| files | task | workers | items/s before | items/s now | first call ms before | now | peak RSS MB | |---|---|---|---|---|---|---|---| | small | extractText | 1 | 4762 | 4762 | 80 | 78 | 135 | | small | extractText | 2 | 1923 | 5882 | 106 | 99 | 246 | | small | extractText | 4 | 1754 | 6452 | 123 | 100 | 351 | | small | merge | 1 | 2174 | 2128 | 137 | 136 | 148 | | small | merge | 4 | 1299 | 3175 | 171 | 142 | 429 | | small | encrypt | 1 | 284 | 288 | 733 | 738 | 171 | | small | encrypt | 4 | 563 | 813 | 370 | 373 | 465 | | large | extractText | 1 | 11 | 11 | 1906 | 1891 | 218 | | large | extractText | 4 | 29 | 37 | 684 | 653 | 422 | | large | merge | 1 | 143 | 135 | 224 | 220 | 176 | | large | merge | 4 | 77 | 196 | 276 | 249 | 429 | | large | encrypt | 1 | 149 | 155 | 213 | 212 | 181 | | large | encrypt | 4 | 93 | 217 | 218 | 210 | 416 |

Bundle size

Gzip (gzip -9) of the built files in dist/. . is dist/index.js (writer and reader); ./write is dist/write.js (writer only: createPdf, fonts, images, graphics, forms, standards, encryption, signing). fflate is a separate file (dist/fflate.js) loaded on first use by either entry.

| entry | before (unminified) | after (minified) | | --- | --- | --- | | . (dist/index.js) | 166.8 KiB (170,811 B) | 133.4 KiB (136,583 B) | | ./write (dist/write.js) | not exported | 85.0 KiB (87,080 B) | | fflate (dist/fflate.js) | 15.5 KiB (15,842 B) | 12.6 KiB (12,953 B) |

Import @reportwright/pdf/write when the code only writes PDFs: it leaves out the reader (loadPdf, mergePdfs) and bulk. The two entries are separate bundles, so an app that imports both pays for the writer twice.

Examples

| file | what it shows | run | |---|---|---| | write.mjs | text, a PNG, a table from paths, a link, bookmarks | node examples/write.mjs out.pdf | | indic.mjs | Devanagari shaped by HarfBuzz, Latin fallback | node examples/indic.mjs NotoSansDevanagari-Regular.ttf out.pdf | | harfbuzz-shaper.mjs | the harfbuzzjs shaper adapter (a module) | imported by indic.mjs | | fill-form.mjs | create, fill and flatten a form | node examples/fill-form.mjs outDir | | encrypt.mjs | AES-256 with permissions, read back | node examples/encrypt.mjs out.pdf | | sign.mjs | PAdES signature while streaming + countersignature | node examples/sign.mjs key.pem cert.pem out.pdf | | accessible.mjs | PDF/UA-1 + PDF/A-2a | node examples/accessible.mjs font.ttf out.pdf | | invoice-facturx.mjs | self-contained Factur-X PDF/A-3a invoice | node examples/invoice-facturx.mjs font.ttf out.pdf | | factur-x.mjs | Factur-X from your own CII XML | node examples/factur-x.mjs invoice.xml font.ttf out.pdf | | merge-split.mjs | merge, split, extractText, saveIncremental | node examples/merge-split.mjs outDir | | bulk.mjs | extractText and encrypt over many files in worker threads | node examples/bulk.mjs outDir | | render.js | pages to PNG with the experimental renderer | node examples/render.js in.pdf outDir 2 | | p12-signer.mjs | a pdf.sign signer from a PKCS#12 (.p12) file through cmsSigner (needs node-forge) | imported by your code |

Run from node_modules/@reportwright/pdf (or the package folder of the repository).

Rendering (experimental)

renderPage(doc, i, { scale }) draws a loaded page to RGBA pixels in pure JS (no canvas, no dependencies); toPng(result) encodes it, drawToCanvas(ctx, result) puts it on a browser canvas. It draws paths, text in embedded TrueType / CFF fonts, Flate, raw and JPEG images, clipping and form fields. The standard 14 fonts draw with the optional @reportwright/pdf-std-fonts package (fonts: stdFonts; Liberation and Noto fonts, SIL OFL) or any font you pass (fonts: { default: ttfBytes }). Shadings (all seven types), tiling patterns, blend modes, transparency groups, soft masks, Type 1 and Type 3 fonts draw. Not yet: JPX / JBIG2 / CCITT images, predefined CJK CMaps, spot colours. What draws, and how close it is to pdf.js: docs/RENDERER.md.

const page = renderPage(await loadPdf(bytes), 0, { scale: 2 });
fs.writeFileSync('page.png', await toPng(page));

renderToCanvas(doc, i, ctx) draws onto a canvas, renderPages(bytes, pages) spreads pages over worker threads, and { wasm: true } speeds up vector-heavy pages. Try it in the playground (its preview is drawn by this renderer); see docs/RENDERER.md for coverage, caps and speed.

Benchmarks

The QA benchmark suite (qa/bench, GitHub Actions run 38317309719, 2026-10-12, the 1.0 release measurement) measures @reportwright/pdf 0.1.0-beta.15 from npm (the same code as 1.0.0: only the version and doc comments changed) against the other libraries of each task on the same runner. Time is wall time from process start to exit (warm invoice: in-process median). Each row is our time and peak memory next to the fastest library in that row. The suite's verdicts: 8 PASS, 10 WARN, 2 FAIL of 20 rows. The losses are listed with the wins.

| Task (size) | @reportwright/pdf | Fastest in the run | We are | Verdict | |---|---|---|---|---| | Writers: plain text pages (10) | 189 ms · 75 MB | printpdf (Rust) 77 ms | 2.46× slower, rank 3 of 13 | WARN | | Writers: plain text pages (100) | 420 ms · 110 MB | fpdf (Go) 129 ms | 3.25× slower, rank 3 of 13 | FAIL | | Writers: grouped report (100 rows) | 159 ms · 72 MB | fpdf (Go) 75 ms | 2.11× slower, rank 3 of 13 | WARN | | Writers: grouped report (1,000 rows) | 240 ms · 79 MB | fpdf (Go) 102 ms | 2.35× slower, rank 3 of 13 | WARN | | Writers: grouped report (10,000 rows) | 614 ms · 116 MB | fpdf (Go) 258 ms | 2.38× slower, rank 3 of 13 | WARN | | Writers: invoice, cold process | 188 ms · 73 MB | printpdf (Rust) 75 ms | 2.49× slower, rank 4 of 13 | WARN | | Writers: invoice, warm (50) | 8.5 ms · 130 MB | PDFsharp (.NET) 5.6 ms | 1.52× slower, rank 2 of 13 | WARN | | Writers: Unicode (Hindi, Arabic, CJK) | 181 ms · 88 MB | fpdf (Go) 79 ms | 2.29× slower, rank 2 of 9 | WARN | | Writers: images (6 MP PNG + 24 MP JPEG) | 991 ms · 197 MB | printpdf (Rust) 354 ms | 2.80× slower, rank 3 of 13 | WARN | | Writers: PDF/A-2b invoice (veraPDF) | 185 ms · 73 MB | pdfkit (JS) 432 ms | 2.3× faster, rank 1 of 2 | PASS | | Read: text of a 1,200-page PDF | 1206 ms · 120 MB | pdftotext (CLI) 795 ms | 1.52× slower, rank 3 of 14 | PASS | | Read: 11 mixed real-world files | 208 ms · 79 MB | pdfium (CLI) 175 ms | 1.19× slower, rank 2 of 6 | PASS | | Edit: merge 100 files | 182 ms · 80 MB | pdfcpu (Go) 68 ms | 2.67× slower, rank 8 of 15 | WARN | | Edit: split 100 pages | 185 ms · 79 MB | pdfium (CLI) 110 ms | 1.68× slower, rank 9 of 15 | WARN | | Edit: encrypt AES-256 | 136 ms · 76 MB | pdfcpu (Go) 44 ms | 3.07× slower, rank 5 of 10 | FAIL | | Edit: incremental metadata change | 137 ms · 72 MB | PyMuPDF (Python) 159 ms | 1.16× faster, rank 1 of 6 | PASS | | Edit: fill and flatten a form | 113 ms · 72 MB | PyMuPDF (Python) 154 ms | 1.36× faster, rank 1 of 7 | PASS | | Edit: sign (PAdES/CMS, test certificate) | 111 ms · 72 MB | pdfbox (Java) 628 ms | 5.7× faster, rank 1 of 4 | PASS | | Robustness: 25 fuzzed PDFs | 22 ok, 3 clean errors | no crash, hang or OOM | | PASS | | Robustness: 6 hostile files | 4 ok, 2 clean errors | no crash, hang or OOM | | PASS |

The small-edit rows (metadata, form, sign) are close to the harness's Node process start-up floor (about 110 ms), so they measure start-up as much as the work of each task.

In short: it is a fast JavaScript writer with flat memory, not the fastest PDF library. It is at or near the top on PDF/A, signing and small edits in this run, and 1.5–3× behind the native and Go writers on plain and report output. It is not the right tool for raw speed on repairs or large rewrites: MuPDF, qpdf and pdfcpu are faster there.

vs Python

Medians from GitHub Actions run 38317309719 of ReportWright (ubuntu-latest, all libraries interleaved in one job), the same build as the Benchmarks table. Wall time from process start (warm invoice: in-process) · peak memory. Full write-up: JavaScript vs Python for PDFs.

| job | @reportwright/pdf | ReportLab | PyMuPDF | pypdf | |---|---|---|---|---| | Grouped report, 100 rows | 159 ms · 72 MB | 199 ms · 33 MB | 372 ms · 59 MB | — | | Grouped report, 10,000 rows | 614 ms · 116 MB | 2378 ms · 45 MB | 13.17 s · 70 MB | — | | Grouped report, 1,000,000 rows (older run 38108358497, 0.1.0-beta.12, not repeated for 1.0) | 29.07 s · 596 MB | 215.17 s · 1292 MB | 1358.32 s · 1279 MB | — | | Invoice, single | 188 ms | 199 ms | 256 ms | — | | Invoice, warm (50 in one process) | 8.5 ms · 130 MB | 8.7 ms · 34 MB | 54 ms · 60 MB | — | | Extract text, 1,200 pages | 1206 ms | — | 1426 ms | 5424 ms | | Split 100 pages | 185 ms | — | 177 ms | 198 ms (pikepdf 132 ms) | | Fill and flatten a form | 113 ms | — | 154 ms | 175 ms |

Faster than the Python libraries at report scale (V8's JIT on the layout loop, typed arrays, a streaming writer that drops each page once written). ReportLab uses the least memory at every size and is within a few per cent on one-page documents; pikepdf (qpdf underneath) is faster at merge, split and encryption.

Options reference

Every option of the writer, the reader and the editing API (coordinates, fonts, pages, shaping, vector graphics, images, links, annotations, forms, encryption, signatures, tagged PDF, PDF/A, Factur-X, layers, reading and modifying) is documented in docs/API.md.

Security model

Every input is budgeted, active content is stripped on read and can never be written, and the reader is hardened against malformed and hostile files. The full model, its guarantees and its limits are in SECURITY.md.

Known limits

  • Images: image masks (stencils); embedded ICC profiles of PNG/JPEG files are ignored.
  • Text: no Unicode bidi algorithm, no hyphenation, no kerning of the standard fonts, no WOFF fonts; text extraction is reading order, not layout-perfect. With textMapping: 'actualText', readers that ignore ActualText or close it early (pdf.js; a tester saw an older MuPDF drop a line's last Bengali cluster) lose the spanned clusters: the writer closes every span after its cluster's last glyph, and MuPDF 1.28.2 (PyMuPDF) reads Bengali, Devanagari and the other test scripts whole.
  • Annotations: no popup annotations.
  • Crypto: RC4 is refused (read only); public-key (certificate) encryption is neither written nor read; signatures are listed with their coverage, but their cryptography is not verified.
  • Standards: no PDF/A-1a or PDF/X; no attachment annotations in PDF/A-3 (use pdf.attach); a Factur-X invoice's content is not validated.
  • Editing: no page edits in an incremental update (use save()).
  • Browsers: reading encrypted files needs Node (WebCrypto has no synchronous AES).

See NOTES.md for the plan, and ARCHITECTURE.md for extending the library.

Public API and stability

From 1.0.0 this package follows semantic versioning.

What 1.0 promises (the public API):

  • The entry points @reportwright/pdf (index.d.ts), @reportwright/pdf/write (the writer without the reader, write.d.ts) and @reportwright/pdf/fflate (fflate as bundled here, fflate.d.ts), and ./examples/* as files.
  • Every name those type files declare, with the options documented in this README's Options reference: createPdf, toBytes, Page, loadPdf, mergePdfs, bulk, BULK_TASKS, nodeSigner, cmsSigner, readFont, checkSubset, svgPathOps, cmyk, STANDARD_NAMES, PdfReadError (and its code values), pdf.core (PdfCore, the low-level extension API) and the option and result types.
  • What a written file is: valid PDF of the declared version and conformance (PDF/A, PDF/UA, Factur-X) as documented. The exact bytes are not part of the API (compression, object order and subsets may change in a minor, the rendered page and extracted text do not).
  • The security model's limits and PdfReadError codes.

Not public: __bulkWorker (@internal: the bulk worker's entry, exported only so the worker can load it from the same bundle), anything reached through dist/ or src/ by path, and the text-position internals of the reader.

Rules: breaking changes only in a major release, listed under Changes with their replacement and deprecated (typed @deprecated, noted here) for at least one minor release first. Security fixes may change behaviour in a patch release when that is what closes the hole (for example a tighter read budget); the change note says so.

Releasing

Releases are published to npm by GitHub Actions from a GitHub Release (tag v<version>). The one-time NPM_TOKEN setup and the steps are in RELEASING.md.

Changes

1.1.0: the experimental renderer (renderPage, toPng, drawToCanvas, renderToCanvas, renderPages) and the optional @reportwright/pdf-std-fonts package; nothing else changes. See CHANGELOG.md.

1.0.0: the first stable release; the API is the one in Public API and stability. No behaviour change from 0.1.0-beta.15. __bulkWorker is marked @internal (it was already documented as internal).

Upgrading from the betas: install without a tag (npm i @reportwright/pdf, latest is 1.0.0; next was the beta line) and pin ^1.0.0. From before beta.14: a check box's doc.fields value is true/false, its on-state name is onValue. From before beta.13: built-in TrueType subsets renumber glyphs (a subsetter function and core keep glyph IDs). From before beta.11: SignatureOptions page, width and height are optional and @reportwright/pdf/write exists. Nothing exported by beta.15 was removed.

0.1.0-beta.15 (performance; no output change except the xref stream's compressed bytes): the xref stream is compressed by Node's zlib (CompressionStream or fflate elsewhere) instead of always loading fflate, so a cold one-page PDF takes 26.3 ms instead of 32.7 ms (laptop, whole process from import); with objectStreamLevel unset the file is now the same as at level 6. Unkerned left-to-right text with an embedded font is encoded through a per-font table (charCodeAt, no string-keyed Map per character), the same bytes: 500-line pages 0.25 → 0.18 ms a page (−27%).

0.1.0-beta.14: doc.fields reads a check box's value as true/false (was its state name, 'Yes'/'Off'), with the on-state name as onValue; Page and the internal __bulkWorker are declared in index.d.ts; examples/invoice-facturx.mjs embeds a complete EN 16931 invoice (line items, VAT breakdown, payment terms) that CI checks against the Factur-X XSD; the README notes pypdf's and MuPDF's Indic extraction quirks.

0.1.0-beta.13: the built-in TrueType subset renumbers its glyphs and drops the tables a PDF does not read (name, OS/2, gasp; OpenType/CFF also drops name and OS/2): a 19-character DejaVu Sans line is 6.5 KB, was 20.2 KB. Renders and extracted text are unchanged. A subsetter function, and core, still keep glyph IDs.

0.1.0-beta.13: the README's benchmarks (wins and losses) were published with the package; they were beta.12's GitHub run. They are superseded by the QA run in Benchmarks.

0.1.0-beta.12: the build no longer keeps function names. esbuild's keepNames wrapped every closure in an Object.defineProperty call, which made writing about 35% slower (10,000 lines 182 → 117 ms). PdfReadError sets its own name; stacks map through the sourcemap.

0.1.0-beta.11: pdf.core, the low-level API under createPdf, is documented (PdfCore in the types), with core.imageResource; encryption now covers every literal and hex string in direct objects (it leaked some and threw on others); readFont and checkSubset read a font's metrics and check a subset before embedding; form fields take a caller's appearance, da, structParent and widgetRef; a document whose pages were written through core can be signed (an invisible field), with a PKCS#12 example (examples/p12-signer.mjs); svgPathOps turns SVG path data into path operators; the build is minified, and @reportwright/pdf/write exports the writer without the reader; the page, width and height of SignatureOptions are optional. It includes everything from beta.9 and beta.10.

0.1.0-beta.10: a WritableStream's writer lock is released when the export ends, fails or is stopped (timeoutMs, signal), so the caller can reuse or cancel the stream; the abort reason is still the original error.

0.1.0-beta.9: stamping every page (page.draw then save()) is about 35% faster on a 1,000-page file: a page's open graphics states are counted with a byte scan instead of a full parse, and FlateDecode inflates whole, valid zlib streams with Node's zlib (damaged, truncated, raw or over-budget streams still go through fflate as before); the output is byte-identical. bulk starts an item's itemTimeoutMs clock when its worker is ready, so a respawned worker's start-up no longer fails good files; a stopped export (timeoutMs or signal) aborts its WritableStream even when the stop lands between two calls.

0.1.0-beta.2: a left-to-right shaped cluster in an ActualText span has its text only in the ActualText, so MuPDF no longer reads it twice (pdf.js, which ignores ActualText, now misses those clusters; poppler, PDFium and extractText read them); extractText drops WJ (U+2060) and reads a code a ToUnicode skips as nothing (U+FFFD only when the font has no mapping at all); @reportwright/pdf/examples/* can be imported.

0.1.0-beta.1: shaped text (Indic, Arabic) extracts once and in order in pdf.js, poppler and extractText; extractText honours ActualText, reads unmapped glyphs as U+FFFD, falls back to an embedded TrueType cmap, spaces separate text objects (spreadsheet cells), orders a line along its baseline (rotated text too), and stops a 2 M-operator page at 170 MB (maxTextItems 1 M); standard-font text writes 40% faster; variable fonts warn; the package ships examples/. The list with details: NOTES.md ("Changes") in the repository.