repair-pdf
v0.2.0
Published
Repair damaged PDF files in the browser: rebuilds cross-reference tables, fixes stream lengths, recovers lost catalogs and page trees.
Maintainers
Readme
repair-pdf
Repair damaged PDF files in the browser. Zero-config, dependency-light (only fflate for compression), ~64 kB gzipped, built with tsdown.
The library does one thing: it takes bytes that are supposed to be a PDF and returns a normalized PDF using recovery strategies drawn from Chromium's PDFium, MuPDF, pdf.js and QPDF.
import { repairPdf } from 'repair-pdf';
const result = repairPdf(bytes); // Uint8Array | ArrayBuffer
result.bytes; // rewritten file, or original bytes if unchanged or repair is incomplete
result.repaired; // true when a rewrite happened
result.issues; // [{ code, severity, message, object? }, ...]
result.pageCount; // pages reachable after repair
result.signed; // true when the input carries digital signatures
result.signatureStatus; // 'present', 'absent', or 'unknown'For upload pipelines, probe the container before doing a full repair:
import { quickCheckPdf, repairPdfIsolated } from 'repair-pdf';
const probe = quickCheckPdf(bytes);
if (probe.status === 'structurally-valid') {
// Canonical envelope, xref offsets, catalog, and root Pages node: keep the
// original bytes when container validity is all the application requires.
return bytes;
}
// An indeterminate encrypted/unsupported container needs a full check; known
// damage needs repair. Use a Worker so timeout/abort is enforceable.
return (await repairPdfIsolated(bytes, { timeoutMs: 30_000 })).bytes;The quick result is deliberately scoped to container validity. It does not
decode content, fonts or images or traverse every semantic structure, so use
analyzePdf/repairPdf when those checks are part of your acceptance policy.
API
repairPdf(input, options?) => RepairResult
Never throws on malformed input.
| option | default | description |
| ------------------ | ------- | ----------- |
| force | false | Rewrite even when no problem was detected. Also permits potentially lossy salvage of incomplete object streams; resource limits still preserve the original. |
| password | '' | User or owner password for encrypted files. |
| removeEncryption | false | Decrypt strings and streams and drop /Encrypt. Useful for libraries such as pdf-lib that refuse encrypted input. Requires the password to be accepted; an empty user password usually is. |
| objectStreams | false | Repack eligible objects into compressed object streams. Produces PDF 1.5+ output and can substantially reduce rewritten file size. Encrypted input requires a valid password. |
| maxSyntheticPages | 10000 | Maximum extra blank pages inferred from a damaged page-tree /Count. |
| maxContentStreamsPerPage | 256 | Inspection ceiling for one page's /Contents array. Exceeding it returns the original with resource-limit; no streams are truncated. |
| maxInputBytes | 268435456 | Refuse larger in-process inputs (hard maximum 2 GiB). |
| maxObjects | 500000 | Maximum xref/object entries accepted from one document (hard maximum 2 million). |
| contentStreams | 'validate' | 'preserve', 'validate', or conservatively 'sanitize' malformed page-description operators and state stacks. |
| maxContentOperations | 1000000 | Maximum operators inspected in one content stream (hard maximum 10 million). |
| maxDecodedStreamBytes | 33554432 | Per-stream decoded-byte budget, including xref/object streams and logical page-content sequences (capped at 512 MiB). |
| maxTotalDecodedBytes | 134217728 | Shared decoding budget across structural recovery and semantic inspection (capped at 1 GiB). |
| repairImages | true | Infer missing JPEG filters/dimensions/colourspaces and append a missing JPEG EOI marker. |
| repairResources | true | Repair resource dictionaries and substitute Helvetica for fonts referenced by content but absent from /Resources. |
| repairFormAppearances | true | Generate missing text/choice-field appearances and synchronize button /AS states. |
| repairTags | true | Repair logical structure types, children, cycles, and parent links. |
| flattenForms | false | Paint usable widget appearances into page content; remove the AcroForm only when every widget was flattened. This changes interactivity. |
| flattenAnnotations | false | Paint usable visible annotation appearances into page content. Hidden/view-dependent annotations are retained. |
| optimizeImages | false | Losslessly recompress image samples whose complete filter chain is understood. |
| deduplicateObjects | false | Merge byte-identical immutable streams and fonts. Skipped for encrypted PDFs. |
| garbageCollect | 'on-rewrite' | Remove unreachable objects on an existing rewrite; true also makes unused objects trigger a rewrite, false retains them. |
RepairResult: bytes, repaired, issues, pageCount (-1 when structural
inspection is incomplete), encrypted, signed,
signatureStatus (unknown when unreadable objects could hide a signature),
reconstructed (xref rebuilt by scanning), version.
repaired reports that a rewrite happened, not that all content was recovered.
Incomplete object-stream recovery returns the original bytes with an
objstm-unreadable error by default. Structural resource limits likewise return
the original with resource-limit, even with force: true. Semantic streams
that cannot be inspected within the remaining budget are preserved and reported
in issues. Decoding limits bound retained output; the decoder also needs a
fixed working window and temporary buffers, so these are not process-memory
limits.
analyzePdf(input, options?) => AnalyzeResult
Same analysis without producing output: issues, needsRepair, pageCount,
encrypted, signed, signatureStatus, reconstructed, version.
Issue severities: error and warning normally trigger a rewrite; preservation
guards can prevent it. info is
diagnostic only (for example duplicate-object, password-required).
quickCheckPdf(input, options?) => QuickCheckResult
A non-mutating fast path that validates the header/EOF envelope, xref chain,
every uncompressed xref offset and object header, compressed-object containers,
catalog, and complete page tree (kids, parents, cycles, duplicates, and subtree
counts). It does not scan the file for recovery or decode page-content streams.
Structural object streams are decoded as needed. The
status is one of structurally-valid, needs-repair, or
full-check-required (for example, for an encrypted compressed catalog).
verifyOffsets: false skips the all-offset pass when latency matters more than
assurance; maxObjects applies the same object ceiling as full repair.
The included 1,500-page benchmark currently checks canonical 3.2 MB and
14.8 MB files in 134 ms and 88 ms respectively on the development machine;
the default full analysis/repair path takes roughly 0.8–1.0 seconds on the
same files. Run node scripts/perf.mjs 1500 on the deployment hardware for a
representative local number.
repairPdfIsolated(input, options?) => Promise<RepairResult>
Runs repairPdf in a fresh module Worker, transfers the result back, and always
terminates the Worker. timeoutMs defaults to 30 seconds (maximum 10 minutes),
signal supports cancellation, and repair contains ordinary repair options.
transferInput: true avoids a large input copy when the caller can allow its
input ArrayBuffer to be detached.
The shipped Worker is self-contained (including compression code). Its default
URL works when the published ESM files are served without rebundling. Bundled
applications should pass an emitted workerUrl, or use framework handling such
as Vite's import RepairWorker from 'repair-pdf/worker?worker' together with
workerFactory: () => new RepairWorker(). This is the recommended boundary for
hostile uploads because synchronous JavaScript can only be forcibly interrupted
by terminating its Worker.
Renderer and Preflight repairs
repairPdfAdvanced adds the operations that intrinsically need a complete
renderer, colour engine, or second-pass writer: page rasterization, true Fast
Web View linearization, image transcoding, certificate decryption, and
PDF/A/X/E conversion. The engine is injected, so the core stays synchronous,
small, CSP-friendly, and MIT licensed.
import { repairPdfAdvanced } from 'repair-pdf';
import { createQpdfRepairEngine } from 'repair-pdf/qpdf';
const engine = await createQpdfRepairEngine({
workerUrl: new URL('qpdf-run/worker', import.meta.url),
qpdfJsUrl: new URL('qpdf-run/qpdf.js', import.meta.url),
wasmUrl: new URL('qpdf-run/qpdf.wasm', import.meta.url),
});
try {
const result = await repairPdfAdvanced(bytes, {
engine,
linearize: true,
generateAppearances: true,
optimizeImages: true,
});
} finally {
await engine.destroy();
}Install qpdf-run to use that optional browser/WASM adapter. It is a peer
dependency and its worker/WASM payload is not bundled into repair-pdf.
For Chrome/PDFium-style visual salvage, provide an engine with rasterize();
renderFallback: 'on-error' or 'always' rebuilds a portable PDF from the
returned JPEG pages. Rasterization deliberately reports that selectable text,
tags, links, forms, and signatures were lost. A commercial/hosted Preflight
engine can implement rewrite() to service conformance and certificate
credentials through the same typed interface.
Engines can declare capabilities; unsupported requests are rejected before
execution. Linearization is checked on the final bytes, certificate output must
be decrypted, and PDF/A/X/E is only reported as verified when the engine also
implements verify(). Renderer results default to limits of 10,000 pages and
512 MiB of JPEG payload (maxRasterPages, maxRasterBytes). JPEG grayscale,
RGB and CMYK metadata is inferred from the SOF marker or can be supplied
explicitly. A structurally invalid or failed-verification engine result is
discarded transactionally and the last usable PDF is returned with an error.
What gets repaired
File envelope
- Garbage before
%PDF-(BOMs, HTML, MIME headers). Like PDFium, offsets are treated as relative to the header, so files that only suffer from a prefix still take the fast path. - Missing or truncated
%%EOF, garbage after it, missing header. streamfollowed by a lone CR, wrong or missingendobj/endstream.
Cross-reference data
- Missing
startxref, wrongstartxref,/Prevloops, offsets shifted by inserted or removed bytes, free-entry off-by-one subsections, hybrid/XRefStmfiles, xref streams with bad/Wor short data. - Every in-use entry is verified against the object actually found at that
offset. Any mismatch, an unreachable catalog, or a page tree that yields no
pages triggers a full reconstruction by scanning the file for
N G obj. - During reconstruction, duplicates are resolved the way MuPDF and QPDF do: the definition latest in the file wins, unless it is truncated. Object-stream members only override top-level objects that appear earlier in the file. Compressed-object entries are salvaged from broken xref streams (their offsets are ignored, only the stream/index mapping is trusted).
Streams
- Wrong, missing or unresolvable
/Length: the real extent is located fromendstream,endobjor the next object header in a single pass, and the dictionary is rewritten with the correct direct length. - Object streams are unpacked into plain objects by default so the output uses
a classic xref table (readable by every library, including ones without
xref-stream support).
objectStreams: truerepacks eligible objects in batches of 100 and emits an xref stream. Truncated Flate data is decoded as far as possible. - Page descriptions, Form XObjects, Type 3 glyph procedures and annotation appearances are checked against the PDF operator grammar. Sanitizing removes unknown/ill-formed operations, recovers truncated inline images by dropping only the broken operation, and balances graphics, text, compatibility and marked-content state. A page's content array is inspected as one logical program, preserving state across streams; sanitization writes a page-owned copy of shared content.
- JPEG image dictionaries can be reconstructed from SOF markers and missing EOI markers appended. Fully decodable sample streams can be recompressed.
Document structure
- Trailer selection prefers the newest trailer whose
/Rootis a real catalog; otherwise the newest/Type /Catalogobject; otherwise a catalog is synthesised. - Page tree: dangling
/Kids, cycles, wrong/Count, wrong/Parent, missing/Type, inline page dictionaries. When no page is reachable, a flat tree is rebuilt from every/Type /Pageobject in the file (something none of the reference engines do), carrying over inheritedMediaBox,Resources,RotateandCropBox. /Inforecovered from any dictionary with/Produceror/Creator.- Missing or unreadable pages become blank pages so numbering is preserved;
a missing subtree gets as many blank slots as its parent's
/Countimplies; duplicated page objects and shared/Pagessubtrees are cloned; pages get aMediaBoxandResourceswhen none is inherited; non-stream/Contentsentries are dropped; pathologically deep page trees are flattened without losing inherited page attributes. - Name and number trees are sorted and repaired (
/Dests,/EmbeddedFiles,/PageLabels, structure/ParentTree); outline chains have dangling links, loops,/Parent,/Prev,/Last, and/Countcorrected; invalid annotation entries are removed, wrong annotation/Plinks are relinked, and annotations shared by pages are separated. A corrupt secondary tree that aliases a live Catalog, Pages, or Page object is discarded without mutating page structure. - Digital signatures and certification permissions are detected, including
unusual field layouts. Opaque objects yield
signatureStatus: 'unknown'. Rewriting a known signed file reportssignature-invalidated. - References to missing objects become
null. - A document with no usable page at all gets one blank page so it can still be
opened (the
no-pageserror remains in the report). - Stale linearization dictionaries and old xref streams are dropped.
- Missing font resources used by
Tfare substituted with a portable Standard-14 font; malformed resource categories are removed. - AcroForm fields and widgets are reconciled, missing text/choice appearances generated, button appearance states synchronized, and appearances can be flattened into page content.
- Logical structure element graphs are cycle-checked and parent links repaired.
- Unreachable objects are garbage-collected on rewrites; optional deduplication merges identical immutable streams and fonts.
Encryption
- Standard security handler R2 to R6 (RC4 40/128, AES-128, AES-256), user and
owner passwords, Unicode SASLprep for R5/R6,
/Identity,/StmF,/StrFand/EFFembedded-file crypt filters, unencrypted metadata. - Encrypted files are repaired without being decrypted: object numbers are preserved so existing ciphertext stays valid, and members unpacked from object streams are re-encrypted. When the password is unknown, members of encrypted object streams are written through untouched via an xref stream.
- Unsupported public-key encrypted files are preserved byte-for-byte by the
core unless
forceis set. Supply certificate material to a capable advanced engine instead; opaque rewriting is more likely to reduce compatibility. - Wrong or missing
/Length, short/U, missing/ID: corrected in the/Encryptdictionary once the password validates, so strict readers (pdf.js, MuPDF) accept the result too.
What it does not do
- The small core does not contain JPEG 2000, JBIG2, CCITT, font-shaping,
colour-management, CMS certificate, or full page-rendering engines. These
jobs use
repairPdfAdvancedwith a compatible engine; qpdf WASM support is supplied asrepair-pdf/qpdf. - Native sanitization intentionally preserves image operators and valid page content rather than attempting to recreate missing artwork. Use renderer fallback when source bytes are irrecoverable.
- Rewriting a file invalidates digital signatures. Clean files are therefore
returned untouched unless
forceorobjectStreamsis set; rewritten signed files reportsignature-invalidated. - Files whose
/Encryptdictionary itself is destroyed cannot be recovered by anyone without the key; a certificate-capable advanced engine may accept private-key material through the adapter interface.
Design notes
The implementation is a tolerant lexer and recursive-descent parser (never throws; unbalanced delimiters, junk tokens and keywords in the wrong place are skipped like pdf.js and PDFium do), a document loader with two strategies (trust-and-verify the xref, else scan), and a writer that re-serialises every object from the parsed model with stream data copied byte-for-byte.
Rules borrowed from the reference engines:
| rule | source |
| ---- | ------ |
| Header-relative offsets, %PDF searched in the first 1024 bytes | PDFium GetHeaderOffset, pdf.js checkHeader, QPDF, MuPDF |
| Verify xref entries, rebuild on mismatch or unusable page tree | PDFium VerifyCrossRefTable / TryInit, pdf.js checkFirstPage |
| Stream end = first of endstream / endobj, strip up to two EOL bytes | PDFium FindStreamEndPos |
| Last object in file wins; truncated duplicate never replaces a good one | MuPDF pdf_repair_obj, pdf.js indexObjects |
| Object-stream members lose to later top-level objects | MuPDF bug 708286 |
| Recovered xref streams contribute only compressed entries | QPDF in_stream_recovery |
| Trailer preference: newest with a valid /Root, then any /Type /Catalog | pdf.js, QPDF, MuPDF |
| Set up decryption from harvested /Encrypt + /ID before opening object streams | MuPDF pdf_repair_xref |
| Object number guard (8 388 607) | MuPDF PDF_MAX_OBJECT_NUMBER |
The complete list of tolerances, with spec sections and the engine each rule comes from, is in docs/tolerances.md.
Development
pnpm install
pnpm test # vitest; uses qpdf and mutool as extra oracles when installed
pnpm build # tsdown -> dist/
pnpm exec playwright install chromium # install the browser for package checks
pnpm test:browser # requires a current build; tests the packed npm artifact in Vite/Chromium
pnpm test:release # requires qpdf/mutool; fetches/verifies corpus, tests, builds and checks browser
node scripts/make-fixtures.mjs # regenerate test/fixtures/base (needs qpdf)
node scripts/try.mjs in.pdf out.pdf [--force] [--decrypt] [--object-streams] [--password=...]
node scripts/perf.mjs 1500 # timing on a generated 15 MB file
node scripts/fetch-corpus.mjs # download the external corpus listed in test/corpus.json
node scripts/harvest-corpus.mjs # clone ~30 000 PDFs of test suites / fuzz corpora (gitignored, ~30 GB)
node scripts/triage.mjs test/fixtures/harvest --jobs=4 --out=test/out/triage # isolated repair + qpdf/mutool/pdf.js comparisonscripts/harvest-corpus.mjs sparse-clones the PDF test directories of pdf.js,
qpdf (incl. fuzz corpora), pdfium and pdfium_tests, poppler, PDFBox, pypdf,
pikepdf, iText, Tika, JHOVE, veraPDF, the SafeDocs and 0ca fuzz aggregates,
Artifex's tests.git, corkami, PDF-Writer's decoder crashers and a dozen more,
plus attachments of pdf.js bugs from Mozilla Bugzilla. scripts/triage.mjs
repairs every file, checks originals and outputs with qpdf, mutool and pdf.js,
each in memory-limited subprocesses, and writes report.txt grouped into
crashes, regressions (output worse than
input), still-broken outputs and files left untouched although an engine
complained. That loop is how most of the rules in docs/tolerances.md were
found. The last run over 28,814 files reported no repair-process crashes.
Files whose structure is hidden behind unsupported certificate (PubSec)
encryption or an experimental filter such as BrotliDecode are preserved
byte-for-byte for a capable viewer or advanced engine; the core does not
replace their inaccessible content with a synthetic document.
test/corpus.json lists real-world broken PDFs from the pdf.js, qpdf, pdfium
and other open-source test suites. They are fetched into the gitignored
test/fixtures/corpus/ directory and exercised by test/corpus.test.ts,
which also runs over anything you drop into test/fixtures/private/ (never
committed, useful for customer files).
The test suite applies 21 corruptions to 13 base fixtures (classic, object
streams, linearized, incremental, RC4/AES encrypted) and checks the output
with pdf.js (page count and text), qpdf --check and mutool.
The external corpus (test/corpus.json, 253 files) collects real-world broken
PDFs from the qpdf, pdfium, pdf.js, pypdf, PDFBox, JHOVE, veraPDF, pikepdf,
SafeDocs and OpenPreserve test suites. Each entry records the defect, the
license of its source, a pinned SHA-256 and the expected page count. 22 are marked
unrecoverable (no engine can open them, only "does not throw" is checked) and
8 contentDamaged (image or content-stream data is destroyed, so only the
structure is verified). Readable source text is compared per page when page
counts match, and across the document when recovery adds page slots. Validator
diagnostics are compared with the original without suppressing font, decoding,
authentication or metadata errors. Newly exposed source defects require narrow,
count-limited exceptions with reasons in the manifest. Selected incomplete
object streams must remain byte-for-byte unchanged by default; explicit forced
salvage is tested separately for four malformed fixtures.
pnpm test:release, the prepublish hook and GitHub Actions require the full
checksum-verified corpus and both native validators. The gate also includes
MuPDF pixel comparisons for preservation regressions and a browser smoke test
of the packed package: bundled/shipped workers, text preservation, input
transfer, timeout, cancellation and password-protected qpdf WASM linearization.
Set REPAIR_PDF_CHROME to a local Chromium executable to override Playwright's
downloaded browser.
