@devmm/puredocs-pdf
v1.0.3
Published
Fast, dependency-light PDF library for Node, Browser, Deno, Bun and edge runtimes. Read, create and manipulate PDF documents in pure TypeScript.
Downloads
404
Maintainers
Readme
@devmm/puredocs-pdf
Fast, dependency-light PDF library for Node, Browser, Deno, Bun and edge runtimes. Read, create and manipulate PDF documents from TypeScript with a small, tree-shakeable API.
Status:
1.0.3— read / create / manipulate, plus Unicode font embedding with subsetting. Advanced features (encryption, digital signatures, rasterisation) are on the roadmap and are honestly marked as not-yet-available rather than promised.
Why this library
- Correct, interoperable compression.
FlateDecodeuses the platform-nativeCompressionStream/DecompressionStream(real zlib / RFC 1950). Files it writes are readable by every PDF viewer, and it reads Flate produced by any tool — no hand-rolled, non-standard deflate. - Random-access parser. Objects are located through the cross-reference table (classic tables and PDF 1.5+ xref streams + compressed object streams), the way real PDFs are structured — not by lexing the whole file into memory.
- Light dependency surface. Zero runtime dependencies; the heavy primitives (zlib, SHA) come from the runtime, not from bundled re-implementations.
- Universal. Pure TypeScript targeting standard Web APIs; runs anywhere modern JS runs.
- No silent corruption. A character the active font cannot draw raises an error naming
it, rather than becoming a
?in a file that opens cleanly and reads wrong. See Unicode text.
Install
npm install @devmm/puredocs-pdfRequires Node ≥ 18 (or any runtime with CompressionStream).
Quick start
import { PDFDocument, rgb } from '@devmm/puredocs-pdf';
// Create
const doc = PDFDocument.create();
doc.setTitle('Hello');
const page = doc.addPage('A4');
page.drawText('Hello World', { x: 50, y: 750, size: 24, color: rgb(0, 0, 0) });
page.drawRectangle({ x: 40, y: 740, width: 200, height: 40, borderColor: rgb(0, 0, 1) });
const bytes = await doc.save();
// Read
const loaded = await PDFDocument.load(bytes);
console.log(loaded.pageCount, loaded.getTitle());
console.log(await loaded.getPage(0).getText());Merge, split, watermark
const merged = await PDFDocument.merge([docA, docB]);
merged.addWatermark('CONFIDENTIAL');
const [firstHalf, secondHalf] = await doc.split([[0, 5], [5, 10]]);Unicode text (Vietnamese, CJK, …)
The standard 14 fonts are WinAnsi-only: Xã Phú Mỹ is simply not expressible in them. Since
1.0.2 asking for it is an error, not a silent X? Ph? M?:
page.drawText('Xã Phú Mỹ', { x: 36, y: 780 });
// PDFUnsupportedCharacterError: Character 'ỹ' (U+1EF9) at index 8 cannot be drawn with
// Helvetica. Context: "Xã Phú Mỹ". Embed a Unicode font — PDFDocument.embedFont(ttfBytes) …Embed a Unicode font and it prints, copies and searches correctly:
const font = await doc.embedFont(ttfBytes); // .ttf or .otf, from fs or fetch
doc.setDefaultFont(font); // nothing can fall back to WinAnsi now
page.drawText('Xã Phú Mỹ', { x: 36, y: 780, size: 12 });Only the glyphs you draw are embedded, and a ToUnicode CMap is written alongside.
No font is bundled with the package — which one goes into your documents is a licensing decision, not ours to make. These families are distributed under the SIL Open Font License 1.1, which permits embedding a font in a document without the document inheriting the licence, and each covers Vietnamese including stacked diacritics:
| Family | Notes | |--------|-------| | Noto Sans | Broadest coverage; regular/bold/italic. The safe default. | | Be Vietnam Pro | Designed for Vietnamese; diacritics sit better at small sizes. | | Source Sans 3 | Good for dense tables and forms. | | Inter | UI-oriented; tight at small sizes. |
Read the OFL.txt shipped with the exact version you download — the licence travels with
the file, not with the family name.
docs/fonts.md — shipped inside the package, so it is there in
node_modules/@devmm/puredocs-pdf/docs/ at the version you installed — covers loading a
font in Node and in the browser, proving coverage up front with embedFont(bytes, { require })
and font.missingGlyphs(text), per-face bold/italic, and the
'error' | 'warn' | 'replace' policy.
Images
const img = await doc.embedPng(pngBytes); // or embedJpg / embedImage
page.drawImage(img, { x: 50, y: 400, width: 300, height: 200, opacity: 0.9 });Feature matrix
| Area | Supported now | Roadmap | |------|---------------|---------| | Parse structure | xref tables, xref streams, object streams, trailer, recovery scan | linearized fast-path | | Filters | FlateDecode, LZWDecode, ASCIIHex, ASCII85, RunLength, PNG/TIFF predictors | CCITT, JBIG2 | | Read | metadata, page geometry, text extraction (WinAnsi + per-font ToUnicode) | image extraction | | Create | pages, text (standard 14 and embedded TrueType/OpenType, subsetted), word wrap, shapes, images (JPEG/PNG+alpha), opacity | text shaping (ligatures, mark positioning) | | Manipulate | merge, split, copy pages, remove, reorder, rotate, watermark | crop, page overlays, optimize/linearize | | Metadata | title/author/subject/keywords/creator/producer | XMP metadata streams | | Security | — | RC4/AES read+write, permissions | | Signatures | — | PKCS#7 sign + validate |
Anything not implemented throws a clear PDFNotSupportedError, and anything that cannot be
drawn throws PDFUnsupportedCharacterError — the library never silently produces wrong
output.
Benchmarks
Real measurements (npm run bench), Node v22, on the developer machine. These are actual
numbers from the code in this repo — not targets. Run it yourself to reproduce.
| Task | ops/sec | avg | 1.0.1 | |------|--------:|----:|------:| | create + save 100-page PDF | ~64 | ~15.6 ms | ~21.5 ms | | load 100-page PDF | ~1560 | ~0.64 ms | ~0.68 ms | | extract text from 100-page PDF | ~60 | ~16.7 ms | ~19.5 ms | | flateEncode 256 KB | ~1670 | ~0.60 ms | ~0.60 ms | | flateDecode 256 KB | ~2050 | ~0.49 ms | ~0.66 ms |
npm run bench:vi runs the bulk-Vietnamese case — an administrative listing of
10,504 rows × 6 columns (63,024 drawText calls) over 234 A4 pages with an embedded
Unicode font. It picks up a font from the machine it runs on; none is bundled.
| | 1.0.1 | 1.0.2 |
|---|------:|------:|
| build + save | 395 ms | 160 ms |
| per cell | 6.27 µs | 2.54 µs |
| wrapText × 2,000 lines | 21.9 ms | 6.4 ms |
| output size | 0.58 MB | 0.58 MB |
To compare against pdf-lib / pdfjs, install them as devDependencies and add cases to
benchmarks/suite.ts; none are bundled so the default results stay honest.
API overview
PDFDocument.create(options?)/PDFDocument.load(bytes)doc.addPage(size),doc.getPage(i),doc.getPages(),doc.pageCountdoc.copyPages(src, indices?),PDFDocument.merge(docs),doc.split(ranges)doc.removePage(i),doc.reorderPages(order)doc.setTitle/…,doc.getTitle/…doc.embedFont(bytes, opts?),doc.setDefaultFont(font),doc.setUnsupportedCharacterPolicy(action)doc.embedJpg/embedPng/embedImage,doc.addWatermark(text, opts?)doc.extractText(),page.getText()page.drawText/drawRectangle/drawLine/drawCircle/drawImage,page.rotate(deg)page.measureText(text, size, font?, opts?),page.wrapText(text, size, font, maxWidth, opts?)font.hasGlyph(codePoint),font.missingGlyphs(text)
Full types ship with the package.
Development
npm install
npm test # vitest
npm run bench # tinybench
npm run bench:vi # bulk Vietnamese table (needs a .ttf on the machine)
npm run build # tsup -> dist (ESM + CJS + d.ts)
npm run check:pack # publintReleasing
Publishing is automatic. .github/workflows/ci.yml runs test → package → publish on
every push to main, and the version field in package.json is the switch: bump it
and the next push releases; leave it alone and the publish job skips. The registry, not the
branch, decides what has already gone out, so re-running a build or pushing a docs fix never
republishes or fails red.
# bump "version" in package.json, add the CHANGELOG entry and
# docs/release-notes/<version>.md, then merge to main. That is the release.The publish job then publishes, tags the exact commit v<version>, and
opens a GitHub Release from docs/release-notes/<version>.md if that file exists.
npm provenance — a signed attestation tying the tarball to the commit it was built from — is added only when the repository is public, because the registry rejects an attestation nobody can verify against readable source.
It needs one repository secret: NPM_TOKEN, an npm access token of type Automation
(Settings → Secrets and variables → Actions). Without it, a push that would release fails
with that message rather than publishing something unsigned.
Running the workflow manually from the Actions tab publishes too; tick dry_run if you want
a rehearsal that stops short of the publish instead.
License
MIT © devmm
