@folsom/tspdf

中文文档
0.1.0发布于 2026/07/31MITTypeScriptES Module近 30 天下载 2206

A pure-TypeScript PDF core: lazy parsing, append-only writing, AcroForm read/fill/flatten/synthesize, and text extraction. No WASM.

原始使用文档
文档目录20 节
  1. Contents
  2. Installation
  3. Usage
  4. Reading a document
  5. Filling a form
  6. Inspecting fields
  7. Working with pages
  8. Text and search
  9. Flattening
  10. Assembling documents
  11. Metadata and navigation
  12. Async loading
  13. Permissions
  14. Untrusted input
  15. Escaping to the object model
  16. Performance
  17. Limitations
  18. How it is tested
  19. Contributing
  20. License

tspdf

Fast, dependency-light PDF forms and text for TypeScript. No WASM.

npm bundle size license

  • Read and fill AcroForm fields, including checkboxes, radios and dropdowns
  • Generate field appearances (/DA, auto-size, alignment, comb, multiline)
  • Flatten forms into page content
  • Extract and search page text
  • Merge, split, copy, reorder and rotate pages
  • Read and write /Info and XMP metadata, outlines, page labels, attachments
  • Open encrypted documents (R2 through R6: RC4, AESV2, AESV3)
  • Incremental saves that append instead of rewriting
  • Same build in Node and the browser

Runs on @noble/hashes, @noble/ciphers and fflate. Nothing else.

Contents

Installation

bun add @folsom/tspdf     # or npm / pnpm / yarn

Usage

Reading a document

import { PdfDocument } from "@folsom/tspdf";

const doc = PdfDocument.open(bytes);

doc.pageCount;      // 2
doc.encrypted;      // false
doc.info.title;     // "2025 Form 1040"

Filling a form

const form = doc.form();

form.field("f1_01")?.setValue("Ada Lovelace");
form.field("c1_checkbox")?.setValue(true);
form.field("dropdown")?.setValue("Option B");

const { bytes } = doc.save();

Saving appends. The original bytes are a byte-for-byte prefix of the output, so setting one value on a 780 KB document adds about 500 bytes rather than rewriting the file. Every previous revision stays intact, which matters for signed documents.

setValue reports what happened instead of throwing:

const r = form.field("f1_01")?.setValue("a name longer than the field allows");

r?.ok;         // false if the field is read-only or the value is not a valid choice
r?.written;    // what was stored, truncated to /MaxLen if necessary
r?.reason;     // why, when ok is false

Inspecting fields

for (const f of form.allFields()) {
  f.name;        // "topmostSubform[0].Page1[0].f1_01[0]"
  f.kind;        // "text" | "checkbox" | "radio" | "choice" | "signature" | "pushbutton"
  f.value;       // current /V, or null
  f.options;     // /Opt choices
  f.maxLen;      // 0 when unset
  f.readOnly;
}

form.fieldNames();

Working with pages

const page = doc.page(0);

page.size;               // { width: 612, height: 792 }, after rotation
page.label;              // "i", "ii", "1" ... per /PageLabels
page.widgets();          // widget annotations on this page
page.box("CropBox");
page.setRotation(90);
page.text();                    // whole page as a string
page.search("gross income");    // [{ page, box, quads, text }, ...]
page.glyphs();                  // per-glyph boxes, for layout work

doc.search("income");           // every page

Search collapses whitespace by default, because PDFs break words across text-showing operators constantly and a literal match finds far less than you would expect.

Flattening

const { baked, dropped, skipped } = doc.flatten();
const { bytes } = doc.save();

Appearances are drawn into the page content and the form is removed. Widgets that cannot be baked (hidden, no rectangle, degenerate box) are left in /Annots and reported in skipped.

Assembling documents

import { createDocument, addPage, copyPages, mergeDocuments, splitDocument } from "@folsom/tspdf";

