PDF Parse
Unreviewed · 2026-04-21 Node + Browser DocumentPDF text + metadata extraction via pdf-extract/lopdf. No pdf.js, no browser pipeline.
- Targets
- Node + Browser
Install
pnpm add @amigo-labs/pdf-parseBenchmarks
Trend (6 pts)Benchmark
example.pdf (~580 bytes)
- pdf-parse 121.29K hz · 17.99×
- @amigo-labs/pdf-parse parseSync napi 6.74K hz
Benchmark
unicode.pdf (~3.9 KB)
- pdf-parse 120.26K hz · 49.72×
- @amigo-labs/pdf-parse parseSync napi 2.42K hz
README
@amigo-labs/pdf-parse
PDF text + metadata extraction via
pdf-extractandlopdf. No pdf.js, no browser render pipeline — just content-stream tokenisation.
Install
pnpm add @amigo-labs/pdf-parse
Usage
import { parse, parseSync } from '@amigo-labs/pdf-parse'
// Drop-in shape for upstream pdf-parse:
const result = await parse(fs.readFileSync('doc.pdf'))
console.log(result.text) // plaintext
console.log(result.numpages) // 42
console.log(result.info.Title) // 'My Whitepaper'
console.log(result.version) // '1.7'
// Sync path for small PDFs (< ~500 KB):
const sync = parseSync(buf)
Install for the browser
The same import works in Angular, React, Vite, esbuild, and webpack ≥ 5 — the bundler picks the WASM build via the browser conditional export:
import { parse } from '@amigo-labs/pdf-parse'
The PDF parser makes this one of the heavier WASM bundles in the family — consider lazy-importing in code-split routes:
const { parse } = await import('@amigo-labs/pdf-parse')
Options
interface PdfParseOptions {
max?: number // process at most N pages
password?: string // RC4 / AES-128 standard security
}
Result
interface PdfParseResult {
text: string // extracted plaintext
numpages: number // total page count
info: Record<string, string> // Title, Author, Creator, ...
metadata?: string // XMP metadata (if any)
version: string // PDF spec version (e.g. "1.7")
}
Scope
- Text extraction — the 95% use-case of
pdf-parse. - Document metadata —
/Infodict and XMP stream. - Page count + PDF version.
- Encrypted PDFs via
passwordoption (RC4, AES-128).
Scope cuts
- No
pagerender(pageData)callback. Per-page JS callbacks would cost an FFI crossing each — seedocs/perf-review/pdf-parse.md. Post-processtext.split('\x0c')to iterate pages. - No Public-Key-Security.
pdf-extractonly implements standard security handlers. - No malformed-PDF recovery on the scale of pdf.js. Scanner-output or very old Adobe files may fail — fall back to upstream in a try/catch pipeline.
See __conformance__/divergences.md
for detailed differences.
License
MIT
Perf review
Candidate review: pdf-parse
Status: GO (as a new package, scoped to text extraction) · Predicted: 🟢 Green · Reviewed: 2026-04-21 Shipped: v0.1 on branch
claude/crate-performance-audit-6KLOJ(2026-04-23). Benchmarks pending full bench suite.
Verdict
pdf-parse is a thin wrapper around Mozilla’s pdf.js — a ~500k-LOC pure-JS PDF renderer. For the pure text-extraction path that is massive overhead: full page-layout graph, font decoding, CIDMap resolution, all in JS land. Rust lopdf + pdf-extract run on SIMD-accelerated byte parsers, use native zlib/LZW decompressors, and skip the render pipeline entirely. The shape is textbook Green: Buffer in / String out, one FFI crossing per document, substantial CPU work per call. The real reservation is parity on pathological PDFs — encrypted, JBIG2, CJK CID mappings, malformed cross-reference tables — not perf.
JS package
- npm:
pdf-parse - Downloads: ~1M/week (Q1 2026 estimate, BACKLOG figure confirmed)
- Exports / API surface:
pdf(dataBuffer, opts?) → Promise<{ text, numpages, numrender, info, metadata, version }>. Minimalist; the second argument allows apagerendercallback (we ignore it in a port — a callback across the FFI boundary is an antipattern). - Typical input: PDF buffers 50 KB – 10 MB. Median ~500 KB – 2 MB (whitepaper, invoice, report)
- Typical output: plaintext string of length 5 KB – 500 KB. Plus a metadata object (small, <1 KB)
- Realistic median use-case: RAG ingestion batch — pushing 100–10 000 PDFs through the pipeline, extracting
textonce per PDF and chunking it. Second use case: ad-hoc server extraction (upload form, one PDF per request). Both have the same shape: one PDF in, one text out, no per-page callbacks needed.
Rust replacement
- Candidate crate(s):
pdf-extract— primary. High-level APIextract_text(bytes) → Result<String>, maintained (jrmuizel), MIT. Covers the 80/20 common PDF features: text streams, ligatures, CID decoding, layout reordering.lopdf— low-level PDF parser used as the backend.pdf-extractbuilds on it. Use it directly if we want more than just text (metadata fields, forms, attachments — fast follow).pdf— alternative parser (pdf-rs/pdf), more active, but the API is unstable between 0.x releases.- Not suitable:
mupdfbindings — that would again be a C-library wrapper (MuPDF in C), the samehnswlib-nodemistake.
- Maintenance / license:
pdf-extractMIT,lopdfMIT, both active (Q1 2026 releases). No supply-chain risk. - Known gotchas / divergences:
- Encrypted PDFs:
pdf-extractv0.7 supports RC4 and AES-128 standard encryption, but no public-key security. For typical corporate PDFs (AES-128) that is sufficient. - JBIG2-compressed images: irrelevant for text extraction, but the parser must skip the stream gracefully.
- CJK fonts with proprietary CMaps: the non-Unicode Adobe CMaps (GB-EUC-H etc.) are only partially implemented in
pdf-extract. A corpus of Chinese/Japanese business PDFs must be checked side by side againstpdf-parse. - Text reordering:
pdf-parseemits text in page-stream order,pdf-extractattempts geometric reordering. Not a bug, but an output divergence — consumers doing regex-based matching on positional context will break. - Form fields (AcroForms):
pdf-parseignores them,pdf-extractpartially. Document the divergence. - Malformed cross-references: pdf.js has decades of recovery heuristics for broken PDFs.
lopdf/pdf-extracthave fewer. For edge-case PDFs (scanner output, old Adobe versions) parity may be missing.
- Encrypted PDFs:
BACKLOG check
Existing entry: BACKLOG.md:12:
pdf-parse (~1M, text-extraction path). Per-document parsing via
pdf-extract/lopdf. Parity on edge-case PDFs is the main risk.
Categorized as “Predicted Green”. This review confirms the prediction with the explicit scope caveat: text extraction only, not pdf.js parity.
Distinction from existing reviews:
docs/perf-review/pdfkit.mdand thetypstreview address the write side (producing PDFs).pdf-parseis the read side. No overlap, perfectly complementary.- The binary-size question is far smaller here than for
typst—pdf-extract+lopdf+ deps land at ~3–5 MB per target, roughly thezip/commonmarkcategory, not thetypstcategory (15–25 MB).
No entry in docs/packages.json.
FFI-overhead prediction
| Factor | Assessment |
|---|---|
| Per-call algorithmic work | High. Text extraction of a 1 MB PDF with 50 pages: pdf-parse/pdf.js ~200–500 ms in V8, pdf-extract expected at 20–80 ms. Substantial compute, FFI share <0.5%. |
| Input size distribution | Buffer 50 KB – 10 MB. Zero-copy via V8 buffer handle (docs/BASELINE.md:30 — flat <200 ns up to 10 MB). No marshalling problem on the input side. |
| Output size distribution | String 5 KB – 500 KB. UTF-16 conversion costs ~0.35 ns/byte (docs/BASELINE.md:27). 500 KB output = ~175 µs conversion overhead — irrelevant against >20 ms of Rust compute (<1%). |
| Reusable setup (stateful potential) | Low. No model/key/schema per call. Document-parser state exists per document, not per API consumer. No NAPI class needed. |
| Batch-usage realism | High. RAG ingestion workload = “process 1000 PDFs”. extractTextMany(buffers: Buffer[]) → string[] or extractTextManyAsync with a rayon pool would be the second lever beyond single calls — justifies a Phase-C1 sprint after v1. |
| FFI-share estimate vs. Rust work | <1% at the median (1 MB → 30 ms Rust). Scales even better on large documents. |
Classification reasoning
PDF text extraction is the canonical Green shape from the inflate/commonmark playbook:
-
The pure-JS baseline is slow. pdf.js is a complete PDF renderer — interpreting the PostScript-like content streams, font-subset decoding, CIDMap resolution, layout compositing. For text extraction everything except the text-showing operators (
Tj,TJ,',") gets thrown away — that is massive waste. Rust can drive the hot path directly: tokenize the content stream → keep only text operators → apply font mapping → concat. V8 optimization changes none of this, because the ballast sits in the parser graph. -
The compute is substantial. A 1 MB PDF with 50 pages often corresponds to 5–15 MB of decompressed content streams that a tokenizer has to chew through. That is real work, not a hashmap lookup. The FFI floor of 109 ns is literally in the 0.0005% range.
-
Input is a Buffer, output is a String. The two most FFI-safe types. No
Vec<Object>, noVec<String>, no callback. Textbook. -
Parity is the only cost item, and industry practice knows it. Tika, PDFBox,
pdftotext(poppler),pdf-parseitself — they all diverge on edge-case PDFs. We document our divergences (__conformance__/divergences.mdas withcommonmark), and the “RAG ingestion” use case easily tolerates a 1–2% document failure rate, because upstream pipelines have fallback loops anyway.
Shape matching:
- ✅ Like
inflate(Buffer in / Buffer out, substantial compute, zlib-rs as the engine) - ✅ Like
commonmark(spec parser, new package, no drop-in parity obligation, “we’re the CommonMark renderer, not the marked clone”) - ❌ Not like
hnswlib-node(no native competition — pdf.js is pure JS, not C++ passed through) - ❌ Not like
deep-equal(no short-input hot-loop trap — we process documents, not bytes)
Benchmark-gap flag: This prediction is made without a spike. Before shipping, four scenarios must be measured (see below). The realistic median (1 MB PDF) must hit ≥3×. The 50 KB bucket must hit ≥2× or be documented as a Yellow edge.
If GO — proposed port
- Recommended crate-name:
@amigo-labs/pdf-parse(drop-in-oriented name; the API shape matches; divergences documented as withcommonmarkagainst the spec) - Primary API sketch:
export interface PdfParseResult { text: string; numpages: number; info: Record<string, string>; // Title, Author, Producer, Creator, CreationDate, ModDate metadata: Record<string, string> | null; // XMP, if present version: string; // "1.7" etc. } export function parse(buf: Buffer | Uint8Array, opts?: { max?: number; // max pages to process (default: all) password?: string; // for AES-128 encrypted PDFs }): Promise<PdfParseResult>; // Synchronous path for small PDFs (<500 KB) export function parseSync(buf: Buffer | Uint8Array, opts?: ...): PdfParseResult; // Batch lever (fast follow in v0.2) export function parseMany( bufs: Buffer[], opts?: { concurrency?: number } ): Promise<PdfParseResult[]>; - Must-have benchmark scenarios (Gate):
- Small: 50 KB PDF (5 pages, English, simple text) — target ≥2× vs.
pdf-parse - Medium: 1 MB PDF (50 pages, mixed text + tables) — target ≥3× (the main Green-gate case)
- Large: 10 MB PDF (500 pages, report with graphics) — target ≥3×
- Batch: 100 × 200 KB PDFs via
parseMany— target ≥4× (rayon lever)
- Small: 50 KB PDF (5 pages, English, simple text) — target ≥2× vs.
- Acceptance thresholds (Green gate): ≥2× on the small PDF AND ≥3× on the median AND ≥3× on large. Anything else becomes a Yellow sprint or a scope cut.
- Risks:
- Parity on CJK fonts — must be validated with a corpus of Chinese/Japanese PDFs, divergences documented
- Encrypted-PDF coverage — only AES-128 + RC4, no public key
- Edge-case recovery — pdf.js has more recovery heuristics. Corpus fuzz testing needed (
fast-checkwith malformed byte flips) - Binary size — ~3–5 MB per platform target × 6 targets. Below
typst, but not trivial.lto=true, strip=symbols, panic=abortmandatory. - Sync interface —
pdf-parseis async (because of pdf.js); we can offer sync and that is a feature, but users who rely onawait pdf(buf)don’t have to change the shape of their code
If NO-GO — BACKLOG entry
Not applicable (GO recommendation).
Section in BACKLOG.md: Under investigation — AI / RAG preprocessing → the entry can stay where it is; update its status to “Reviewed GO 2026-04-21, ready for v0.1 spike.”