pdf-codec 2.2.6 → 2.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -93
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
> 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 [`document-schema.js`](https://github.com/ExaDev/document-schema.js)'s `LayoutDocument` pivot and [Zod 4](https://zod.dev) codecs.
|
|
6
6
|
|
|
7
|
-
`pdf-codec` is the PDF-reading-and-writing half of [`documents.js`](https://github.com/ExaDev/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`](https://github.com/101arrowz/fflate) for raw DEFLATE/zlib compression
|
|
7
|
+
`pdf-codec` is the PDF-reading-and-writing half of [`documents.js`](https://github.com/ExaDev/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`](https://github.com/101arrowz/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 its reader needs to open an encrypted PDF (`crypto/` — MD5, SHA-2, RC4, AES), because `node:crypto` would end this package's platform neutrality and WebCrypto offers neither MD5 nor RC4 nor a synchronous API. The one bundled binary asset is the vendored STIX Two Math font itself (OFL-1.1, see [Fidelity](#fidelity) and `assets/fonts/NOTICE.md`).
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
This is a genuinely large undertaking with an honest trade-off spelled out in [Fidelity](#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`.
|
|
10
10
|
|
|
11
|
-
`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](#architecture)
|
|
11
|
+
`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](#architecture) for exactly where the boundary sits and why a real `MathBox` value crosses it with zero cast or wrapper.
|
|
12
12
|
|
|
13
13
|
```mermaid
|
|
14
14
|
graph TD
|
|
@@ -79,9 +79,9 @@ const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text
|
|
|
79
79
|
const bytes = writePdf(layout);
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
An encrypted PDF that opens without a password decrypts transparently
|
|
82
|
+
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`. See [Gotchas](#gotchas-and-quirks) for exactly which encryption is supported.
|
|
83
83
|
|
|
84
|
-
Both accept an optional `signal` (`AbortSignal`); `readPdf` additionally takes a `sink` (
|
|
84
|
+
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](#conventions)), and `writePdf` an `onSubstitution` callback (called once per character not representable in a standard-14 font — see [Fidelity](#fidelity)).
|
|
85
85
|
|
|
86
86
|
The same round trip is also available as a schema-validated [`z.codec()`](https://zod.dev) pair:
|
|
87
87
|
|
|
@@ -93,9 +93,9 @@ const layout = z.decode(pdfCodec, pdfBytes); // throws a ZodError if pdfBytes ha
|
|
|
93
93
|
const pdfBytes2 = z.encode(pdfCodec, layout);
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
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.
|
|
97
97
|
|
|
98
|
-
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
|
|
98
|
+
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](#architecture)).
|
|
99
99
|
|
|
100
100
|
```ts
|
|
101
101
|
import { loadMathFont, writePdf } from 'pdf-codec';
|
|
@@ -107,9 +107,9 @@ const { box } = layoutFormula(mathml, { metrics: metricsAt(12), sizePt: 12, colo
|
|
|
107
107
|
const pdfBytes = writePdf(doc, { formulas: [{ pageIndex: 0, xPt: 50, yPt: 700, box }] });
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
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
|
|
110
|
+
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.
|
|
111
111
|
|
|
112
|
-
Sizing a stretchy glyph — a parenthesis
|
|
112
|
+
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:
|
|
113
113
|
|
|
114
114
|
```ts
|
|
115
115
|
import { loadMathFont } from 'pdf-codec';
|
|
@@ -121,17 +121,15 @@ const paren = stretchGlyph(0x28, 'vertical', 40, 12); // a '(' stretched to 40pt
|
|
|
121
121
|
// paren.placements -> [{ glyphId, offset, advance }, ...], bottom to top, seams already overlapped
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
Three outcomes
|
|
124
|
+
Three 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.
|
|
125
125
|
|
|
126
|
-
A stretched construction is
|
|
126
|
+
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 `(`.
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
`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.
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
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.
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
Embedding real fonts instead of substituting standard-14 faces, via a `FontRegistry` (see `src/font-registry.ts` for the full 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:
|
|
132
|
+
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:
|
|
135
133
|
|
|
136
134
|
```ts
|
|
137
135
|
import { createFontMeasurer, createFontRegistry, writePdf } from 'pdf-codec';
|
|
@@ -145,9 +143,9 @@ const measurer = createFontMeasurer(fonts);
|
|
|
145
143
|
const pdfBytes = writePdf(doc, { fonts, onMissingGlyph: (m) => console.warn('no glyph for', m.from) });
|
|
146
144
|
```
|
|
147
145
|
|
|
148
|
-
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
|
|
146
|
+
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`).
|
|
149
147
|
|
|
150
|
-
Inspecting a standalone font file before handing it to a `FontRegistry` as a `ProvidedFont`:
|
|
148
|
+
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:
|
|
151
149
|
|
|
152
150
|
```ts
|
|
153
151
|
import { createFontRegistry, readFontFace } from 'pdf-codec';
|
|
@@ -156,7 +154,7 @@ const { family, bold, italic } = readFontFace(brandSansTtfBytes, 'BrandSans-Bold
|
|
|
156
154
|
const fonts = createFontRegistry({ fonts: [{ family, bold, italic, bytes: brandSansTtfBytes }] });
|
|
157
155
|
```
|
|
158
156
|
|
|
159
|
-
`createFontMeasurer`'s second argument carries `verticalMetrics`, a `VerticalMetricPolicy` of `'hhea'` (
|
|
157
|
+
`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.
|
|
160
158
|
|
|
161
159
|
Reading a JPEG 2000 image directly, either as pixels or as metadata alone:
|
|
162
160
|
|
|
@@ -170,11 +168,11 @@ console.log(metadata.width, metadata.height, metadata.transform, metadata.layers
|
|
|
170
168
|
const image = decodeJpeg2000(jp2OrCodestreamBytes); // -> { width, height, bitDepth, components: Int32Array[] }, one plane per component
|
|
171
169
|
```
|
|
172
170
|
|
|
173
|
-
Both accept a whole JP2 file or a bare codestream; `decodeJpeg2000` takes an optional `onWarning` for
|
|
171
|
+
Both 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](#jpeg-2000-scope). Inside a PDF none of this needs calling: `readPdf` decodes a `/JPXDecode` image XObject through the same path automatically.
|
|
174
172
|
|
|
175
|
-
The generic byte- and image-container primitives (`crc32`, `deflate`/`inflate`/`inflateTolerant`, `ByteReader`/`ByteWriter`/`concatBytes`, `readJpegInfo`, `decodePng`/`encodePng`, `unfilterScanlines`/`filterScanlines`) are re-exported from [byte-codec](https://github.com/ExaDev/byte-codec)
|
|
173
|
+
The generic byte- and image-container primitives (`crc32`, `deflate`/`inflate`/`inflateTolerant`, `ByteReader`/`ByteWriter`/`concatBytes`, `readJpegInfo`, `decodePng`/`encodePng`, `unfilterScanlines`/`filterScanlines`) are re-exported from [byte-codec](https://github.com/ExaDev/byte-codec). The PDF-specific image codecs (`decodeCcittFax`, `decodeJbig2Embedded`, `decodeJpeg2000`/`readJpeg2000Metadata`/`parseJp2Container`) stay here.
|
|
176
174
|
|
|
177
|
-
Every module under `src/` is also deep-importable directly by its own subpath
|
|
175
|
+
Every module under `src/` is also deep-importable directly by its own subpath:
|
|
178
176
|
|
|
179
177
|
```ts
|
|
180
178
|
import { crc32 } from 'pdf-codec/bytes/crc32';
|
|
@@ -187,109 +185,98 @@ This works via package.json's `"./*"` wildcard export, resolving any `pdf-codec/
|
|
|
187
185
|
|
|
188
186
|
The package is layered from generic primitives outward to the codec itself:
|
|
189
187
|
|
|
190
|
-
- **`src/math-types.ts`** and **`src/formula.ts`** — a local, structurally-compatible mirror of `documents.js`'s own
|
|
191
|
-
- **`src/bytes/`** and **`src/image/`** — generic byte and image-container primitives with zero PDF-specific knowledge: a chunked byte writer,
|
|
192
|
-
- **`src/util/`** — two small
|
|
193
|
-
- **`src/crypto/`** — MD5, SHA-256/384/512, RC4, and AES-CBC, hand-written with zero local imports
|
|
194
|
-
- **The codec itself, importing only `math-types`/`formula`/`bytes`/`image`/`crypto` (no OOXML or ODF knowledge
|
|
195
|
-
- **Write**: `objects.ts` (the `PdfObject` discriminated union), `afm-widths.ts`/`encoding.ts`/`winansi.ts`/`fonts.ts` (standard-14 metrics, WinAnsi encoding, family resolution), `font-registry.ts` (
|
|
196
|
-
- **sfnt font tables**: `sfnt.ts` (
|
|
197
|
-
- **sfnt subsetting**: `sfnt-subset.ts`
|
|
198
|
-
- **Embedded math font**: `math-font.ts` (parses
|
|
199
|
-
- **Embedded text faces**: `embedded-font.ts` (parses
|
|
200
|
-
- **ToUnicode CMaps**: `tounicode.ts`, shared by both embedded-font writers
|
|
201
|
-
- **CFF reading**: `cff.ts`
|
|
202
|
-
- **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, `/Prev` chains, linear-scan recovery, the page tree with attribute inheritance), `encrypt.ts` (
|
|
203
|
-
- `codec.ts` — `pdfCodec`, a `z.codec()` pair over `readPdf`/`writePdf`, plus a standalone
|
|
204
|
-
- **`src/test-support
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
Dependency direction is strictly downward and checkable: `math-types`/`formula`/`bytes`/`crypto`/`util` import nothing local (`bytes/flate.ts` imports `fflate`); `image` imports `bytes` only; the codec itself imports `math-types`+`formula`+`bytes`+`image`+`crypto`+`util` only. 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 — it never crosses a public boundary and is constructed exclusively by this package's own parser.
|
|
188
|
+
- **`src/math-types.ts`** and **`src/formula.ts`** — a local, structurally-compatible mirror of `documents.js`'s own MathML layout types (`MathColor`/`MathGlyphRun`/`MathRule`/`MathStroke`/`MathLayoutItem`/`MathBox`/`MathGlyphMetrics`/`MathFontMetrics`) and `PositionedFormula`. Deliberately not imported from `documents.js` — that would be a circular dependency once `documents.js` depends on this package. Because every one of these types is plain data (only `MathFontMetrics` carries a method), a real `MathBox` value `documents.js` produces passes into `writePdf({ formulas })` with zero cast, zero wrapper, and zero transformation.
|
|
189
|
+
- **`src/bytes/`** and **`src/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.ts` MQ 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.ts` owns all PDF knowledge for CCITT/JBIG2 (resolving parameters, `/JBIG2Globals`, inverting polarity); `src/images-read.ts` owns the JPEG 2000 PDF integration. `src/bytes/flate.ts` is the only file that imports `fflate`.
|
|
190
|
+
- **`src/util/`** — two small independently-duplicated copies of family-shared logic: `base64.ts` (verbatim copy of `odf.js`'s own, replacing a former `ooxml.js` dependency for this one helper) and `abort.ts` (`throwIfAborted`, called at every page loop boundary — there is no `await` point in this synchronous pipeline for cancellation to hook into implicitly).
|
|
191
|
+
- **`src/crypto/`** — MD5, SHA-256/384/512, RC4, and AES-CBC, hand-written with zero local imports. Not a preference: ISO 32000-1's key-derivation algorithms name MD5 and RC4 directly, neither offered by any portable platform crypto API, and `crypto.subtle` is asynchronous where this codec's read path is synchronous end to end. Reaching for `node:crypto` would break the browser bundle. Each module cites its specification (RFC 1321, FIPS 180-4, FIPS 197) and is tested against published conformance vectors.
|
|
192
|
+
- **The codec itself, importing only `math-types`/`formula`/`bytes`/`image`/`crypto` (no OOXML or ODF knowledge):**
|
|
193
|
+
- **Write**: `objects.ts` (the `PdfObject` discriminated union), `afm-widths.ts`/`encoding.ts`/`winansi.ts`/`fonts.ts` (standard-14 metrics, WinAnsi encoding, family resolution), `font-registry.ts` (resolution port plus `resolveFaceWithRegistry`, the one step both measurer and writer resolve through so they can never disagree about which face a `LayoutFont` means), `font-face.ts` (`readFontFace`, reading a standalone font file's family/bold/italic triple off its `name`/`OS/2`/`head` tables), `measure.ts`/`text-layout.ts` (greedy line-wrapping against either standard-14 AFM widths plus per-family correction or a resolved face's own real `hmtx` advances — never both), `content-write.ts` (`LayoutItem[]` → content-stream operators, with text branching on standard-14 vs embedded face encoding, pair-kerning split into `TJ` arrays, and stroke `style` becoming real dash/line-cap state), `write.ts` (the full object graph, cross-reference table, trailer, and embedded font groups).
|
|
194
|
+
- **sfnt font tables**: `sfnt.ts` (bounds-checked table-directory reader), `cmap-table.ts` (Unicode → glyph ID, formats 4/12/6), `hmtx-table.ts` (per-glyph advance widths), `font-tables.ts` (`head`/`maxp`/`OS/2`/`post`/`name`), `glyf.ts` (`loca` offset index, per-glyph headers, composite component records, `glyphInkBounds`), `math-table.ts` (OpenType `MATH` constants/glyph-info/variants subtables). `ot-layout-common.ts` (Coverage/ClassDef, stored as sorted glyph ranges searched by bisection). `gpos-table.ts` reads `GPOS` for exactly one thing: pair-advance kerning through the `kern` feature, both PairPos formats and LookupType 9 Extension indirection — mark attachment, cursive joining, and contextual positioning have no consumer here. Every parser degrades to `undefined` on a missing/truncated table rather than throwing.
|
|
195
|
+
- **sfnt subsetting**: `sfnt-subset.ts` — a TrueType-outline glyph subsetter (Unicode code points → glyph IDs via `cmap`, transitive closure over `glyf` composite 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 rebuilds `head`/`hhea`/`maxp`/`loca`/`glyf`/`hmtx`, copies hinting programs verbatim, stubs `post`, omits `cmap`/`name`/`OS/2`/`GSUB`/`GPOS`/`kern` (none read through a `CIDFontType2` program per ISO 32000-1 9.9). Applies to `glyf`-flavoured fonts only; CFF returns `undefined`.
|
|
196
|
+
- **Embedded math font**: `math-font.ts` (parses/caches the vendored STIX Two Math font, exposing size-specific `MathFontMetrics` and 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`/`l` operators for rules, glyph-ID-addressed text objects for stretched constructions wrapped in `/ActualText`).
|
|
197
|
+
- **Embedded text faces**: `embedded-font.ts` (parses one TrueType-outline face's metrics and `GPOS` pair kerning, and `encodeForShowEmbedded` — 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.ts` builds the `/Type0`/`/CIDFontType2`/`/FontDescriptor`/`/FontFile2`/ToUnicode group, with `/CIDToGIDMap /Identity` written explicitly and `/Length1` set 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.
|
|
198
|
+
- **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.
|
|
199
|
+
- **CFF reading**: `cff.ts` (shared `INDEX`/`DICT` container 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' `BoundsPen` to within 0.01 design units). `cff-probe.ts` reads a bare CFF program's header/Name INDEX/Top DICT to detect the `ROS` operator 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.
|
|
200
|
+
- **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, `/Prev` chains, linear-scan recovery, the page tree with attribute inheritance), `encrypt.ts` (standard security handler: `/Encrypt` parsing, empty-user-password key derivation and `/U` verification, 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` (`/ToUnicode` CMaps, font-dictionary resolution), `images-read.ts` (Image XObjects → PNG/JPEG bytes), `read.ts` (`readPdf`, assembling all of the above into a `LayoutDocument`).
|
|
201
|
+
- `codec.ts` — `pdfCodec`, a `z.codec()` pair over `readPdf`/`writePdf`, plus a standalone local copy of the `%PDF-` header check.
|
|
202
|
+
- **`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 by `writePdf` would let a writer bug hide from the corresponding reader test). `encrypted-pdfs.ts` applies the same principle: real PDFs encrypted by [qpdf](https://qpdf.sourceforge.io/), embedded as base64, so a bug in key derivation cannot cancel out between write and read halves. `fonts.ts` holds the real vendored Carlito and Caladea faces as raw sfnt bytes, and asserts values read out of the `.ttf` files by a standalone script with a bare `DataView`, not by this package's own parsers — external cross-checks rather than a parser's output compared against itself.
|
|
203
|
+
|
|
204
|
+
Dependency direction is strictly downward and checkable: `math-types`/`formula`/`bytes`/`crypto`/`util` import nothing local (`bytes/flate.ts` imports `fflate`); `image` imports `bytes` only; the codec itself imports `math-types`+`formula`+`bytes`+`image`+`crypto`+`util` only. 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.
|
|
209
205
|
|
|
210
206
|
## Conventions
|
|
211
207
|
|
|
212
|
-
- **Zod-first schema/type/guard**: `PdfBytesSchema`/`LayoutDocumentSchema` (the latter imported from `document-schema.js`) are the only two schemas this package validates against; every other model type (`PdfObject`, `MathBox` and friends) is plain TypeScript, never Zod-validated
|
|
213
|
-
- **`z.codec()` for the one schema-to-schema round trip
|
|
214
|
-
- **`PdfObject` has 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
|
|
215
|
-
- **The `MathBox`/`MathFontMetrics` family is structurally typed on purpose, not validated by Zod
|
|
208
|
+
- **Zod-first schema/type/guard**: `PdfBytesSchema`/`LayoutDocumentSchema` (the latter imported from `document-schema.js`) are the only two schemas this package validates against; every other model type (`PdfObject`, `MathBox` and friends) is plain TypeScript, never Zod-validated.
|
|
209
|
+
- **`z.codec()` for the one schema-to-schema round trip**: `pdfCodec` (PDF bytes ⇄ `LayoutDocument`), wrapping the already-independently-tested `readPdf`/`writePdf` pair and adding automatic two-way schema validation. Deliberately the no-options form — `readPdf`/`writePdf` remain the primary entry points wherever a caller needs an `AbortSignal`, a `PdfDiagnosticSink`, or an `onSubstitution` callback.
|
|
210
|
+
- **`PdfObject` has 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 own `kind` discriminant.
|
|
211
|
+
- **The `MathBox`/`MathFontMetrics` family 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.
|
|
216
212
|
- **No type assertions anywhere.** Every third-party or loosely-typed value is narrowed through a type guard or a Zod parse at the boundary.
|
|
217
|
-
- **A three-tier PDF-read failure policy
|
|
213
|
+
- **A three-tier PDF-read failure policy**: throw a typed `PdfParseError`/`PdfEncryptedError`/`PdfPasswordRequiredError` for a file that cannot be meaningfully processed at all; recover with a `PdfDiagnostic` (`severity: 'warning'`) for something malformed but salvageable; degrade with a diagnostic for an individual unsupported feature while the rest of the document still reads.
|
|
218
214
|
- **Conventional commits**, enforced via commitlint + husky.
|
|
219
215
|
|
|
220
216
|
## Gotchas and quirks
|
|
221
217
|
|
|
222
|
-
- **Reading arbitrary real-world PDFs is the single largest risk surface in this package**, and the parser
|
|
223
|
-
- **An encrypted PDF is readable when, and only when, it opens without a password
|
|
224
|
-
- **Nothing in this codec accepts, prompts for, or guesses a password.**
|
|
225
|
-
- **`CCITTFaxDecode`, `JBIG2Decode` and `JPXDecode` images all decode for real
|
|
226
|
-
- **`interpret.ts` tracks general vector paths, not just axis-aligned `re` rectangles.** `m`/`l`/`c`/`v`/`y`/`h` (and `re` itself
|
|
227
|
-
-
|
|
228
|
-
-
|
|
229
|
-
- **
|
|
230
|
-
- **
|
|
231
|
-
- **
|
|
232
|
-
- **
|
|
233
|
-
- **An
|
|
234
|
-
-
|
|
235
|
-
- **
|
|
236
|
-
-
|
|
237
|
-
-
|
|
238
|
-
- **`GPOS` pair kerning is read and applied for an embedded face; no other OpenType layout feature is.** An embedded run's glyphs are placed at their own `hmtx` advances adjusted by the font's own pair kerning, written into the page as a real `TJ` array (see the gotcha below). `GSUB` is a different matter and is genuinely not supported: ligature substitution, contextual alternates, and small caps are never applied, so a face's `fi` ligature is drawn as two separate glyphs. The legacy `kern` table is not read either, and nothing is lost by that for the vendored families — neither Carlito nor Caladea ships one in any face; both carry all of their real pair kerning in `GPOS`.
|
|
239
|
-
- **A kerned run is shown with `TJ`, and the sign of a `TJ` number is the opposite of the adjustment it expresses.** ISO 32000-1 9.4.3 defines a number in a `TJ` array as being SUBTRACTED from the current horizontal coordinate, in thousandths of a unit of text space — so a positive number moves the next glyph closer, and a pair the font tightens by 43.457 glyph-space units is written as `+43.457`, not `-43.457`. `content-write.ts` negates the advance delta at exactly that one point, and `interpret.ts`'s own `TJ` handling is the reader half of the same convention, so a page written here and read back through this package's own parser recovers the positions it was written with (`write-embedded-font.test.ts` asserts that round trip specifically, since it is what settles the direction empirically rather than by argument from the specification alone). A run whose adjacent pairs the face kerns nothing about is still shown as one unsplit hex string with `Tj`, byte for byte what this package emitted before kerning existed — only a genuinely kerned run pays for an array.
|
|
240
|
-
- **Kerning applies to whole shown strings, so a wrap decision does not see a pair straddling the boundary between two separately-measured words.** `text-layout.ts` decides where a line breaks by summing separately-measured word and whitespace atoms, and a pair spanning one of those boundaries (Caladea kerns a comma or a full stop against a following space by -30 design units; Carlito kerns nothing against a space at all) is therefore not counted at that one decision point. Everything downstream of the decision is exact: a fragment's reported width and the glyphs actually drawn for it both come from measuring that whole fragment in one call, so the width a line reports is the width the page draws. Making the wrap decision itself exact would mean widening the `TextMeasurer` port with a cross-string pair-adjustment method — a public API change for a sub-point difference that, for both vendored families, only ever errs towards breaking a line early rather than overrunning a column.
|
|
241
|
-
- **`font-substitutes.ts` maps both `Calibri` and `Calibri Light` onto the same, ordinary-weight Carlito face.** Carlito ships only one weight per style axis (regular/bold/italic/bolditalic) — there is no distinct Light design to embed — so `Calibri Light` substitutes to standard Carlito rather than a genuinely lighter face. An honest, documented approximation (see that file's own top-of-file comment), not a faithful weight match: a caller relying on Calibri Light's visibly thinner strokes will not see them, only its width metrics.
|
|
242
|
-
- **`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, and today's embedding never needs it.** Every face this package currently embeds — the vendored Carlito/Caladea substitutes, and any caller-supplied face via `sourceFonts`/`fonts` — is `glyf`-flavoured TrueType, and `sfnt-subset.ts` already refuses (returns `undefined` for) anything that isn't before `cff-probe.ts` would ever run against it. The guard is what a future phase embedding a real, subsetted CFF program (rather than the whole-table CFF embed `math-font-write.ts` already does for STIX Two Math) will need before it can trust CID == GID against an arbitrary caller-supplied font: a CID-keyed CFF carries its own CID → glyph-index charset, so that identity does not hold for one, and nothing about the file signals the mismatch to a reader — it just renders the wrong glyphs.
|
|
218
|
+
- **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.
|
|
219
|
+
- **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 /Standard` at `/V` 1, 2, 4, and 5 — RC4-40, RC4-128, AES-128, AES-256 — including `/EncryptMetadata false` and `/Identity` crypt filters. A file genuinely needing a user password throws `PdfPasswordRequiredError` (distinct from `PdfEncryptedError`, because "supply the password" and "this codec cannot read this at all" are different things to tell a user).
|
|
220
|
+
- **Nothing in this codec 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.
|
|
221
|
+
- **`CCITTFaxDecode`, `JBIG2Decode` and `JPXDecode` images all decode for real** via hand-written decoders (`src/image/ccitt.ts` for ITU-T T.4/T.6 fax; `src/image/jbig2*.ts` for ITU-T T.88; `src/image/jpeg2000*.ts` for ISO/IEC 15444-1). JPEG images (`DCTDecode`) pass through losslessly in both directions. See [JBIG2 scope](#jbig2-scope) and [JPEG 2000 scope](#jpeg-2000-scope) for exactly what is and is not implemented.
|
|
222
|
+
- **`interpret.ts` tracks general vector paths, not just axis-aligned `re` rectangles.** `m`/`l`/`c`/`v`/`y`/`h` (and `re` itself) 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 generic `LayoutPath`: an axis-aligned closed four-corner subpath is a `LayoutRect`; a closed subpath of four cubic segments meeting its bounding box at cardinal points with kappa-ratio control points is a `LayoutEllipse`; an open single-straight-segment stroke-only subpath is a `LayoutLine` (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 a `LayoutPath`.
|
|
223
|
+
- **`writePdf`/`readPdf` round-trip a page's own `notes` field via a hidden `/Subtype /Text` annotation** with the `Hidden` flag 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.js` uses this to carry pptx/odp speaker notes through PDF.
|
|
224
|
+
- **STIX Two Math is a CFF-flavoured OpenType font, not TrueType/glyf** — the **entire** `CFF ` table is embedded verbatim as a single `/FontFile3` `/Subtype /CIDFontType0C` stream (a real, correct, working embedded font, just not glyph-subsetted). Everything else genuinely IS built from a targeted parse of only what's used: `cmap` resolves exactly the Unicode code points a document's formulas reference, and the emitted `/W` widths 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 `/CIDFontType0` whose `/FontFile3` is a non-CID-keyed CFF program is read with CID directly indexing `CharStrings` by glyph order (CID == GID).
|
|
225
|
+
- **The OpenType `MATH` table's `MathVariants` is parsed, stretchy-glyph assembly implemented, and the result genuinely drawable** via `MathAssembledGlyphs` (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 `/ActualText` span carrying the operator's own text so it still extracts as `(`. `MathConstants` and `MathGlyphInfo` (italics correction, top-accent attachment) are parsed in full.
|
|
226
|
+
- **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 own `src/mathml/layout.ts` currently stretches vertical fences in an `mrow` and nothing else.
|
|
227
|
+
- **Real per-glyph ink bounds are measured from the outline (`inkAscentPt`/`inkDescentPt`), computed by walking each glyph's Type 2 charstring.** `ascentPerEm`/`descentPerEm` remain 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). `inkDescentPt` is negative where the glyph's lowest ink sits above the baseline.
|
|
228
|
+
- **A `LayoutLine`/`LayoutPath` `style` of `dashed`/`dotted` becomes a real dash-array (`d`) operator scaled to the stroke's own width; `double` becomes two genuinely separate offset strokes.** Dash lengths are stroke-width multiples so a hairline and a thick rule both read as recognisably dashed: `dashed` emits `[3w 3w] 0 d`, `dotted` emits `[0 2w] 0 d` with a `1 J` round 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. `double` has no PDF operator and is drawn as geometry: width `w` splits into three equal bands, each rule `w/3` wide with centreline `w/3` offset, outer edges matching the single stroke's. Nothing on the read side recovers a stroke style — a dashed line read back comes back solid.
|
|
229
|
+
- **An embedded `CIDFontType2` program needs no `cmap` table of its own**, and `sfnt-subset.ts`'s output doesn't carry one — character code → CID goes through the `Type0` font'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).
|
|
230
|
+
- **`GPOS` pair kerning is read and applied for an embedded face; no other OpenType layout feature is.** `GSUB` (ligatures, contextual alternates, small caps) is never applied — a face's `fi` ligature is drawn as two separate glyphs. The legacy `kern` table is not read either (neither Carlito nor Caladea ships one). A kerned run is shown with `TJ`, and the sign of a `TJ` number 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 with `Tj`.
|
|
231
|
+
- **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 `TextMeasurer` port for a sub-point difference that only ever errs towards breaking a line early.
|
|
232
|
+
- **`font-substitutes.ts` maps both `Calibri` and `Calibri Light` onto the same ordinary-weight Carlito face** — Carlito ships only one weight per style axis, so `Calibri Light` substitutes to standard Carlito rather than a genuinely lighter face. An honest, documented approximation: width metrics match, visibly thinner strokes do not.
|
|
233
|
+
- **`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 is `glyf`-flavoured TrueType, and `sfnt-subset.ts` already refuses anything else before this guard would run.
|
|
243
234
|
|
|
244
235
|
## JBIG2 scope
|
|
245
236
|
|
|
246
|
-
`src/image/jbig2*.ts` is a hand-written ITU-T T.88 decoder
|
|
247
|
-
|
|
248
|
-
**Implemented.** The MQ arithmetic decoder (Annex E) and the arithmetic integer and symbol-ID procedures (Annex A). Generic region decoding (6.2) for all four templates, with adaptive (AT) pixels at any offset, typical prediction (TPGDON), and the MMR variant — which is a plain ITU-T T.6 bitstream, so it routes through `src/image/ccitt.ts` rather than duplicating a Group 4 decoder. Generic refinement region decoding (6.3) for both templates. Symbol dictionaries (6.5) and text regions (6.4) in their arithmetic form, covering height classes, the export-flag runs, every reference corner, transposed regions, multi-row strips, a non-zero `SBDSOFFSET`, and refined symbol instances. Segment framing (clause 7) including the long referred-to-segment form, page composition with all five combination operators, and the `/JBIG2Globals` stream a PDF uses to share one symbol dictionary across images.
|
|
237
|
+
`src/image/jbig2*.ts` is a hand-written ITU-T T.88 decoder covering what real scanned PDFs actually contain.
|
|
249
238
|
|
|
250
|
-
**
|
|
239
|
+
**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.
|
|
251
240
|
|
|
252
|
-
**
|
|
241
|
+
**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.
|
|
253
242
|
|
|
254
|
-
**
|
|
243
|
+
**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.
|
|
255
244
|
|
|
256
|
-
|
|
245
|
+
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).
|
|
257
246
|
|
|
258
247
|
## JPEG 2000 scope
|
|
259
248
|
|
|
260
|
-
`src/image/jp2-boxes.ts` and `src/image/jpeg2000*.ts` are a hand-written ISO/IEC 15444-1 (ITU-T T.800) decoder
|
|
261
|
-
|
|
262
|
-
**Implemented.** The JP2 file format (Annex I): the box structure, the image header, enumerated and ICC colour specifications, channel definitions, and the contiguous codestream box — as well as the bare codestream a PDF `/JPXDecode` stream may carry instead (ISO 32000-1 7.4.9 permits either). The codestream syntax (Annex A): SIZ, COD, COC, QCD, QCC, POC, RGN, COM, SOT and SOD, with tile-part header overrides resolving against the main header in the precedence A.6 defines. Tier-2 packet decoding (Annex B): the stuffed-bit packet-header reader, tag trees, code-block inclusion across quality layers, zero-bit-plane signalling, the coding-pass prefix code, `Lblock` growth and segment lengths, precinct partitions at any size, SOP and EPH markers, and the tile/resolution/subband/precinct/code-block geometry those index into — at any image and tile origin on the reference grid, not only at zero. Tier-1 EBCOT (Annex D): the three coding passes over every bit-plane, the zero-coding context tables for all four subband orientations, sign coding with its XOR bit, magnitude refinement, cleanup with run-length mode, and the vertically-causal-context, reset-contexts and segmentation-symbol code-block styles. Both wavelets (Annex F): the reversible 5-3 integer lifting and the irreversible 9-7 floating-point lifting, with whole-sample symmetric extension. Dequantization (Annex E) for no-quantization, scalar-derived and scalar-expounded styles. Both component transforms and the DC level shift (Annex G). LRCP and RLCP progression in general; RPCL, PCRL and CPRL when every resolution level holds a single precinct, which is where those three collapse to a plain loop.
|
|
249
|
+
`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.
|
|
263
250
|
|
|
264
|
-
**
|
|
251
|
+
**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.
|
|
265
252
|
|
|
266
|
-
**
|
|
253
|
+
**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.
|
|
267
254
|
|
|
268
|
-
**
|
|
255
|
+
**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).
|
|
269
256
|
|
|
270
257
|
## Fidelity
|
|
271
258
|
|
|
272
|
-
**Ordinary text
|
|
259
|
+
**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.
|
|
273
260
|
|
|
274
|
-
**Supply a `FontRegistry`
|
|
261
|
+
**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.
|
|
275
262
|
|
|
276
|
-
**The acceptance bar for embedded-font fidelity is
|
|
263
|
+
**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 — measured and drawn from one shared computation. What separates this from line-identical: `GSUB` ligatures/contextual alternates are never applied, and kerning is within each shown string rather than across whitespace boundaries a wrap decision measures separately.
|
|
277
264
|
|
|
278
|
-
**The one exception is math-formula rendering (`WritePdfOptions.formulas`): this genuinely embeds a real, hand-parsed font.** Real box-model glyph runs
|
|
265
|
+
**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.
|
|
279
266
|
|
|
280
|
-
**`readPdf(writePdf(doc))` is not guaranteed to reproduce `doc` exactly, and `writePdf(readPdf(bytes))` is not guaranteed to reproduce `bytes` exactly
|
|
267
|
+
**`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*.
|
|
281
268
|
|
|
282
|
-
**Optional real-world corpus.** `test/corpus/` (gitignored
|
|
269
|
+
**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.
|
|
283
270
|
|
|
284
271
|
## Release and publishing
|
|
285
272
|
|
|
286
273
|
`.github/workflows/ci.yml` runs commitlint, lint, typecheck, the unit suite, and the smoke test on every push and pull request. On a push to `main` where those all pass, `release.config.ts` drives [semantic-release](https://semantic-release.gitbook.io/semantic-release): commit history since the last tag decides the version bump, `CHANGELOG.md` and `package.json` are committed back to `main`, a GitHub Release is cut, and the package publishes to [npmjs.org](https://www.npmjs.com/package/pdf-codec) — via npm's OIDC trusted publishing, so no `NPM_TOKEN` exists anywhere in the pipeline.
|
|
287
274
|
|
|
288
|
-
Whether that release actually published a new version is detected by diffing `package.json`'s version before and after the release step
|
|
275
|
+
Whether that release actually published a new version is detected by diffing `package.json`'s version before and after the release step. Two further jobs gate on that: one republishes the same build under the scoped `@exadev/pdf-codec` alias to GitHub Packages (authenticating with `GITHUB_TOKEN`), and one packs the release, generates an SPDX SBOM (`pnpm sbom`), and signs both an SBOM and a build-provenance attestation against that exact tarball — verifiable independently of the registry, and still present if the package is later unpublished.
|
|
289
276
|
|
|
290
277
|
## Contributing
|
|
291
278
|
|
|
292
|
-
Commits follow Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`, …), enforced by commitlint (`commitlint.config.ts`) via a husky `commit-msg` hook and a CI `commitlint` job — semantic-release's version bump depends on these being well-formed, not just style. A husky `pre-commit` hook runs `lint-staged` (`eslint --fix` on staged `*.ts` files) and `pre-push` runs the test suite. The package's scripts are turbo-wrapped
|
|
279
|
+
Commits follow Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`, …), enforced by commitlint (`commitlint.config.ts`) via a husky `commit-msg` hook and a CI `commitlint` job — semantic-release's version bump depends on these being well-formed, not just style. A husky `pre-commit` hook runs `lint-staged` (`eslint --fix` on staged `*.ts` files) and `pre-push` runs the test suite. The package's scripts are turbo-wrapped:
|
|
293
280
|
|
|
294
281
|
```sh
|
|
295
282
|
pnpm build # tsdown (ESM + CJS + .d.ts)
|
|
@@ -305,11 +292,11 @@ There is a single `main` branch and no open pull request workflow established so
|
|
|
305
292
|
|
|
306
293
|
## References
|
|
307
294
|
|
|
308
|
-
- [documents.js](https://github.com/ExaDev/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 real `MathBox` into this package's `writePdf({ formulas })` with zero cast
|
|
309
|
-
- [document-schema.js](https://github.com/ExaDev/document-schema.js) — the sibling package that owns `LayoutDocument` itself (the PDF-side pivot this codec reads into and writes from), and the canonical `ContentDocument` pivot the wider
|
|
310
|
-
- [qpdf](https://qpdf.sourceforge.io/) — the independent implementation that produces this package's encrypted-PDF test fixtures
|
|
311
|
-
- The specifications `src/crypto/` implements, each cited in the module that implements it and checked against
|
|
312
|
-
- [STIX Two Math](https://github.com/stipub/stixfonts) — the embedded math font
|
|
295
|
+
- [documents.js](https://github.com/ExaDev/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 real `MathBox` into this package's `writePdf({ formulas })` with zero cast).
|
|
296
|
+
- [document-schema.js](https://github.com/ExaDev/document-schema.js) — the sibling package that owns `LayoutDocument` itself (the PDF-side pivot this codec reads into and writes from), and the canonical `ContentDocument` pivot the wider family shares.
|
|
297
|
+
- [qpdf](https://qpdf.sourceforge.io/) — 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.
|
|
298
|
+
- The specifications `src/crypto/` implements, each cited in the module that implements it and checked against published conformance vectors: [RFC 1321](https://www.rfc-editor.org/rfc/rfc1321) (MD5), [FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) (SHA-256/384/512), [FIPS 197](https://csrc.nist.gov/pubs/fips/197/final) (AES), and [NIST SP 800-38A](https://csrc.nist.gov/pubs/sp/800/38/a/final) (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.
|
|
299
|
+
- [STIX Two Math](https://github.com/stipub/stixfonts) — the embedded math font, vendored at `assets/fonts/STIXTwoMath-Regular.otf` and embedded into `dist/` as a base64 string (`src/assets/stix-two-math-font.ts`, generated by `scripts/generate-math-font-asset.mjs`). Copyright 2001-2021 The STIX Fonts Project Authors, licensed [OFL-1.1](assets/fonts/OFL.txt) — see `assets/fonts/NOTICE.md` for the exact source commit and version.
|
|
313
300
|
|
|
314
301
|
## npm aliases
|
|
315
302
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pdf-codec",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.8",
|
|
4
4
|
"description": "Hand-written, dependency-minimal PDF codec: parses arbitrary real-world PDFs and generates new ones, built on document-schema.js's LayoutDocument pivot and Zod 4 codecs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"packageManager": "pnpm@11.6.0",
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"byte-codec": "^1.0.4",
|
|
84
|
-
"document-schema.js": "^2.7.
|
|
84
|
+
"document-schema.js": "^2.7.6",
|
|
85
85
|
"fflate": "^0.8.3",
|
|
86
86
|
"zod": "^4.4.3"
|
|
87
87
|
},
|