const merged = mergeDocuments([a, b]);
const [chapter1, chapter2] = splitDocument(doc, [[0, 4], [5, 9]]);   // inclusive ranges

const dst = createDocument();
addPage(dst);
copyPages(src, dst, [0, 2, 4]);   // omit indices to copy every page

Copied pages keep their form fields: grafted widgets are re-rooted under the destination's /AcroForm, so a merged or split document is still fillable.

Metadata and navigation

doc.info;            // { title, author, subject, keywords, creator, producer, ... }
doc.setInfo({ title: "Filed 2025" });

doc.outline;         // [{ title, page, children }, ...]
doc.pageLabels;      // ["i", "ii", "1", "2", ...]
doc.attachments;     // [{ name, description, data }, ...]

Async loading

open() is synchronous on every platform. load() decodes structural streams up front with the platform's native DecompressionStream, which is faster in browsers, and every query afterwards stays synchronous.

const doc = await PdfDocument.load(bytes, { pages: [0] });

Permissions

Documents can restrict filling, modification and copying via /P. tspdf enforces those flags, so the restricted operation throws unless you opt out:

doc.page(0).text();                            // PermissionDenied on a copy-restricted file
doc.page(0).text({ allowRestricted: true });   // your call

doc.permissions;   // { print, modify, copy, annotate, fillForms, ... }

These flags are not a security boundary, since anyone holding the file can strip them. They are enforced so that bypassing one is a decision rather than an accident.

Untrusted input

Opening a PDF means parsing a hostile data structure. Both bounds are off by default:

PdfDocument.open(bytes, {
  timeoutMs: 5_000,                    // bounds parsing, page walks and xref recovery
  decodeBudget: 64 * 1024 * 1024,      // total decompressed bytes for the document
});

Streams that exceed the budget decode to empty and set doc.budgetExhausted rather than throwing, so a bomb degrades instead of taking the process down.

Escaping to the object model

The facade covers the common cases. For anything else, @folsom/tspdf/lowlevel exposes the raw layer:

import { N, isDict, isStream } from "@folsom/tspdf/lowlevel";

const cat = doc.dict(doc.trailer.get(N.Root));
const custom = doc.dict(cat?.get(N.AcroForm));

Value types (PdfDict, PdfStream, PdfValue) are re-exported from the package root, so signatures stay nameable without importing lowlevel.

Every facade method delegates to a free function that is also exported. doc.page(0).text() and getPageText(doc, 0) are the same code.

Performance

Reproduce with bun run bench. Measured on f990.pdf, 778 KB with 1075 fields.

tspdfpdfiummupdfpdf-lib
open + read all fields7.3 ms34.4 ms20.8 ms221.2 ms
fill 20 fields and save7.0 msn/a43.8 ms549.7 ms
flatten and save11.7 msn/an/a525.4 ms
extract text, one page2.6 ms4.8 msn/an/a
output size790 KBn/a1842 KB1492 KB

Two things are not like-for-like: the mupdf and pdf-lib read columns fetch field names only, and pdfium builds a richer text page than tspdf does, sorting into reading order and deriving boxes from glyph outlines.

Holding a document open is where the difference compounds. mupdf's fill() costs the same whether you set one value or two hundred, because each call reopens and re-serialises the file:

values setmupdftspdfappended
16.50 ms0.060 ms0.5 KB
106.20 ms0.116 ms4.3 KB
2006.57 ms0.848 ms76.0 KB

Cold start in a fresh process is 36.8 ms against pdfium's 86.9 ms and mupdf's 67.9 ms, which is the number that matters for a CLI that handles one file and exits.

Limitations

