@dionaditya/wasm-pdf-reader
v0.3.0
Published
A PDF parser, rasterizer and text extractor in WebAssembly — plain-JS viewer plus React components, no server and no CDN.
Downloads
279
Maintainers
Readme
wasm-pdf-reader
A PDF reader in WebAssembly: it parses, rasterizes and extracts text from a PDF inside the browser tab. No server, no upload, no CDN — a 3.1 MB module (1.9 MB gzipped, 0.9 MB of it the Adobe CMap tables) that a vanilla page or a React app can drive.
Packaged as @dionaditya/wasm-pdf-reader. Try it without installing anything:
vanilla demo ·
React demo.

The C++ core (lexer, xref, object streams, filters, content-stream interpreter, scanline rasterizer, font subsetting) is the same code that powers the PDF tab in wasm-ui, packaged here on its own.
What it does
Parsing — classic xref tables and xref streams, object streams,
/Prevchains, incremental updates, xref rebuild for damaged files, Flate/LZW/ ASCII85/ASCIIHex/RunLength with PNG and TIFF predictors. Object-stream offsets are read relative to/First, which is how calibre, Acrobat and Ghostscript write them.Encryption — Standard security handler: RC4 40/128-bit, AES-128 and AES-256 (R5/R6). Pass the password as an option and it is tried as the user password first, then as the owner password (R2–R4 via /O, R5/R6 via /OE with /Perms verification):
const doc = await PdfDocument.open(bytes, { password: 'secret' });Files with no password open with
PdfDocument.open(bytes). A file that needs one fails withencrypted PDF: a user password is required(orwrong password), never a blank page. The demo prompts for the password once when the reader asks for it.Verified against qpdf:
scripts/make_encrypted_fixtures.pywrites one file per revision (R2 RC4-40, R4 RC4-128, R4 AES-128, R5 and R6 AES-256, plus an owner-password-only file and one with a corrupted/Perms), andscripts/verify_encryption.shcompares the file key this engine derives withqpdf --show-encryption --show-encryption-key. All thirteen user/owner rows match byte-for-byte. Two behaviours are deliberate: an owner-only file opens with an empty password (that is its user password), and a corrupt/Permsmakes the owner path refuse with a warning naming the permissions block — qpdf ignores it and opens anyway; refusing is the safer read of a tampered file.Rendering — paths, fills (nonzero/evenodd), strokes with dash/cap/join, clipping, text, Form and Image XObjects, inline images,
/Rotatepages, axial and radial shadings, tiling patterns (/PatternType 1, cached per pattern and scale, clipped to the painted path), shading patterns, all 15 PDF blend modes,/Group /Transparencycompositing with/caand/CA,/SMasksoft masks and/Maskstencil masks. Software rasterizer with 4× vertical supersampling, returning RGBA8. Images larger than the destination are box-averaged, so a 5000 px scan drawn at 600 px stays readable instead of aliasing.Images — Flate, DCT (JPEG), CCITT G3/G4 fax, JBIG2, and JPEG 2000 (see Limits: the JPX path only handles some encoders). PDFium's JBIG2 and fax decoders are translated to C with
wasm2cand linked intopdf-reader.wasm, so scans decode in the same pass as everything else — no second binary, no JS hand-off, no re-render. Stencil/Maskstreams decode through the same path.Colour — DeviceGray/RGB/CMYK (CMYK through a 9^4 table calibrated against the reference renderer, because device CMYK has no profile of its own), ICCBased profiles (matrix/TRC and A2B0 LUTs),
/Separationand/DeviceNspot inks through their tint transform (sampled, exponential or stitched function),/Indexed.Fonts — TrueType (
/FontFile2), bare CFF and CID-keyed CFF, CFF inside an OpenType (OTTO) wrapper, and Type3 fonts whose glyphs are content streams (/FontFile3 /Type1C,/CIDFontType0C) through an own Type2 interpreter, and sfnt-wrapped/OpenType. CID fonts resolve glyphs through/CIDToGIDMapor the CFF charset, after the character code is turned into a CID by the font's/Encoding:/Identity-H, one of the 179 predefined Adobe CMaps (/UniJIS-UCS2-H,/GBK-EUC-H,/90ms-RKSJ-H, …) or an embedded CMap stream, including/usecmapinheritance and mixed 1/2-byte codespaces. Embedded subsets that ship without acmaptable (common for Arabic and CJK) are patched with a synthetic one instead of falling back to a substitute face. Advances come from/W, falling back to the face'shmtx.Annotations — link annotations (
/URI,/GoTo, direct/Destand named destinations) are exposed as data and rendered as clickable overlays; every other annotation is drawn from its/APappearance stream, which is where signatures, stamps and meterai actually live. Markup annotations that ship without an appearance (the common case for highlight, underline, strikeout and squiggly) are synthesised from/QuadPointsand/C, including one mark per quad;/Textnotes without an/APicon get a note icon built from/Rect. An appearance stream always wins when present.Optional content —
/OCProperties/OFF//ONdecide which OCGs paint; hidden/OC ... BDCsections and XObjects with an/OCentry are skipped.Outline —
document.outlinereturns[{title, page}]for the bookmark tree.AcroForm —
document.formlists{name, type, value, page, rect};document.setField(name, value)writes/V(and/AS) in memory so the next render shows the change. Widgets that ship without an/APstream get a synthesised appearance: background, border, value text and a tick for buttons.Viewer chrome — page rail with lazily rendered thumbnails and a print action that rasterizes every page at ~120 dpi into a detached sheet, prints it and discards it (no print stylesheet can reach into the host page).
Text layer — glyph runs with page-space boxes, so you can select, copy, search and draw highlights without a second parser. The viewer mounts a transparent
.wpdf-textlayerover the canvas (each run is a span, stretched to the width the engine measured), which makes drag-selection, Ctrl+C and the browser's own find-in-page work. Turn it off withtextLayer: falseif you only need pixels.No network — the wasm binary loads from your own origin; the file never leaves the tab.
Not supported, and reported through document.warnings rather than silently
approximated: text shaping for scripts that are not stored pre-shaped (Arabic,
Indic), Type 1 PFB fonts (a substitute face is used), shading types 1, 6 and 7
(function shadings and Coons/tensor patch meshes), soft masks attached with
gs /SMask (an image's own /SMask and a group's /ca//BM do work),
non-isolated transparency groups (they are composited isolated, which only
differs when the group relies on the page backdrop), vertical writing (-V
CMaps paint horizontally), XFA and form JavaScript (/AA), public-key
encryption, and ranged/streamed loading (the whole file is resident in wasm
memory). Mesh shadings 4 and 5 decode but are still experimental — see
Limits. Outlines, AcroForm fields (readable and settable in memory)
and page thumbnails are supported. Annotations render and links are clickable,
but there is no panel for note text, and a highlight uses alpha rather than a
multiply blend, so overlapping marks darken instead of saturating.
Rendered examples
Every image below is the engine's own output at scale 1 (72 dpi), not a screenshot of another viewer.
Annotations without appearance streams — left: highlight (two quads),
underline, strikeout and squiggly painted from /QuadPoints + /C; right:
three /Text notes whose icon is built from /Rect alone:

CJK through a predefined CMap — /Encoding /UniJIS-UCS2-H mapping UCS-2
codes to Adobe-Japan1 CIDs, then /CIDToGIDMap to the embedded Noto subset.
Ink ratio 0.0047 against MuPDF's 0.0049, mean abs diff 0.08/255:

JBIG2 scan — a 19th-century two-column page decoded from a JBIG2 stream with a global symbol dictionary, drawn at 1:1:

Security notes
- No
eval, by construction. Parsing and rasterizing are C++; no JavaScript is generated from document data, and the build sets-s DYNAMIC_EXECUTION=0so Emscripten cannot emiteval/new Functioneither.dist/contains neither, and neither appears as a string inpdf-reader.wasm. - CVE-2024-4367 (pdf.js
/FontMatrixinjection) does not apply. That bug exists because pdf.js compiled glyph commands into JS source; we interpret Type 1/Type 2 charstrings in C++ and read/FontMatrixentries as doubles. Checked against the Codean Labs PoC: the file opens, renders, raises no warnings, and executes nothing. - Bounds instead of trust. Page bitmaps are capped at 40 MP, images at
20000 px per side, form nesting at depth 6, and every number read from the
file goes through one
objNumcoercion. The failure mode for hostile input is a refused page, not code execution.
Limits
- The whole file is resident. No ranged loading: the PDF sits in the wasm heap while it is open, plus the parsed objects and, per page, one RGBA bitmap. For text documents that is roughly the file size; scans add the decoded image. Measured: a 78 MB / 16-page PDF of full-page JPEGs opens and renders its first page in ~0.8 s on desktop.
- The wasm heap only grows. Emscripten cannot return memory to the OS, so the
figure you see is a high-water mark for the module's lifetime; tearing the
module down (
resetModule()) frees it.npm run test:memoryasserts it plateaus rather than climbing per render or per open/close. Measured on a JBIG2 sheet at 2x: 112 MB, no drift over rounds. The first JBIG2 image pre-grows the heap by 64 MB (the decoder's allocator is safest when it has headroom before a decode), and pages are decoded into one reusable RGBA buffer, so peak per render is about two page bitmaps, not one per page. - The heap is capped at 1 GiB (
-sMAXIMUM_MEMORY=1073741824). A 4 GiB ceiling looked harmless and broke phones: iOS refuses to grow a module that has asked to reserve that much address space, so allocation failed early with a misleadingout of memory. Beyond ~1 GiB, shrink the file instead. - One page, 40 MP. A page is rendered whole, so
scaleis bounded by pixel count: 40 MP is the ceiling (a 10000×10000 px page is refused). Failures say which limit they hit:render failed: page needs 10000x10000 px (100.0 MP) at this scale; the renderer caps a page at 40 MPrender failed: not enough memory for a 6000x8000 px page (183 MB); the wasm heap could not grow
- JPEG 2000 covers one encoder so far. The bundled JPX decoder renders the
pdf.js reference fixture correctly, but a codestream written by OpenJPEG —
what Pillow and most converters emit — decodes to noise: a flat-colour
64x48 image differs from MuPDF by 14/255 and a grey ramp by 74/255, while the
same images as JPEG, CCITT or JBIG2 match to ~2/255. Fixable; until then do
not trust
/JPXDecodepages. - Mesh shadings 4/5 are experimental. The triangles fill, but neither type
is verified: on
test/fixtures/mesh.pdfthe type 4 page is malformed (it omits the per-vertex edge flag), so MuPDF paints nothing and our gradient only proves we are more permissive; the type 5 lattice disagrees with MuPDF by 87/255 because the cells are connected in the wrong order. Types 1 (function shading), 6 (Coons) and 7 (tensor) are unimplemented and raise a warning. See M4 inROADMAP.md. - Encrypted files need their password up front. There is no callback asking
the handler for one mid-parse; pass
passwordtoopen()(the demo prompts once and retries).
How it compares: pdf.js and MuPDF.js
The scores below are generated by node scripts/competitive-parity.mjs, which is
the source of truth: 15 areas weighted for a document viewer (what a host app
embeds), each score tied to a named missing capability, so a number moves only
when the capability does. Referenced builds: pdf.js 6.4.299, MuPDF.js 1.28.1.
Capability parity
| Area | Weight | This engine | pdf.js | MuPDF.js | | --- | ---: | ---: | ---: | ---: | | Parsing, xref, object streams, damaged files | 8% | 92% | 100% | 100% | | Encryption (Standard handler R2–R6) | 5% | 92% | 85% | 100% | | Image codecs (Flate/DCT/CCITT/JBIG2/JPX) | 8% | 85% | 100% | 100% | | Colour management (ICC, spot, DeviceN) | 7% | 70% | 85% | 100% | | Fonts and encodings (TT, CFF/CID, CMaps, Type3) | 10% | 80% | 90% | 95% | | Content stream: paths, clips, blend, transparency | 10% | 90% | 95% | 100% | | Shadings (types 1–7) | 5% | 40% | 100% | 100% | | Tiling patterns, images, masks | 5% | 90% | 95% | 100% | | Annotations (render, links, markup) | 6% | 75% | 95% | 95% | | Forms (AcroForm read, fill, save) | 6% | 45% | 90% | 100% | | Text extraction and structure | 6% | 78% | 95% | 100% | | Viewer UX (toolbar, rail, search, print) | 8% | 72% | 100% | 50% | | Accessibility (tagged PDF, screen readers) | 5% | 20% | 90% | 35% | | Architecture (worker, ranged loading, cache) | 6% | 35% | 100% | 80% | | Ecosystem (release, docs, integrations, testing) | 5% | 30% | 100% | 90% | | Weighted total | 100% | 69% | 95% | 90% |
Parity: 73 % of pdf.js, 77 % of MuPDF.js (weighted score 69.4 of 100; pdf.js 94.8, MuPDF.js 90.3). Not a benchmark: a capability count. It says nothing about speed on a specific file — that needs the 30-document pilot described in Embedding in a host app.
What the numbers mean for a host app
- Text documents are already at parity. Parsing, encryption, fonts (including CJK CMaps), text extraction and the text layer are all in the high 80s–90s. A contract, an invoice, a scanned letter: a swap is defensible today.
- The losses cluster in three places. Visually heavy documents (shadings 40 %, ICC 70 %, JPX 85 %), interactive ones (forms 45 %, annotations 75 %), and non-visual ones (accessibility 20 %, architecture 35 %).
- MuPDF.js loses where pdf.js wins and vice versa. MuPDF.js has no viewer at all (50 % viewer UX, 35 % a11y) but beats everything on decoding and can edit and write. pdf.js is the only one of the three with real accessibility, a worker, and ranged loading.
- Interop reality:
mupdf1.28.1 is AGPL-3.0-or-later (14.3 MB unpacked, commercial licence sold separately);pdfjs-dist6.4.299 is Apache-2.0 (34.9 MB unpacked: several builds, CMaps, standard fonts, two extra wasm modules); this package is MIT and 3.1 MB of wasm with no side modules.
Where this engine is ahead, and stays ahead
- No dynamic execution, by construction.
-s DYNAMIC_EXECUTION=0, and no document data ever becomes JavaScript — that is why CVE-2024-4367 cannot exist here, and agrepforeval/new Functionindist/is part of the test suite. - One module, one pass. JBIG2 and fax are linked into
pdf-reader.wasm, not a second binary with an async hand-off; a scanned page decodes in the same render as everything else. - Encryption keys verified against qpdf, per revision, in CI — not "we support AES-256" on faith.
- Deterministic pixels. A per-page screenshot hash is committed, so a rasterizer regression fails the build.
- Size: half of MuPDF.js unpacked, a tenth of pdf.js, with the viewer included.
Where it is behind, and by how much
Everything left, priced in points of the same weighted score (that is what the
+points column adds to 69.4):
| Remaining item | +points | Result | Effort | Why it matters |
| --- | ---: | ---: | --- | --- |
| Display list + web worker + ranged loading (M9) | +3.0 | 85% | 2–3 weeks | main-thread stalls on big pages, whole-file residency, no eviction |
| Shadings 1/6/7 and a verified 4/5 | +2.8 | 95% | 3–5 days | gradient-heavy marketing PDFs currently lose their background |
| Forms: input overlay, /AP regeneration, save/flatten | +2.4 | 85% | 1–2 weeks | a filled field under a real producer's /AP still looks empty |
| Accessibility: tagged structure tree, roles, read order | +2.3 | 65% | 2–3 weeks | the text layer is selectable but not navigable by a screen reader |
| Ecosystem: publish 0.3.0, docs site, framework examples, CI matrix | +1.8 | 65% | 1 week + ongoing | registry is stale, and there is no fuzzing or disclosure pipeline |
| Viewer UX: outline panel, rotate, zoom presets, gestures | +1.6 | 92% | 1 week | the chrome is the first thing a reviewer compares against pdf.js |
| ICC for fills via lcms2 | +1.4 | 90% | 3–4 days | print-derived CMYK art is visibly off next to MuPDF |
| JPEG 2000: vendor openjpeg's j2k decoder | +1.2 | 100% | 3–5 days | the only output that is garbage rather than merely off |
| Type1 PFB, vertical CMaps, no-shaped-fallback warning | +1.2 | 92% | 4–6 days | legacy TeX output and Japanese vertical text fall back to a substitute face |
| Annotations: note panel, multiply highlights, authoring | +0.9 | 90% | 1 week | review workflows stop at "markup renders" |
| Text structure: lines/blocks, /ToUnicode policy, document index | +0.8 | 92% | 3–4 days | every pdf.js consumer expects line/block grouping |
| Encryption: public-key handler, onPassword retry | +0.3 | 97% | 2–3 days | rare, but a hard fail when a host meets a /Adobe.PubSec file |
| All of the above | +19.5 | 89% | | 94 % of pdf.js, 99 % of MuPDF.js on this weighting |
Two things do not move with any of that: writing (editing, redaction, signing) and full accessibility certification. The first is a declared non-goal; the second is 2–3 weeks of structure-tree work and then an audit, and no weight in this table can make it disappear. If your deployment has a VPAT requirement, pdf.js is the answer, full stop.
Regenerate the table after a milestone and paste it in — do not hand-edit:
node scripts/competitive-parity.mjsRoadmap
ROADMAP.md is the ordered gap list to MuPDF-grade output, with
touchpoints, fixtures and acceptance numbers.
- M1–M8 are done (ICC parsing, tiling patterns, blend modes and transparency
groups, mesh 4/5, Separation/DeviceN, Type3 and
OTTO-wrapped CFF, OCG visibility, AcroForm fields, outline, thumbnails, print). What is left of them is residue, not scope: full ICC for fills, shadings types 1/6/7, the JPEG 2000 decoder (OpenJPEG codestreams decode to noise), Type1 PFB,gs /SMask, and the form-field repaint. - M9 (architecture) is the only open milestone, and the next thing to do: web worker, ranged loading, display list + byte-budgeted page cache. Its three sub-items have their own acceptance lines in the roadmap.
- Non-goals — shaping, writing, OCR, other input formats — are listed there too; they are what keeps this a reader rather than a PDF suite.
Install
npm install @dionaditya/wasm-pdf-readerThe registry carries 0.1.0. This repository is the source of truth and is at
0.3.0 — tiling patterns, blend modes and transparency groups,
Separation/DeviceN, Type3 and CMap fonts, OCG and AcroForm, the outline and the
page rail; npm publish pushes it. To track the repository instead of the
registry, install from a commit — the
built dist/, wasm included, is committed, so nothing is compiled on install:
npm install github:dionaditya/wasm-pdf-readerESM only. The wasm glue is a sibling of pdf-reader.wasm, so Vite, webpack and
esbuild pick up both automatically.
Use it from plain JavaScript
Framework recipes — Vite, Next.js, webpack/CRA, Vue, Svelte, Angular, plain script
tags, CDN, workers, CSP and the asset-resolution pitfalls — are in
docs/INTEGRATION.md. The short version:
import { PdfDocument, mountPdfReader } from '@dionaditya/wasm-pdf-reader';
import '@dionaditya/wasm-pdf-reader/styles.css';
// 1. the whole viewer, toolbar included
const viewer = mountPdfReader('#viewer', {
file: '/contracts/lease.pdf', // ArrayBuffer, Blob, File, URL or Response
fit: 'width', // 'width' | 'page' | 'none'
theme: { bg: '#0b0f19', accent: '#7dd3fc' },
});
viewer.goToPage(2);
viewer.search('deposit');
// 2. or just the pixels, and draw them yourself
const doc = await PdfDocument.open(file);
const { imageData } = await doc.render(0, { scale: 2, dpr: devicePixelRatio });
const text = doc.getPageText(0);
doc.close();
Use it from React
import { PdfReader } from '@dionaditya/wasm-pdf-reader/react';
import '@dionaditya/wasm-pdf-reader/styles.css';
export default function Preview({ file }) {
return (
<PdfReader
file={file}
fit="width"
onPageChange={(page) => console.log(page)}
toolbar={(api) => <MyToolbar {...api} />}
/>
);
}
What you can change
| prop | what it does |
|---|---|
| file | document source: ArrayBuffer, Uint8Array, Blob/File, URL, Response |
| page / defaultPage | controlled or uncontrolled page (zero-based) |
| scale / defaultScale / fit | controlled zoom, or fit="width" \| "page" |
| continuous | stack all pages and render them lazily (IntersectionObserver) |
| toolbar | false, a node, or (api) => node |
| components | replace Page and Toolbar with your own components |
| theme | CSS variables: bg, toolbarBg, accent, radius, pageShadow, … |
| pageOverlay | (page, { document, scale }) => node — your own overlays |
| onLink | (link, { document, goToPage, pageCount }) => boolean \| void — link clicks; return false to keep the default |
| search | a query; matching lines get highlight rectangles |
| pixelTransform | post-process the RGBA of every page (invert, tint, watermark) |
| renderer | swap the rendering backend entirely ({ render(doc, page, opts) }) |
| renderLoading / renderError / renderEmpty | replace the built-in states |
The hooks underneath are public: usePdfDocument, usePdfPage, useInView.
The core has no React dependency at all, and the components are just one way to
lay it out.
Embedding in a host app
The bundle is plain ESM plus one .wasm sibling, so a host that ships static
assets can vendor it with no build step:
- ownCloud Classic —
dionaditya/owncloud_files_pdfviewer(branchfeat/replace-pdfjs) replaces the PDF.js viewer with this engine. The route, the?file=contract and the CSP stay the app's; parsing, rasterizing, text and annotations are the engine's. The bundle is vendored underjs/vendor/wasm-pdf-reader/with aVERSION.jsonrecording this repo's commit and a per-file sha256, refreshed bynpm run sync-viewer(see that directory's README).scripts/sync-viewer.mjsthere is the template for any host that wants "pinned, traceable, no install-time build". - Any page —
mountPdfReader('#viewer', { file: url })for the full viewer, orPdfDocument.open(bytes)for pixels and text only. Servedist/wasm/pdf-reader.wasmbesidedist/loader.js; a CSP needsscript-src 'wasm-unsafe-eval'.
API
PdfDocument.open(source, { wasmUrl, wasmBinary, module, filename, password })
.pageCount // number
.metadata // { Title, Author, Subject, Producer, ... }
.warnings // ["rebuilt xref", ...]
.getPageSize(i) // { width, height, rotation, boxX0, boxY0 }
.render(i, { scale, dpr, pixelTransform, stats }) // { imageData, width, height, ... }
.renderToCanvas(canvas, i, opts)
.getText(i) // [{ text, x0, y0, x1, y1, ox, oy, adv, fs }]
.getPageText(i) / .getAllText()
.getLinks(i) // [{ x0, y0, x1, y1, uri?, page? }]
.search(query, { caseSensitive, limit, pages }) // [{ page, index, length, line, rects }]
.boxToCss(box, i, scale) // for overlays
.close()Full types ship with the package (dist/index.d.ts, dist/react/index.d.ts).
Demo
npm install
node examples/build-react.mjs # bundles the React demo
node examples/server.mjs 4444 # http://localhost:4444/http://localhost:5177/examples/vanilla/http://localhost:5177/examples/react/
The GitHub Pages build (npm run build:demo → _site/) serves the same demos
under a sub-path; .github/workflows/pages.yml publishes it on every push.

