A hand-written, dependency-minimal PDF codec: parses arbitrary real-world PDFs into a structured, positioned-content document and generates new PDFs from one, built on its own codec-owned
LayoutDocumentitem model and Zod 4 codecs.
pdf-codec is the PDF-reading-and-writing half of documents.js, extracted into its own package: every layer of the PDF format — the object model, the cross-reference table, the content-stream operators, standard-font metrics, the parser's cross-reference/object-stream resolution and content-stream interpreter — is hand-written against the ISO 32000-1 specification, with no external PDF library (pdf-lib, pdfjs-dist, mupdf, or any other) as a dependency. The one exception is fflate for raw DEFLATE/zlib compression. The OpenType/CFF font parsing this package's own writer uses to embed a real math font (sfnt.ts/math-*.ts) is hand-written too, as are the cryptographic primitives the reader needs to open an encrypted PDF and the writer needs to produce one (crypto/ — MD5, SHA-2, RC4, AES, and a getRandomValues-backed CSPRNG for the writer's own salts/keys/IVs), because node:crypto would end this package's platform neutrality and WebCrypto's crypto.subtle offers neither MD5 nor RC4 nor a synchronous API (crypto.getRandomValues itself is synchronous and used directly). The one bundled binary asset is the vendored STIX Two Math font itself (OFL-1.1, see Fidelity and assets/fonts/NOTICE.md).
This is a genuinely large undertaking with an honest trade-off spelled out in Fidelity: this is not, and does not attempt to be, as robust against adversarial or badly malformed real-world PDFs as a library with 15+ years of hardening. What it buys instead is a dependency-free, fully auditable PDF implementation with no supply-chain surface beyond document-schema.js, fflate, and zod.
documents.js uses this package to convert docx/pptx/odt/odp/ods/odg to and from PDF, and to render MathML formulas (typeset by its own src/mathml/ engine) through the embedded math font this package parses and writes. That MathML layout engine deliberately stays in documents.js — see Architecture for exactly where the boundary sits and why a real MathBox value crosses it with zero cast or wrapper. document-outline.js's segmentPdfRegions (ExaDev/documents.js#931) is a second, independent consumer of this package's LayoutPage/LayoutItem types alone — a recursive X-Y cut page-segmentation pass classifying a page's own positioned items into columns, tables, figures, and captions, entirely without the semantic reconstruction this package deliberately keeps out of itself.
graph TD
schema("document-schema.js")
ooxml("ooxml.js")
odf("odf.js")
pdfcodec("pdf-codec")
mdcodec("markdown-codec")
bytecodec("byte-codec")
documents("documents.js")
mcp("document-mcp")
cli("document-cli")
outline("document-outline.js")
schema --> ooxml
schema --> odf
schema --> pdfcodec
schema --> mdcodec
schema --> documents
schema --> outline
ooxml --> documents
odf --> documents
pdfcodec --> documents
mdcodec --> documents
bytecodec --> pdfcodec
bytecodec --> documents
pdfcodec --> outline
documents --> mcp
pdfcodec --> mcp
documents --> cli
odf --> cli
pdfcodec --> cli
outline --> mcp
outline --> cli
click schema "https://github.com/ExaDev/documents.js/tree/main/packages/document-schema.js" "document-schema.js"
click ooxml "https://github.com/ExaDev/documents.js/tree/main/packages/ooxml.js" "ooxml.js"
click odf "https://github.com/ExaDev/documents.js/tree/main/packages/odf.js" "odf.js"
click pdfcodec "https://github.com/ExaDev/documents.js/tree/main/packages/pdf-codec" "pdf-codec"
click mdcodec "https://github.com/ExaDev/documents.js/tree/main/packages/markdown-codec" "markdown-codec"
click bytecodec "https://github.com/ExaDev/documents.js/tree/main/packages/byte-codec" "byte-codec"
click documents "https://github.com/ExaDev/documents.js" "documents.js"
click mcp "https://github.com/ExaDev/documents.js/tree/main/packages/document-mcp" "document-mcp"
click cli "https://github.com/ExaDev/documents.js/tree/main/packages/document-cli" "document-cli"
click outline "https://github.com/ExaDev/documents.js/tree/main/packages/document-outline.js" "document-outline.js"
style pdfcodec fill:#f9a825,stroke:#333,stroke-width:3px
Requires Node.js >=20 and pnpm 11.6.0 (pinned via packageManager in package.json).
pnpm installInstall as a dependency in another project:
pnpm add pdf-codec
# or
npm install pdf-codecReading and writing PDF bytes:
import { readPdf, writePdf } from "pdf-codec";
const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text/image/rect/line/ellipse/path/link items
const bytes = writePdf(layout);LayoutDocument and its whole item family — every item/page/image-asset type and schema, plus LAYOUT_FORMAT_VERSION — are this package's own exports, ported from document-schema.js (which dropped them) so a codec's native model lives in the codec, the same family pattern as ooxml.js's Package/XmlElement and markdown-codec's AST. readPdf/writePdf keep their signatures; callers see the same names from a new home. documents.js re-exports the family onward from its own barrel — those re-exports now source from pdf-codec rather than document-schema.js, same names, new source.
There is deliberately no DocumentTree-returning read or DocumentTree-accepting write here, and readPdf/writePdf are this package's primary API precisely because of that. Only markdown-codec's own read entry point returns document-schema.js's flat ContentDocument directly — ooxml.js's readDocx/readPptx return DocxDocument/PptxDocument and odf.js's readOdt/readOds return OdtDocument/OdsDocument, each codec's own native model, with only ooxml.js's separate readXlsxContent producing a ContentDocument outright. What lets documents.js offer one tree-native entry point uniformly across those formats (decompose/assembleTree outward, flattenTree back) is its own readXContent wrapper layer (readDocxContent, readOdtContent, and siblings), projecting each codec's native model into the flat ContentDocument — a step the codecs themselves don't take. PDF has no such wrapper to project through: readPdf yields layout cheaply, because positioned glyphs and paths are all the format actually states, and semantic content only through a separate, expensive, lossy reconstruction pass that infers paragraphs, headings, tables, and shapes back out of geometry. That inference is semantic policy rather than codec business, so it lives in documents.js — a caller wanting a PDF as a DocumentTree passes onDocument to a named conversion function or to convertDocument itself and reads the tree off that callback (convertDocument on its own returns only bytes), or uses createLocalDocumentConverter()'s DocumentConverter port, whose ConversionResult.package is populated by wiring that same callback internally. The write direction is asymmetric for a different reason than it might look: turning a DocumentTree into PDF bytes is not itself a layout pass — documents.js's layoutDocumentFromPackage is a mechanical inverse that walks the positions a prior layout pass already stamped onto the package's own content nodes as frames, and it only works at all when those frames exist (a bridge conversion's own dump, e.g. odt-to-docx, carries no pages and cannot reach PDF this way). The actual font-measuring, line-breaking engine runs earlier, wherever the package first passed through an X-to-PDF or PDF-to-X conversion — both the frame-stamping and the frame-walking are documents.js's. Keeping both edges out of this package is still what makes the item layer an honest record of what a file says, separate from what any consumer thinks it means.
An encrypted PDF that opens without a password decrypts transparently — no extra option, no password parameter; one that genuinely needs a user password throws PdfPasswordRequiredError. writePdf encrypts on the way out via its own encryption option (WritePdfOptions.encryption, PdfEncryptionOptions from pdf-codec) — a user password, an optional owner password, a choice of scheme ("rc4-40" | "rc4-128" | "aes-128" | "aes-256", default "aes-256"), and permission flags. See Gotchas for exactly which encryption is supported on both sides.
Both accept an optional signal (AbortSignal); readPdf additionally takes a sink (PdfDiagnosticSink, called once per recoverable parse diagnostic — see the three-tier failure policy under Conventions), and writePdf an onSubstitution callback (called once per character not representable in a standard-14 font — see Fidelity).
Reading a subset of pages. readPdf(bytes, { pages: [0, 1, 2, -1] }) reads only the listed pages: zero-based indices into the page tree, a negative index counted from the end (-1 is the last page). What a selected read skips: an unselected page's content stream is never fetched, decoded or interpreted, so the fonts and images only unselected pages use are never resolved, and no embedded file is decoded, so attachments is absent from a page-selected result (an attachment is document-level payload whose size is independent of the pages asked for); an absent attachments therefore means "not read" whenever pageCount is set, which only a page-selected read does, so check pageCount before concluding a document has no attachments. The case this exists for is identifying a document from its first pages, or reading a code from its last page, inside a memory-capped runtime such as a Cloudflare Worker isolate, without paying for every page's content. What it still reads whole, because page references resolve against it or because it is document-level and holds no page content stream: the page tree, the outline, named destinations, optional-content layers, the AcroForm, the structure element tree and the /ParentTree number tree (both of which scale with the document), metadata and package residue; every one of those but the page tree and metadata can be left out too, with surfaces (below). Two surfaces are built from page content and so cover selected pages only: the structure-ownership association an item's structure field names (with its parent-tree diagnostics) and the page-boxes residue row. A destination a link states as a direct array is minted into destinations (as dest1, dest2, ...) only when its page is interpreted, so an unselected page's direct link destinations are absent and a minted name depends on which pages were read (and on whether the outline was read, below); named destinations are unaffected. Duplicates collapse and the result's pages holds the selected pages in ascending document order, each carrying its source position as index (read it through sourcePageIndex(page, position), which falls back to the position on a full read), with the page tree's length as the document's pageCount; every document-level page reference keeps naming source indices, so a reference to a selected page resolves through index, and one to an unselected page names a page the result does not hold. A full read sets neither field, because there pages.length and each page's position already state them. An empty selection is valid and reads no page, which makes { pages: [] } the cheapest way to learn a file's page count and metadata. An entry naming no page (outside -pageCount to pageCount - 1, or not an integer, NaN and the infinities included; -0 is page 0) throws PdfPageSelectionError (a PdfParseError with code pdf/page-out-of-range or pdf/page-index-not-integer, carrying pageIndex and pageCount) before any page is read. A page-selected LayoutDocument is a reading of part of a file, not a document to re-emit: writePdf refuses one (any document carrying pageCount) with PdfPartialDocumentError, since it places pages by position and the references would point at the wrong pages.
Leaving document-level surfaces out. readPdf(bytes, { surfaces: { structure: false, outline: false } }) reads the document without the surfaces turned off. ReadPdfOptions.surfaces is one object of independent booleans (ReadPdfSurfaces), one per surface, each read unless set to false, so omitting the setting reads everything exactly as before. It is independent of pages, applying to a full read and a page-selected read alike, and combined with a small selection it is what keeps identifying a document cheap: on a long tagged PDF the structure element tree and its /ParentTree can be most of what a page-selected read still parses, and { pages: [0, 1, 2, -1], surfaces: { outline: false, destinations: false, form: false, layers: false, structure: false, residue: false } } reads nothing beyond opening the document (cross-reference table, trailer, catalog and any encryption dictionary), the page tree, metadata and those four pages. A surface turned off is never parsed (its part of the catalog is not walked, so a malformed outline, form, layer list or structure tree reports no diagnostic), its LayoutDocument field is absent whatever the file holds, and the result names it in skippedSurfaces (in the order LayoutDocumentSurfaceSchema lists them, absent when every surface was read). An absent outline, destinations, form, layers, structure or source therefore means "not read" whenever skippedSurfaces names it, so check skippedSurfaces before concluding a document has none, as with pageCount and attachments.
Each surface, and what page items lose without it (everything else about every page item is exactly what a read with the surface on produces, apart from the renumbering a skipped outline causes):
-
outline: the/Outlinesbookmark tree (outline). Page items do not depend on it, except through minted names. A destination stated as a direct array is named with the first unuseddestNin the order the reader meets it, and the outline is read before any page, so an outline entry's direct destination takes a lower number than any link's. Without the outline, the names minted for page links' direct destinations are renumbered anddestinationsloses the entries only the outline minted, while each link still resolves to the same target. -
destinations: the named destinations of the catalog/Destsdictionary and the/Names /Destsname tree, and the registry links and outline entries name their targets through (destinations). Without it nothing can name an internal target, so a link whose target is internal (a/Dest, or a/GoToaction, whether it names a destination or states one directly) is left out of its page's items, and an outline entry, if the outline is read, carries nodestination. A link to a URI is kept. -
form: the AcroForm field tree (form). Page items do not depend on it, since a widget annotation is never a page item or annotation either way. -
layers: optional content, the/OCPropertiesgroups and their default visibility (layers). Without it no item carries alayer. The items themselves are all still read, so content the default configuration hides cannot be told from visible content, and a consumer that drops hidden-layer content (as documents.js's reconstruction does) keeps it. -
structure: the/StructTreeRootelement tree and its/ParentTreeownership map (structure). Without it no item carries astructureowner, and the parent-tree diagnostics are not reported. -
residue: the package-level residue rows (source), the per-pagepage-boxesrow included. Page items do not depend on it, andmetadatastill reads the XMP packet it falls back to.
A LayoutDocument read with any surface skipped is a reading with parts of the file deliberately unread, so writePdf refuses one (any document carrying skippedSurfaces) with PdfSkippedSurfacesError, which names the skipped surfaces; a document that is also page-selected reports PdfPartialDocumentError first.
Page boundaries: the crop box is the visible region (ISO 32000-1 14.11.2). A page's reported widthPt/heightPt and coordinate frame come from its effective /CropBox — the rectangle a viewer displays and prints, inherited through the page tree like /MediaBox and defaulting to it — not from the media box: content an author placed outside the crop box (printer's marks, bleed, off-page slugs) is not visible and does not extract. Content wholly outside the crop box is dropped; content straddling the boundary keeps its original unclipped geometry (the item layer records what the file states — a viewer's clipping is a rendering fact, not source data); link annotations are anchored constructs rather than painted content and are never filtered, the same line the optional-content filter draws. The declared boundary rectangles a distinct crop box hides — plus /BleedBox//TrimBox//ArtBox, print-production facts with no field in the layout model — are quarantined verbatim as the page-boxes package-level residue row rather than silently dropped. A degenerate /CropBox (zero width or height) falls back to the media box with a pdf/invalid-crop-box warning.
Cancellation granularity and cost, for CPU-metered runtimes. Both pipelines are synchronous end to end — there is no await point for cancellation to hook into implicitly — so the signal is checked explicitly, once per page-loop iteration (and once before readPdf's document-open phase begins). A signal aborted mid-parse therefore takes effect at the next page boundary, not instantly: readPdf's document-open phase (cross-reference resolution, object parsing) and a single page's content-stream interpretation are the two spans that cannot be interrupted, and a document consisting of one enormous page is effectively uninterruptible however many pages it claims. Cost is roughly linear in decompressed content length, so budget for the worst single page, not the page count. On Cloudflare Workers this is the honest shape of the trade: the parse holds the isolate for its whole duration with no opportunity to yield or report progress, and an AbortSignal shared with whatever can abort concurrently (a binding, another context) makes a deadline enforceable at page granularity — but it cannot convert a synchronous parse into a resumable one. An async page-at-a-time API is a deliberate non-goal of this package's current surface.
The same round trip is also available as a schema-validated z.codec() pair:
import { z } from "zod";
import { pdfCodec } from "pdf-codec";
const layout = z.decode(pdfCodec, pdfBytes); // throws a ZodError if pdfBytes has no %PDF- header
const pdfBytes2 = z.encode(pdfCodec, layout);This is the no-extra-options form only — readPdf/writePdf remain the entry points for cancellation, diagnostics, or substitution reporting, none of which fit z.codec()'s fixed decode(input)/encode(output) signature.
Embedding a real math formula: writePdf's own formulas option takes an array of PositionedFormula — an already-laid-out MathBox (positioned glyph runs, fraction/radical rules, radical hook strokes) placed at a page position. This package supplies the font — loadMathFont() parses and caches the vendored STIX Two Math font once per process — but it does not lay MathML out itself; that is a separate concern this package deliberately doesn't own (see Architecture).
import { loadMathFont, writePdf } from "pdf-codec";
import { layoutFormula } from "documents.js"; // or any other producer of a structurally-compatible MathBox
const { metricsAt } = loadMathFont();
const { box } = layoutFormula(mathml, {
metrics: metricsAt(12),
sizePt: 12,
color: { r: 0, g: 0, b: 0 },
});
const pdfBytes = writePdf(doc, {
formulas: [{ pageIndex: 0, xPt: 50, yPt: 700, box }],
});Because MathBox and its own constituent types (MathGlyphRun/MathRule/MathStroke/MathAssembledGlyphs/MathColor) are plain, structurally-typed data — not a class, not branded — any producer whose output matches the shape works here with no cast, no wrapper, and no transformation.
Sizing a stretchy glyph — a parenthesis tall enough to wrap a big fraction, a radical sign sized to its radicand, an over-brace as wide as the content under it — is the OpenType MATH table's MathVariants job, and loadMathFont() exposes it directly:
import { loadMathFont } from "pdf-codec";
const { stretchGlyph } = loadMathFont();
const paren = stretchGlyph(0x28, "vertical", 40, 12); // a '(' stretched to 40pt, set at 12pt
// paren.kind -> 'assembly' (no single pre-built variant reaches 40pt)
// paren.size -> the extent actually achieved, >= 40 whenever the font can reach it
// paren.placements -> [{ glyphId, offset, advance }, ...], bottom to top, seams already overlappedThree outcomes: 'base' (unstretched glyph already big enough), 'variant' (a pre-built larger glyph selected, always preferred over assembly), 'assembly' (built by repeating the font's extender piece between end pieces, overlapping each seam by as much as both sides' connector lengths allow). For design-unit callers, MathFont.stretchyConstruction(codePoint, axis) returns the raw MathVariants data and assembleStretchyGlyph performs the same computation over it.
A stretched construction is drawn through MathBox's own MathAssembledGlyphs item kind: { glyphId, xPt, yPt } placements addressed by glyph ID, not Unicode text — most glyphs a MathVariants construction names have no code point at all. The composite font this package embeds is Identity-H with CID == GID, so a bare glyph ID is directly showable. An unencoded glyph gets no ToUnicode entry, so MathAssembledGlyphs also carries the operator's original text, emitted as an /ActualText marked-content span so a tall assembled bracket still extracts as (.
MathFontMetrics.stretch is the layout-facing form: it resolves a construction at a target size and additionally measures it — inkAscentPt/inkDescentPt are the whole construction's real ink extent about its drawing origin, taken from actual glyph outlines.
Building a layout engine on top of this codec (this is what documents.js's own src/layout/ does): TextMeasurer/createStandardFontMeasurer answer "how wide does this text render, and where does this line break" against standard-14 metrics; resolveStandardFont/STANDARD_METRICS map an arbitrary family/weight/style onto one of the 14 standard PDF faces. The text-wrapping primitive (wrapRunsToWidth) and shape-rotation geometry (rotatePointAboutCenter) live in documents.js (they had no internal pdf-codec caller); the port types they consume (TextMeasurer, StyledRun, WrappedLine, ProvidedFont, the MathBox/MathFontMetrics family) live in document-schema.js, the neutral shared-schema package, while the LayoutDocument they build is this package's own type.
Embedding real fonts instead of substituting standard-14 faces, via a FontRegistry (see src/font-registry.ts for the source-document → caller-supplied → vendored-substitute → standard-14 resolution order). Pass the same registry to both the measurer and writePdf so what was measured and what gets drawn come from one font:
import { createFontMeasurer, createFontRegistry, writePdf } from "pdf-codec";
// With no `fonts`/`sourceFonts` of its own, the registry still maps Calibri onto the vendored,
// metric-compatible Carlito face this package embeds (and Cambria onto Caladea).
const fonts = createFontRegistry();
const measurer = createFontMeasurer(fonts);
// ... wrap text + build a LayoutDocument using the measurer (documents.js owns wrapRunsToWidth) ...
const pdfBytes = writePdf(doc, {
fonts,
onMissingGlyph: (m) => console.warn("no glyph for", m.from),
});Every text run whose family resolves to a real face is subsetted to the glyphs the document actually uses and embedded as its own /Type0 + /CIDFontType2 + /FontFile2 group. Omit fonts and nothing changes at all — output is byte-identical to a build with no embedded-font support (asserted against golden digests in src/write-embedded-font.test.ts).
Inspecting a standalone font file before handing it to a FontRegistry as a ProvidedFont: readFontFace reads family/bold/italic straight off the font's own name/OS/2/head tables — exactly the triple ProvidedFont needs:
import { createFontRegistry, readFontFace } from "pdf-codec";
const { family, bold, italic } = readFontFace(
brandSansTtfBytes,
"BrandSans-Bold.ttf",
); // throws FontFaceParseError, naming the source, for a .ttc/.woff file or one with no family name
const fonts = createFontRegistry({
fonts: [{ family, bold, italic, bytes: brandSansTtfBytes }],
});createFontMeasurer's second argument carries verticalMetrics, a VerticalMetricPolicy of 'hhea' (default) / 'os2Typo' / 'os2Win', deciding which of the three competing ascent/descent/line-gap sets an sfnt declares should drive line height for an embedded face.
Reading a JPEG 2000 image directly, either as pixels or as metadata alone:
import { decodeJpeg2000, readJpeg2000Metadata } from "pdf-codec";
// Works on any conforming codestream — including one whose pixels this decoder refuses, which is what `decodable`/`undecodableReason` are for.
const metadata = readJpeg2000Metadata(jp2OrCodestreamBytes);
console.log(
metadata.width,
metadata.height,
metadata.transform,
metadata.layers,
metadata.decodable,
metadata.undecodableReason,
);
const image = decodeJpeg2000(jp2OrCodestreamBytes); // -> { width, height, bitDepth, components: Int32Array[] }, one plane per componentBoth accept a whole JP2 file or a bare codestream; decodeJpeg2000 takes an optional onWarning for recoverable cases and throws Jpeg2000UnsupportedError for anything outside JPEG 2000 scope. Inside a PDF none of this needs calling: readPdf decodes a /JPXDecode image XObject through the same path automatically.
Rendering a page, or one region of one, to pixels — for OCR, a vision model, or a thumbnail — through a rasteriser the caller supplies (#1198):
import { renderPdfPage } from "pdf-codec";
import { createCpuRasteriser } from "pdf-raster-cpu"; // the pure-software reference backend
const pngBytes = renderPdfPage(
pdfBytes,
0,
{ dpi: 200 },
createCpuRasteriser(),
);renderPdfPage(pdfBytes, pageIndex, options, rasteriser) walks the page's content through the same read machinery readPdf runs (the same interpreter, the same image recovery), transforms everything into a top-left-origin device space at the requested scale (or dpi — a PDF point is 1/72 inch, so dpi: 144 is scale: 2), and drives the rasteriser with positioned draw operations: rectangle fills, filled/stroked vector paths (ellipses, arbitrary curves, and glyph outlines), and affine-placed images. It returns whatever the rasteriser's finish() produces — PNG bytes with the reference backend. The deliberate design point, from the issue's own clarification: this package defines the PageRasteriser interface and takes an implementation; it does not pick a rasteriser or depend on any canvas API. A runtime with no canvas at all (Cloudflare Workers has neither canvas nor OffscreenCanvas) and a runtime with a free hardware-accelerated one consume the same port, and the weight of a real raster backend lands only in the backend package a consumer installs — pdf-raster-cpu is this family's pure-software reference implementation, a browser consumer can wrap its own 2D canvas, and a Cloudflare consumer can drive Browser Rendering. No OCR, no vision, no network calls anywhere: the port hands a caller pixels and stops.
options.clipPt names the region to render, in the same point coordinates LayoutFrame (and LayoutPage items, and document-outline.js's segmentPdfRegions region bounds) use — PDF user space relative to the visible region's lower-left corner, y increasing upward. A caller that located a figure via segmentPdfRegions passes region.bounds straight through as clipPt and gets exactly that figure's pixels, re-origined at the image's own top-left and scaled; src/raster.test.ts pins the coordinate agreement against readPdf itself (including a rotated, cropped page), so the clip contract is a tested fact rather than a hope. Text render order, fill rules, and optional-content visibility follow what a viewer displays: items in a layer the default configuration leaves OFF are not drawn.
Text renders through the page's own embedded sfnt outlines, or not at all. Each text run's embedded TrueType program (/Type0 + /Identity-H + /CIDFontType2, the dominant mainstream shape, or a simple /TrueType font with a usable cmap) is opened with this package's own glyf/cmap readers and handed to the rasteriser as filled outline paths — a backend needs no font machinery of any kind. Faces that state no sfnt outlines are refused through named diagnostics (raster/text-cff-outlines for CFF programs, raster/text-outlines-unavailable for standard-14/unembedded fonts, Type 3 glyph procedures, and unreadable programs) rather than approximated with a substitute shape; the rest of the page still renders. Per-glyph placement follows the run's own text matrices, with one documented approximation: character-, word-, and horizontal-spacing state (Tc/Tw/Tz) is not modelled per glyph, and the residual is distributed uniformly across the run so its total extent always matches what the page draws.
The generic byte- and image-container primitives (crc32, deflate/inflate/inflateTolerant, ByteWriter/concatBytes, readJpegInfo, decodePng/encodePng, unfilterScanlines/filterScanlines) are re-exported from byte-codec. The PDF-specific image codecs (decodeCcittFax, decodeJbig2Embedded, decodeJpeg2000/readJpeg2000Metadata/parseJp2Container) stay here.
Every module under src/ is also deep-importable directly by its own subpath:
import { crc32 } from "pdf-codec/bytes/crc32";
import { readJpegInfo } from "pdf-codec/image/jpeg-info";This works via package.json's "./*" wildcard export, resolving any pdf-codec/<path> subpath to the correspondingly-named file under dist/ — both ESM import and CJS require resolve the same way.
One subpath is a declared entry point in its own right: pdf-codec/read (an explicit exports entry onto src/read.ts, the read pipeline's own module). A consumer that only ever reads PDFs and imports the root barrel statically reaches write.ts's module-scope imports — math-font.ts (the vendored STIX Two Math font) and font-registry.ts (the four Carlito/Caladea faces), together ~2.9 MB of font binaries it can never execute, which on Cloudflare Workers' free plan (3 MB gzipped for an entire Worker) is most of the budget. The read entry's module graph provably excludes them:
import { readPdf } from "pdf-codec/read";src/read-graph.test.ts walks the entry's static import graph and fails the build if write.ts, math-font.ts, font-registry.ts, or any asset module becomes reachable (type-only imports are exempt — they erase at compile time — so a read-side module can keep typing against the root barrel's types while its runtime graph stays narrow). The entry carries readPdf and the read pipeline's own helpers (normalizeRotation, pageRotationTransform); the other read-adjacent surfaces it does not itself own stay deep-importable through the wildcard and are asset-free the same way — the diagnostics vocabulary (pdf-codec/diagnostics), the LayoutDocument item family (pdf-codec/layout), PDF string/date scalar decoding (pdf-codec/pdf-text), standard-14 resolution/AFM metrics (pdf-codec/fonts, pdf-codec/afm-widths), and the rasterisation port (pdf-codec/raster, whose glyph machinery reads outlines out of the page's own embedded font programs rather than any vendored face).
The package is layered from generic primitives outward to the codec itself:
src/layout.ts— this package's own native document model: theLayoutDocumentitem family — Zod schemas and inferred types for text/image/rect/line/ellipse/path/link items, pages (with the hidden speaker-notes channel), the image-asset registry, andLAYOUT_FORMAT_VERSION. Ported verbatim fromdocument-schema.js, which carried it until its 4.0.0 dropped it — a codec's native model lives in the codec, likeooxml.js'sPackage/XmlElementandmarkdown-codec's AST; only PDF's native model was ever a public shared-schema export, an accident of this package predating the content pivot. The item layer remains the honest boundary between what the format says (positions) and what we think it means (structure): when reconstruction misjudges a wrapped paragraph, the items stay inspectable as the PDF's actual testimony. The shared leaf shapes the family composes from (Color,ContentStrokeStyleSchema,LayoutFont,LayoutMetadata) stay indocument-schema.jsand are imported, keeping one definition of each across content and layout.- The math port types —
MathColor/MathGlyphRun/MathRule/MathStroke/MathLayoutItem/MathBox/MathGlyphMetrics/MathFontMetricsandPositionedFormula, sourced fromdocument-schema.js's math layout port (one shared definition across the family, not a local mirror). Deliberately not imported fromdocuments.js— that would be a circular dependency oncedocuments.jsdepends on this package. Because every one of these types is plain data (onlyMathFontMetricscarries a method), a realMathBoxvaluedocuments.jsproduces passes intowritePdf({ formulas })with zero cast, zero wrapper, and zero transformation. src/bytes/andsrc/image/— generic byte and image-container primitives with zero PDF-specific knowledge: a chunked byte writer, backtracking byte reader, CRC32, a hand-written PNG decoder/encoder, JPEG marker scanning for dimensions only (compressed bytes pass through unchanged), a hand-written CCITT Group 3/Group 4 fax decoder (ITU-T T.4/T.6), a hand-written JBIG2 decoder (ITU-T T.88 —jbig2-arith.tsMQ decoder,jbig2-bitmap.ts,jbig2-generic.ts,jbig2-text.ts,jbig2.ts), and a hand-written JPEG 2000 decoder (ISO/IEC 15444-1 —jp2-boxes.ts,jpeg2000-codestream.ts,jpeg2000-tagtree.ts,jpeg2000-t2.ts,jpeg2000-t1.ts,jpeg2000-dwt.ts,jpeg2000.ts).src/filters.tsowns all PDF knowledge for CCITT/JBIG2 (resolving parameters,/JBIG2Globals, inverting polarity);src/images-read.tsowns the JPEG 2000 PDF integration.src/bytes/flate.tsis the only file that importsfflate.src/util/—abort.ts(throwIfAborted, called at every page loop boundary — there is noawaitpoint in this synchronous pipeline for cancellation to hook into implicitly). The base64 helper that used to sit beside it, as a verbatim copy ofodf.js's own, isbyte-codec's now (ExaDev/documents.js#1282): one implementation for the whole family, reached through the dependency this package already had.src/crypto/— MD5, SHA-256/384/512, RC4, and AES-CBC, hand-written with zero local imports, plus a thinrandom.tswrapper aroundglobalThis.crypto.getRandomValuesfor the writer's own salts/file keys/IVs. Not a preference: ISO 32000-1's key-derivation algorithms name MD5 and RC4 directly, neither offered by any portable platform crypto API, andcrypto.subtleis asynchronous where this codec's read and write paths are both synchronous end to end —getRandomValuesitself, unlikecrypto.subtle, is synchronous and portable, so it is used directly rather than hand-written. Reaching fornode:cryptowould break the browser bundle. Each hash/cipher module cites its specification (RFC 1321, FIPS 180-4, FIPS 197) and is tested against published conformance vectors.- The codec itself, importing only
layout/bytes/image/crypto/utilplusdocument-schema.js's port types (no OOXML or ODF knowledge):- Write:
objects.ts(thePdfObjectdiscriminated union),afm-widths.ts/encoding.ts/winansi.ts/fonts.ts(standard-14 metrics, WinAnsi encoding, family resolution),font-registry.ts(resolution port plusresolveFaceWithRegistry, the one step both measurer and writer resolve through so they can never disagree about which face aLayoutFontmeans),font-face.ts(readFontFace, reading a standalone font file's family/bold/italic triple off itsname/OS/2/headtables),measure.ts/text-layout.ts(greedy line-wrapping against either standard-14 AFM widths plus per-family correction or a resolved face's own realhmtxadvances — never both),content-write.ts(LayoutItem[]→ content-stream operators, with text branching on standard-14 vs embedded face encoding, pair-kerning split intoTJarrays, strokestylebecoming real dash/line-cap state, and/P <</MCID n /OC R>> BDC…EMCspans around items carrying a structure owner or an optional-content layer — the one marked-content spelling both read-side channels resolve through),write.ts(the full object graph, cross-reference table, trailer, and embedded font groups, plus the document-level round-trip surfaces: the/Names /EmbeddedFilesattachments tree and/Outlinesbookmark tree,/OCPropertiesoptional-content groups with each layer stated explicitly into the default configuration's/ONor/OFFlist, the/AcroFormfield tree with the merged-field/widget spelling (a single widget merges into its field dict; a multi-widget field's widgets are separate/Subtype /Widgetkids, each also referenced from its page's/Annots— the same annotation object in both places, so a viewer reading only page-level annotations still renders every widget — and fully-qualified names decompose back into the/Tchain), the/StructTreeRootelement tree with its/ParentTreenumber tree associating each marked item's MCID to its owning element, and the package-level residue rows restored inline onto the Catalog or trailer — the XMP packet as an uncompressed/Metadatastream, and any row whose serialisation references source-file objects skipped rather than emitted as a dangling reference). - sfnt font tables:
sfnt.ts(bounds-checked table-directory reader),cmap-table.ts(character code → glyph ID, formats 0/4/6/12, exposed both as the best Unicode lookup and as every subtable individually, since a (3, 0) or (1, 0) subtable is keyed by a font's own codes rather than by code points),hmtx-table.ts(per-glyph advance widths),font-tables.ts(head/maxp/OS/2/post/name, includingpost's own glyph names where a font still carries them),glyf.ts(locaoffset index, per-glyph headers, composite component records,glyphInkBounds),glyf-contours.ts(the point arrays glyf.ts deliberately leaves undecoded: a glyph's on/off-curve contours, composites resolved, consumed by the raster port's outline rendering and differentially tested against the header-declared ink boxes),math-table.ts(OpenTypeMATHconstants/glyph-info/variants subtables).ot-layout-common.ts(Coverage/ClassDef, stored as sorted glyph ranges searched by bisection).gpos-table.tsreadsGPOSfor exactly one thing: pair-advance kerning through thekernfeature, both PairPos formats and LookupType 9 Extension indirection — mark attachment, cursive joining, and contextual positioning have no consumer here.gsub-table.tsreadsGSUBfor the default-on feature set (liga/rlig/calt/clig) through Single, Ligature, Contextual, and Chaining Contextual substitutions (LookupTypes 1, 4, 5, 6, and the 7 Extension wrapper), with each lookup'slookupFlagskip semantics — ignore-base/ligature/marks, mark attachment classes, mark filtering sets — evaluated againstgdef-table.ts's reading of the font'sGDEF;smcp/dligstay opt-in throughGsubShaperOptions, the switch a real shaper's caller flips. Every parser degrades toundefinedon a missing/truncated table rather than throwing. - sfnt subsetting:
sfnt-subset.ts— a TrueType-outline glyph subsetter (Unicode code points → glyph IDs viacmap, transitive closure overglyfcomposite components, rebuilt sfnt carrying only used outlines). Glyph IDs are preserved, never renumbered, keeping composite component references valid and making CID == GID trivially true. Output rebuildshead/hhea/maxp/loca/glyf/hmtx, copies hinting programs verbatim, stubspost, omitscmap/name/OS/2/GSUB/GPOS/kern(none read through aCIDFontType2program per ISO 32000-1 9.9). Applies toglyf-flavoured fonts only; CFF returnsundefined. - Embedded math font:
math-font.ts(parses/caches the vendored STIX Two Math font, exposing size-specificMathFontMetricsand stretchy-glyph entry points),math-stretch.ts(OpenType MATH two-stage stretching: pick smallest pre-built variant reaching target, else assemble from repeated parts with seams overlapped),math-font-write.ts(builds the/Type0//CIDFontType0//FontDescriptor//FontFile3/ToUnicode group),math-content-write.ts(PositionedFormula[]→ content-stream bytes, Identity-H CIDs for text,re/m/loperators for rules, glyph-ID-addressed text objects for stretched constructions wrapped in/ActualText). - Embedded text faces:
embedded-font.ts(parses one TrueType-outline face's metrics andGPOSpair kerning, andencodeForShowEmbedded— the single code path both measurement and text-showing go through so encoding and measuring cannot disagree). Every geometry field is converted into PDF's 1000-units-per-em glyph space.embedded-font-write.tsbuilds the/Type0//CIDFontType2//FontDescriptor//FontFile2/ToUnicode group, with/CIDToGIDMap /Identitywritten explicitly and/Length1set to the uncompressed subset length. Its subset tag is a CRC32 over the face's PostScript name and glyph-ID list, so identical input yields byte-identical output. - ToUnicode CMaps:
tounicode.ts, shared by both embedded-font writers — a character code → Unicode code point mapping written as a bfchar CMap (9.10.3), with supplementary-plane code points encoded as UTF-16BE surrogate pairs and entries emitted in blocks of at most 100. - CFF reading:
cff.ts(sharedINDEX/DICTcontainer structures),cff-bounds.ts(a Type 2 charstring interpreter computing each glyph's tight ink bounding box by tracking the current point through every path operator and solving each cubic's real extrema from the roots of its derivative — a path walker, not a rasteriser; verified against the vendored STIX Two Math font's whole 5,543-glyph repertoire, matching fontTools'BoundsPento within 0.01 design units).cff-probe.tsreads a bare CFF program's header/Name INDEX/Top DICT to detect theROSoperator defining a CID-keyed font — the guard a future source-embedded-font phase needs before it can trust CID == GID against an arbitrary caller-supplied font. - Read:
lexer.ts/parse.ts(byte tokenizer and tokens →PdfObject),filters.ts/predictors.ts(Flate/LZW/ASCII85/ASCIIHex/RunLength/CCITTFax, TIFF/PNG predictors),xref.ts/document.ts(classic and cross-reference-stream resolution, object streams,/Prevchains, linear-scan recovery, the page tree with attribute inheritance),encrypt.ts(standard security handler:/Encryptparsing, empty-user-password key derivation and/Uverification, per-object keys, transparent string/stream decryption),content-read.ts/interpret.ts(content-stream tokenizer and graphics/text state machine, form-XObject recursion, general vector-path tracking),cmap.ts/font-style.ts/font-read.ts(/ToUnicodeCMaps, font-dictionary resolution),builtin-encoding.ts(a font's own built-in encoding, read out of the embedded program itself — see Text extraction and font encodings),images-read.ts(Image XObjects → PNG/JPEG bytes),names.ts(the document-level name-tree walker: one flattening pass for every/Namestenant),navigation.ts(named destinations from/Destsand/Names/Dests, reader-minted entries for direct destination arrays, and the/Outlinesbookmark tree),attachments.ts(embedded files from the name tree,/FileAttachmentfilespecs, and/AF),optional-content.ts(/OCPropertiesgroups and default-configuration visibility, plus the/OC-to-name resolutioninterpret.tsstamps onto span items),annotations.ts(sticky notes, FreeText, the/QuadPointsmarkup family, and residue for the opaque kinds),form.ts(the AcroForm field tree),structure.ts(the tagged-PDF/StructTreeRootelement tree with/RoleMapresolution and/ClassMapattribute merging, plus the/ParentTreewalk that resolves each(page, MCID)pair to the owning elementread.tsstamps onto an item),xmp.ts(a bounded Dublin Core extractor for the/Metadatapacket),pdf-text.ts(PDF string/date scalar decoding),read.ts(readPdf, assembling all of the above into aLayoutDocument). - Raster:
raster.ts— thePageRasteriserport andrenderPdfPage(#1198): re-walks one page throughinterpretContentStream(the same interpreterreadPdfruns), recomputes the page's crop/rotation geometry (test-pinned to agree withreadPdfbit for bit, since theclipPtcontract rides on it), and drives a caller-supplied rasteriser with device-space draw ops — rect fills, filled/stroked vector paths, affine-placed images, and text as filled glyph-outline paths resolved from the page's own embedded sfnt programs (glyf-contours.ts's decoded contours through the run's text matrices, per-glyph advances corrected against the run's exact end matrix). Optional-content layers the default configuration leaves OFF are not drawn; faces with no sfnt outlines are refused by name. No canvas dependency, no backend, no OCR/vision/network — the port is pure data plus callbacks, sopdf-raster-cpu(or a browser canvas wrapper) implements it elsewhere. codec.ts—pdfCodec, az.codec()pair overreadPdf/writePdf, plus a standalone local copy of the%PDF-header check.
- Write:
src/test-support/— hand-built PDF fixtures (pdf.ts) built by literal byte/string concatenation and deliberately importing NOTHING from this package's own writer (a fixture built bywritePdfwould let a writer bug hide from the corresponding reader test).encrypted-pdfs.tsapplies the same principle: real PDFs encrypted by qpdf, embedded as base64, so a bug in key derivation cannot cancel out between write and read halves.fonts.tsholds the real vendored Carlito and Caladea faces as raw sfnt bytes, and asserts values read out of the.ttffiles by a standalone script with a bareDataView, not by this package's own parsers — external cross-checks rather than a parser's output compared against itself.
Dependency direction is strictly downward and checkable: layout imports only zod and document-schema.js's shared leaf schemas; bytes/crypto/util import nothing local (bytes/flate.ts imports fflate); image imports bytes only; the codec itself imports layout+bytes+image+crypto+util plus document-schema.js's port and leaf types, and nothing else from outside. Nothing anywhere under src/ imports a node: builtin, which is what lets tsdown's platform: 'neutral' build run unchanged in a browser bundle. No PdfObject/PdfDict/PdfStream type appears outside the codec's own read/write modules.
- Zod-first schema/type/guard:
PdfBytesSchema(incodec.ts) andLayoutDocumentSchema(insrc/layout.ts, this package's own native model) are the only two schemas this package validates against; every other model type (PdfObject,MathBoxand friends) is plain TypeScript, never Zod-validated. z.codec()for the one schema-to-schema round trip:pdfCodec(PDF bytes ⇄LayoutDocument), wrapping the already-independently-testedreadPdf/writePdfpair and adding automatic two-way schema validation. Deliberately the no-options form —readPdf/writePdfremain the primary entry points wherever a caller needs anAbortSignal, aPdfDiagnosticSink, or anonSubstitutioncallback.PdfObjecthas no Zod schema at all, deliberately: it never crosses a public boundary or round-trips through JSON, and is constructed exclusively by this package's own parser. It narrows natively on its ownkinddiscriminant.- The
MathBox/MathFontMetricsfamily is structurally typed on purpose, not validated by Zod — the mechanism that lets a caller (documents.js) hand this package a real value produced by a completely independent module, with zero cast, zero wrapper, and no shared class or branded type. - No type assertions anywhere. Every third-party or loosely-typed value is narrowed through a type guard or a Zod parse at the boundary.
- A three-tier PDF-read failure policy: throw a typed
PdfParseError/PdfEncryptedError/PdfPasswordRequiredErrorfor a file that cannot be meaningfully processed at all (orPdfPageSelectionErrorfor a page selection naming no page of it); recover with aPdfDiagnostic(severity: 'warning') for something malformed but salvageable; degrade with a diagnostic for an individual unsupported feature while the rest of the document still reads. - Conventional commits, enforced via commitlint + husky.
- Reading arbitrary real-world PDFs is the single largest risk surface in this package, and the parser targets cleanly-generated output from mainstream producers (Word, PowerPoint, Chrome, LibreOffice, Acrobat), recovering from the malformations those producers actually create, and failing loudly and specifically on anything else — not matching a mature library's robustness against adversarial input.
- An encrypted PDF is readable when, and only when, it opens without a password — the overwhelmingly common real-world case (a permissions-only file whose owner password may be set but whose user password is empty). Supported:
/Filter /Standardat/V1, 2, 4, and 5 — RC4-40, RC4-128, AES-128, AES-256 — including/EncryptMetadata falseand/Identitycrypt filters. A file genuinely needing a user password throwsPdfPasswordRequiredError(distinct fromPdfEncryptedError, because "supply the password" and "this codec cannot read this at all" are different things to tell a user). - Nothing in this codec's reader accepts, prompts for, or guesses a password. Authenticating as owner is a permissions escalation, not a way to read a file you were already allowed to read.
writePdfproduces real, spec-conformant encryption for all four of the schemes above (src/encrypt-write.ts,WritePdfOptions.encryption) — genuine/O//U//OE//UE//Permscomputation per ISO 32000-2 7.6.4.4's Algorithms 3, 8, 9, and 10, a real user and (optional, defaulting to the user password) owner password, and per-/P-bit permission flags. Unlike the reader, the writer's own password is not scoped to empty: a genuinely non-empty user password is fully supported and round-trips against the published algorithm (seeencrypt-write.test.ts), even though this package's own reader — scoped as above — can only ever open the result back up if the user password used to encrypt it was itself empty. Two boundaries are deliberately narrower than the full spec rather than approximated silently: a revision 2-4 (rc4-40/rc4-128/aes-128) password must be plain printable ASCII, not the full 8-bit PDFDocEncoding table, and a revision 6 (aes-256) password is Unicode-normalised with NFKC rather than the full SASLPrep/stringprep profile (prohibited-character tables and the bidirectional-text rule are not implemented). Both throwPdfEncryptionErrorfor a password outside that scope rather than silently mis-encoding it into one that will not open the file it was meant to protect.CCITTFaxDecode,JBIG2DecodeandJPXDecodeimages all decode for real via hand-written decoders (src/image/ccitt.tsfor ITU-T T.4/T.6 fax;src/image/jbig2*.tsfor ITU-T T.88;src/image/jpeg2000*.tsfor ISO/IEC 15444-1). JPEG images (DCTDecode) pass through losslessly in both directions. See JBIG2 scope and JPEG 2000 scope for exactly what is and is not implemented.- The write side re-encodes bilevel images as CCITT Group 4 (
src/image/ccitt-encode.ts, a hand-written ITU-T T.6 encoder sharing the decoder's own code-table transcription): a grayscale image whose samples are all 0 or 255, with no soft mask, is written as/CCITTFaxDecodewith/K -1whenever the G4 encoding comes out smaller than Flate over the same pixels — which for vertically coherent bilevel content (a scan's edges and text baselines) it does by an order of magnitude. Whichever encoding is smaller wins, deterministically, so decorrelated or repetitive bilevel content where Flate happens to win keeps Flate. JBIG2 and JPEG 2000 re-encoding remain permanent boundaries, not gaps: a hand-written JBIG2 encoder is research-grade symbol-dictionary design and a JPEG 2000 encoder is the full EBCOT/wavelet stack, and re-encoding lossy JPX back is not fidelity-preserving anyway. What no longer follows from that boundary is generation loss on a pdf-to-pdf round trip:LayoutImageAsset.original(the layout twin ofdocument-schema.js'sContentImageOriginal, landed through the #975-scorecard schema change it named) carries a source's own JBIG2/JPX compressed stream beside the decoded canonical, captured at read time together with the decoded/JBIG2Globalssegments a symbol-dictionary stream needs, and the writer re-embeds those bytes verbatim under their own filter — the decoded canonical never reaches the file at all for such an image, a source soft mask still re-emits as a generated/SMask, and the round trip pays zero generation loss for exactly the two filters this package cannot re-encode. interpret.tstracks general vector paths, not just axis-alignedrerectangles.m/l/c/v/y/h(andreitself) accumulate real subpaths — CTM-transformed line/cubic segments, open or closed — and any paint operator emits an item built from them. A recovered path matching one of three characteristic shape patterns comes back as that shape's own kind, not a genericLayoutPath: an axis-aligned closed four-corner subpath is aLayoutRect; a closed subpath of four cubic segments meeting its bounding box at cardinal points with kappa-ratio control points is aLayoutEllipse; an open single-straight-segment stroke-only subpath is aLayoutLine(tolerance:max(1e-3pt, 1e-4 × extent)). These are deliberate, bounded heuristics — a false positive changes an item's kind, never its geometry, since every detected shape reproduces its source path's own points exactly. Off-axis rotations, non-quadrant curves, polygons, and multi-subpath figures stay aLayoutPath.renderPdfPagerenders vectors, images, and embedded-sfnt text; everything else it refuses by name. Text whose font states no TrueType/glyf outlines (CFF programs, standard-14 faces with nothing embedded, Type 3 glyph procedures, Type0 fonts under a non-Identity-H encoding) draws nothing and reportsraster/text-cff-outlinesorraster/text-outlines-unavailablethrough the sink — a substitute shape would be a silent misrender, so the refusal is the honest behaviour and the rest of the page still renders. Image pixels are the rasteriser's own concern: the port hands through exactly whatimages-read.tsrecovered (PNG for every decoded filter, JPEG bytes verbatim), so a canvas-owning backend draws DCTDecode scans natively whilepdf-raster-cpudeclines them by name (it has no JPEG decoder, and a scanned page's XObject remains extractable throughreadPdffor consumers that want the scan's own pixels). Per-glyph text placement distributes the interpreter's spacing state (Tc/Tw/Tz) uniformly across each run rather than modelling it per glyph, so a run's extent is exact while its internal glyph spacing under active spacing state is a bounded approximation.writePdf/readPdfround-trip a page's ownnotesfield via a hidden/Subtype /Textannotation with theHiddenflag set so it never renders or prints, distinguished from a genuine third-party sticky note by an internal author marker. This is a round-trip mechanism specific to this package's own writer/reader pair.documents.jsuses this to carry pptx/odp speaker notes through PDF.- STIX Two Math is a CFF-flavoured OpenType font, not TrueType/glyf — the entire
CFFtable is embedded verbatim as a single/FontFile3/Subtype /CIDFontType0Cstream (a real, correct, working embedded font, just not glyph-subsetted). Everything else genuinely IS built from a targeted parse of only what's used:cmapresolves exactly the Unicode code points a document's formulas reference, and the emitted/Wwidths array covers only drawn glyph IDs. A CID-keyed composite font built this way needs no/CIDToGIDMap— per ISO 32000-1 9.7.4.2, a/CIDFontType0whose/FontFile3is a non-CID-keyed CFF program is read with CID directly indexingCharStringsby glyph order (CID == GID). - The OpenType
MATHtable'sMathVariantsis parsed, stretchy-glyph assembly implemented, and the result genuinely drawable viaMathAssembledGlyphs(glyph-ID-addressed placements, since most construction glyphs have no Unicode code point — they draw directly because CID == GID here). An unencoded glyph gets no ToUnicode entry; the construction is wrapped in an/ActualTextspan carrying the operator's own text so it still extracts as(.MathConstantsandMathGlyphInfo(italics correction, top-accent attachment) are parsed in full. - What this package draws for a stretchy glyph is decided entirely by its caller. Which operators a document actually stretches is a layout-engine decision —
documents.js's ownsrc/mathml/layout.tscurrently stretches vertical fences in anmrowand nothing else. - Real per-glyph ink bounds are measured from the outline (
inkAscentPt/inkDescentPt), computed by walking each glyph's Type 2 charstring.ascentPerEm/descentPerEmremain alongside them as the uniform face-wide figure, still the right measure for anything sized against the font rather than particular characters, and the fallback for a glyph with no outline to measure. An ink box is genuinely tight, which for a math font is often larger than the nominal metrics (over a tenth of STIX Two Math's repertoire draws above its nominal ascent).inkDescentPtis negative where the glyph's lowest ink sits above the baseline. - A
LayoutLine/LayoutPathstyleofdashed/dottedbecomes a real dash-array (d) operator scaled to the stroke's own width;doublebecomes two genuinely separate offset strokes. Dash lengths are stroke-width multiples so a hairline and a thick rule both read as recognisably dashed:dashedemits[3w 3w] 0 d,dottedemits[0 2w] 0 dwith a1 Jround cap (the zero on-length under a round cap paints a filled circle — exactly a dot; under PDF's default butt cap it paints nothing). Both are reset immediately after the paint operator ([] 0 d,0 J) since the graphics state persists for the whole content stream.doublehas no PDF operator and is drawn as geometry: widthwsplits into three equal bands, each rulew/3wide with centrelinew/3offset, outer edges matching the single stroke's. The read side tracks thedoperator's own dash array as graphics state and recoversdashed/dottedback onto a strokedLayoutLine/LayoutPathfrom it — a zero on-length reads asdotted, any other non-empty array asdashed, an empty array (or none at all) assolid.doublehas no operator of its own to recover from, so it reads back as whatever geometry its two offset strokes actually are (typically two plain solid lines or paths), not asdouble. - An embedded
CIDFontType2program needs nocmaptable of its own, andsfnt-subset.ts's output doesn't carry one — character code → CID goes through theType0font's/Encoding(Identity-H, so CID == character code), and CID → GID through/CIDToGIDMap /Identity(matching the GID-preserving design). Both happen inside the PDF's object graph, before the embedded font program is consulted (ISO 32000-1 9.7.4.2). GPOSpair kerning andGSUB's default-on feature set (liga/rlig/calt/clig) are read and applied for an embedded face; the opt-in layout features are not — until a caller names them. Anfiin a face whoseligafeature declares the ligature draws as that real ligature glyph (advances, kerning, subsetting, and the ToUnicode CMap all describing the one substituted sequence — a ligature's ToUnicode entry maps back to its whole character run, so copy/paste recoversfi, not one glyph's worth of it). The contextual features apply for real: Carlito'scaltis a pair of Chaining Contextual format 3 lookups carrying the Greek uppercase accent discipline, and they shapeΆΙto Alpha + Iotadieresis exactly as HarfBuzz does. Every lookup'slookupFlagis honoured against the font'sGDEF(whichgdef-table.tsreads for glyph classes, mark attachment classes, and mark glyph sets): Caladea Italic's oneligalookup carries the ignore-marks flag, so itsfi/flligatures form across combining marks, the mark drawing after the ligature glyph and its text joining the ligature's ToUnicode destination — the cluster merge a real shaper performs. Still refused by default: the opt-in features (smcp,dligand friends), which no real shaper turns on until a caller asks —buildGsubShaper'soptInFeaturesoption is that ask. The legacykerntable is not read either (neither Carlito nor Caladea ships one). A kerned run is shown withTJ, and the sign of aTJnumber is the opposite of the adjustment it expresses (ISO 32000-1 9.4.3: a positive number moves the next glyph closer). A run with no kerning pairs stays as one unsplit hex string withTj.- Kerning applies to whole shown strings, so a wrap decision does not see a pair straddling the boundary between two separately-measured words. The width a line reports is the width the page draws; making the wrap decision itself exact would mean widening the
TextMeasurerport for a sub-point difference that only ever errs towards breaking a line early. font-substitutes.tsmaps bothCalibriandCalibri Lightonto the same ordinary-weight Carlito face — Carlito ships only one weight per style axis (no Light face exists upstream to vendor), soCalibri Lightsubstitutes to standard Carlito rather than a genuinely lighter face. An honest, documented approximation: width metrics match, visibly thinner strokes do not — and a REPORTED one, never silent: the registry'sonSubstitutionchannel fires for it like every vendored substitution, namingCalibri Light→carlitoso a caller can act on the weight mismatch (pinned by test in write-embedded-font.test.ts).cff-probe.ts's CID-keyed CFF guard exists for a source-embedded-font phase this package hasn't built yet — it is not wired into any write path today. Every face currently embedded isglyf-flavoured TrueType, andsfnt-subset.tsalready refuses anything else before this guard would run.
A character code in a content stream means nothing on its own: what character it draws is decided by the font it is shown in. readPdf resolves that through every source the format offers, in the order ISO 32000-1 clause 9.6.6 puts them, and reports an honestly unmapped code where they are all silent rather than guessing.
For a simple font, /ToUnicode wins wherever it covers a code, then /Encoding's own /Differences array, then the base encoding. What "the base encoding" is depends on the font: a symbolic font (one whose /FontDescriptor sets the Symbolic flag, or whose /BaseFont is Symbol or ZapfDingbats) is encoded by its own font program, so the embedded program's built-in encoding is read first and the two fixed standard-14 symbol tables next; an ordinary text font takes an explicitly named /WinAnsiEncoding, /MacRomanEncoding or /StandardEncoding first, falls through to its own program when it names none, and only then to WinAnsi. For a composite font, /ToUnicode is the authority, and an Identity-H font whose CIDs are its program's own glyph IDs falls back to the program the same way.
Reading a program's built-in encoding is what stops a symbol-encoded subset — a handful of glyphs embedded to draw Ω, µ, ± or ≤ at whatever codes the producing tool picked — from decoding as whatever character the assumed default encoding happens to put at that code. That failure is silent by construction: W for an ohm sign is not missing or garbled text, it is a plausible different character that nothing downstream can detect. builtin-encoding.ts reads the encoding out of all three program shapes a PDF can embed — a TrueType cmap's (3, 0) or (1, 0) subtable with the glyph identified through post names or the font's own Unicode subtable read backwards, a CFF Top DICT's Encoding operator with glyphs named through its charset, and a Type 1 program's cleartext /Encoding array — resolving each through the Adobe Glyph List, including its constructed uniXXXX/uXXXXXX name forms.
Where nothing states an answer, the answer is the replacement character plus a text/unmapped-encoding diagnostic, never a guess. Two cases reach it in practice: a symbolic font with no embedded program and no /ToUnicode, and a subsetted font that both strips its glyph names and maps its glyphs only from private-use code points — a private-use code point identifies a glyph inside one font and says nothing about the character it draws, so it is treated as no answer rather than a wrong one.
readPdf reports one positioned run per text-showing operator, and a PDF says nothing about lines or words: a line of prose can arrive as one run, as one run per word, or as one run per glyph, entirely at the producer's discretion. groupPdfTextRuns turns those runs back into lines of words, and pdf-codec/text-group serves it without the write half or the vendored fonts, alongside pdf-codec/read.
import { readPdf } from "pdf-codec/read";
import { groupPdfTextRuns } from "pdf-codec/text-group";
const page = readPdf(bytes).pages[0];
const runs = page.items.filter((item) => item.kind === "text");
for (const line of groupPdfTextRuns(runs)) {
console.log(line.text);
}Two rules decide everything, and both were got wrong often enough by hand (#1317) to be worth stating.
Two runs are on one line when their baselines sit within a fraction of an em of each other, measured against the smaller of the two. Taken from the larger, a 30pt heading's own window reaches the 9pt line beneath it, the merged line's runs sort by x into a sequence whose gaps are negative, a negative gap reads as one word, and the two lines concatenate into exactly the run-together text the grouping exists to prevent. The default fraction is two thirds, bounded on both sides by real typography: two consecutive lines are never closer than solid setting puts them, one full em, so it has to stay under 1; a superscript raised by at most a third of its parent's size at no less than half that size shifts by at most two thirds of its own em, so anything tighter cuts footnote markers off the line they annotate.
A run whose widthPt is absent states no advance, so where it ends is unknown and the gap after it is underivable. runGapPt reports undefined for that, and an undefined gap is no evidence of a space. Reading it as zero puts the previous run's end at its own start, turns its whole advance into an apparent gap, and produces "Com plete ly".
Gaps that are derivable become a word space at an eighth of an em (the narrowest genuine one: the standard fourteen faces set their space glyph at 250/1000 em, and justified setting compresses to no less than half of that) and a column boundary past a full em (wider than the em space, the widest single space character there is), so a table's row reads as "North\t4.2m\t11%" rather than as one sentence. runsShareBaseline and runGapPt are exported on their own for a consumer with its own pipeline around them, and every threshold is overridable.
Limits, each inherent to what a PDF states rather than to the implementation: grouping is by baseline alone with no page segmentation, so on a multi-column page whose columns sit at different vertical offsets a line of one can fall within tolerance of a line of the next and the two are reported as one line with a column boundary between them, which is the cut a caller needs (document-outline.js's segmentPdfRegions finds the gutter first if you want them genuinely apart); text comes out in visual order, so a right-to-left script needs the Unicode Bidi Algorithm applied to the result; a vertically set run groups into a column instead of a line, reported with a rotationDeg of 270 and its own writingMode, with columns ordered right to left; and a word hyphenated across a line end stays split, hyphen intact, because rejoining it is a paragraph-level judgement this package leaves to its consumers.
A composite font's /Encoding CMap chooses a writing mode (ISO 32000-1 9.7.5.1). Mode 1 is vertical: the glyphs stay upright but the text position advances down the page, by the descendant CIDFont's own /DW2 and /W2 vertical metrics rather than by its horizontal widths, and each glyph paints offset from that position by its own position vector. readPdf reads all of it (#1358), from a predefined CMap's -V name, from the bare V CMap, or from an embedded CMap stream's own /WMode.
A vertically set run carries writingMode: "vertical", and that is a separate fact from rotationDeg, which reports the text rendering matrix turning the glyphs themselves on their side. An ordinary Japanese column is writingMode: "vertical" with a rotationDeg of 0: upright glyphs, running downward. On such a run, xPt/yPt is the top of the column and widthPt measures downward.
const runs = page.items.filter((item) => item.kind === "text");
for (const line of groupPdfTextRuns(runs)) {
// A vertical column reports rotationDeg 270 (the direction it advances) and writingMode "vertical".
console.log(line.writingMode ?? "horizontal", line.text);
}groupPdfTextRuns groups such runs into columns, ordered right to left, and never lets a column and a line merge even when they advance along the same axis: a column of upright glyphs and a line of glyphs turned on their side are not the same text, whatever direction they share.
What is not supported: a predefined non-Identity CMap's own byte decoding. 90ms-RKSJ-V and its siblings remap multi-byte character codes to CIDs through tables this package does not carry, so a font using one still has its codes read as two-byte CIDs. That is this package's long-standing composite-font limit, unchanged by and unrelated to writing mode; Identity-V (what modern producers emit) is read exactly. writePdf produces horizontal text only, and has no way to request a vertical one.
src/image/jbig2*.ts is a hand-written ITU-T T.88 decoder covering what real scanned PDFs actually contain.
Implemented. The MQ arithmetic decoder (Annex E) and arithmetic integer/symbol-ID procedures (Annex A). Generic region decoding (6.2) for all four templates with adaptive pixels, typical prediction (TPGDON), and the MMR variant (routing through src/image/ccitt.ts). Generic refinement region decoding (6.3) for both templates. Symbol dictionaries (6.5) and text regions (6.4) in arithmetic form, covering height classes, export-flag runs, every reference corner, transposed regions, multi-row strips, and refined symbol instances. Segment framing (clause 7) including long referred-to-segment form, page composition with all five combination operators, and /JBIG2Globals streams.
Not implemented, each raising Jbig2UnsupportedError by name: Huffman-coded forms (SDHUFF/SBHUFF), halftone regions and pattern dictionaries, intermediate regions, segments of unknown length, EXTTEMPLATE, imported arithmetic coding contexts, typical prediction in a refinement region (TPGRON), and aggregate refinement/aggregate symbol coding. src/filters.ts turns each into a pdf/jbig2-undecodable diagnostic, leaving the image's bytes undecoded while the rest of the page still reads.
Verification. src/test-support/jbig2.ts holds real streams from three independent producers: jbig2enc (the encoder behind essentially every JBIG2-in-PDF in the wild), libtiff (MMR payloads), and a hand-written T.88 Annex E arithmetic encoder for templates jbig2enc will not emit. Every stream — hand-encoded ones included — is decoded by jbig2dec (Ghostscript's independent implementation) before being written out, and the bitmap recorded as each fixture's expected output is jbig2dec's, not this package's. The symbol-mode fixtures exist in six variants with only REFCORNER/TRANSPOSED bits rewritten, turning jbig2dec's output into a real differential test of the placement rules jbig2enc never exercises.
A differential test pins the set of template positions and offsets but not their order — a context index is only a label for a neighbourhood pattern, so any consistent permutation cancels between encoder and decoder. TPGRON is refused rather than shipped unverified: jbig2enc's refinement support is disabled upstream, so the only available stream is one this package encoded itself, which cannot pin the pseudo-context constant even in principle (brute-forcing all 1024 candidates confirmed different unrelated bands of constants pass depending on the test image).
src/image/jp2-boxes.ts and src/image/jpeg2000*.ts are a hand-written ISO/IEC 15444-1 (ITU-T T.800) decoder. The MQ arithmetic decoder is reused from jbig2-arith.ts (T.800 Annex C and T.88 Annex E specify one identical coder), with JPEG 2000's own three non-zero initial context states applied on top.
Implemented. The JP2 file format (Annex I) and bare codestreams (which a PDF /JPXDecode stream may carry — ISO 32000-1 7.4.9). The codestream syntax (Annex A): SIZ, COD, COC, QCD, QCC, POC, RGN, COM, SOT, SOD with tile-part overrides. Tier-2 packet decoding (Annex B): stuffed-bit packet headers, tag trees, code-block inclusion, precinct partitions at any size, SOP/EPH markers. Tier-1 EBCOT (Annex D): three coding passes, zero-coding context tables for all four subband orientations, sign coding, magnitude refinement, cleanup with run-length, and vertically-causal/reset/segmentation-symbol code-block styles. Both wavelets (Annex F): reversible 5-3 integer and irreversible 9-7 floating-point. Dequantization (Annex E), both component transforms and DC level shift (Annex G). LRCP and RLCP progression generally; RPCL, PCRL, CPRL when every resolution holds a single precinct.
Not implemented, each raising Jpeg2000UnsupportedError by name: sub-sampled components, regions of interest (RGN), progression-order changes (POC), packed packet headers (PPM/PPT), selective arithmetic coding bypass and terminate-on-every-pass code-block styles, JP2 palette boxes, and mixed component bit depths/signedness. src/images-read.ts turns each into an image/jpx-undecodable diagnostic and skips the image.
Verification. src/test-support/jpeg2000.ts holds real codestreams from OpenJPEG's own opj_compress. For every reversible fixture the generator first proves byte-identical round-trip through opj_decompress, then records the source image as the expected output — the original integers the encoder was handed, which no shared encoder/decoder mistake can fake. The irreversible 9-7 wavelet is lossy by construction, so expected samples are opj_decompress's output; the test asserts every sample within one, with under 1% differing (in practice a handful by exactly one — floating-point rounding at a round-to-nearest boundary, where OpenJPEG carries a slightly truncated normalisation constant).
Ordinary text uses the standard 14 fonts only, unless a caller supplies WritePdfOptions.fonts. Without a registry, Helvetica/Times-Roman are metric-compatible substitutes for Arial/Times New Roman, but Calibri/Cambria/Aptos are not — measure.ts narrows the gap with a per-family width-correction table (Calibri 8% narrower, Verdana 9% wider) and content-write.ts draws at the matching Tz scale so measurement and drawing agree, but it remains a stretched standard-14 face rather than the real one.
Supply a FontRegistry and Calibri/Cambria stop being an approximation at all — the vendored Carlito/Caladea faces are the real metric-compatible TrueType families, resolved automatically. Aptos still has no vendored substitute. A resolved face is measured at its own real hmtx advances (never the width-correction table — applying both would silently draw text narrower than measured), subsetted to used glyphs, and embedded as a real /Type0 + /CIDFontType2 + /FontFile2 group. Remaining limits: only TrueType (glyf) outlines can be embedded; a character with no glyph is drawn as .notdef and reported through onMissingGlyph; and vertical-metric policy is caller-chosen.
The acceptance bar for embedded-font fidelity is "no page-count drift on a real corpus", not "line-identical". An embedded run is placed at real hmtx advances adjusted by real GPOS pair kerning, shaped through real GSUB ligatures and contextual alternates — measured and drawn from one shared computation. What separates this from line-identical: the opt-in layout features are never applied unless a caller names them, and kerning is within each shown string rather than across whitespace boundaries a wrap decision measures separately.
The one exception is math-formula rendering (WritePdfOptions.formulas): this genuinely embeds a real, hand-parsed font. Real box-model glyph runs through the embedded STIX Two Math font with genuine per-glyph metrics and font-wide layout constants parsed directly from the MATH table. Stretchy constructions are real: a MathVariants variant or assembly resolved, measured against actual outlines, and drawn by glyph ID.
readPdf(writePdf(doc)) is not guaranteed to reproduce doc exactly, and writePdf(readPdf(bytes)) is not guaranteed to reproduce bytes exactly. A PDF page is fundamentally positioned drawing operators, not a structured document — a shape drawn any way other than the recognised characteristic patterns, or rotated off-axis, collapses to a generic LayoutPath. This is a deliberate, permanent contrast with format-preserving codecs like ooxml.js's packageCodec. pdfCodec shares z.codec()'s mechanism (schema-validated both ways) but not that guarantee.
Optional real-world corpus. test/corpus/ (gitignored) holds a pnpm test:corpus vitest project for manual conformance checking against real PDFs — Word/PowerPoint/Chrome/LibreOffice exports. Not part of pnpm test and does not gate CI; drop files in locally before a significant parser change.
Release, CI, and commit-message conventions are all workspace-wide, not package-local — see the monorepo root README for the mechanism (topological per-package semantic-release via @exadev/semantic-release-workspace, OIDC trusted npm publishing, automatic sibling dependency-range rewriting) and its post-release republishing and attestation note on the restored GitHub Packages mirrors, npm aliases, and SBOM/provenance signing.
Conventional Commits, enforced workspace-wide by commitlint through a root commit-msg hook. Work inside packages/pdf-codec/; see CONTRIBUTING.md for the shared git hooks and history conventions. The package's own scripts are turbo-wrapped:
pnpm build # tsdown (ESM + CJS + .d.ts)
pnpm typecheck # tsc -p tsconfig.json + tsconfig.node.json
pnpm lint # eslint . --fix --cache --max-warnings 0
pnpm test # vitest run --project unit
pnpm test:workers # vitest run --config vitest.workers.config.ts
pnpm test:smoke # tsdown && vitest run --project smoke
pnpm test:corpus # vitest run --project corpus (manual, gitignored fixtures)There is a single main branch and no open pull request workflow established so far.
- documents.js — the package this codec was extracted from, and its principal downstream consumer: docx/pptx/odt/odp/ods/odg ⇄ PDF conversion, and MathML formula rendering (its own
src/mathml/typesetting engine feeds a realMathBoxinto this package'swritePdf({ formulas })with zero cast). - document-schema.js — the sibling package that owns the canonical
ContentDocument/DocumentTreepivots the wider family shares, plus the shared leaf shapes and port types this package imports (Color,LayoutFont,LayoutMetadata,TextMeasurer, the math family). TheLayoutDocumentitem family itself lived there until moving into this package. - qpdf — the independent implementation that produces this package's encrypted-PDF test fixtures. A build-time and test-time tool only, never a dependency of the package itself.
- The specifications
src/crypto/implements, each cited in the module that implements it and checked against published conformance vectors: RFC 1321 (MD5), FIPS 180-4 (SHA-256/384/512), FIPS 197 (AES), and NIST SP 800-38A (CBC mode). The standard security handler is ISO 32000-1 7.6, extended for revisions 5 and 6 by ISO 32000-2 7.6.4.3; the write side's own password algorithms (src/encrypt-write.ts) are ISO 32000-2 7.6.4.4's Algorithms 3, 8, 9, and 10. - STIX Two Math — the embedded math font, vendored at
assets/fonts/STIXTwoMath-Regular.otfand embedded intodist/as a base64 string (src/assets/stix-two-math-font.ts, generated byscripts/generate-math-font-asset.mjs). Copyright 2001-2021 The STIX Fonts Project Authors, licensed OFL-1.1 — seeassets/fonts/NOTICE.mdfor the exact source commit and version.
This package also published under the following alternate npm names from the pre-monorepo pipeline:
Republished automatically — the alias's trusted publisher is registered against this repository and workflow (2026-09-10), so every release from the backfill run onward publishes under this name too; the registration evidence is on ExaDev/documents.js#729.
bytesToBase64 and base64ToBytes were this package's own copy of helpers every codec in the family carried separately. They live in byte-codec now, as one implementation (ExaDev/documents.js#1282), so the module behind the pdf-codec/util/base64 deep import is gone.
Import from byte-codec directly:
import { base64ToBytes, bytesToBase64 } from "byte-codec";Both names are also still on this package's own barrel, exactly as before, so import { bytesToBase64 } from "pdf-codec" is unaffected.
MIT