tspdf does not render. There is no rasteriser and no plan for one; if you need pixels, use pdfium or mupdf. It also does not:

  • Decode image codecs. DCT, JPX, JBIG2, CCITTFax and Brotli streams come back as raw bytes with an undecoded flag instead of throwing, so structure and text still work on files that use them.
  • Embed or subset fonts, or embed images. Standard-14 metrics only.
  • Draw. There is no drawText / drawRectangle / drawImage.
  • Read embedded font programs. Widths and Unicode come from the font dictionary, not the TrueType/CFF cmap, which is the main source of remaining text extraction error.
  • Handle annotations other than widgets with a typed API, though they survive a round-trip.
  • Accept a user password. The handler assumes an empty one, which is the permissions-lock case that most published forms use. It reports when that assumption fails.
  • Support predefined CJK CMaps or vertical writing. Named non-Identity encodings are treated as 2-byte identity and Identity-V lays out horizontally.
  • Create digital signatures, though existing ones survive an incremental save.

How it is tested

bun run test runs in a bare checkout: 122 behaviour checks against PDFs generated in-process, no fixture files and no oracle. test/fixtures.ts builds them; add a case there rather than committing a binary.

The deeper gates check tspdf against pdfium and mupdf as oracles rather than against its own expectations, and need a corpus under ../data. Field reads are compared record by record (9418 widgets across 46 government and corporate forms, no mismatches); written files are handed back to both engines to read; generated appearances are rasterised by mupdf and compared as ink coverage. Text extraction agrees with pdfium on 99.80% of characters.

Broader coverage comes from the pdf.js and pdfium regression corpora, 932 files that each exist because they broke a parser. tspdf opens 908 of them; pdfium opens 896. Between them they exercise every security handler revision from R2 to R6, classic and stream xrefs, object streams and the recovery path.

Contributing

bun install
bun run build     # dist/ with .js and .d.ts
bun run check     # typecheck, lint, format
bun run test
bun run bench

The differential gates need the pdfium and mupdf baselines plus a corpus under ../data:

bun run gate:diff        # field-for-field against pdfium
bun run gate:roundtrip   # fill, save, re-read with pdfium and mupdf
bun run gate:text        # extraction and window agreement
bun run gate:appearance  # generated /AP against mupdf's rendering
bun run gate:wild <dir>  # obscure and hostile corpora

bun run gate:spec needs none of that and is the one to run while working.

test/fetch-corpus.sh fetches the wild corpus. mupdf, pdfium and pdf-lib are devDependencies used only as measurement baselines.

test/pdfium.ts is the pdfium oracle: raw FPDF_* bindings over the WASM module @hyzyla/pdfium already ships, since its published wrapper covers only rendering. It deliberately imports nothing from src/ — an oracle that shares code with the thing it judges proves nothing.

src/ is grouped by subsystem: core (bytes, objects, lexer, parser), codec (filters, crypto), doc, text, form, write. Compound module names are kebab-case (page-labels.ts).

License

MIT

版本与发布记录

@folsom/tspdf 共发布 1 个版本,首次发布于 2026/07/31;过去 12 个月发布了 1 个版本。

版本发布日期距今
0.1.0latest2026/07/313 个月前

关于 @folsom/tspdf 的常见问题

@folsom/tspdf 是什么?

A pure-TypeScript PDF core: lazy parsing, append-only writing, AcroForm read/fill/flatten/synthesize, and text extraction. No WASM.

如何安装 @folsom/tspdf?

在项目目录运行 npm install @folsom/tspdf,或使用 pnpm add / yarn add / bun add。

@folsom/tspdf 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@folsom/tspdf 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@folsom/tspdf 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@folsom/tspdf 可以商用吗?

@folsom/tspdf 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@folsom/tspdf 的下载量有多少?

根据 npm 官方下载统计,@folsom/tspdf 在 2026/10/15 – 2026/11/13 这 30 天内被下载 2,206 次,日均约 74 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@folsom/tspdf 还在维护吗?

最新版本 0.1.0 发布于 2026/07/31(3 个月前);至今共发布 1 个版本,其中过去 12 个月发布了 1 个;首次发布于 2026/07/31。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。