Build from source
Requirements: emsdk
on PATH and Node 18+.
. ~/emsdk/emsdk_env.sh
npm install
./build.sh # C++ -> dist/wasm/pdf-reader.mjs + pdf-reader.wasm
npm run build:js # src/js -> dist/ (ESM, plus JSX -> JS for React)dist/wasm is committed, so npm install and CI work without emsdk; rerun
./build.sh only when the C++ changes.
Tests
npm test # core: parse, raster, text, search, links, annotations, CMaps, encryption (62 checks)
npm run test:e2e # headless Chrome: demos, screenshots, pixel baseline
npm run test:pages # the Pages build under a /wasm-pdf-reader/ base path
npm run test:memory # soak the heap: render, reopen, extract; assert it plateaus
scripts/verify_encryption.sh # keys vs qpdf for every revision (needs qpdf)
npm run test:visual # rewrite the pixel baseline after an intended changetest:unit runs the real wasm module in Node against generated fixtures
(including encrypted RC4/AES-128/AES-256 files — with and without passwords —
and a JBIG2 scan) plus:
scripts/make-fixture.mjs (three pages with text, vector art, an image XObject,
clipping and a /Rotate 90 page) and scripts/make-objstm-fixture.mjs, a
calibre-shaped document whose catalog, page tree and font live in an object
stream with a non-zero /First and whose xref is an /XRef stream with a PNG
predictor. test:e2e drives the vanilla and React demos over CDP, asserts ink
coverage and navigation, hashes each page bitmap, and writes the screenshots in
docs/screenshots/.
Working on the code
CLAUDE.md— the operating manual for an agent or a new maintainer: build and test loop, how to run the e2e suite and read its failures, a one-line brief on every file, the invariants that bite, the definition of done for a milestone, and the deploy procedure.ROADMAP.md— what is left, in order, with acceptance numbers.docs/INTEGRATION.md— how to embed it in any JS project or React app.node scripts/competitive-parity.mjs— regenerates the comparison tables above.
Layout
src/cpp/ reader + rasterizer, CFF/Type2, JPEG 2000, annotations, C ABI
src/cpp/jbig2gen/ PDFium JBIG2/fax decoder, generated from wasm by wasm2c
src/js/ loader, PdfDocument, vanilla viewer, text helpers
src/js/react/ PdfReader, PdfPage, PdfToolbar, hooks
src/types/ hand-written .d.ts for both entry points
scripts/ fixture generator, JS build, demo build
examples/ vanilla and React demos + static server
test/ unit, e2e (CDP), Pages checks, fixtures, baseline
dist/ published output (ESM + wasm)Licence
MIT for this library. The engine's ~6,500 first-party lines sit on large vendored
and generated components: PDFium's JBIG2/fax (Apache-2.0), nanopdf's JPEG 2000
and CFF tables (Apache-2.0), stb_truetype/stb_image (MIT / public domain),
wabt (BSD-2), simde (MIT), Adobe's CMaps (BSD-3), and eight metric-compatible
font families — DejaVu (Bitstream Vera licence) and Liberation/Carlito/Caladea
(SIL OFL-1.1, subsetted, so Modified Versions).
THIRD-PARTY.md maps every file to its licence and states the
split by line count; the full OFL-1.1 and Bitstream Vera texts are in
LICENSES/, because those fonts ship inside pdf-reader.wasm.
