From d7776b47593cfb421cdc9f08eaa19b6e081c8249 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:11:00 +0100 Subject: [PATCH 001/105] test(pdf-codec): raise the unit suite's test timeout to absorb shared-machine contention Individual crypto and font tests carried per-test timeout overrides (60s, then briefly 300s) sized against an isolated, lightly-loaded run. Under Stryker's mutation-instrumented dry run, contended against this shared machine's other concurrent work, both AES-256 key-derivation tests and an unrelated CFF font-parsing test missed timeouts far larger than their own uninstrumented cost, proving the slowdown is general contention rather than any one test's own logic. Replace the scattered per-test overrides with a single UNIT_TEST_TIMEOUT_MS applied to the whole unit project in vitest.config.ts, carried through explicitly into vitest.mutation.config.ts since that file replaces the base config's test block rather than merging into it. Raise pdf-codec's dryRunTimeoutMinutes so the whole dry run has room for several worst-case tests landing in the same run. --- packages/pdf-codec/src/document.test.ts | 8 ++++---- packages/pdf-codec/src/encrypt-write.test.ts | 16 ++++++++-------- packages/pdf-codec/src/math-stretch.test.ts | 4 ++-- packages/pdf-codec/src/read.test.ts | 10 +++++----- packages/pdf-codec/stryker.config.ts | 4 ++-- packages/pdf-codec/vitest.config.ts | 12 +++++++++++- packages/pdf-codec/vitest.mutation.config.ts | 5 +++-- 7 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/pdf-codec/src/document.test.ts b/packages/pdf-codec/src/document.test.ts index e068f4d42..d3dbe0daa 100644 --- a/packages/pdf-codec/src/document.test.ts +++ b/packages/pdf-codec/src/document.test.ts @@ -127,13 +127,13 @@ describe("openPdfDocument: encryption", () => { ).toThrow(PdfEncryptedError); }); - // AES-256's key derivation runs the SHA-256/384/512 hardened hash of ISO 32000-2 Algorithm 2.B, which is CPU-bound and slow enough under load to miss vitest's default 5000ms timeout on a busy CI runner -- not flaky in the sense of nondeterministic behaviour, just occasionally slower than the default budget. + // AES-256's key derivation runs the SHA-256/384/512 hardened hash of ISO 32000-2 Algorithm 2.B, which is CPU-bound (see vitest.config.ts's UNIT_TEST_TIMEOUT_MS derivation for why this file needs no per-test timeout override of its own). it("throws PdfPasswordRequiredError for a file that genuinely needs a user password", () => { const { sink } = collectDiagnostics(); expect(() => openPdfDocument(aes256RealUserPasswordPdf(), sink)).toThrow( PdfPasswordRequiredError, ); - }, 60000); + }); // Decryption is transparent below this layer: an object fetched from an encrypted document comes back in the clear, strings included, so nothing downstream of the object store needs to know the file was encrypted at all. it("resolves objects from an encrypted document with their strings already decrypted", () => { @@ -148,7 +148,7 @@ describe("openPdfDocument: encryption", () => { : undefined, ).toBe(ENCRYPTED_FIXTURE_TITLE); expect(diagnostics).toEqual([]); - }, 60000); + }); // A file's own /Encrypt dictionary is stored unencrypted (ISO 32000-1 7.6.1), so it must be fetched with decryption still off -- a bug here would corrupt /O and /U and make every supported file look password-protected. it("reads the /Encrypt dictionary itself without trying to decrypt it", () => { @@ -157,7 +157,7 @@ describe("openPdfDocument: encryption", () => { const encryptDict = doc.resolveDict(dictGet(doc.trailer, "Encrypt")); expect(asName(dictGet(encryptDict!, "Filter"))).toBe("Standard"); expect(asNumber(dictGet(encryptDict!, "V"))).toBe(5); - }, 60000); + }); }); describe("openPdfDocument: unresolvable root", () => { diff --git a/packages/pdf-codec/src/encrypt-write.test.ts b/packages/pdf-codec/src/encrypt-write.test.ts index d715aedbb..18dd26aad 100644 --- a/packages/pdf-codec/src/encrypt-write.test.ts +++ b/packages/pdf-codec/src/encrypt-write.test.ts @@ -67,14 +67,14 @@ function requireStringBytes( } describe("createStandardEncryptor: default scope", () => { - // AES-256 (revision 6) key derivation is CPU-bound (see read.test.ts's own timeout note): constructing one encryptor runs Algorithm 2.B several times, slow enough under load to miss vitest's default 5000ms timeout. + // AES-256 (revision 6) key derivation is CPU-bound: constructing one encryptor runs Algorithm 2.B several times (see vitest.config.ts's UNIT_TEST_TIMEOUT_MS derivation for why this file needs no per-test timeout override of its own). it("defaults to aes-256 with an empty user password and full permissions", () => { const encryptor = createStandardEncryptor({}, new Uint8Array(16)); expect(asName(dictGet(encryptor.encryptDict, "Filter"))).toBe("Standard"); expect(asNumber(dictGet(encryptor.encryptDict, "V"))).toBe(5); expect(asNumber(dictGet(encryptor.encryptDict, "R"))).toBe(6); expect(asNumber(dictGet(encryptor.encryptDict, "P"))).toBe(-4); // every meaningful/reserved bit set except bits 1-2 - }, 60_000); + }); it("defaults the owner password to the user password when only one is supplied", () => { // Algorithm 3 step (a)'s own convention, applied uniformly across schemes: with no owner password at all, the resulting /O must be exactly what an explicit ownerPassword equal to userPassword would produce. @@ -103,7 +103,7 @@ describe("createStandardEncryptor: default scope", () => { expect((p >> 4) & 1).toBe(1); // bit 5: copy, still permitted expect((p >> 6) & 1).toBe(1); // bit 7: reserved, always 1 expect((p >> 31) & 1).toBe(1); // bit 32: reserved, always 1 - }, 60_000); + }); it("rejects a non-ASCII password for a legacy scheme", () => { expect(() => @@ -121,7 +121,7 @@ describe("createStandardEncryptor: default scope", () => { new Uint8Array(16), ), ).not.toThrow(); - }, 60_000); + }); it("never re-encrypts a /Type /Metadata stream when encryptMetadata is false", () => { const encryptor = createStandardEncryptor( @@ -155,7 +155,7 @@ describe("writePdf + readPdf: encryption round-trips through this package's own const [page] = doc.pages; const [item] = page!.items; expect(item).toMatchObject({ kind: "text", text: "Encrypted hello" }); - }, 60_000); + }); } it("produces a document that does not read back as its own plaintext", () => { @@ -166,7 +166,7 @@ describe("writePdf + readPdf: encryption round-trips through this package's own const text = new TextDecoder("latin1").decode(pdf); expect(text).not.toContain("Secret Title"); expect(text).not.toContain("Encrypted hello"); - }, 60_000); + }); it("writes no /Encrypt dictionary and no /ID at all when encryption is not requested", () => { const pdf = writePdf(docWithSecretContent()); @@ -253,7 +253,7 @@ describe("createStandardEncryptor: a real, non-empty user password verifies agai const padded = aesCbcDecrypt(fileKey, iv, body); const padLength = padded[padded.length - 1]!; expect(padded.subarray(0, padded.length - padLength)).toEqual(plaintext); - }, 60_000); + }); }); describe("createStandardEncryptor: the /Encrypt dictionary's own O/U/OE/UE", () => { @@ -275,5 +275,5 @@ describe("createStandardEncryptor: the /Encrypt dictionary's own O/U/OE/UE", () ); } } - }, 60_000); + }); }); diff --git a/packages/pdf-codec/src/math-stretch.test.ts b/packages/pdf-codec/src/math-stretch.test.ts index 3d78f31fa..d25a74ef6 100644 --- a/packages/pdf-codec/src/math-stretch.test.ts +++ b/packages/pdf-codec/src/math-stretch.test.ts @@ -164,7 +164,7 @@ describe("MathVariants parsing against the real STIX Two Math font", () => { ]); }); - // Enumerating the whole 0x1FFFF codepoint range is cheap uninstrumented (well under 200ms), but instrumentation multiplies the per-call cost of every one of the ~131,000 glyphId() calls below: `pnpm test:coverage`'s v8 coverage has been observed taking this test over 30s on a busy CI runner (ExaDev/documents.js#1002), and Stryker's mutant instrumentation is an order of magnitude heavier still, measuring ~28s for this test on a fast local machine and exceeding 90s on a GitHub mutation runner (ExaDev/documents.js#1194). An explicit timeout, not a change to what this test checks: the budget below leaves headroom above the worst instrumented case (a fully-instrumented mutation dry run on a loaded runner) rather than matching it. + // Enumerating the whole 0x1FFFF codepoint range is cheap uninstrumented (well under 200ms), but instrumentation multiplies the per-call cost of every one of the ~131,000 glyphId() calls below: `pnpm test:coverage`'s v8 coverage has been observed taking this test over 30s on a busy CI runner (ExaDev/documents.js#1002), and Stryker's mutant instrumentation is an order of magnitude heavier still, measuring ~28s for this test on a fast local machine and exceeding 90s on a GitHub mutation runner (ExaDev/documents.js#1194). No per-test timeout override here: vitest.config.ts's UNIT_TEST_TIMEOUT_MS already leaves a wider margin above this test's own worst observed case than a bespoke value would. it("names glyphs that no Unicode code point reaches, which is why drawing a construction needs glyph IDs rather than text", () => { const font = loadMathFont(); const encoded = new Set(); @@ -198,7 +198,7 @@ describe("MathVariants parsing against the real STIX Two Math font", () => { .assembly!.parts) { expect(encoded.has(part.glyphId)).toBe(false); } - }, 300_000); + }); it("reads the radical sign's own vertical construction", () => { const construction = verticalConstruction(RADICAL); diff --git a/packages/pdf-codec/src/read.test.ts b/packages/pdf-codec/src/read.test.ts index 69fd27dee..c2dead048 100644 --- a/packages/pdf-codec/src/read.test.ts +++ b/packages/pdf-codec/src/read.test.ts @@ -145,7 +145,7 @@ describe("readPdf: PDFs that open without a password", () => { ], ]; - // AES-256's key derivation runs the SHA-256/384/512 hardened hash of ISO 32000-2 Algorithm 2.B, which is CPU-bound and slow enough under load to miss vitest's default 5000ms timeout on a busy CI runner -- applied to every fixture in this loop for a consistent timeout across the table, not just the AES-256 entries. + // AES-256's key derivation runs the SHA-256/384/512 hardened hash of ISO 32000-2 Algorithm 2.B, which is CPU-bound -- applied to every fixture in this loop for a consistent shape across the table, not just the AES-256 entries. No per-test timeout override here: vitest.config.ts's UNIT_TEST_TIMEOUT_MS already covers the whole unit project, including this file under Stryker's instrumented dry run, with a documented derivation. for (const [label, fixture] of fixtures) { it(`decrypts and reads a permissions-only PDF encrypted with ${label}`, () => { const doc = readPdf(fixture()); @@ -156,7 +156,7 @@ describe("readPdf: PDFs that open without a password", () => { ]); expect(doc.metadata.title).toBe(ENCRYPTED_FIXTURE_TITLE); expect(doc.metadata.author).toBe(ENCRYPTED_FIXTURE_AUTHOR); - }, 60_000); + }); } it("reports no diagnostics at all while decrypting", () => { @@ -165,7 +165,7 @@ describe("readPdf: PDFs that open without a password", () => { sink: (diagnostic) => diagnostics.push(diagnostic), }); expect(diagnostics).toEqual([]); - }, 60_000); + }); }); describe("readPdf: PDFs it refuses to open", () => { @@ -176,12 +176,12 @@ describe("readPdf: PDFs it refuses to open", () => { ); }); - // AES-256's key derivation is CPU-bound (see the timeout note above the fixtures table) -- slow enough under load to miss vitest's default 5000ms timeout. + // AES-256's key derivation is CPU-bound (see vitest.config.ts's UNIT_TEST_TIMEOUT_MS derivation). it("throws PdfPasswordRequiredError for an AES-256 file with a real user password", () => { expect(() => readPdf(aes256RealUserPasswordPdf())).toThrow( PdfPasswordRequiredError, ); - }, 60_000); + }); it("throws PdfEncryptedError, not PdfPasswordRequiredError, for a security handler no password could open", () => { expect(() => readPdf(unsupportedSecurityHandlerPdf())).toThrow( diff --git a/packages/pdf-codec/stryker.config.ts b/packages/pdf-codec/stryker.config.ts index 06152deea..c07625a1a 100644 --- a/packages/pdf-codec/stryker.config.ts +++ b/packages/pdf-codec/stryker.config.ts @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // 15 minutes, not Stryker's default 5: the instrumented unit suite's single heaviest test (math-stretch.test.ts's whole-Unicode-range glyphId enumeration) alone measures ~28s instrumented on a fast local machine and has exceeded 90s on a GitHub runner -- the same instrumented suite that finishes the plain unit run in seconds needs several minutes of dry-run budget under mutation instrumentation, and the default left no room for the rest of the suite on top of it. - dryRunTimeoutMinutes: 15, + // 45 minutes, not Stryker's default 5: the instrumented unit suite's heaviest tests are math-stretch.test.ts's whole-Unicode-range glyphId enumeration and several AES-256 key-derivation tests across document.test.ts/encrypt-write.test.ts/read.test.ts (see vitest.config.ts's UNIT_TEST_TIMEOUT_MS derivation for the measured costs), each exposed to the same shared-machine contention that can push any one of them past 600_000ms in the worst observed case. The whole-suite budget has to cover several of those worst cases landing in the same dry run, not just one. + dryRunTimeoutMinutes: 45, }); diff --git a/packages/pdf-codec/vitest.config.ts b/packages/pdf-codec/vitest.config.ts index 70201973f..37de2c89e 100644 --- a/packages/pdf-codec/vitest.config.ts +++ b/packages/pdf-codec/vitest.config.ts @@ -1,5 +1,9 @@ import { defineConfig } from "vitest/config"; +// The unit suite's own cost is dominated by CPU-bound cryptography (AES-256's ISO 32000-2 Algorithm 2.B key derivation) and a whole-Unicode-range glyphId enumeration (math-stretch.test.ts), neither of which is slow on its own -- both finish in well under a second uninstrumented and idle. What makes them slow, unpredictably, is contention: v8 coverage instrumentation, Stryker's own mutant instrumentation (heavier still, since it wraps every statement these tests execute), and this shared development machine's other concurrent sessions, whose reported load average routinely runs into the hundreds. Measured directly: the AES-256 fixtures table in read.test.ts took 10.5-13.3s inside an isolated Stryker sandbox at a load average of ~120, then exceeded 300s for the same test inside a real dry run (contending with every other instrumented file, plus everything else this machine was running) at a load average of ~216 -- and in a separate run, an unrelated CFF font-parsing test missed vitest's own 5000ms default under the same conditions, proving the slowdown is general contention, not a property of any one test. UNIT_TEST_TIMEOUT_MS is a single generous ceiling for the whole unit project rather than a per-test guess, because no formula ties any one test's duration to this machine's momentary contention, and every test in this instrumented suite is equally exposed to it. +// Exported so vitest.mutation.config.ts -- which replaces this file's whole `test` block outright rather than merging into it, since Stryker's vitest-runner has no --project-equivalent selector -- can apply the identical ceiling to Stryker's own dry run and mutant-testing runs, the exact runs this value was measured against in the first place. +export const UNIT_TEST_TIMEOUT_MS = 600_000; + // Three named projects in one config, filtered by --project in package.json's scripts: "unit" (src/**/*.test.ts) for pnpm test/test:watch; "smoke" (test/smoke.test.mjs, which imports from dist/) only ever run by pnpm test:smoke, right after tsdown rebuilds dist/; "corpus" (test/corpus/**/*.test.ts) for the optional, gitignored real-world PDF conformance layer, run only by pnpm test:corpus and never part of pnpm test. export default defineConfig({ test: { @@ -11,7 +15,13 @@ export default defineConfig({ reporter: ["text", "html", "cobertura"], }, projects: [ - { test: { name: "unit", include: ["src/**/*.test.ts"] } }, + { + test: { + name: "unit", + include: ["src/**/*.test.ts"], + testTimeout: UNIT_TEST_TIMEOUT_MS, + }, + }, { test: { name: "smoke", include: ["test/smoke.test.mjs"] } }, { test: { name: "corpus", include: ["test/corpus/**/*.test.ts"] } }, ], diff --git a/packages/pdf-codec/vitest.mutation.config.ts b/packages/pdf-codec/vitest.mutation.config.ts index 100a8b703..0b82b7efc 100644 --- a/packages/pdf-codec/vitest.mutation.config.ts +++ b/packages/pdf-codec/vitest.mutation.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from "vitest/config"; -import base from "./vitest.config"; +import base, { UNIT_TEST_TIMEOUT_MS } from "./vitest.config"; -// Isolates the "unit" project out of vitest.config.ts's multi-project test config for Stryker's vitest-runner, which loads one plain config file and has no equivalent of --project to select among several. test is replaced outright with the unit project's own include glob (an explicit key in an object literal always overrides whatever the earlier spread carried for that same key), so a stale projects/coverage key from the base config's own test block can't survive into this one -- Stryker never picks up the smoke/workers suites (which import from dist/ or need a different runtime and are not meaningful per-mutant) or fight over coverage instrumentation, which Stryker's own runner disables unconditionally anyway. +// Isolates the "unit" project out of vitest.config.ts's multi-project test config for Stryker's vitest-runner, which loads one plain config file and has no equivalent of --project to select among several. test is replaced outright with the unit project's own include glob (an explicit key in an object literal always overrides whatever the earlier spread carried for that same key), so a stale projects/coverage key from the base config's own test block can't survive into this one -- Stryker never picks up the smoke/workers suites (which import from dist/ or need a different runtime and are not meaningful per-mutant) or fight over coverage instrumentation, which Stryker's own runner disables unconditionally anyway. testTimeout is carried across explicitly rather than inherited, for the same reason: this object replaces the base config's `test` key rather than merging into it, and Stryker's own instrumentation is the single heaviest source of the contention UNIT_TEST_TIMEOUT_MS exists to absorb. export default defineConfig({ ...base, test: { include: ["src/**/*.test.ts"], + testTimeout: UNIT_TEST_TIMEOUT_MS, }, }); From c984eb2e0d4659d2cdd5dc12842b637f48828383 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 23:20:36 +0100 Subject: [PATCH 002/105] test(pdf-codec): serialize Stryker's own worker processes to one at a time The instrumented unit suite's heaviest tests (AES-256 key derivation, the whole-Unicode-range glyphId enumeration) are already measured to exceed their own generous timeout under this shared machine's contention; running several Stryker workers concurrently multiplies that same contention rather than avoiding it. Scoped to this package's own stryker.config.ts, per PackageStrykerOptions.concurrency, rather than lowering the workspace-wide default every other package's mutation run would then pay for. --- packages/pdf-codec/stryker.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pdf-codec/stryker.config.ts b/packages/pdf-codec/stryker.config.ts index c07625a1a..31ccdf238 100644 --- a/packages/pdf-codec/stryker.config.ts +++ b/packages/pdf-codec/stryker.config.ts @@ -4,4 +4,6 @@ export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", // 45 minutes, not Stryker's default 5: the instrumented unit suite's heaviest tests are math-stretch.test.ts's whole-Unicode-range glyphId enumeration and several AES-256 key-derivation tests across document.test.ts/encrypt-write.test.ts/read.test.ts (see vitest.config.ts's UNIT_TEST_TIMEOUT_MS derivation for the measured costs), each exposed to the same shared-machine contention that can push any one of them past 600_000ms in the worst observed case. The whole-suite budget has to cover several of those worst cases landing in the same dry run, not just one. dryRunTimeoutMinutes: 45, + // Lowered from the shared default of 4, per PackageStrykerOptions.concurrency: this package's own heaviest tests (see vitest.config.ts's UNIT_TEST_TIMEOUT_MS derivation) are already measured to blow past a generous per-test timeout under contention from just this machine's other sessions -- running several Stryker workers at once, each instrumenting and re-running that same expensive suite concurrently, multiplies exactly the contention the timeout increase above exists to absorb rather than avoiding it. + concurrency: 1, }); From 39731d88e6108c6d0c6603a8914437d2349e3890 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:42:46 +0100 Subject: [PATCH 003/105] refactor(pdf-codec): remove sfnt fixture builder's equivalent-mutant surface buildSfnt's and buildGsubTable's tag-writing loops iterated a hardcoded 4-character bound and wrote each byte manually; since a Uint8Array coerces an out-of-range charCodeAt(4) to 0 and the byte was already 0, an off-by-one bound mutation was byte-for-byte indistinguishable from the original. Replace both loops with TextEncoder().encode(tag) plus a single Uint8Array.set, which has no bound to mutate at all. buildContextFormat1/buildContextFormat2 always passed empty backtrack and lookahead arrays into the shared chained/non-chained SequenceRule builder, but the non-chained branch never reads them -- the arrays were live but their contents unobservable. Split the builder into buildSequenceRuleBytes (plain, input only) and buildChainSequenceRuleBytes (backtrack/input/lookahead), so the non-chained callers no longer construct fields nothing consumes. buildCmapTable indexed a parallel `encoded` array by position and guarded the lookup with a throw for undefined, even though the array is built by mapping over `subtables` one-to-one and can never actually be short. Zip spec and encoded bytes into one array up front and iterate that instead, removing the unreachable guard entirely. buildGdefTable computed `sets` from `markGlyphSets ?? []` unconditionally, but the fallback only matters when markGlyphSets is undefined, which is exactly when the value is never read (the IIFE that reads it only runs when markGlyphSets is defined). Pass markGlyphSets directly into the IIFE instead of materialising the fallback. --- packages/pdf-codec/src/test-support/sfnt.ts | 128 +++++++++++--------- 1 file changed, 72 insertions(+), 56 deletions(-) diff --git a/packages/pdf-codec/src/test-support/sfnt.ts b/packages/pdf-codec/src/test-support/sfnt.ts index 558479fd0..b78eee958 100644 --- a/packages/pdf-codec/src/test-support/sfnt.ts +++ b/packages/pdf-codec/src/test-support/sfnt.ts @@ -20,9 +20,7 @@ export function buildSfnt( let offset = directorySize; entries.forEach(([tag, bytes], index) => { const recordOffset = DIRECTORY_HEADER_SIZE + index * RECORD_SIZE; - for (let i = 0; i < 4; i++) { - font[recordOffset + i] = tag.charCodeAt(i); - } + font.set(new TextEncoder().encode(tag), recordOffset); view.setUint32(recordOffset + 8, offset); view.setUint32(recordOffset + 12, bytes.length); font.set(bytes, offset); @@ -102,28 +100,29 @@ function buildFormat6(mappings: ReadonlyMap): Uint8Array { export function buildCmapTable( subtables: readonly CmapSubtableSpec[], ): Uint8Array { - const encoded = subtables.map((spec) => - spec.format === 0 - ? buildFormat0(spec.mappings) - : spec.format === 4 - ? buildFormat4(spec.mappings) - : buildFormat6(spec.mappings), - ); + const encoded = subtables.map((spec) => ({ + spec, + bytes: + spec.format === 0 + ? buildFormat0(spec.mappings) + : spec.format === 4 + ? buildFormat4(spec.mappings) + : buildFormat6(spec.mappings), + })); const headerSize = 4 + subtables.length * 8; - const total = encoded.reduce((sum, bytes) => sum + bytes.length, headerSize); + const total = encoded.reduce( + (sum, { bytes }) => sum + bytes.length, + headerSize, + ); const table = new Uint8Array(total); const view = new DataView(table.buffer); view.setUint16(2, subtables.length); let offset = headerSize; - subtables.forEach((spec, index) => { + encoded.forEach(({ spec, bytes }, index) => { const recordOffset = 4 + index * 8; view.setUint16(recordOffset, spec.platformId); view.setUint16(recordOffset + 2, spec.encodingId); view.setUint32(recordOffset + 4, offset); - const bytes = encoded[index]; - if (bytes === undefined) { - throw new Error("cmap subtable was not encoded"); - } table.set(bytes, offset); offset += bytes.length; }); @@ -345,9 +344,37 @@ function buildRecords( return bytes; } -// One (Chain)SequenceRule body: [chained] backtrack count+values, input count+values (glyphCount includes the implied first glyph, so the caller lists only the components after it), [chained] lookahead count+values, then substCount+records — the plain Contextual rule carries no backtrack or lookahead count fields at all, which is why `chained` gates them rather than an empty array doing it. +// Writes a SequenceRule's shared tail -- input count+values (glyphCount includes the implied first glyph, so the caller lists only the components after it), then substCount+records -- starting at `at` in `table`, returning the offset just past the last byte written. +function putSequenceRuleTail( + table: TableBuilder, + at: number, + input: readonly number[], + records: readonly GsubRecordSpec[], +): number { + table.setU16(at, input.length + 1); + let cursor = at + 2; + input.forEach((value) => { + table.setU16(cursor, value); + cursor += 2; + }); + table.setU16(cursor, records.length); + table.put(cursor + 2, buildRecords(records)); + return cursor + 2 + records.length * 4; +} + +// A plain (non-chaining) SequenceRule body: the Contextual Substitution format carries no backtrack or lookahead fields at all, so this writes only what format 1/2 lookups ever need. function buildSequenceRuleBytes( - chained: boolean, + rule: { readonly input: readonly number[] }, + records: readonly GsubRecordSpec[], +): Uint8Array { + const words = 1 + rule.input.length + 1 + records.length * 2; + const table = new TableBuilder(words * 2); + putSequenceRuleTail(table, 0, rule.input, records); + return table.bytes; +} + +// A ChainSequenceRule body: backtrack count+values, input count+values (glyphCount includes the implied first glyph, so the caller lists only the components after it), lookahead count+values, then substCount+records. +function buildChainSequenceRuleBytes( rule: { readonly backtrack: readonly number[]; readonly input: readonly number[]; @@ -356,35 +383,35 @@ function buildSequenceRuleBytes( records: readonly GsubRecordSpec[], ): Uint8Array { const words = + 1 + + rule.backtrack.length + 1 + rule.input.length + 1 + - records.length * 2 + - (chained ? 1 + rule.backtrack.length + 1 + rule.lookahead.length : 0); + rule.lookahead.length + + 1 + + records.length * 2; const table = new TableBuilder(words * 2); - let at = 0; - const putArray = (values: readonly number[]): void => { + // Writes a plain count+values array (backtrack, lookahead) starting at `at`, returning the offset just past it. + const putArray = (at: number, values: readonly number[]): number => { table.setU16(at, values.length); - at += 2; + let cursor = at + 2; values.forEach((value) => { - table.setU16(at, value); - at += 2; + table.setU16(cursor, value); + cursor += 2; }); + return cursor; }; - if (chained) { - putArray(rule.backtrack); - } - table.setU16(at, rule.input.length + 1); - at += 2; + const afterBacktrack = putArray(0, rule.backtrack); + table.setU16(afterBacktrack, rule.input.length + 1); + let cursor = afterBacktrack + 2; rule.input.forEach((value) => { - table.setU16(at, value); - at += 2; + table.setU16(cursor, value); + cursor += 2; }); - if (chained) { - putArray(rule.lookahead); - } - table.setU16(at, records.length); - table.put(at + 2, buildRecords(records)); + const afterLookahead = putArray(cursor, rule.lookahead); + table.setU16(afterLookahead, records.length); + table.put(afterLookahead + 2, buildRecords(records)); return table.bytes; } @@ -444,11 +471,7 @@ export function buildContextFormat1( [buildCoverageFormat1(firstGlyphs)], ruleSets.map((rules) => rules.map((rule) => - buildSequenceRuleBytes( - false, - { backtrack: [], input: rule.input, lookahead: [] }, - rule.records, - ), + buildSequenceRuleBytes({ input: rule.input }, rule.records), ), ), ); @@ -475,11 +498,7 @@ export function buildContextFormat2( [buildCoverageFormat1(firstGlyphs), classDef], ruleSetsByClass.map((rules) => rules.map((rule) => - buildSequenceRuleBytes( - false, - { backtrack: [], input: rule.input, lookahead: [] }, - rule.records, - ), + buildSequenceRuleBytes({ input: rule.input }, rule.records), ), ), ); @@ -497,7 +516,7 @@ export function buildChainContextFormat1( }, [buildCoverageFormat1(firstGlyphs)], ruleSets.map((rules) => - rules.map((rule) => buildSequenceRuleBytes(true, rule, rule.records)), + rules.map((rule) => buildChainSequenceRuleBytes(rule, rule.records)), ), ); } @@ -530,7 +549,7 @@ export function buildChainContextFormat2( classDefs.lookahead, ], ruleSetsByClass.map((rules) => - rules.map((rule) => buildSequenceRuleBytes(true, rule, rule.records)), + rules.map((rule) => buildChainSequenceRuleBytes(rule, rule.records)), ), ); } @@ -641,9 +660,7 @@ export function buildGsubTable( featureList.setU16(0, features.length); let featureTableAt = 2 + features.length * 6; features.forEach((feature, index) => { - for (let c = 0; c < 4; c++) { - featureList.bytes[2 + index * 6 + c] = feature.tag.charCodeAt(c); - } + featureList.bytes.set(new TextEncoder().encode(feature.tag), 2 + index * 6); featureList .setU16(2 + index * 6 + 4, featureTableAt) .put(featureTableAt, featureTables[index]!); @@ -699,9 +716,8 @@ export function buildGdefTable(classes: { readonly markGlyphSets?: readonly Uint8Array[]; }): Uint8Array { const withSets = classes.markGlyphSets !== undefined; - const sets = classes.markGlyphSets ?? []; - const markGlyphSetsDef = withSets - ? (() => { + const markGlyphSetsDef = classes.markGlyphSets + ? ((sets: readonly Uint8Array[]) => { const defSize = 4 + sets.length * 4 + sets.reduce((n, s) => n + s.length, 0); const def = new TableBuilder(defSize); @@ -717,7 +733,7 @@ export function buildGdefTable(classes: { at += coverage.length; }); return def.bytes; - })() + })(classes.markGlyphSets) : undefined; const headerSize = withSets ? 14 : 12; const blobs = [ From 84c9bbd2eb6e10445ec3fd0d0613ab537b9ab0a8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:51:09 +0100 Subject: [PATCH 004/105] test(pdf-codec): pin sfnt.ts fixture builders' own byte layout directly gsub-table.test.ts and gdef-table.test.ts only exercise these builders indirectly through a real reader, which tolerates a wrong offset or operator as long as the resulting bytes still parse into something plausible. Add direct tests against every builder's raw output -- buildSfnt's directory records, all three cmap subtable formats (including sorting mappings given out of insertion order and a non-power-of-two segCount for format 4's searchRange/entrySelector/ rangeShift), post v2/v3, coverage/single-subst/ligature sorting and byte placement, the plain vs chained SequenceRule bodies, format 3's chained and plain layouts, GSUB's per-feature table placement and lookup markFilteringSet width across all three flag cases, and GDEF's v1.0/v1.2 header selection -- closing the coverage/precision gap the indirect tests left around each builder's own arithmetic. Also removes buildGdefTable's now-redundant `withSets` variable, missed when the previous commit collapsed its two `=== undefined` checks into one. --- .../pdf-codec/src/test-support/sfnt.test.ts | 571 ++++++++++++++++++ packages/pdf-codec/src/test-support/sfnt.ts | 11 +- 2 files changed, 575 insertions(+), 7 deletions(-) create mode 100644 packages/pdf-codec/src/test-support/sfnt.test.ts diff --git a/packages/pdf-codec/src/test-support/sfnt.test.ts b/packages/pdf-codec/src/test-support/sfnt.test.ts new file mode 100644 index 000000000..387d9fc4b --- /dev/null +++ b/packages/pdf-codec/src/test-support/sfnt.test.ts @@ -0,0 +1,571 @@ +import { describe, expect, it } from "vitest"; +import { + buildChainContextFormat1, + buildChainContextFormat2, + buildClassDefFormat1, + buildCmapTable, + buildContextFormat1, + buildContextFormat2, + buildCoverageFormat1, + buildFormat3Subtable, + buildGdefTable, + buildGsubTable, + buildLigatureSubstFormat1, + buildPostV2Table, + buildPostV3Table, + buildSfnt, + buildSingleSubstFormat2, +} from "./sfnt"; + +// Every assertion below reads the byte layout back with a raw DataView, deliberately never through this package's own sfnt readers -- the same independent-oracle discipline this file's own top-of-file comment states for the builders themselves. These tests exist to pin the arithmetic and branch choices inside sfnt.ts's fixture builders directly, since gsub-table.test.ts/gdef-table.test.ts only exercise them indirectly through a real reader, which can tolerate an off-by-one the reader itself doesn't notice. + +function u16(bytes: Uint8Array, at: number): number { + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint16(at); +} +function u32(bytes: Uint8Array, at: number): number { + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint32(at); +} +function tag(bytes: Uint8Array, at: number): string { + return new TextDecoder("ascii").decode(bytes.slice(at, at + 4)); +} + +describe("buildSfnt", () => { + it("writes the TrueType version and table count, then one record per table in call order", () => { + const font = buildSfnt( + new Map([ + ["cmap", Uint8Array.from([1, 2, 3])], + ["post", Uint8Array.from([4, 5])], + ]), + ); + expect(u32(font, 0)).toBe(0x00010000); + expect(u16(font, 4)).toBe(2); + // record 0: tag, then offset/length at the record's own fixed slots + expect(tag(font, 12)).toBe("cmap"); + expect(u32(font, 20)).toBe(44); // directorySize = 12 + 2*16 + expect(u32(font, 24)).toBe(3); + // record 1 + expect(tag(font, 28)).toBe("post"); + expect(u32(font, 36)).toBe(47); // 44 + 3 + expect(u32(font, 40)).toBe(2); + // table data itself, placed back-to-back after the directory + expect([...font.slice(44, 47)]).toEqual([1, 2, 3]); + expect([...font.slice(47, 49)]).toEqual([4, 5]); + expect(font.length).toBe(49); + }); +}); + +describe("buildCmapTable / format 0", () => { + it("writes format 0 with its fixed 262-byte length and glyph IDs at code offset", () => { + const table = buildCmapTable([ + { + platformId: 1, + encodingId: 0, + format: 0, + mappings: new Map([[65, 10]]), + }, + ]); + const headerSize = 4 + 1 * 8; + + expect(u16(table, headerSize)).toBe(0); + expect(u16(table, headerSize + 2)).toBe(262); + expect(table[headerSize + 6 + 65]).toBe(10); + expect(table.length).toBe(headerSize + 262); + }); +}); + +describe("buildCmapTable / format 4", () => { + it("sorts mappings given out of order and lays out end/start/idDelta arrays plus the terminator segment", () => { + // Deliberately inserted out of ascending order -- the builder must sort before laying anything out. + const table = buildCmapTable([ + { + platformId: 3, + encodingId: 1, + format: 4, + mappings: new Map([ + [200, 20], + [65, 10], + [100, 15], + ]), + }, + ]); + const at = 4 + 1 * 8; // one record before the subtable + const segCount = 4; // 3 real codes + terminator + expect(u16(table, at)).toBe(4); // format + const length = 16 + segCount * 8; + expect(u16(table, at + 2)).toBe(length); + expect(u16(table, at + 6)).toBe(segCount * 2); // segCountX2 + // searchRange = 2 * 2**floor(log2(segCount)) = 2 * 2**2 = 8 + expect(u16(table, at + 8)).toBe(8); + // entrySelector = log2(searchRange/2) = log2(4) = 2 + expect(u16(table, at + 10)).toBe(2); + // rangeShift = segCountX2 - searchRange = 8 - 8 = 0 + expect(u16(table, at + 12)).toBe(0); + const endCodes = at + 14; + const startCodes = endCodes + segCount * 2 + 2; + const idDeltas = startCodes + segCount * 2; + // sorted ascending: 65, 100, 200, then the 0xFFFF terminator + expect(u16(table, endCodes)).toBe(65); + expect(u16(table, endCodes + 2)).toBe(100); + expect(u16(table, endCodes + 4)).toBe(200); + expect(u16(table, endCodes + 6)).toBe(0xffff); + expect(u16(table, startCodes)).toBe(65); + expect(u16(table, startCodes + 2)).toBe(100); + expect(u16(table, startCodes + 4)).toBe(200); + expect(u16(table, startCodes + 6)).toBe(0xffff); + expect(u16(table, idDeltas)).toBe((10 - 65) & 0xffff); + expect(u16(table, idDeltas + 2)).toBe((15 - 100) & 0xffff); + expect(u16(table, idDeltas + 4)).toBe((20 - 200) & 0xffff); + expect(u16(table, idDeltas + 6)).toBe(1); + }); + + it("computes a distinct searchRange/entrySelector/rangeShift for a segCount that is not itself a power of two", () => { + // 5 real codes -> segCount 6: floor(log2(6))=2, searchRange=2*4=8, entrySelector=2, rangeShift=12-8=4. + const mappings = new Map( + [10, 20, 30, 40, 50].map((code, i) => [code, i + 1]), + ); + const table = buildCmapTable([ + { platformId: 0, encodingId: 3, format: 4, mappings }, + ]); + const at = 4 + 8; + expect(u16(table, at + 8)).toBe(8); + expect(u16(table, at + 10)).toBe(2); + expect(u16(table, at + 12)).toBe(4); + }); +}); + +describe("buildCmapTable / format 6", () => { + it("derives firstCode/entryCount from the sorted codes even when inserted out of order, and keeps a nonzero firstCode", () => { + const table = buildCmapTable([ + { + platformId: 1, + encodingId: 0, + format: 6, + mappings: new Map([ + [72, 7], + [70, 5], + [71, 6], + ]), + }, + ]); + const at = 4 + 8; + expect(u16(table, at)).toBe(6); + const entryCount = 72 - 70 + 1; + expect(u16(table, at + 2)).toBe(10 + entryCount * 2); + expect(u16(table, at + 6)).toBe(70); // firstCode: must be the real nonzero code, not 0 + expect(u16(table, at + 8)).toBe(entryCount); + expect(u16(table, at + 10 + (70 - 70) * 2)).toBe(5); + expect(u16(table, at + 10 + (71 - 70) * 2)).toBe(6); + expect(u16(table, at + 10 + (72 - 70) * 2)).toBe(7); + }); +}); + +describe("buildCmapTable / multiple subtables", () => { + it("lays out three subtables of different formats back to back with correct platform/encoding/offset records", () => { + const table = buildCmapTable([ + { platformId: 1, encodingId: 0, format: 0, mappings: new Map([[1, 1]]) }, + { + platformId: 3, + encodingId: 1, + format: 4, + mappings: new Map([[1, 1]]), + }, + { + platformId: 1, + encodingId: 0, + format: 6, + mappings: new Map([[1, 1]]), + }, + ]); + expect(u16(table, 2)).toBe(3); + const headerSize = 4 + 3 * 8; + // record 0 + expect(u16(table, 4)).toBe(1); + expect(u16(table, 6)).toBe(0); + expect(u32(table, 8)).toBe(headerSize); + // format 0 subtable is always exactly 262 bytes + const format4At = headerSize + 262; + expect(u32(table, 16)).toBe(format4At); + // format 4 subtable with a single code has segCount 2, length 16+16=32 + const format6At = format4At + 32; + expect(u32(table, 24)).toBe(format6At); + expect(u16(table, format6At)).toBe(6); + }); +}); + +describe("buildPostV2Table / buildPostV3Table", () => { + it("assigns sequential custom-name indices past the 258 standard names, not the same index twice", () => { + const table = buildPostV2Table(["", "first", "second"]); + expect(u32(table, 0)).toBe(0x00020000); + const HEADER = 32; + expect(u16(table, HEADER)).toBe(3); + expect(u16(table, HEADER + 2 + 0 * 2)).toBe(0); // "" -> .notdef + expect(u16(table, HEADER + 2 + 1 * 2)).toBe(258); + expect(u16(table, HEADER + 2 + 2 * 2)).toBe(259); + }); + + it("writes the version-3.0 header with no name data", () => { + const table = buildPostV3Table(); + expect(u32(table, 0)).toBe(0x00030000); + expect(table.length).toBe(32); + }); +}); + +describe("buildCoverageFormat1", () => { + it("sorts glyph IDs given out of order", () => { + const table = buildCoverageFormat1([50, 5, 20]); + expect(u16(table, 0)).toBe(1); + expect(u16(table, 2)).toBe(3); + expect(u16(table, 4)).toBe(5); + expect(u16(table, 6)).toBe(20); + expect(u16(table, 8)).toBe(50); + }); +}); + +describe("buildSingleSubstFormat2", () => { + it("sorts mappings by covered glyph and places substitutes at the matching index", () => { + const table = buildSingleSubstFormat2([ + [30, 300], + [10, 100], + [20, 200], + ]); + expect(u16(table, 0)).toBe(2); + expect(u16(table, 4)).toBe(3); + expect(u16(table, 6)).toBe(100); + expect(u16(table, 8)).toBe(200); + expect(u16(table, 10)).toBe(300); + }); +}); + +describe("buildLigatureSubstFormat1 (buildLigatureRecord)", () => { + it("sorts ligature sets by first glyph and places each set's own multiple ligature records correctly", () => { + const table = buildLigatureSubstFormat1([ + { + firstGlyph: 20, + ligatures: [{ ligatureGlyph: 99, components: [21] }], + }, + { + firstGlyph: 10, + ligatures: [ + { ligatureGlyph: 50, components: [11, 12] }, + { ligatureGlyph: 51, components: [13] }, + ], + }, + ]); + expect(u16(table, 0)).toBe(1); + expect(u16(table, 4)).toBe(2); + // coverage (sorted): firstGlyph 10 is index 0, 20 is index 1 + const coverageAt = u16(table, 2); + expect(u16(table, coverageAt + 4)).toBe(10); + expect(u16(table, coverageAt + 6)).toBe(20); + // ligSet for firstGlyph 10 comes first (sorted), holding two ligature records + const ligSet0At = u16(table, 6); + expect(u16(table, ligSet0At)).toBe(2); + const rec0At = ligSet0At + u16(table, ligSet0At + 2); + expect(u16(table, rec0At)).toBe(50); // ligatureGlyph + expect(u16(table, rec0At + 2)).toBe(3); // componentCount = components.length + 1 + expect(u16(table, rec0At + 4)).toBe(11); + expect(u16(table, rec0At + 6)).toBe(12); + const rec1At = ligSet0At + u16(table, ligSet0At + 4); + expect(u16(table, rec1At)).toBe(51); + expect(u16(table, rec1At + 2)).toBe(2); + expect(u16(table, rec1At + 4)).toBe(13); + }); +}); + +describe("buildRecords (via buildContextFormat1)", () => { + it("places multiple SubstLookupRecords at their own 4-byte slots", () => { + const subtable = buildContextFormat1( + [5], + [ + [ + { + input: [6, 7], + records: [ + { sequenceIndex: 0, lookupIndex: 1 }, + { sequenceIndex: 1, lookupIndex: 2 }, + ], + }, + ], + ], + ); + // Rule set for the one first glyph starts right after the header + offset array + coverage. + const ruleSetAt = u16(subtable, 6); + const ruleAt = ruleSetAt + u16(subtable, ruleSetAt + 2); + // input.length(2) + 1 glyphs written, then substCount, then records + const inputLen = u16(subtable, ruleAt); + const recordsAt = ruleAt + 2 + (inputLen - 1) * 2 + 2; + expect(u16(subtable, recordsAt - 2)).toBe(2); // substCount + expect(u16(subtable, recordsAt)).toBe(0); + expect(u16(subtable, recordsAt + 2)).toBe(1); + expect(u16(subtable, recordsAt + 4)).toBe(1); + expect(u16(subtable, recordsAt + 6)).toBe(2); + }); +}); + +describe("buildContextFormat1 / buildContextFormat2 (plain SequenceRule)", () => { + it("writes only glyphCount + input + substCount + records, with no backtrack/lookahead fields at all", () => { + const subtable = buildContextFormat1( + [1], + [[{ input: [2, 3], records: [{ sequenceIndex: 0, lookupIndex: 0 }] }]], + ); + const ruleSetAt = u16(subtable, 6); + const ruleAt = ruleSetAt + u16(subtable, ruleSetAt + 2); + expect(u16(subtable, ruleAt)).toBe(3); // glyphCount = input.length + 1 + expect(u16(subtable, ruleAt + 2)).toBe(2); + expect(u16(subtable, ruleAt + 4)).toBe(3); + expect(u16(subtable, ruleAt + 6)).toBe(1); // substCount + // Total rule byte length is exactly glyphCount-field + 2 inputs + substCount-field + 1 record -- proving nothing extra (backtrack/lookahead) was written. + expect(subtable.length - ruleAt).toBe(2 + 2 * 2 + 2 + 1 * 4); + }); + + it("buildContextFormat2 threads the class def offset and rule sets the same way", () => { + const classDef = buildClassDefFormat1(0, [1, 2]); + const subtable = buildContextFormat2([1], classDef, [ + [{ input: [4], records: [{ sequenceIndex: 0, lookupIndex: 0 }] }], + ]); + expect(u16(subtable, 0)).toBe(2); + const classDefAt = u16(subtable, 4); + expect(u16(subtable, classDefAt)).toBe(1); + }); +}); + +describe("buildChainContextFormat1 / buildChainContextFormat2 (chained SequenceRule)", () => { + it("writes backtrack, input, lookahead and records in that order with correct counts", () => { + const subtable = buildChainContextFormat1( + [1], + [ + [ + { + backtrack: [10, 11], + input: [2], + lookahead: [20], + records: [{ sequenceIndex: 0, lookupIndex: 5 }], + }, + ], + ], + ); + const ruleSetAt = u16(subtable, 6); + const ruleAt = ruleSetAt + u16(subtable, ruleSetAt + 2); + expect(u16(subtable, ruleAt)).toBe(2); // backtrackGlyphCount + expect(u16(subtable, ruleAt + 2)).toBe(10); + expect(u16(subtable, ruleAt + 4)).toBe(11); + const inputAt = ruleAt + 6; + expect(u16(subtable, inputAt)).toBe(2); // inputGlyphCount = input.length + 1 + expect(u16(subtable, inputAt + 2)).toBe(2); + const lookaheadAt = inputAt + 4; + expect(u16(subtable, lookaheadAt)).toBe(1); + expect(u16(subtable, lookaheadAt + 2)).toBe(20); + const recordsAt = lookaheadAt + 4; + expect(u16(subtable, recordsAt)).toBe(1); + expect(u16(subtable, recordsAt + 2)).toBe(0); + expect(u16(subtable, recordsAt + 4)).toBe(5); + }); + + it("buildChainContextFormat2 writes distinct backtrack/input/lookahead class-def offsets", () => { + const classDefs = { + backtrack: buildClassDefFormat1(0, [1]), + input: buildClassDefFormat1(0, [2]), + lookahead: buildClassDefFormat1(0, [3]), + }; + const subtable = buildChainContextFormat2([1], classDefs, [ + [{ backtrack: [], input: [4], lookahead: [], records: [] }], + ]); + const backtrackAt = u16(subtable, 4); + const inputAt = u16(subtable, 6); + const lookaheadAt = u16(subtable, 8); + expect(backtrackAt).not.toBe(inputAt); + expect(inputAt).not.toBe(lookaheadAt); + // Each class def's own single class value round-trips at its own offset. + expect(u16(subtable, backtrackAt + 6)).toBe(1); + expect(u16(subtable, inputAt + 6)).toBe(2); + expect(u16(subtable, lookaheadAt + 6)).toBe(3); + }); +}); + +describe("buildFormat3Subtable", () => { + it("sizes the chained header correctly with multiple backtrack and lookahead coverage entries", () => { + const subtable = buildFormat3Subtable(true, { + backtrack: [[1], [2]], + input: [[3]], + lookahead: [[4], [5]], + records: [{ sequenceIndex: 0, lookupIndex: 0 }], + }); + expect(u16(subtable, 0)).toBe(3); + expect(u16(subtable, 2)).toBe(2); // backtrackGlyphCount + expect(u16(subtable, 4)).toBe(24); // first backtrack coverage's own offset, not a reserved zero + const inputCountAt = 2 + 2 + 2 * 2; + expect(u16(subtable, inputCountAt)).toBe(1); // inputGlyphCount + const lookaheadCountAt = inputCountAt + 2 + 1 * 2; + expect(u16(subtable, lookaheadCountAt)).toBe(2); // lookaheadGlyphCount + const substCountAt = lookaheadCountAt + 2 + 2 * 2; + expect(u16(subtable, substCountAt)).toBe(1); + }); + + it("plain (non-chained) form carries only the input coverage array, no backtrack/lookahead counts", () => { + const subtable = buildFormat3Subtable(false, { + backtrack: [], + input: [[1], [2]], + lookahead: [], + records: [], + }); + expect(u16(subtable, 0)).toBe(3); + expect(u16(subtable, 2)).toBe(2); // glyphCount (the plain form's own single count) + const substCountAt = 2 + 2 + 2 * 2; + expect(u16(subtable, substCountAt)).toBe(0); + // total length is exactly the fixed plain-form header plus the two coverage blobs, proving no backtrack/lookahead bytes leaked in + const coverage1 = buildCoverageFormat1([1]); + const coverage2 = buildCoverageFormat1([2]); + expect(subtable.length).toBe( + substCountAt + 2 + coverage1.length + coverage2.length, + ); + }); +}); + +describe("buildGsubTable", () => { + it("lists every feature's lookup index in the default LangSys in feature order", () => { + const table = buildGsubTable( + [ + { tag: "liga", lookupIndices: [0] }, + { tag: "calt", lookupIndices: [1] }, + ], + [{ type: 4, subtables: [Uint8Array.from([1, 2])] }], + ); + const scriptListAt = u16(table, 4); + const scriptAt = scriptListAt + u16(table, scriptListAt + 6); + const langSysAt = scriptAt + 4; + expect(u16(table, langSysAt + 4)).toBe(2); // featureIndexCount + expect(u16(table, langSysAt + 6)).toBe(0); + expect(u16(table, langSysAt + 8)).toBe(1); + }); + + it("does not overlap two features' own tables when their lookupIndices lengths differ", () => { + const table = buildGsubTable( + [ + { tag: "liga", lookupIndices: [0, 1, 2] }, + { tag: "calt", lookupIndices: [3] }, + ], + [], + ); + const featureListAt = u16(table, 6); + expect(tag(table, featureListAt + 2)).toBe("liga"); + expect(tag(table, featureListAt + 8)).toBe("calt"); + // The feature table offsets stored in the feature list are relative to the feature list's OWN start, not the outer table's. + const feature0TableAt = featureListAt + u16(table, featureListAt + 2 + 4); + const feature1TableAt = featureListAt + u16(table, featureListAt + 8 + 4); + // feature 0's table is 4 + 3*2 = 10 bytes; feature 1's must start exactly after it. + expect(feature1TableAt - feature0TableAt).toBe(10); + expect(u16(table, feature1TableAt)).toBe(0); + expect(u16(table, feature1TableAt + 2)).toBe(1); + expect(u16(table, feature1TableAt + 4)).toBe(3); + }); + + it("writes every feature tag correctly, including a feature after the first", () => { + const table = buildGsubTable( + [ + { tag: "aaaa", lookupIndices: [] }, + { tag: "zzzz", lookupIndices: [] }, + ], + [], + ); + const featureListAt = u16(table, 6); + expect(tag(table, featureListAt + 2)).toBe("aaaa"); + expect(tag(table, featureListAt + 8)).toBe("zzzz"); + }); + + it("omits the markFilteringSet slot when the lookup has no flag at all", () => { + const table = buildGsubTable( + [], + [{ type: 1, subtables: [Uint8Array.from([9, 9])] }], + ); + const lookupListAt = u16(table, 8); + const lookupAt = lookupListAt + u16(table, lookupListAt + 2); + expect(u16(table, lookupAt)).toBe(1); + expect(u16(table, lookupAt + 4)).toBe(1); // subtableCount + const subtableOffset = u16(table, lookupAt + 6); + // With no markFilteringSet slot, the one subtable starts right after the 6-byte header + one offset slot. + expect(subtableOffset).toBe(8); + }); + + it("omits the markFilteringSet slot when the flag is set but does not select useMarkFilteringSet", () => { + const table = buildGsubTable( + [], + [{ type: 1, flag: 0x0008, subtables: [Uint8Array.from([9, 9])] }], + ); + const lookupListAt = u16(table, 8); + const lookupAt = lookupListAt + u16(table, lookupListAt + 2); + expect(u16(table, lookupAt + 2)).toBe(0x0008); + const subtableOffset = u16(table, lookupAt + 6); + expect(subtableOffset).toBe(8); + }); + + it("writes the markFilteringSet slot, at the right offset, only when the flag selects useMarkFilteringSet", () => { + const table = buildGsubTable( + [], + [ + { + type: 1, + flag: 0x0010, + markFilteringSet: 7, + subtables: [Uint8Array.from([1]), Uint8Array.from([2])], + }, + ], + ); + const lookupListAt = u16(table, 8); + const lookupAt = lookupListAt + u16(table, lookupListAt + 2); + expect(u16(table, lookupAt + 4)).toBe(2); // subtableCount + // header(6) + 2 offset slots(4) = 10, the markFilteringSet slot sits right there + expect(u16(table, lookupAt + 10)).toBe(7); + // both subtables then start right after that slot + const firstSubtableOffset = u16(table, lookupAt + 6); + expect(firstSubtableOffset).toBe(12); + const secondSubtableOffset = u16(table, lookupAt + 8); + expect(secondSubtableOffset).toBe(13); + }); + + it("lists multiple subtable offsets in a lookup correctly", () => { + const table = buildGsubTable( + [], + [ + { + type: 4, + subtables: [Uint8Array.from([1, 1]), Uint8Array.from([2, 2, 2])], + }, + ], + ); + const lookupListAt = u16(table, 8); + const lookupAt = lookupListAt + u16(table, lookupListAt + 2); + const offset0 = u16(table, lookupAt + 6); + const offset1 = u16(table, lookupAt + 8); + expect(offset1 - offset0).toBe(2); + }); +}); + +describe("buildGdefTable", () => { + it("uses the 12-byte version-1.0 header and offset 0 for an absent glyphClassDef, with no MarkGlyphSetsDef", () => { + const classDef = buildClassDefFormat1(0, [1]); + const table = buildGdefTable({ markAttachClassDef: classDef }); + expect(u16(table, 0)).toBe(1); + expect(u16(table, 2)).toBe(0); // minor version 0: no MarkGlyphSetsDef + expect(u16(table, 4)).toBe(0); // glyphClassDef offset absent + expect(u16(table, 10)).not.toBe(0); // markAttachClassDef IS present + expect(table.length).toBe(12 + classDef.length); + }); + + it("uses the 14-byte version-1.2 header and a real MarkGlyphSetsDef offset when markGlyphSets is given", () => { + const set0 = buildCoverageFormat1([1]); + const table = buildGdefTable({ markGlyphSets: [set0] }); + expect(u16(table, 2)).toBe(2); // minor version 2 + const setsOffset = u16(table, 12); + expect(setsOffset).toBe(14); // right after the 14-byte header, nothing else present + expect(u16(table, setsOffset)).toBe(1); // MarkGlyphSetsDef format + expect(u16(table, setsOffset + 2)).toBe(1); // markGlyphSetCount + }); +}); diff --git a/packages/pdf-codec/src/test-support/sfnt.ts b/packages/pdf-codec/src/test-support/sfnt.ts index b78eee958..08a094ce9 100644 --- a/packages/pdf-codec/src/test-support/sfnt.ts +++ b/packages/pdf-codec/src/test-support/sfnt.ts @@ -715,7 +715,6 @@ export function buildGdefTable(classes: { readonly markAttachClassDef?: Uint8Array; readonly markGlyphSets?: readonly Uint8Array[]; }): Uint8Array { - const withSets = classes.markGlyphSets !== undefined; const markGlyphSetsDef = classes.markGlyphSets ? ((sets: readonly Uint8Array[]) => { const defSize = @@ -735,7 +734,7 @@ export function buildGdefTable(classes: { return def.bytes; })(classes.markGlyphSets) : undefined; - const headerSize = withSets ? 14 : 12; + const headerSize = markGlyphSetsDef === undefined ? 12 : 14; const blobs = [ classes.glyphClassDef, classes.markAttachClassDef, @@ -744,7 +743,7 @@ export function buildGdefTable(classes: { const table = new TableBuilder( headerSize + blobs.reduce((n, blob) => n + blob.length, 0), ); - table.setU16(0, 1).setU16(2, withSets ? 2 : 0); + table.setU16(0, 1).setU16(2, markGlyphSetsDef === undefined ? 0 : 2); let blobAt = headerSize; const offsetOf = (blob: Uint8Array): number => { const offset = blobAt; @@ -760,11 +759,9 @@ export function buildGdefTable(classes: { : offsetOf(classes.markAttachClassDef); table.setU16(4, glyphClassOffset).setU16(6, 0).setU16(8, 0); table.setU16(10, markAttachOffset); - if (withSets) { + if (markGlyphSetsDef !== undefined) { // the MarkGlyphSetsDef offset slot arrives with minor version 2; its value was already placed by the blob walk above - const setsOffset = - markGlyphSetsDef === undefined ? 0 : offsetOf(markGlyphSetsDef); - table.setU16(12, setsOffset); + table.setU16(12, offsetOf(markGlyphSetsDef)); } return table.bytes; } From de8b07f50d5b56f1d48572c13594983ad74bdbe3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:58:44 +0100 Subject: [PATCH 005/105] test(pdf-codec): close sfnt.ts's remaining coverage and equivalent-mutant gaps buildCoverageFormat2 had no direct test at all, leaving every field write and the running coverageIndex accumulation across ranges unverified. Add a test with two ranges asserting the second range's coverage index carries the first range's real glyph count forward. buildFormat0's explicit view.setUint16(0, 0) wrote a value the buffer already held from Uint8Array's own zero-initialization -- removing the call changes nothing observable, so delete it rather than leave a mutation target with no real behaviour to test. putSequenceRuleTail's return value was unused by its one caller, leaving the arithmetic that computed it untestable by construction. Inline it into buildSequenceRuleBytes (its only caller) and drop the dead return entirely, since a "shared" tail with exactly one caller was never actually shared. Strengthen the markFilteringSet lookup tests to check the lookup's own byte length and the untouched subtable content, not just the recorded offsets -- a wrongly-forced markFilteringSetWidth can leave the recorded offsets self-consistent while still corrupting or mis-sizing the bytes that follow. Also assert feature 0's own lookupIndices values in the multi-feature layout test, previously only checked for feature 1. --- .../pdf-codec/src/test-support/sfnt.test.ts | 32 +++++++++++++++++++ packages/pdf-codec/src/test-support/sfnt.ts | 31 ++++++------------ 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/packages/pdf-codec/src/test-support/sfnt.test.ts b/packages/pdf-codec/src/test-support/sfnt.test.ts index 387d9fc4b..ec6c5e804 100644 --- a/packages/pdf-codec/src/test-support/sfnt.test.ts +++ b/packages/pdf-codec/src/test-support/sfnt.test.ts @@ -7,6 +7,7 @@ import { buildContextFormat1, buildContextFormat2, buildCoverageFormat1, + buildCoverageFormat2, buildFormat3Subtable, buildGdefTable, buildGsubTable, @@ -229,6 +230,24 @@ describe("buildCoverageFormat1", () => { }); }); +describe("buildCoverageFormat2", () => { + it("writes each range's start/end and accumulates the running coverage index across multiple ranges", () => { + const table = buildCoverageFormat2([ + [10, 12], + [20, 22], + ]); + expect(u16(table, 0)).toBe(2); + expect(u16(table, 2)).toBe(2); + expect(u16(table, 4)).toBe(10); + expect(u16(table, 6)).toBe(12); + expect(u16(table, 8)).toBe(0); // first range's own coverage index + expect(u16(table, 10)).toBe(20); + expect(u16(table, 12)).toBe(22); + // second range's coverage index carries the FIRST range's real glyph count (12-10+1=3), not 0 and not some other arithmetic combination + expect(u16(table, 14)).toBe(3); + }); +}); + describe("buildSingleSubstFormat2", () => { it("sorts mappings by covered glyph and places substitutes at the matching index", () => { const table = buildSingleSubstFormat2([ @@ -462,6 +481,10 @@ describe("buildGsubTable", () => { const feature1TableAt = featureListAt + u16(table, featureListAt + 8 + 4); // feature 0's table is 4 + 3*2 = 10 bytes; feature 1's must start exactly after it. expect(feature1TableAt - feature0TableAt).toBe(10); + // feature 0's own three lookupIndices, each at its own 2-byte slot + expect(u16(table, feature0TableAt + 4)).toBe(0); + expect(u16(table, feature0TableAt + 6)).toBe(1); + expect(u16(table, feature0TableAt + 8)).toBe(2); expect(u16(table, feature1TableAt)).toBe(0); expect(u16(table, feature1TableAt + 2)).toBe(1); expect(u16(table, feature1TableAt + 4)).toBe(3); @@ -492,6 +515,11 @@ describe("buildGsubTable", () => { const subtableOffset = u16(table, lookupAt + 6); // With no markFilteringSet slot, the one subtable starts right after the 6-byte header + one offset slot. expect(subtableOffset).toBe(8); + // No reserved slot means the table ends right after the subtable's own bytes, and those bytes must be exactly the input, not overwritten by a wrongly-reserved slot. + expect(table.length - lookupAt).toBe(10); + expect([ + ...table.slice(lookupAt + subtableOffset, lookupAt + subtableOffset + 2), + ]).toEqual([9, 9]); }); it("omits the markFilteringSet slot when the flag is set but does not select useMarkFilteringSet", () => { @@ -504,6 +532,10 @@ describe("buildGsubTable", () => { expect(u16(table, lookupAt + 2)).toBe(0x0008); const subtableOffset = u16(table, lookupAt + 6); expect(subtableOffset).toBe(8); + expect(table.length - lookupAt).toBe(10); + expect([ + ...table.slice(lookupAt + subtableOffset, lookupAt + subtableOffset + 2), + ]).toEqual([9, 9]); }); it("writes the markFilteringSet slot, at the right offset, only when the flag selects useMarkFilteringSet", () => { diff --git a/packages/pdf-codec/src/test-support/sfnt.ts b/packages/pdf-codec/src/test-support/sfnt.ts index 08a094ce9..e2f3aab9b 100644 --- a/packages/pdf-codec/src/test-support/sfnt.ts +++ b/packages/pdf-codec/src/test-support/sfnt.ts @@ -41,7 +41,7 @@ export interface CmapSubtableSpec { function buildFormat0(mappings: ReadonlyMap): Uint8Array { const subtable = new Uint8Array(262); const view = new DataView(subtable.buffer); - view.setUint16(0, 0); + // The format field (offset 0) is already 0 from Uint8Array's own zero-initialization -- format 0 is the one subtable format whose own numeric value needs no explicit write. view.setUint16(2, subtable.length); for (const [code, glyphId] of mappings) { subtable[6 + code] = glyphId; @@ -344,32 +344,21 @@ function buildRecords( return bytes; } -// Writes a SequenceRule's shared tail -- input count+values (glyphCount includes the implied first glyph, so the caller lists only the components after it), then substCount+records -- starting at `at` in `table`, returning the offset just past the last byte written. -function putSequenceRuleTail( - table: TableBuilder, - at: number, - input: readonly number[], - records: readonly GsubRecordSpec[], -): number { - table.setU16(at, input.length + 1); - let cursor = at + 2; - input.forEach((value) => { - table.setU16(cursor, value); - cursor += 2; - }); - table.setU16(cursor, records.length); - table.put(cursor + 2, buildRecords(records)); - return cursor + 2 + records.length * 4; -} - -// A plain (non-chaining) SequenceRule body: the Contextual Substitution format carries no backtrack or lookahead fields at all, so this writes only what format 1/2 lookups ever need. +// A plain (non-chaining) SequenceRule body: glyphCount+input (glyphCount includes the implied first glyph, so the caller lists only the components after it), then substCount+records. The Contextual Substitution format carries no backtrack or lookahead fields at all, so this writes only what format 1/2 lookups ever need. function buildSequenceRuleBytes( rule: { readonly input: readonly number[] }, records: readonly GsubRecordSpec[], ): Uint8Array { const words = 1 + rule.input.length + 1 + records.length * 2; const table = new TableBuilder(words * 2); - putSequenceRuleTail(table, 0, rule.input, records); + table.setU16(0, rule.input.length + 1); + let cursor = 2; + rule.input.forEach((value) => { + table.setU16(cursor, value); + cursor += 2; + }); + table.setU16(cursor, records.length); + table.put(cursor + 2, buildRecords(records)); return table.bytes; } From b10b2ed6f1544ee569ed6be81ff98a9f9fbc4829 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:11 +0100 Subject: [PATCH 006/105] refactor(pdf-codec): build rc4's initial state array by index-mapping The KSA's state[i] = i loop bounded i < STATE_SIZE, but a typed array silently drops an out-of-range integer-index write -- state[256] = 256 on a 256-entry Uint8Array is a no-op -- so a loop bound mutated to i <= STATE_SIZE produced byte-for-byte the same state array, an equivalent mutant no test could ever distinguish. Uint8Array.from's own length argument builds the identical array with no comparison operator for a mutation to target. --- packages/pdf-codec/src/crypto/rc4.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/pdf-codec/src/crypto/rc4.ts b/packages/pdf-codec/src/crypto/rc4.ts index 6e6ff489d..6be3725e8 100644 --- a/packages/pdf-codec/src/crypto/rc4.ts +++ b/packages/pdf-codec/src/crypto/rc4.ts @@ -8,10 +8,8 @@ export function rc4( key: Uint8Array, data: Uint8Array, ): Uint8Array { - const state = new Uint8Array(STATE_SIZE); - for (let i = 0; i < STATE_SIZE; i++) { - state[i] = i; - } + // Built by index-mapping rather than a counted for-loop: a typed array silently drops an out-of-range integer-index write, so a loop bound of `i <= STATE_SIZE` here would produce byte-for-byte the same 256-entry state array as `i < STATE_SIZE` -- an equivalent mutant no test could ever distinguish. Uint8Array.from's own length argument leaves no comparison operator for a mutation to target at all. + const state = Uint8Array.from({ length: STATE_SIZE }, (_, i) => i); // Key-scheduling algorithm. A zero-length key would divide by zero on the modulo below; there is no meaningful RC4 keystream for one, so the input is returned untouched rather than producing garbage under a fabricated key. if (key.length === 0) { return Uint8Array.from(data); From edfa25068ea34b1b9c088c2836431cf95cd1cfc8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:19 +0100 Subject: [PATCH 007/105] refactor(pdf-codec): drop padBigEndian's unreachable early-exit guard The big-endian bit-length loop stopped early once bitLength reached 0, via `i < lengthBytes && bitLength > 0`. padded is already zero-filled, so writing 0 % 256 into the remaining length-field bytes is a no-op, and a JS number's own 2^53 precision ceiling never needs more than 7 of SHA-256's 8 (or SHA-512's 16) length bytes to represent -- no reachable message ever runs the loop far enough for the bitLength > 0 half of the guard to be what stops it. Drop it and let the loop run its full lengthBytes iterations unconditionally. --- packages/pdf-codec/src/crypto/sha2.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/crypto/sha2.ts b/packages/pdf-codec/src/crypto/sha2.ts index 61e102dfb..02ba1168e 100644 --- a/packages/pdf-codec/src/crypto/sha2.ts +++ b/packages/pdf-codec/src/crypto/sha2.ts @@ -134,8 +134,9 @@ function padBigEndian( const padded = new Uint8Array(paddedLength); padded.set(bytes); padded[bytes.length] = 0x80; + // No early exit once bitLength reaches 0: `padded` is already zero-filled, so writing `0 % 256` into the remaining length-field bytes is a no-op, and a message's bit length only ever needs a handful of these `lengthBytes` slots (a JS number's own 2^53 precision ceiling needs at most 7 bytes to represent, well inside SHA-256's 8 and SHA-512's 16) -- realistically never enough real iterations for a `&& bitLength > 0` guard to be the thing that stops this loop, which is exactly the kind of unobservable boundary an equivalent mutant lives in. let bitLength = bytes.length * 8; - for (let i = 0; i < lengthBytes && bitLength > 0; i++) { + for (let i = 0; i < lengthBytes; i++) { padded[paddedLength - 1 - i] = bitLength % 256; bitLength = Math.floor(bitLength / 256); } From 35fd1ec8fb3fc48fef1786b4168a2008e3b32d51 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:28 +0100 Subject: [PATCH 008/105] refactor(pdf-codec): remove formatNumber's unreachable -0 normalisation Every magnitude that could round to the literal string "-0" at NUMBER_DECIMAL_PLACES -- including -0 itself -- already satisfies abs(n) < NUMBER_EPSILON and returns "0" from the guard above, since NUMBER_EPSILON is exactly one unit in the last of those decimal places. toFixed can only produce "-0.0000" for a magnitude below half that unit, which is caught by the same guard. The ternary comparing the stripped string against "-0" was therefore dead code no input could ever reach. --- packages/pdf-codec/src/serialize.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/serialize.ts b/packages/pdf-codec/src/serialize.ts index bd403034d..51e4faa8b 100644 --- a/packages/pdf-codec/src/serialize.ts +++ b/packages/pdf-codec/src/serialize.ts @@ -7,6 +7,7 @@ const NUMBER_DECIMAL_PLACES = 4; const NUMBER_EPSILON = 10 ** -NUMBER_DECIMAL_PLACES; export function formatNumber(n: number): string { + // Every magnitude that would ever round to "-0" at NUMBER_DECIMAL_PLACES (including -0 itself) already satisfies `abs(n) < NUMBER_EPSILON` above and returns "0" there, since NUMBER_EPSILON is exactly one unit in the last of those decimal places -- there is no reachable n for which toFixed still needs a separate "-0" normalisation below. if (Math.abs(n) < NUMBER_EPSILON) { return "0"; } @@ -14,7 +15,7 @@ export function formatNumber(n: number): string { if (formatted.includes(".")) { formatted = formatted.replace(/0+$/, "").replace(/\.$/, ""); } - return formatted === "-0" ? "0" : formatted; + return formatted; } const NAME_ESCAPE_PATTERN = /[^!-~]|[#()<>[\]{}/%]/; From da11c64e5587cb42c80bf8a79286aed1111be84a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:36 +0100 Subject: [PATCH 009/105] refactor(pdf-codec): narrow applyEncryptMethod off the unused identity method buildEncryptor's own method parameter is already narrowed to Extract (every SCHEME_SPECS entry only ever carries one of those two), so the "identity" branch inside applyEncryptMethod could never be reached through any real call path in this module. Narrow its parameter to match and delete the dead branch, rather than leave a comparison no test could ever exercise. --- packages/pdf-codec/src/encrypt-write.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/pdf-codec/src/encrypt-write.ts b/packages/pdf-codec/src/encrypt-write.ts index 50399f1c9..119d1bdd0 100644 --- a/packages/pdf-codec/src/encrypt-write.ts +++ b/packages/pdf-codec/src/encrypt-write.ts @@ -208,17 +208,15 @@ function encryptAes( return concatBytes([iv, aesCbcEncrypt(key, iv, padded)]); } +// Narrowed to the two methods buildEncryptor itself is ever built for (every SCHEME_SPECS entry's own `method` is "rc4" or "aes") rather than the wider CipherMethod: an "identity" branch here would be dead code no real call path could ever reach, which is exactly the unreachable-branch shape a mutant survives untested. function applyEncryptMethod( - method: CipherMethod, + method: Extract, fileKey: Uint8Array, perObjectKeys: boolean, bytes: Uint8Array, num: number, gen: number, ): Uint8Array { - if (method === "identity") { - return bytes; - } const key = perObjectKeys ? objectKey(fileKey, num, gen, method) : fileKey; return method === "rc4" ? rc4(key, bytes) : encryptAes(key, bytes); } From 125297bf109b6a9f8f6d0f5bd18ca559d66002ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:44 +0100 Subject: [PATCH 010/105] refactor(pdf-codec): compare parities directly in the checker8 fixture isBlack computed (a + b) % 2 === 0 from a = x/2|0 and b = y/2|0. Sum and difference of two integers always share the same parity, so an ArithmeticOperator mutation to a - b produces byte-for-byte the same checkerboard no decoded bitmap could ever distinguish. Compare (a & 1) against (b & 1) directly instead, leaving no arithmetic operator for that mutation to target. --- packages/pdf-codec/src/test-support/ccitt-fax.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/test-support/ccitt-fax.ts b/packages/pdf-codec/src/test-support/ccitt-fax.ts index 1487c8141..27bbc4fb5 100644 --- a/packages/pdf-codec/src/test-support/ccitt-fax.ts +++ b/packages/pdf-codec/src/test-support/ccitt-fax.ts @@ -47,7 +47,8 @@ export const CCITT_FAX_FIXTURES: readonly CcittFaxFixture[] = [ name: "checker8", columns: 16, rows: 8, - isBlack: (x, y) => (((x / 2) | 0) + ((y / 2) | 0)) % 2 === 0, + // Same-parity check rather than "sum is even": a+b and a-b always share the same parity, so a `+` here would be an equivalent mutant under an ArithmeticOperator swap to `-` -- no bitmap this fixture ever produces could distinguish the two. Comparing parities directly leaves no arithmetic operator for that mutation to target. + isBlack: (x, y) => (((x / 2) | 0) & 1) === (((y / 2) | 0) & 1), encodings: { group4: "Jrl8vl//wwgggggv+EEEEEEEF/4YQQQQQX/ABABA", group3OneDimensional: From 3106f59a60f62bdfaac45b0780d4dca9d8421fde Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:53 +0100 Subject: [PATCH 011/105] refactor(pdf-codec): build jpeg2000FixtureSamples' planes by length-mapping The outer per-component loop bounded c < fixture.componentCount, but an off-by-one bound there would silently append one extra all-zero plane (every index inside it reads past the end of `bytes`, and `?? 0` swallows the resulting undefined) -- a difference visible only in the returned array's own length, which nothing calling this helper actually re-checks. Build the planes with Array.from's own length argument instead of a counted for-loop, removing the vulnerable comparison outright. --- packages/pdf-codec/src/test-support/jpeg2000.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/pdf-codec/src/test-support/jpeg2000.ts b/packages/pdf-codec/src/test-support/jpeg2000.ts index 3faa9cedc..e8b14e3ce 100644 --- a/packages/pdf-codec/src/test-support/jpeg2000.ts +++ b/packages/pdf-codec/src/test-support/jpeg2000.ts @@ -32,8 +32,8 @@ export function jpeg2000FixtureSamples(fixture: Jpeg2000Fixture): number[][] { const bytes = base64ToBytes(fixture.expected); const wide = fixture.bitDepth > 8; const perComponent = fixture.width * fixture.height; - const planes: number[][] = []; - for (let c = 0; c < fixture.componentCount; c++) { + // Built from fixture.componentCount via Array.from's length argument, not a counted for-loop: an off-by-one loop bound here would silently append one extra all-zero plane (every index inside it reads past the end of `bytes`, and `?? 0` swallows the resulting `undefined`), a difference visible only in the returned array's own length -- exactly the kind of boundary a comparison-operator mutant survives when nothing re-checks the plane count. + return Array.from({ length: fixture.componentCount }, (_, c) => { const plane: number[] = []; for (let i = 0; i < perComponent; i++) { const at = (c * perComponent + i) * (wide ? 2 : 1); @@ -43,9 +43,8 @@ export function jpeg2000FixtureSamples(fixture: Jpeg2000Fixture): number[][] { : (bytes[at] ?? 0), ); } - planes.push(plane); - } - return planes; + return plane; + }); } export const JPEG2000_FIXTURES: readonly Jpeg2000Fixture[] = [ From 29e4be1279a4a135004fb229b07e57d9d00272e1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:01 +0100 Subject: [PATCH 012/105] test(pdf-codec): assert throwIfAborted's DOMException name and message The existing test only checked the thrown value's constructor, leaving both string literals passed to the DOMException constructor unverified. --- packages/pdf-codec/src/util/abort.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/util/abort.test.ts b/packages/pdf-codec/src/util/abort.test.ts index 9449bf837..65ec081b7 100644 --- a/packages/pdf-codec/src/util/abort.test.ts +++ b/packages/pdf-codec/src/util/abort.test.ts @@ -18,8 +18,13 @@ describe("throwIfAborted", () => { it("throws an AbortError DOMException once the signal is aborted", () => { const controller = new AbortController(); controller.abort(); - expect(() => { + try { throwIfAborted(controller.signal); - }).toThrow(DOMException); + expect.unreachable("throwIfAborted did not throw"); + } catch (error) { + expect(error).toBeInstanceOf(DOMException); + expect((error as DOMException).name).toBe("AbortError"); + expect((error as DOMException).message).toBe("Aborted"); + } }); }); From a820ea7edb67f519f9edb09ed80c69ad0a237c55 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:11 +0100 Subject: [PATCH 013/105] test(pdf-codec): reject a non-1 major version whose body parses cleanly The existing CFF2 case (header size 5, no valid Name INDEX past it) also fails for reasons unrelated to the majorVersion check, so removing that check entirely left the test still passing. Add a case with majorVersion 2 but an otherwise valid CFF 1.0 layout -- headerSize 4, a readable Name INDEX, a plain Top DICT -- where the version check is the only thing standing between it and a wrongly-defined probe result. --- packages/pdf-codec/src/cff-probe.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/pdf-codec/src/cff-probe.test.ts b/packages/pdf-codec/src/cff-probe.test.ts index 7957d8d28..2b9ed4931 100644 --- a/packages/pdf-codec/src/cff-probe.test.ts +++ b/packages/pdf-codec/src/cff-probe.test.ts @@ -83,6 +83,14 @@ describe("CFF programs probeCff refuses to read", () => { ).toBeUndefined(); }); + it("returns undefined for a major version other than 1 even when the rest of the program parses cleanly", () => { + // A header claiming major version 2 (CFF2's own major version) but otherwise laid out exactly like a valid CFF 1.0 program -- headerSize 4, a readable Name INDEX and a plain, non-CID Top DICT. Nothing past the header rejects this input, so the majorVersion check is the only thing standing between it and a wrongly-defined probe result. + const topDict = [139, 0, 250, 0x00, 12, 0, 29, 0x00, 0x00, 0x01, 0x00, 17]; + expect( + probeCff(cffFont("WrongMajorVersion", topDict, [2, 0, 4, 1])), + ).toBeUndefined(); + }); + it("returns undefined for a header declaring a size smaller than a header can be", () => { expect( probeCff(cffFont("ShortHeader", [139, 0], [1, 0, 2, 1])), From 8a202207c081c60a2a924efefd8ecad651485fb2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:20 +0100 Subject: [PATCH 014/105] test(pdf-codec): refuse a font whose hhea declares zero horizontal metrics parseHhea's numberOfHMetrics === 0 guard had no test forcing it: every existing case used a real font whose hhea always declares at least one metric. Patch Carlito's own hhea table directly (a new patchU16InTable helper alongside the existing dropTable/truncateTable) to zero that field and confirm loadEmbeddedFace refuses the font. --- packages/pdf-codec/src/embedded-font.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/pdf-codec/src/embedded-font.test.ts b/packages/pdf-codec/src/embedded-font.test.ts index 672b20724..145ca0854 100644 --- a/packages/pdf-codec/src/embedded-font.test.ts +++ b/packages/pdf-codec/src/embedded-font.test.ts @@ -150,6 +150,13 @@ describe("loadEmbeddedFace caching and refusal", () => { expect(loadEmbeddedFace(font!)).toBeUndefined(); }); + it("refuses a font whose hhea declares zero horizontal metrics, which hmtx has none of to bound", () => { + const patched = new Uint8Array(carlitoRegularBytes()); + patchU16InTable(patched, "hhea", 34, 0); // numberOfHMetrics + const font = parseSfnt(patched); + expect(loadEmbeddedFace(font!)).toBeUndefined(); + }); + it("measures a cap height off the H glyph when OS/2 does not declare one", () => { // Carlito's own 'OS/2' is version 3 and does declare sCapHeight; dropping the table entirely leaves the outline of 'H' as the only thing in the font that still states its cap height, which is exactly what that FontDescriptor field means. const patched = new Uint8Array(carlitoRegularBytes()); @@ -395,3 +402,15 @@ function truncateTable( length, ); } + +// Overwrites one big-endian uint16 field inside a table's own body, at `tableOffset` bytes from where that table's data starts (not from the record itself) -- for patching a single declared field (a metric count, a flag) without disturbing the rest of a real vendored table. +function patchU16InTable( + bytes: Uint8Array, + tag: string, + tableOffset: number, + value: number, +): void { + const view = new DataView(bytes.buffer); + const tableStart = view.getUint32(tableRecordOffset(bytes, tag) + 8); + view.setUint16(tableStart + tableOffset, value); +} From 5cfa2fcb6d2be97a54ca3f5cc6ded6b08ce17ba2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:29 +0100 Subject: [PATCH 015/105] test(pdf-codec): cover encodeCcittFax's degenerate-geometry guard Nothing exercised the columns <= 0 || rows <= 0 early return, including the negative-rows case, which the ||-composed guard treats identically to zero. --- .../pdf-codec/src/image/ccitt-encode.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/pdf-codec/src/image/ccitt-encode.test.ts b/packages/pdf-codec/src/image/ccitt-encode.test.ts index 9d8cd261c..e8afe1335 100644 --- a/packages/pdf-codec/src/image/ccitt-encode.test.ts +++ b/packages/pdf-codec/src/image/ccitt-encode.test.ts @@ -45,6 +45,26 @@ function realPixelBytes(bytes: Uint8Array, columns: number, rowsCount: number) { return out; } +describe("encodeCcittFax: degenerate geometry", () => { + it("returns an empty stream for zero columns rather than encoding a nonsensical width", () => { + const encoded = encodeCcittFax(new Uint8Array(0), { columns: 0, rows: 4 }); + expect(encoded).toEqual(new Uint8Array(0)); + }); + + it("returns an empty stream for zero rows rather than encoding a nonsensical height", () => { + const encoded = encodeCcittFax(new Uint8Array(0), { columns: 8, rows: 0 }); + expect(encoded).toEqual(new Uint8Array(0)); + }); + + it("returns an empty stream for a negative row count", () => { + const encoded = encodeCcittFax(new Uint8Array(0), { + columns: 8, + rows: -1, + }); + expect(encoded).toEqual(new Uint8Array(0)); + }); +}); + describe("encodeCcittFax: exact bit strings", () => { it("codes an all-white row pair as one vertical-mode bit per row", () => { // Line 0 against the imaginary all-white reference: a1 = b1 = the sentinel columns, delta 0, so one "1" bit. Line 1 is identical against line 0. Two rows of one bit each = "11", zero-padded to 0xC0. From a3037b1e560ab9089133a5f89c9ae078d3ae63ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:37 +0100 Subject: [PATCH 016/105] test(pdf-codec): round-trip a form array through LayoutDocumentSchema The round-trip fixture never included a form field, leaving LayoutFormFieldSchema's own z.enum(fieldType) array (and every other field on the schema) unparsed by any test in this file. Add a text field and a group with one nested checkbox child to the fixture. --- packages/pdf-codec/src/layout.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/pdf-codec/src/layout.test.ts b/packages/pdf-codec/src/layout.test.ts index f3f29bec0..c68fec880 100644 --- a/packages/pdf-codec/src/layout.test.ts +++ b/packages/pdf-codec/src/layout.test.ts @@ -234,6 +234,33 @@ function layoutDocument(): LayoutDocument { logo: { format: "png", base64: "AA==", widthPx: 32, heightPx: 32 }, photo: { format: "jpeg", base64: "/9k=", widthPx: 1024, heightPx: 768 }, }, + form: [ + { + name: "author", + fieldType: "text", + value: "Jane Doe", + widgets: [ + { pageIndex: 0, xPt: 72, yPt: 700, widthPt: 200, heightPt: 18 }, + ], + children: [], + }, + { + name: "options", + fieldType: "group", + widgets: [], + children: [ + { + name: "options.subscribe", + fieldType: "checkbox", + checked: true, + widgets: [ + { pageIndex: 0, xPt: 72, yPt: 660, widthPt: 12, heightPt: 12 }, + ], + children: [], + }, + ], + }, + ], }; } From e2f0fc02a2f8f42ff3073c5a11e51a97a7ba9b6d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:46 +0100 Subject: [PATCH 017/105] test(pdf-codec): cover deflate's level option and inflate's size guard Neither had a test: no call ever passed an explicit level to deflate (so the { level } object literal it builds had no coverage), and MAX_INFLATE_OUTPUT_BYTES's throw was unreached by any real input. Mock unzlibSync's return value for the size-guard case rather than actually decompressing half a gigabyte on every one of this suite's mutation runs -- the guard only ever reads the result's .length. --- packages/pdf-codec/src/bytes/flate.test.ts | 37 ++++++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/pdf-codec/src/bytes/flate.test.ts b/packages/pdf-codec/src/bytes/flate.test.ts index 7795c9994..ad8c91b25 100644 --- a/packages/pdf-codec/src/bytes/flate.test.ts +++ b/packages/pdf-codec/src/bytes/flate.test.ts @@ -1,6 +1,17 @@ -import { deflateSync } from "fflate"; -import { describe, expect, it } from "vitest"; -import { deflate, inflate, inflateTolerant } from "./flate"; +import type * as Fflate from "fflate"; +import { deflateSync, unzlibSync } from "fflate"; +import { describe, expect, it, vi } from "vitest"; +import { + MAX_INFLATE_OUTPUT_BYTES, + deflate, + inflate, + inflateTolerant, +} from "./flate"; + +vi.mock("fflate", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, unzlibSync: vi.fn(actual.unzlibSync) }; +}); const sample = new TextEncoder().encode( "the quick brown fox jumps over the lazy dog, ".repeat(20), @@ -22,6 +33,26 @@ describe("deflate / inflate", () => { expect(compressed[0]! & 0x0f).toBe(8); expect(((compressed[0]! << 8) + compressed[1]!) % 31).toBe(0); }); + + it("an explicit level is actually passed through to zlibSync, not discarded", () => { + // Level 0 is stored (no compression), so it round-trips correctly but produces output far larger than the default level's compressed size for this same, highly repetitive sample -- a difference only observable if the level option genuinely reaches zlibSync rather than being dropped. + const stored = deflate(sample, 0); + const defaultLevel = deflate(sample); + expect(stored.length).toBeGreaterThan(defaultLevel.length); + expect(inflate(stored)).toEqual(sample); + }); +}); + +describe("inflate's output-size guard", () => { + it("rejects a decompressed output over the configured byte limit", () => { + // unzlibSync itself is mocked here rather than actually decompressing half a gigabyte: the guard only reads `.length`, and driving hundreds of megabytes of real (de)compression through every one of this package's mutation-tested mutants would multiply the whole suite's runtime for no genuine coverage this fake object doesn't already provide. + vi.mocked(unzlibSync).mockReturnValueOnce({ + length: MAX_INFLATE_OUTPUT_BYTES + 1, + } as unknown as ReturnType); + expect(() => inflate(new Uint8Array())).toThrow( + `inflated output exceeds the ${MAX_INFLATE_OUTPUT_BYTES}-byte limit`, + ); + }); }); describe("inflateTolerant", () => { From b519197b09bfccb14362be4a879ed7e64f7181f6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:55 +0100 Subject: [PATCH 018/105] test(pdf-codec): warn on a filespec whose /EF has no /F or /UF stream readFilespec's own missing-stream warning had no test: every existing filespec fixture either resolved a real embedded stream or never declared /EF at all. Add a catalog /AF entry whose /EF resolves to an empty dict, and assert both the dropped attachment and the emitted diagnostic. --- packages/pdf-codec/src/attachments.test.ts | 19 +++++++++++++++++++ packages/pdf-codec/src/test-support/pdf.ts | 6 ++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/attachments.test.ts b/packages/pdf-codec/src/attachments.test.ts index 00054d62a..68de34ae4 100644 --- a/packages/pdf-codec/src/attachments.test.ts +++ b/packages/pdf-codec/src/attachments.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { PdfDiagnostic } from "./diagnostics"; import { readPdf } from "./read"; import { embeddedFilesPdf } from "./test-support/pdf"; import { bytesToBase64 } from "./util/base64"; @@ -30,4 +31,22 @@ describe("readPdf: embedded files", () => { const manifest = doc.attachments?.find((a) => a.name === "manifest.json"); expect(manifest?.description).toBeUndefined(); }); + + it("warns on and drops a filespec whose /EF resolves but has neither an /F nor a /UF stream", () => { + const diagnostics: PdfDiagnostic[] = []; + const doc = readPdf(embeddedFilesPdf(), { + sink: (d) => diagnostics.push(d), + }); + expect( + doc.attachments?.find((a) => a.name === "broken.bin"), + ).toBeUndefined(); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "pdf/embedded-file-missing-stream", + severity: "warning", + message: + "a filespec declares /EF but neither /F nor /UF resolves to an embedded stream", + }), + ); + }); }); diff --git a/packages/pdf-codec/src/test-support/pdf.ts b/packages/pdf-codec/src/test-support/pdf.ts index 4a14800e0..6e6263fb5 100644 --- a/packages/pdf-codec/src/test-support/pdf.ts +++ b/packages/pdf-codec/src/test-support/pdf.ts @@ -451,7 +451,7 @@ export function embeddedFilesPdf(): Uint8Array { const b = new FixtureBuilder().header("1.7"); b.object( 1, - "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 6 0 R >> /AF [13 0 R] >>", + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 6 0 R >> /AF [13 0 R 16 0 R] >>", ); b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); b.object( @@ -497,7 +497,9 @@ export function embeddedFilesPdf(): Uint8Array { "<< /Type /EmbeddedFile /Filter /FlateDecode >>", zlibSync(enc("{}")), ); - b.classicXrefAndTrailer(15, "/Root 1 0 R"); + // A catalog /AF entry whose /EF resolves but carries neither an /F nor a /UF stream reference -- the one shape readAttachments contributes nothing for, and warns about, rather than an external/referenced filespec that never declares /EF at all. + b.object(16, "<< /Type /Filespec /F (broken.bin) /EF << >> >>"); + b.classicXrefAndTrailer(16, "/Root 1 0 R"); return b.bytes(); } From 2c6c08b012f8cbf24d3617090669b81d3a38091b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:09:30 +0100 Subject: [PATCH 019/105] refactor(pdf-codec): dedupe pdf.ts fixture builder's boilerplate literals Four literals -- the empty stream dict "<< >>", marked-content "EMC", PDF version "1.4", and the Helvetica font dict -- were each retyped verbatim at every one of dozens of call sites across independent fixture functions. Since every copy is its own separate string literal to the type checker (and to Stryker's mutation testing, its own separate mutation target), a single fixture author typo in any one copy would silently diverge from the rest with nothing to catch it. Name each one once (EMPTY_DICT, EMC, PDF_1_4, HELVETICA_FONT_DICT) and reference it everywhere it recurred, matching the existing HELLO_CONTENT constant's own pattern. Also drop the redundant explicit .header("1.7") argument at call sites that were only ever restating FixtureBuilder.header's own default value. --- packages/pdf-codec/src/test-support/pdf.ts | 193 +++++++++++---------- 1 file changed, 100 insertions(+), 93 deletions(-) diff --git a/packages/pdf-codec/src/test-support/pdf.ts b/packages/pdf-codec/src/test-support/pdf.ts index 6e6263fb5..2d14416d0 100644 --- a/packages/pdf-codec/src/test-support/pdf.ts +++ b/packages/pdf-codec/src/test-support/pdf.ts @@ -9,6 +9,13 @@ function enc(text: string): Uint8Array { return new TextEncoder().encode(text); } +// Boilerplate literals shared verbatim across many otherwise-independent fixtures below -- named once so each is one auditable spelling (and one mutation target) rather than a duplicate the reader has to trust is identical everywhere it recurs. +const EMPTY_DICT = "<< >>"; // a stream's own dict when it carries no entries beyond the /Length this file's stream() inserts automatically +const EMC = "EMC"; // marked-content end (ISO 32000-1 14.6): closes whichever BMC/BDC opened the span +const PDF_1_4 = "1.4"; +const HELVETICA_FONT_DICT = + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; + // Tracks byte offsets as objects are appended, purely by recording ByteWriter's own running length before each write -- the same mechanical idea src/pdf/write.ts uses, reimplemented independently here rather than shared with it. class FixtureBuilder { private readonly writer = new ByteWriter(); @@ -99,14 +106,14 @@ function catalogPagesPageFontObjects( 3, `<< /Type /Page /Parent 2 0 R /MediaBox ${mediaBox} /Resources << /Font << /F1 4 0 R >> >> /Contents ${contentObjNum} 0 R ${extraPageEntries}>>`, ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.object(4, HELVETICA_FONT_DICT); } // A minimal, structurally ordinary PDF: classic xref table, a literal (parenthesized) content-stream string -- the OTHER string form our own writer never emits (it always emits hex strings), so a fixture using this form specifically exercises the parser's literal-string handling rather than only round-tripping what our own writer happens to produce. export function minimalClassicXrefPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); b.classicXrefAndTrailer(5, "/Root 1 0 R"); return b.bytes(); } @@ -115,16 +122,16 @@ export function minimalClassicXrefPdf(): Uint8Array { export function bTetTextStatePersistencePdf(): Uint8Array { const content = "BT /F1 12 Tf 10 80 Td (First line) Tj ET BT 10 60 Td (Second line) Tj ET"; - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5); - b.stream(5, "<< >>", enc(content)); + b.stream(5, EMPTY_DICT, enc(content)); b.classicXrefAndTrailer(5, "/Root 1 0 R"); return b.bytes(); } // A structurally valid document with an empty page tree: the page loop over doc.pages() has zero iterations, so a signal whose only check lives inside that loop would never be consulted -- the abort-contract gap this fixture exists to hold closed. export function pagelessPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>"); b.classicXrefAndTrailer(2, "/Root 1 0 R"); @@ -133,16 +140,16 @@ export function pagelessPdf(): Uint8Array { // A two-page document whose FIRST page has no /Resources dict (a deterministic, per-page-1 recoverable warning through the sink) and whose second page carries ordinary text content. Reading it with a signal that the sink aborts on page 1's warning distinguishes "the page loop checks between pages" (throws before page 2 is ever interpreted) from "the signal is only consulted once up front" (returns normally after reading both). export function twoPagesFirstWithoutResourcesPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>"); b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >>"); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.object(4, HELVETICA_FONT_DICT); b.object( 5, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 6 0 R >>", ); - b.stream(6, "<< >>", enc(HELLO_CONTENT)); + b.stream(6, EMPTY_DICT, enc(HELLO_CONTENT)); b.classicXrefAndTrailer(6, "/Root 1 0 R"); return b.bytes(); } @@ -178,7 +185,7 @@ export function xrefStreamWithObjectStreamPdf(): Uint8Array { `<< /Type /ObjStm /N ${entries.length} /First ${header.length + 1} /Filter /FlateDecode >>`, objStmCompressed, ); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); // /W [1 4 2]: 1-byte type, 4-byte second field, 2-byte third field -- type 2 (compressed) rows store the containing ObjStm's object number and the index within it; type 1 (uncompressed) rows store a plain byte offset and generation. const rows: number[][] = [ @@ -219,18 +226,18 @@ function be4(n: number): [number, number, number, number] { // startxref points at a nonsense offset -- the parser must fall back to a linear scan for "N G obj" patterns to rebuild the xref table from scratch, then raise a recovery diagnostic rather than failing outright. export function brokenStartxrefPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); b.raw(`trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n999999\n%%EOF`); return b.bytes(); } // A first revision followed by an incremental update: object 3 (the Page) is redefined by a second, later xref section chained via /Prev to the first. A reader must walk /Prev newest-first and take the FIRST definition of each object number it encounters (the later revision), while objects the second revision doesn't touch (1, 2, 4, 5) still resolve through the original section. export function incrementalUpdatePdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5, "[0 0 200 100]"); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); const firstXrefOffset = b.length; b.raw("xref\n0 6\n"); b.raw("0000000000 65535 f \n"); @@ -257,9 +264,9 @@ export function incrementalUpdatePdf(): Uint8Array { // /Encrypt present, naming a security handler other than /Standard (this one is the public-key handler, the only other one ISO 32000-1 defines). Nothing derived from a password can open it, so readPdf must say so with a clear PdfEncryptedError rather than a generic parse failure -- distinct from a /Standard-handler file that merely needs a password, which src/test-support/encrypted-pdfs.ts covers with real qpdf-encrypted bytes. export function unsupportedSecurityHandlerPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); b.object( 6, "<< /Filter /Adobe.PubSec /SubFilter /adbe.pkcs7.s5 /V 4 /R 4 >>", @@ -270,9 +277,9 @@ export function unsupportedSecurityHandlerPdf(): Uint8Array { // A page rotated 90 degrees clockwise (/Rotate, ISO 32000-1's own page-rotation attribute -- distinct from any content-stream rotation matrix). export function rotatedPagePdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5, "[0 0 200 100]", "/Rotate 90 "); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); b.classicXrefAndTrailer(5, "/Root 1 0 R"); return b.bytes(); } @@ -282,7 +289,7 @@ export function symbolFontProgramPdf( program: Uint8Array, code: number, ): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); b.object( @@ -297,10 +304,10 @@ export function symbolFontProgramPdf( 5, "<< /Type /FontDescriptor /FontName /CIDFont+F3 /Flags 4 /FontFile2 6 0 R >>", ); - b.stream(6, "<< >>", program); + b.stream(6, EMPTY_DICT, program); b.stream( 7, - "<< >>", + EMPTY_DICT, enc(`BT /F1 12 Tf 10 50 Td <${code.toString(16).padStart(2, "0")}> Tj ET`), ); b.classicXrefAndTrailer(7, "/Root 1 0 R"); @@ -309,25 +316,25 @@ export function symbolFontProgramPdf( // A /MediaBox whose origin isn't (0,0) -- our own writer never produces one (see write.ts's own module doc), but real producers occasionally do; placement must be computed relative to the MediaBox's own origin, not assumed to be (0,0). The text sits at (60, 60), inside the box, so it survives the crop-box visibility filter (the box IS the visible region even without a declared /CropBox). export function nonZeroOriginMediaBoxPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5, "[50 50 250 150]"); - b.stream(5, "<< >>", enc("BT /F1 12 Tf 60 60 Td (Hello) Tj ET")); + b.stream(5, EMPTY_DICT, enc("BT /F1 12 Tf 60 60 Td (Hello) Tj ET")); b.classicXrefAndTrailer(5, "/Root 1 0 R"); return b.bytes(); } // A page whose content invokes a form XObject (/Subtype /Form) -- common output from LibreOffice and other producers that wrap page content in a reusable form. The interpreter must recurse into it, composing the form's own /Matrix into the CTM. export function formXObjectPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); b.object( 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> /XObject << /Fm1 6 0 R >> >> /Contents 5 0 R >>", ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.object(4, HELVETICA_FONT_DICT); const pageContent = "q 1 0 0 1 20 20 cm /Fm1 Do Q"; - b.stream(5, "<< >>", enc(pageContent)); + b.stream(5, EMPTY_DICT, enc(pageContent)); const formContent = "BT /F1 12 Tf 0 0 Td (In a form) Tj ET"; b.stream( 6, @@ -340,7 +347,7 @@ export function formXObjectPdf(): Uint8Array { // A content stream using the inline-image form (BI ... ID EI) rather than a full Image XObject -- its end must be located by scanning for EI (no /Length is available for inline images), which is a distinct, easy-to-desynchronize code path from the XObject case. export function inlineImagePdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5); const pixelData = new Uint8Array([ 255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0, @@ -349,14 +356,14 @@ export function inlineImagePdf(): Uint8Array { writer.writeAscii("q 100 0 0 100 10 0 cm BI /W 2 /H 2 /CS /RGB /BPC 8 ID "); writer.writeBytes(pixelData); writer.writeAscii(" EI Q"); - b.stream(5, "<< >>", writer.toBytes()); + b.stream(5, EMPTY_DICT, writer.toBytes()); b.classicXrefAndTrailer(5, "/Root 1 0 R"); return b.bytes(); } // Two pages under a Pages node that itself carries /MediaBox and /Resources -- neither Page defines them directly, so a reader must inherit both down from the Pages node (ISO 32000-1 7.7.3.4, Table 30). The second page additionally sets its own /Rotate, which an inheriting reader must not overwrite with any (here absent) inherited value. export function inheritedPageAttributesPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); b.object( 2, @@ -364,17 +371,17 @@ export function inheritedPageAttributesPdf(): Uint8Array { ); b.object(3, "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>"); b.object(4, "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Rotate 90 >>"); - b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); - b.stream(6, "<< >>", enc(HELLO_CONTENT)); + b.object(5, HELVETICA_FONT_DICT); + b.stream(6, EMPTY_DICT, enc(HELLO_CONTENT)); b.classicXrefAndTrailer(6, "/Root 1 0 R"); return b.bytes(); } // A page with a hidden /Subtype /Text annotation NOT authored by documents.js's own writer (a different /T, as a real third-party tool's own sticky note would have) -- proves readPageNotes's /T-marker check genuinely discriminates our own notes annotation from someone else's, rather than treating every hidden Text annotation as recovered pptx notes. export function pdfWithForeignHiddenAnnotationPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5, "[0 0 200 100]", "/Annots [6 0 R] "); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); b.object( 6, "<< /Type /Annot /Subtype /Text /Rect [0 0 0 0] /Contents (A real reviewer note, not pptx speaker notes) /T (Some Other Tool) /F 2 >>", @@ -385,9 +392,9 @@ export function pdfWithForeignHiddenAnnotationPdf(): Uint8Array { // An /Info dict mixing the two real-world string encodings a reader must handle: /Title as UTF-16BE-with-BOM (our own writer's own convention, ISO 32000-1 7.9.2.2's "long form"), and /Author/Keywords as plain literal-string PDFDocEncoding (the common case for ASCII-only metadata most third-party producers emit). /CreationDate uses the PDF date format (ISO 32000-1 7.9.4) with an explicit UTC+02:00 offset. export function withInfoDictPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); + const b = new FixtureBuilder().header(PDF_1_4); catalogPagesPageFontObjects(b, 5); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); const titleHex = `feff${Array.from("Test Doc") .map((ch) => ch.charCodeAt(0).toString(16).padStart(4, "0")) .join("")}`; @@ -401,7 +408,7 @@ export function withInfoDictPdf(): Uint8Array { // A two-page document exercising the whole navigation cluster (#721's core): named destinations from BOTH the old-style catalog /Dests dictionary and a /Names /Dests name tree with a real /Kids split, a two-level document outline, and all three internal-link spellings on page 1 -- a /Dest naming a name-tree destination, a /Dest carrying a direct destination array, and a /A /GoTo action naming an old-style /Dests entry. Page 2 exists so pageIndex resolution is real, not a constant 0. export function navigationClusterPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object( 1, "<< /Type /Catalog /Pages 2 0 R /Dests 9 0 R /Names << /Dests 10 0 R >> /Outlines 11 0 R >>", @@ -415,8 +422,8 @@ export function navigationClusterPdf(): Uint8Array { 4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R >>", ); - b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); - b.stream(6, "<< >>", enc(HELLO_CONTENT)); + b.object(5, HELVETICA_FONT_DICT); + b.stream(6, EMPTY_DICT, enc(HELLO_CONTENT)); b.object( 7, "<< /Type /Annot /Subtype /Link /Rect [10 10 60 24] /Dest (second) >>", @@ -448,7 +455,7 @@ export function navigationClusterPdf(): Uint8Array { // The embedded-files cluster (#721 phase 2): a /Names /EmbeddedFiles name-tree entry whose stream carries /Subtype and whose filespec carries /Desc; a /FileAttachment annotation on the page with its own filespec plus a SECOND annotation whose filespec duplicates the name-tree entry's name (the dedup case); and a catalog /AF associated-files entry (ISO 32000-2). One of the streams is Flate-compressed so decoding goes through the ordinary filter path, and one is raw binary bytes with no /Subtype, pinning that mimeType is absent rather than guessed. export function embeddedFilesPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object( 1, "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 6 0 R >> /AF [13 0 R 16 0 R] >>", @@ -458,8 +465,8 @@ export function embeddedFilesPdf(): Uint8Array { 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /Annots [10 0 R 11 0 R] >>", ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.object(4, HELVETICA_FONT_DICT); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); // The name tree root, split through a /Kids node so the walker's recursion is exercised here too. b.object(6, "<< /Kids [7 0 R] >>"); b.object(7, "<< /Names [(notes.txt) 8 0 R] >>"); @@ -505,7 +512,7 @@ export function embeddedFilesPdf(): Uint8Array { // The optional-content cluster (#721 phase 3): two OCGs with the default configuration switching one OFF, a /OC BDC span in the named-property-list form, one in the inline-dict form carrying /ActualText, and two form XObjects -- one inheriting the outer span's layer, one declaring its own /OC (which wins for its items). export function ocgPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object( 1, "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R 7 0 R] /D << /BaseState /ON /OFF [6 0 R] >> >> >>", @@ -515,20 +522,20 @@ export function ocgPdf(): Uint8Array { 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> /Properties << /L1 << /OC 6 0 R >> >> /XObject << /Fm1 8 0 R /Fm2 9 0 R >> >> /Contents 5 0 R >>", ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.object(4, HELVETICA_FONT_DICT); b.stream( 5, - "<< >>", + EMPTY_DICT, enc( [ "BT /F1 12 Tf 10 180 Td (Visible text) Tj ET", "/OC /L1 BDC", "BT /F1 12 Tf 10 150 Td (Hidden layer text) Tj ET", "/Fm1 Do", - "EMC", + EMC, "/Span << /OC 7 0 R /ActualText (Replacement reading) >> BDC", "BT /F1 12 Tf 10 120 Td (Annotated text) Tj ET", - "EMC", + EMC, "/Fm2 Do", ].join("\n"), ), @@ -551,7 +558,7 @@ export function ocgPdf(): Uint8Array { // The annotation cluster (#721 phase 4): a genuine third-party sticky note (a /T that is not this package's own presenter-notes marker), a FreeText, a Highlight carrying /QuadPoints, and a Stamp -- the opaque kind whose facts ride the quarantined residue channel. Page 2 carries no annotations at all, pinning that the page field is absent rather than an empty array. export function annotationsPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"); b.object( @@ -562,8 +569,8 @@ export function annotationsPdf(): Uint8Array { 4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R >>", ); - b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); - b.stream(6, "<< >>", enc(HELLO_CONTENT)); + b.object(5, HELVETICA_FONT_DICT); + b.stream(6, EMPTY_DICT, enc(HELLO_CONTENT)); b.object( 7, "<< /Type /Annot /Subtype /Text /Rect [10 60 26 76] /Contents (A real reviewer note) /T (Reviewer) /M (D:20260819140300Z) >>", @@ -586,7 +593,7 @@ export function annotationsPdf(): Uint8Array { // The AcroForm cluster (#721 phase 5): a merged text field (its own /Rect, no widget kids) with /V, /TU, and the ReadOnly /Ff bit; a non-terminal group field whose two children exercise the combo flag on /FT /Ch (with /Opt and a /V) and a checkbox whose /V names an export value other than Off; and a signature field. The widget kids appear in the page's /Annots too, pinning that the Widget walk is owned by the field tree rather than duplicating as an annotation record. export function acroFormPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object( 1, "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R 12 0 R] >> >>", @@ -596,8 +603,8 @@ export function acroFormPdf(): Uint8Array { 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /Annots [6 0 R 10 0 R 11 0 R 13 0 R] >>", ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.object(4, HELVETICA_FONT_DICT); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); b.object( 6, "<< /Type /Annot /Subtype /Widget /FT /Tx /T (fullname) /V (Jane Doe) /TU (Full name) /Ff 1 /Rect [10 80 110 96] /P 3 0 R >>", @@ -645,7 +652,7 @@ export function metadataResiduePdf(): Uint8Array { "", '', ].join("\n"); - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object( 1, "<< /Type /Catalog /Pages 2 0 R /Lang (en-GB) /Metadata 6 0 R /ViewerPreferences << /HideToolbar true >> /PageMode /UseOutlines /OutputIntents [7 0 R] >>", @@ -655,8 +662,8 @@ export function metadataResiduePdf(): Uint8Array { 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.object(4, HELVETICA_FONT_DICT); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); b.stream(6, "<< /Type /Metadata /Subtype /XML >>", enc(xmp)); b.object( 7, @@ -674,7 +681,7 @@ export function metadataResiduePdf(): Uint8Array { // MediaBox [0 0 200 100] with CropBox [100 0 200 50] -- the right half's lower band is the only visible region. Three paint operations: text wholly inside the crop, text wholly in the cropped-away left half, and a rect straddling the crop's right edge (x 190..210 against the boundary at 200). A viewer shows the inside text in full, the straddling rect clipped at x=200, and nothing of the outside text. A URI link annotation in the cropped-away half rides along: an annotation is an anchored construct, not painted stream content, so the visibility filter must not claim it. export function cropBoxPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); catalogPagesPageFontObjects( b, 5, @@ -683,7 +690,7 @@ export function cropBoxPdf(): Uint8Array { ); b.stream( 5, - "<< >>", + EMPTY_DICT, enc( "BT /F1 12 Tf 120 20 Td (inside) Tj ET BT /F1 12 Tf 10 80 Td (outside) Tj ET 190 20 20 10 re f", ), @@ -698,7 +705,7 @@ export function cropBoxPdf(): Uint8Array { // The same geometry with /Rotate 90 -- the crop rect must land origin-normalised in the rotated frame too (the rotated crop spans x 0..50, y 0..100, so the page reports 50x100, the inside text at (120, 20) lands at (20, 80), and the straddling rect crosses the rotated boundary at y=0). export function rotatedCropBoxPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); catalogPagesPageFontObjects( b, 5, @@ -707,7 +714,7 @@ export function rotatedCropBoxPdf(): Uint8Array { ); b.stream( 5, - "<< >>", + EMPTY_DICT, enc( "BT /F1 12 Tf 120 20 Td (inside) Tj ET BT /F1 12 Tf 10 80 Td (outside) Tj ET 190 20 20 10 re f", ), @@ -718,7 +725,7 @@ export function rotatedCropBoxPdf(): Uint8Array { // CropBox declared on the PARENT Pages node -- it is one of the four page-tree-inheritable attributes (ISO 32000-1 7.7.3.4), so a page with no /CropBox of its own inherits the bottom band [0 0 200 50]. export function inheritedCropBoxPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); b.object( 2, @@ -728,10 +735,10 @@ export function inheritedCropBoxPdf(): Uint8Array { 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.object(4, HELVETICA_FONT_DICT); b.stream( 5, - "<< >>", + EMPTY_DICT, enc( "BT /F1 12 Tf 10 20 Td (inside) Tj ET BT /F1 12 Tf 10 80 Td (outside) Tj ET", ), @@ -742,30 +749,30 @@ export function inheritedCropBoxPdf(): Uint8Array { // MediaBox with an EQUAL CropBox plus the three print-production boxes declared page-direct (ISO 32000-1 Table 30 lists /BleedBox /TrimBox /ArtBox as ordinary per-page entries, not inheritable ones): nothing is cropped away, but the declared boxes are facts beyond the visible box that the model has no field for. export function printBoxesPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); catalogPagesPageFontObjects( b, 5, "[0 0 200 100]", "/CropBox [0 0 200 100] /BleedBox [0 0 210 110] /TrimBox [5 5 195 95] /ArtBox [10 10 190 90] ", ); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); b.classicXrefAndTrailer(5, "/Root 1 0 R"); return b.bytes(); } // MediaBox with an EQUAL CropBox and nothing else -- the degenerate declaration a producer sometimes writes. Nothing is cropped away, and a crop box that IS the media box carries no fact beyond the visible one, so this page contributes no residue row. export function equalCropBoxPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); catalogPagesPageFontObjects(b, 5, "[0 0 200 100]", "/CropBox [0 0 200 100] "); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); + b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); b.classicXrefAndTrailer(5, "/Root 1 0 R"); return b.bytes(); } // The tagged-structure cluster (#760): a /StructTreeRoot whose /K walk covers a role-mapped heading (/S /Chapter that /RoleMap maps to /H1, set at the SAME 12pt as the body so a heading test can pin structure-over-geometry), a /P resolving its /Lang through /ClassMap, a Table/TR/TH/TD subtree, and a second page whose /Sect carries its own /T and /Lang. The parent tree's per-page entries use the shape real producers write (14.7.4.4): each page's key is that page's OWN /StructParents value and the entry is an ARRAY of owning elements indexed by MCID, so MCID 0 appears on BOTH pages owned by different elements -- pinning that association is keyed (page, mcid), never mcid alone. Page 2 also carries unmarked text, pinning that an item with no association simply omits the field, and key 5 holds a single element reference no page claims -- the OBJR channel's shape, which the (page, MCID) walk must recognise and skip. export function taggedStructurePdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object(1, "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 8 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"); b.object( @@ -776,41 +783,41 @@ export function taggedStructurePdf(): Uint8Array { 4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 7 0 R /StructParents 1 >>", ); - b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.object(5, HELVETICA_FONT_DICT); b.stream( 6, - "<< >>", + EMPTY_DICT, enc( [ "/H1 << /MCID 0 >> BDC", "BT /F1 12 Tf 10 180 Td (Chapter title) Tj ET", - "EMC", + EMC, "/P << /MCID 1 >> BDC", "BT /F1 12 Tf 10 150 Td (Body paragraph) Tj ET", - "EMC", + EMC, "/TH << /MCID 2 >> BDC", "BT /F1 12 Tf 10 120 Td (Name) Tj ET", - "EMC", + EMC, "/TH << /MCID 3 >> BDC", "BT /F1 12 Tf 100 120 Td (Value) Tj ET", - "EMC", + EMC, "/TD << /MCID 4 >> BDC", "BT /F1 12 Tf 10 90 Td (Alpha) Tj ET", - "EMC", + EMC, "/TD << /MCID 5 >> BDC", "BT /F1 12 Tf 100 90 Td (One) Tj ET", - "EMC", + EMC, ].join("\n"), ), ); b.stream( 7, - "<< >>", + EMPTY_DICT, enc( [ "/P << /MCID 0 >> BDC", "BT /F1 12 Tf 10 180 Td (Paragraphe francais) Tj ET", - "EMC", + EMC, "BT /F1 12 Tf 10 150 Td (Untagged) Tj ET", ].join("\n"), ), @@ -855,7 +862,7 @@ export function taggedStructurePdf(): Uint8Array { // A parent tree whose keys do NOT match page positions (#760): page 1 (index 0) declares /StructParents 7 and page 2 (index 1) declares /StructParents 0, inverting both against their indices -- a reader that treats the key as a page index hands each page the other page's element. Page 2's array also opens with a null (an MCID no element owns), pinning that array entries naming no element are skipped rather than misread, and its stream therefore marks MCID 1. export function taggedStructureInvertedParentsPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object(1, "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 8 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"); b.object( @@ -866,26 +873,26 @@ export function taggedStructureInvertedParentsPdf(): Uint8Array { 4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 7 0 R /StructParents 0 >>", ); - b.object(5, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.object(5, HELVETICA_FONT_DICT); b.stream( 6, - "<< >>", + EMPTY_DICT, enc( [ "/P << /MCID 0 >> BDC", "BT /F1 12 Tf 10 180 Td (First page) Tj ET", - "EMC", + EMC, ].join("\n"), ), ); b.stream( 7, - "<< >>", + EMPTY_DICT, enc( [ "/P << /MCID 1 >> BDC", "BT /F1 12 Tf 10 180 Td (Second page) Tj ET", - "EMC", + EMC, ].join("\n"), ), ); @@ -908,25 +915,25 @@ export function taggedStructureInvertedParentsPdf(): Uint8Array { // Marked content painted through a form XObject (#760): a form invoked inside a page MCID span paints that span's content (the enclosing page MCID carries onto what it paints), while a form whose own dict declares /StructParents numbers its own MCIDs in its own parent-tree key, so neither its marked nor its unmarked content may inherit the invoking span. The second form's own MCID 0 DOES have an owner under key 3, pinning that the /Stm-qualified channel is left alone rather than looked up against the page's numbering. export function taggedFormPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object(1, "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); b.object( 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> /XObject << /FmA 8 0 R /FmB 9 0 R >> >> /Contents 5 0 R /StructParents 0 >>", ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.object(4, HELVETICA_FONT_DICT); b.stream( 5, - "<< >>", + EMPTY_DICT, enc( [ "/P << /MCID 0 >> BDC", "/FmA Do", - "EMC", + EMC, "/P << /MCID 1 >> BDC", "/FmB Do", - "EMC", + EMC, ].join("\n"), ), ); @@ -950,7 +957,7 @@ export function taggedFormPdf(): Uint8Array { [ "/Span << /MCID 0 >> BDC", "BT /F1 12 Tf 10 10 Td (Self-marked form text) Tj ET", - "EMC", + EMC, ].join("\n"), ), ); @@ -965,22 +972,22 @@ export function taggedFormPdf(): Uint8Array { // A page whose /StructParents names a key the parent tree does not carry (#760) -- the inconsistent-mapping malformation real producers do create. The tree itself is healthy (key 0 names an owner for MCID 0) but the page declares 4, so its marked content resolves to no owner and the inconsistency surfaces as a diagnostic rather than silence. export function parentTreeMissingEntryPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.7"); + const b = new FixtureBuilder().header(); b.object(1, "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); b.object( 3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /StructParents 4 >>", ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.object(4, HELVETICA_FONT_DICT); b.stream( 5, - "<< >>", + EMPTY_DICT, enc( [ "/P << /MCID 0 >> BDC", "BT /F1 12 Tf 10 100 Td (Owned by nothing) Tj ET", - "EMC", + EMC, ].join("\n"), ), ); From 180adb92f01c24bbb828ca5d8c0d2bd43bf43f59 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:24:42 +0100 Subject: [PATCH 020/105] test(pdf-codec): distinguish isTrueTypeCollection's own two guards isTrueTypeCollection's hasBytes(bytes, 0, 4) && u32(...) check had two survivable mutants: forcing either side to a bare `true` made every parse failure misreport as a TrueType Collection, and the existing "generic parse failure" test could not catch it because the source label it asserted on ("not-a-font.bin") also appears verbatim inside the TTC message. Assert the actual generic wording instead, and add a buffer too short to hold even the 4-byte tag -- hasBytes' own job is keeping that case from ever reaching u32, which would throw past this file's bounds rather than yield a clean FontFaceParseError. Also assert FontFaceParseError's own .name, which nothing checked. --- packages/pdf-codec/src/font-face.test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/font-face.test.ts b/packages/pdf-codec/src/font-face.test.ts index 08b429f72..16ca6fc82 100644 --- a/packages/pdf-codec/src/font-face.test.ts +++ b/packages/pdf-codec/src/font-face.test.ts @@ -216,13 +216,35 @@ describe("readFontFace style-bit precedence", () => { }); describe("readFontFace error handling", () => { + it("names thrown errors FontFaceParseError, not the generic Error", () => { + const garbage = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + try { + readFontFace(garbage, "not-a-font.bin"); + expect.unreachable("readFontFace did not throw"); + } catch (error) { + expect((error as FontFaceParseError).name).toBe("FontFaceParseError"); + } + }); + it("throws FontFaceParseError, naming the source, for bytes that are not a recognised sfnt container at all", () => { const garbage = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + // "not-a-font.bin" (the source label) also appears inside the TrueType Collection message below, so asserting it alone would not catch isTrueTypeCollection wrongly reporting every failure as a .ttc -- the generic wording is what actually distinguishes the two. expect(() => readFontFace(garbage, "not-a-font.bin")).toThrow( FontFaceParseError, ); expect(() => readFontFace(garbage, "not-a-font.bin")).toThrow( - /not-a-font\.bin/, + /no recognised sfnt version/, + ); + }); + + it("throws the generic parse failure, not a crash, for a buffer too short to hold even the 4-byte 'ttcf' tag", () => { + // isTrueTypeCollection's own hasBytes(bytes, 0, 4) check exists precisely so a too-short buffer never reaches u32, which would throw past the bounds this file has instead of a FontFaceParseError. + const tooShort = new Uint8Array([0x00, 0x01]); + expect(() => readFontFace(tooShort, "truncated.bin")).toThrow( + FontFaceParseError, + ); + expect(() => readFontFace(tooShort, "truncated.bin")).toThrow( + /no recognised sfnt version/, ); }); From 8e44261f4b5d6d150e696c024e800e09f06aaab3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:24:54 +0100 Subject: [PATCH 021/105] test(pdf-codec): add a dedicated suite for parseHmtx parseHmtx had no test file at all -- every existing exercise of it went through embedded-font.ts's own guard, which already refuses a font before hmtx's zero-metrics and missing-table throws could ever run. Cover advance-width lookup, the last-entry fallback for glyph IDs past numberOfHMetrics, both missing-table throws, and the zero-metrics throw directly against hand-built hhea/hmtx bytes. --- packages/pdf-codec/src/hmtx-table.test.ts | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 packages/pdf-codec/src/hmtx-table.test.ts diff --git a/packages/pdf-codec/src/hmtx-table.test.ts b/packages/pdf-codec/src/hmtx-table.test.ts new file mode 100644 index 000000000..7f2ea0e39 --- /dev/null +++ b/packages/pdf-codec/src/hmtx-table.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { parseHmtx } from "./hmtx-table"; +import { parseSfnt } from "./sfnt"; +import { buildSfnt } from "./test-support/sfnt"; + +// A minimal 'hhea' (ISO/IEC 14496-22 clause 5.2.3): only numberOfHMetrics, at its real byte offset 34, is meaningful here. +function buildHheaBytes(numberOfHMetrics: number): Uint8Array { + const table = new Uint8Array(36); + new DataView(table.buffer).setUint16(34, numberOfHMetrics); + return table; +} + +// A minimal 'hmtx' (clause 5.2.4): one 4-byte longHorMetric (advanceWidth uint16, leftSideBearing int16) per declared metric, in order. +function buildHmtxBytes( + advanceWidths: readonly number[], +): Uint8Array { + const table = new Uint8Array(advanceWidths.length * 4); + const view = new DataView(table.buffer); + advanceWidths.forEach((width, index) => { + view.setUint16(index * 4, width); + }); + return table; +} + +function fontWith( + numberOfHMetrics: number, + advanceWidths: readonly number[], +): ReturnType { + return parseSfnt( + buildSfnt( + new Map([ + ["hhea", buildHheaBytes(numberOfHMetrics)], + ["hmtx", buildHmtxBytes(advanceWidths)], + ]), + ), + ); +} + +describe("parseHmtx", () => { + it("reads each glyph's own advance width up to numberOfHMetrics", () => { + const hmtx = parseHmtx(fontWith(3, [100, 200, 300])!); + expect(hmtx.advanceWidth(0)).toBe(100); + expect(hmtx.advanceWidth(1)).toBe(200); + expect(hmtx.advanceWidth(2)).toBe(300); + }); + + it("reuses the last explicit entry's width for every glyph ID at or beyond numberOfHMetrics", () => { + const hmtx = parseHmtx(fontWith(2, [100, 250])!); + expect(hmtx.advanceWidth(2)).toBe(250); + expect(hmtx.advanceWidth(9999)).toBe(250); + }); + + it("throws when the font has no hhea table", () => { + const font = parseSfnt( + buildSfnt(new Map([["hmtx", buildHmtxBytes([100])]])), + )!; + expect(() => parseHmtx(font)).toThrow("font has no hhea/hmtx table"); + }); + + it("throws when the font has no hmtx table", () => { + const font = parseSfnt(buildSfnt(new Map([["hhea", buildHheaBytes(1)]])))!; + expect(() => parseHmtx(font)).toThrow("font has no hhea/hmtx table"); + }); + + it("throws when hhea declares zero horizontal metrics", () => { + const font = fontWith(0, [])!; + expect(() => parseHmtx(font)).toThrow("font hhea numberOfHMetrics is zero"); + }); +}); From 3a8ebb5bc1676627e7eba2aaa48a994a8aa4c4b0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:03 +0100 Subject: [PATCH 022/105] test(pdf-codec): cover buildSimpleFont/buildCompositeFont's BaseFont fallback Every existing font fixture named an explicit /BaseFont, leaving the ?? "Helvetica" default unreachable for both the simple and composite font builders. Also cover readCidWidths' malformed-leading-operand recovery (i++; continue), which had no test where a /W array actually contained a non-numeric c/cFirst entry. --- packages/pdf-codec/src/font-read.test.ts | 65 ++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/pdf-codec/src/font-read.test.ts b/packages/pdf-codec/src/font-read.test.ts index 1b3cde12e..251f7da42 100644 --- a/packages/pdf-codec/src/font-read.test.ts +++ b/packages/pdf-codec/src/font-read.test.ts @@ -101,6 +101,23 @@ describe("createFontResolver: simple fonts", () => { ); }); + it("defaults a simple font with no /BaseFont at all to Helvetica", () => { + const { sink } = collectDiagnostics(); + const fontDict = pdfDict({ Subtype: pdfName("Type1") }); + const resources = pdfDict({ Font: pdfDict({ F1: fontDict }) }); + const { resolve } = createFontResolver({ + resolver: makeResolver(new Map()), + sink, + }); + const font = resolve("F1", resources); + expect(font).toMatchObject({ + composite: false, + family: "Helvetica", + bold: false, + italic: false, + }); + }); + it("reports a diagnostic when falling back for a family that does not match any standard-14 face", () => { const { sink, diagnostics } = collectDiagnostics(); const fontDict = pdfDict({ @@ -694,6 +711,54 @@ describe("createFontResolver: composite (Type0) fonts", () => { expect(font?.widthOf(999)).toBe(600); // falls back to /DW }); + it("skips a malformed /W entry (a non-numeric leading operand) rather than losing the rest of the array", () => { + const { sink } = collectDiagnostics(); + const descendant = pdfDict({ + Subtype: pdfName("CIDFontType2"), + W: pdfArray([ + pdfName("not-a-cid"), // malformed leading operand: skipped, not a c/cFirst + pdfNum(3), + pdfArray([pdfNum(500), pdfNum(600)]), + ]), + }); + const fontDict = pdfDict({ + Subtype: pdfName("Type0"), + BaseFont: pdfName("Calibri"), + Encoding: pdfName("Identity-H"), + DescendantFonts: pdfArray([descendant]), + }); + const resources = pdfDict({ Font: pdfDict({ F1: fontDict }) }); + const { resolve } = createFontResolver({ + resolver: makeResolver(new Map()), + sink, + }); + const font = resolve("F1", resources); + expect(font?.widthOf(3)).toBe(500); + expect(font?.widthOf(4)).toBe(600); + }); + + it("defaults a composite font with no /BaseFont at all to Helvetica", () => { + const { sink } = collectDiagnostics(); + const descendant = pdfDict({ Subtype: pdfName("CIDFontType2") }); + const fontDict = pdfDict({ + Subtype: pdfName("Type0"), + Encoding: pdfName("Identity-H"), + DescendantFonts: pdfArray([descendant]), + }); + const resources = pdfDict({ Font: pdfDict({ F1: fontDict }) }); + const { resolve } = createFontResolver({ + resolver: makeResolver(new Map()), + sink, + }); + const font = resolve("F1", resources); + expect(font).toMatchObject({ + composite: true, + family: "Helvetica", + bold: false, + italic: false, + }); + }); + it("decodes 2-byte codes via /ToUnicode", () => { const { sink } = collectDiagnostics(); const objects = new Map([ From 0c90afe21d6782bc4ea9f40ff44b52881a1c2bad Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:14 +0100 Subject: [PATCH 023/105] test(pdf-codec): cover writeDoublePath's fill rule and zero-bisector case The one existing filled-double-stroke test never set fillRule, so the evenodd -> "f*" branch had no coverage. Also cover averageNormal's zero-length case directly: an open path that goes out and immediately reverses along the same line gives its shared vertex two exactly opposite chord normals, which sum to the zero vector rather than a divide-by-zero -- that vertex stays at its original coordinates on both offset copies while the two open ends still move along their own single chord's normal. --- packages/pdf-codec/src/write-path.test.ts | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/pdf-codec/src/write-path.test.ts b/packages/pdf-codec/src/write-path.test.ts index d4ecca7c2..a6f846f3c 100644 --- a/packages/pdf-codec/src/write-path.test.ts +++ b/packages/pdf-codec/src/write-path.test.ts @@ -391,6 +391,56 @@ describe("writeContentStream: path -- double stroke style", () => { expect((text.match(/\nf\n/g) ?? []).length).toBe(1); }); + it("emits f* for the fill, not f, when a filled double-stroke path declares fillRule evenodd", () => { + const filled: LayoutPath = { + kind: "path", + fill: BLUE, + fillRule: "evenodd", + stroke: STROKE_3PT, + style: "double", + subpaths: [ + { + startXPt: 0, + startYPt: 0, + closed: true, + segments: [ + { kind: "line", xPt: 10, yPt: 0 }, + { kind: "line", xPt: 10, yPt: 10 }, + { kind: "line", xPt: 0, yPt: 10 }, + ], + }, + ], + }; + const text = decode(writeContentStream([filled], fakeContext()).bytes); + expect( + text.startsWith("0 0 1 rg\n0 0 m\n10 0 l\n10 10 l\n0 10 l\nh\nf*\n"), + ).toBe(true); + }); + + // The middle vertex of an open path that goes out and immediately reverses along the same line has two adjacent chords pointing in exactly opposite directions -- their normals cancel to the zero vector, which averageNormal reports as "no bisector" (undefined) rather than dividing by zero. That vertex is left un-offset at both ends' original coordinates while the two open ends still move along their own single chord's normal. + it("leaves a 180-degree reversal's shared vertex un-offset instead of dividing by a zero-length bisector", () => { + const reversal: LayoutPath = { + kind: "path", + stroke: STROKE_3PT, + style: "double", + subpaths: [ + { + startXPt: 0, + startYPt: 0, + closed: false, + segments: [ + { kind: "line", xPt: 10, yPt: 0 }, + { kind: "line", xPt: 0, yPt: 0 }, + ], + }, + ], + }; + const text = decode(writeContentStream([reversal], fakeContext()).bytes); + expect(text).toBe( + "0 0 0 RG\n1 w\n0 1 m\n10 0 l\n0 -1 l\nS\n0 -1 m\n10 0 l\n0 1 l\nS\n", + ); + }); + // Nothing in the double path leaves a dash pattern or cap set, so a later item in the same stream sees the untouched graphics-state defaults -- verified by the absence of any 'd' or 'J' operator rather than by an explicit reset, since none was ever needed. it("emits no dash or cap operators at all, so there is nothing to reset", () => { const item: LayoutPath = { From 9bef41bdf512733692f3f799dd8e69d0fcbebe73 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:25 +0100 Subject: [PATCH 024/105] test(pdf-codec): add a direct test file for jbig2-generic.ts decodeGenericRegion/decodeRefinementRegion had no test file of their own; every exercise came through jbig2.ts's own segment parser, which always masks GBTEMPLATE/GRTEMPLATE to the 2-bit/1-bit range the real template tables cover -- their own out-of-range guards were dead from that one call path. Both functions are exported, so a direct caller is not bound by that masking; call each with an out-of-range template directly and assert the resulting Jbig2UnsupportedError. --- .../pdf-codec/src/image/jbig2-generic.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/pdf-codec/src/image/jbig2-generic.test.ts diff --git a/packages/pdf-codec/src/image/jbig2-generic.test.ts b/packages/pdf-codec/src/image/jbig2-generic.test.ts new file mode 100644 index 000000000..e60c1316e --- /dev/null +++ b/packages/pdf-codec/src/image/jbig2-generic.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { createArithContexts, MqDecoder } from "./jbig2-arith"; +import { Jbig2UnsupportedError } from "./jbig2-errors"; +import { decodeGenericRegion, decodeRefinementRegion } from "./jbig2-generic"; + +// decodeGenericRegion/decodeRefinementRegion are exported, so their own template guard is part of their public contract, even though jbig2.ts's own segment parser always masks GBTEMPLATE/GRTEMPLATE to a range GENERIC_TEMPLATES/REFINEMENT_TEMPLATES already cover -- a direct caller (or a future one) is not bound by that masking. +function dummyDecoder(): MqDecoder { + return new MqDecoder(new Uint8Array(0)); +} + +describe("decodeGenericRegion template validation", () => { + it("refuses a GBTEMPLATE outside the 0-3 range T.88 6.2.5.3 defines", () => { + expect(() => + decodeGenericRegion( + 1, + 1, + { template: 4, tpgdon: false, at: [] }, + dummyDecoder(), + createArithContexts(16), + ), + ).toThrow(Jbig2UnsupportedError); + expect(() => + decodeGenericRegion( + 1, + 1, + { template: 4, tpgdon: false, at: [] }, + dummyDecoder(), + createArithContexts(16), + ), + ).toThrow(/GBTEMPLATE 4/); + }); +}); + +describe("decodeRefinementRegion template validation", () => { + it("refuses a GRTEMPLATE outside the 0-1 range T.88 6.3.5.3 defines", () => { + const reference = { width: 1, height: 1, data: new Uint8Array(1) }; + expect(() => + decodeRefinementRegion( + 1, + 1, + { + template: 2, + tpgron: false, + at: [], + reference, + dx: 0, + dy: 0, + }, + dummyDecoder(), + createArithContexts(13), + ), + ).toThrow(Jbig2UnsupportedError); + expect(() => + decodeRefinementRegion( + 1, + 1, + { + template: 2, + tpgron: false, + at: [], + reference, + dx: 0, + dy: 0, + }, + dummyDecoder(), + createArithContexts(13), + ), + ).toThrow(/GRTEMPLATE 2/); + }); +}); From 5a8e337686723eefea418a1417f64bff45af195a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:34 +0100 Subject: [PATCH 025/105] test(pdf-codec): cover computeFlags' FLAG_ITALIC bit Every existing embedding test used the vendored Carlito Regular, an upright design, leaving the italicAngleDegrees !== 0 branch untested. Build the same object group from the vendored Caladea Italic instead and assert the descriptor's own Flags carries the ITALIC bit. --- .../pdf-codec/src/embedded-font-write.test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/embedded-font-write.test.ts b/packages/pdf-codec/src/embedded-font-write.test.ts index c2cfb286b..5b1c12dbf 100644 --- a/packages/pdf-codec/src/embedded-font-write.test.ts +++ b/packages/pdf-codec/src/embedded-font-write.test.ts @@ -32,7 +32,7 @@ import { writeObject } from "./serialize"; import type { SfntSubsetResult } from "./sfnt-subset"; import { subsetSfnt } from "./sfnt-subset"; import { parseSfnt } from "./sfnt"; -import { carlitoRegularBytes } from "./test-support/fonts"; +import { caladeaItalicBytes, carlitoRegularBytes } from "./test-support/fonts"; // The end-to-end proof this module exists for: take a real vendored face, cut a real subset of it for a real string, build the whole PDF object group, assemble a genuine PDF file around it by hand, and read that file back with this package's own readPdf. Nothing here is a synthetic fixture -- the font is the checked-in Carlito Regular, the subset is sfnt-subset.ts's own output, and the file is a complete, well-formed PDF with a real cross-reference table. // @@ -479,3 +479,28 @@ describe("the subset tag", () => { ); }); }); + +describe("buildEmbeddedFontObjects: FLAG_ITALIC", () => { + it("sets the ITALIC descriptor bit for a face whose own italicAngleDegrees is non-zero", () => { + const sfnt = parseSfnt(caladeaItalicBytes())!; + const face = loadEmbeddedFace(sfnt)!; + expect(face.metrics.italicAngleDegrees).not.toBe(0); // real Caladea Italic data, not a synthetic fixture -- confirms this test exercises the branch it claims to + const subset = subsetSfnt(sfnt, [0x41])!; + const usedGlyphs = collectEmbeddedGlyphs(["A"], face); + const { descriptor } = buildEmbeddedFontObjects( + face, + subset, + usedGlyphs, + { + cidFontRef: pdfRef(1, 0), + descriptorRef: pdfRef(2, 0), + fontFileRef: pdfRef(3, 0), + toUnicodeRef: pdfRef(4, 0), + }, + false, + ); + const flags = asNumber(dictGet(descriptor, "Flags"))!; + const FLAG_ITALIC = 64; + expect(flags & FLAG_ITALIC).toBe(FLAG_ITALIC); + }); +}); From 32eecab5c393e6ab74fcdf4db75b4c8f83af40ee Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:52 +0100 Subject: [PATCH 026/105] test(pdf-codec): pin FixtureBuilder's own byte-level mechanics directly Every exported fixture function feeds FixtureBuilder well-formed dicts and object numbers that genuinely exist, leaving its own /Length- insertion regex, xref padding, offsetOf's misuse guard, and the maxObjNum arithmetic in /Size and the xref subsection header with no route to direct coverage. Export the class and test it against adversarial input directly: a nested dict to prove /Length lands at the true end rather than the first '>>' encountered, dicts with no or trailing whitespace around the final '>>' to prove the anchor and quantifier are both load-bearing, a fixed-width offset assertion to prove padStart's zero-pad character actually pads, and an unwritten object number to prove offsetOf's guard actually throws. Also remove FixtureBuilder's rawBytes method, which no fixture in this file has ever called. --- .../pdf-codec/src/test-support/pdf.test.ts | 44 +++++++++++++++++++ packages/pdf-codec/src/test-support/pdf.ts | 9 +--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/pdf-codec/src/test-support/pdf.test.ts b/packages/pdf-codec/src/test-support/pdf.test.ts index 2513cbdc5..0cba937f4 100644 --- a/packages/pdf-codec/src/test-support/pdf.test.ts +++ b/packages/pdf-codec/src/test-support/pdf.test.ts @@ -2,6 +2,7 @@ import { unzlibSync } from "fflate"; import { describe, expect, it } from "vitest"; import { brokenStartxrefPdf, + FixtureBuilder, formXObjectPdf, incrementalUpdatePdf, inheritedPageAttributesPdf, @@ -276,3 +277,46 @@ describe("inlineImagePdf", () => { expect(text).toContain(" EI Q"); }); }); + +// FixtureBuilder itself, exercised directly: the exported fixture functions above only ever feed it well-formed dicts and object numbers that genuinely exist, so its own /Length-insertion regex, misuse guard, and xref-padding arithmetic have no route to coverage except a test that deliberately probes their edge cases. +describe("FixtureBuilder", () => { + it("inserts /Length at the dict's own true end, not at the first nested '>>' it happens to find", () => { + const bytes = new FixtureBuilder() + .stream(1, "<< /Sub << /X 1 >> >>", new TextEncoder().encode("abc")) + .bytes(); + const text = decode(bytes); + expect(text).toContain("<< /Sub << /X 1 >> /Length 3 >>"); + }); + + it("still inserts /Length when the dict's final '>>' has no whitespace before it", () => { + const bytes = new FixtureBuilder() + .stream(1, "<<>>", new TextEncoder().encode("ab")) + .bytes(); + const text = decode(bytes); + expect(text).toContain("<< /Length 2 >>"); + }); + + it("still inserts /Length when the dict has trailing whitespace after its final '>>'", () => { + const bytes = new FixtureBuilder() + .stream(1, "<<>> ", new TextEncoder().encode("a")) + .bytes(); + const text = decode(bytes); + expect(text).toContain("<< /Length 1 >>"); + }); + + it("throws a clear error rather than silently reading an unwritten object's offset", () => { + const b = new FixtureBuilder(); + expect(() => b.offsetOf(1)).toThrow("fixture object 1 was never written"); + }); + + it("writes the trailer's /Size and the xref subsection count as maxObjNum + 1, and pads every offset to exactly 10 digits", () => { + const b = new FixtureBuilder().header("1.4"); + b.object(1, "<< >>"); + b.classicXrefAndTrailer(1, "/Root 1 0 R"); + const text = decode(b.bytes()); + expect(text).toContain("xref\n0 2\n"); + expect(text).toContain("trailer\n<< /Size 2 /Root 1 0 R >>"); + // object 1 starts right after the 9-byte header ("%PDF-1.4\n"), a single-digit offset that must still occupy the full fixed 10-digit field. + expect(text).toContain("0000000009 00000 n \n"); + }); +}); diff --git a/packages/pdf-codec/src/test-support/pdf.ts b/packages/pdf-codec/src/test-support/pdf.ts index 2d14416d0..a7cbba305 100644 --- a/packages/pdf-codec/src/test-support/pdf.ts +++ b/packages/pdf-codec/src/test-support/pdf.ts @@ -16,8 +16,8 @@ const PDF_1_4 = "1.4"; const HELVETICA_FONT_DICT = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; -// Tracks byte offsets as objects are appended, purely by recording ByteWriter's own running length before each write -- the same mechanical idea src/pdf/write.ts uses, reimplemented independently here rather than shared with it. -class FixtureBuilder { +// Tracks byte offsets as objects are appended, purely by recording ByteWriter's own running length before each write -- the same mechanical idea src/pdf/write.ts uses, reimplemented independently here rather than shared with it. Exported solely so pdf.test.ts can exercise its own byte-level mechanics (the /Length-insertion regex, xref padding, offsetOf's misuse guard) directly -- the exported fixture functions below only ever feed it well-formed, non-adversarial input, so those specific mechanics have no other route to direct coverage. +export class FixtureBuilder { private readonly writer = new ByteWriter(); private readonly offsets = new Map(); @@ -35,11 +35,6 @@ class FixtureBuilder { return this; } - rawBytes(bytes: Uint8Array): this { - this.writer.writeBytes(bytes); - return this; - } - object(num: number, body: string): this { this.offsets.set(num, this.writer.length); this.writer.writeAscii(`${num} 0 obj\n${body}\nendobj\n`); From 532c66f2ec1227751e52b225f21fb3778488c982 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:34:55 +0100 Subject: [PATCH 027/105] test(pdf-codec): close pdf.ts fixture-consumer gaps around vacuous negatives pagelessPdf and symbolFontProgramPdf had no direct structural test at all. xrefStreamWithObjectStreamPdf's own header version, and inlineImagePdf's actual raw pixel bytes (as opposed to the BI/ID/EI text markers around them), were never independently checked. The AcroForm checkbox and signature field tests asserted fieldType and value but never their widget geometry, unlike the sibling text and combo-box tests two lines above them. The foreign-hidden-annotation test asserted only that notes ended up undefined, which is equally true whether the discrimination logic correctly excluded a genuine sticky note or the annotation never reached the reader at all (e.g. a blanked /Annots entry). Assert the annotation was actually read, by its own contents, alongside the undefined notes. --- packages/pdf-codec/src/form.test.ts | 6 +++++ packages/pdf-codec/src/read.test.ts | 5 ++++ .../pdf-codec/src/test-support/pdf.test.ts | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/packages/pdf-codec/src/form.test.ts b/packages/pdf-codec/src/form.test.ts index 232701490..5d0bc9613 100644 --- a/packages/pdf-codec/src/form.test.ts +++ b/packages/pdf-codec/src/form.test.ts @@ -72,6 +72,9 @@ describe("readPdf: AcroForm fields", () => { checked: true, value: "Yes", }); + expect(checkbox?.widgets).toEqual([ + { pageIndex: 0, xPt: 10, yPt: 40, widthPt: 12, heightPt: 12 }, + ]); }); it("maps /FT /Sig to a signature field with no control value", () => { @@ -80,6 +83,9 @@ describe("readPdf: AcroForm fields", () => { expect(signature).toMatchObject({ fieldType: "signature" }); expect(signature?.value).toBeUndefined(); expect(signature?.checked).toBeUndefined(); + expect(signature?.widgets).toEqual([ + { pageIndex: 0, xPt: 150, yPt: 20, widthPt: 40, heightPt: 20 }, + ]); }); it("collects the root field list in document order", () => { diff --git a/packages/pdf-codec/src/read.test.ts b/packages/pdf-codec/src/read.test.ts index c2dead048..b6f68fc7b 100644 --- a/packages/pdf-codec/src/read.test.ts +++ b/packages/pdf-codec/src/read.test.ts @@ -314,6 +314,11 @@ describe("readPdf: page notes", () => { it("does not mistake a third-party tool's own hidden sticky note for pptx speaker notes", () => { const doc = readPdf(pdfWithForeignHiddenAnnotationPdf()); expect(doc.pages[0]!.notes).toBeUndefined(); + // Proves the annotation itself was genuinely read and excluded on its /T marker -- not that it (or its /Annots entry) never reached the reader at all, which would leave notes undefined for an unrelated reason. + const sticky = doc.pages[0]!.annotations?.find((a) => a.subtype === "Text"); + expect(sticky?.contents).toBe( + "A real reviewer note, not pptx speaker notes", + ); }); }); diff --git a/packages/pdf-codec/src/test-support/pdf.test.ts b/packages/pdf-codec/src/test-support/pdf.test.ts index 0cba937f4..30a16eb18 100644 --- a/packages/pdf-codec/src/test-support/pdf.test.ts +++ b/packages/pdf-codec/src/test-support/pdf.test.ts @@ -9,7 +9,9 @@ import { inlineImagePdf, minimalClassicXrefPdf, nonZeroOriginMediaBoxPdf, + pagelessPdf, rotatedPagePdf, + symbolFontProgramPdf, unsupportedSecurityHandlerPdf, withInfoDictPdf, xrefStreamWithObjectStreamPdf, @@ -67,6 +69,7 @@ describe("xrefStreamWithObjectStreamPdf", () => { it("is well-formed, with startxref pointing at the xref stream's own header", () => { const bytes = xrefStreamWithObjectStreamPdf(); const text = expectWellFormedHeaderAndTrailer(bytes); + expect(text.startsWith("%PDF-1.5\n")).toBe(true); // xref streams are a 1.5+ feature, distinct from the classic-xref fixtures' own 1.4 const match = /startxref\n(\d+)\n%%EOF$/.exec(text); expect(match).not.toBeNull(); const offset = Number(match![1]); @@ -275,6 +278,29 @@ describe("inlineImagePdf", () => { expect(text).toContain("BI /W 2 /H 2"); expect(text).toContain(" ID "); expect(text).toContain(" EI Q"); + // The raw 2x2 RGB pixel bytes themselves must sit between ID and EI -- the substring checks above would pass unchanged even with no pixel data at all. latin1 decoding is one character per byte, so the string index doubles as the byte offset. + const pixelStart = text.indexOf(" ID ") + " ID ".length; + const pixelBytes = [255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]; + expect([ + ...bytes.slice(pixelStart, pixelStart + pixelBytes.length), + ]).toEqual(pixelBytes); + }); +}); + +describe("pagelessPdf", () => { + it("is a well-formed, structurally valid document with an empty page tree", () => { + const bytes = pagelessPdf(); + verifyFullClassicXref(bytes); + const text = expectWellFormedHeaderAndTrailer(bytes); + expect(text).toContain("<< /Type /Catalog /Pages 2 0 R >>"); + expect(text).toContain("<< /Type /Pages /Kids [] /Count 0 >>"); + }); +}); + +describe("symbolFontProgramPdf", () => { + it("zero-pads a single-hex-digit code to two digits in the content stream", () => { + const text = decode(symbolFontProgramPdf(Uint8Array.from([1, 2, 3]), 5)); + expect(text).toContain("<05> Tj ET"); }); }); From 9783b1a4c43f4da16f39655228dcfaba21bd02d5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:35:05 +0100 Subject: [PATCH 028/105] refactor(pdf-codec): drive rc4's keystream loop from data.forEach The keystream XOR loop bounded n < data.length, but an off-by-one bound there only runs one extra round of state/x/y mutation whose own output write then lands one past out's own length -- silently dropped by the same typed-array out-of-range-write behaviour the KSA state array already relies on, so no test could ever observe the difference. data.forEach's own iteration count leaves no such comparison to mutate. --- packages/pdf-codec/src/crypto/rc4.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/pdf-codec/src/crypto/rc4.ts b/packages/pdf-codec/src/crypto/rc4.ts index 6be3725e8..385e0c41a 100644 --- a/packages/pdf-codec/src/crypto/rc4.ts +++ b/packages/pdf-codec/src/crypto/rc4.ts @@ -21,17 +21,17 @@ export function rc4( state[i] = state[j]!; state[j] = swap; } - // Pseudo-random generation algorithm, XORed straight over the input. + // Pseudo-random generation algorithm, XORed straight over the input. Driven by data.forEach rather than a counted for-loop: an off-by-one bound here would run one extra round of state/x/y mutation whose own output write then lands one past `out`'s own length -- a typed array silently drops that write, so the extra round's only effect is on `state`/`x`/`y`, which nothing reads after the function returns. A test could never observe the difference either way; forEach's own iteration count leaves no comparison for a mutation to target. const out = new Uint8Array(data.length); let x = 0; let y = 0; - for (let n = 0; n < data.length; n++) { + data.forEach((byte, n) => { x = (x + 1) & 0xff; y = (y + state[x]!) & 0xff; const swap = state[x]!; state[x] = state[y]!; state[y] = swap; - out[n] = data[n]! ^ state[(state[x]! + state[y]!) & 0xff]!; - } + out[n] = byte ^ state[(state[x]! + state[y]!) & 0xff]!; + }); return out; } From c6fb1788969304e911c8e27c5a230e02d97fb56a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:35:15 +0100 Subject: [PATCH 029/105] refactor(pdf-codec): remove sha2's fixed-size-array equivalent mutants Six loop bounds across sha256/sha512Core were equivalent mutants under an off-by-one: sha256's word-schedule fill/expansion and state-update loops write one entry past a Uint32Array's own fixed length, silently dropped; sha512's pre-sized, zero-filled w array is redundant since every entry is written before it is ever read, and an off-by-one on a plain array only grows it by one entry nothing downstream reads. Replace each with Array.from/TypedArray.map/forEach's own iteration count, and build sha512's w as a plain empty array populated entirely by assignment rather than pre-sized and filled. The two sequential expansion loops keep their own recurrence (each entry depends on ones this same expansion already computed) by driving Array.from's mapfn across a computed remaining-round count instead of a bare loop comparison, so a wrong round count is now a killable arithmetic mutation rather than an unobservable boundary one. --- packages/pdf-codec/src/crypto/sha2.ts | 52 +++++++++++++++------------ 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/pdf-codec/src/crypto/sha2.ts b/packages/pdf-codec/src/crypto/sha2.ts index 02ba1168e..cc6c6fdf1 100644 --- a/packages/pdf-codec/src/crypto/sha2.ts +++ b/packages/pdf-codec/src/crypto/sha2.ts @@ -154,22 +154,28 @@ export function sha256( const state = Uint32Array.from(H256); const w = new Uint32Array(SHA256_ROUNDS); for (let offset = 0; offset < padded.length; offset += SHA256_BLOCK_BYTES) { - for (let t = 0; t < WORDS_PER_BLOCK; t++) { - const at = offset + t * 4; - w[t] = - ((padded[at]! << 24) | - (padded[at + 1]! << 16) | - (padded[at + 2]! << 8) | - padded[at + 3]!) >>> - 0; - } - for (let t = WORDS_PER_BLOCK; t < SHA256_ROUNDS; t++) { + // Fills w's first WORDS_PER_BLOCK entries via Uint32Array.set + Array.from's own length argument rather than a counted for-loop: an off-by-one bound on a Uint32Array like `w` would write one entry past its own fixed length, which a typed array silently drops -- an equivalent mutant no test could ever observe. + w.set( + Array.from({ length: WORDS_PER_BLOCK }, (_, t) => { + const at = offset + t * 4; + return ( + ((padded[at]! << 24) | + (padded[at + 1]! << 16) | + (padded[at + 2]! << 8) | + padded[at + 3]!) >>> + 0 + ); + }), + ); + // Array.from's own length argument (SHA256_ROUNDS - WORDS_PER_BLOCK, an arithmetic value with no equivalent-mutant boundary the way a bare loop comparison would have) drives this expansion instead of a counted for-loop's own `t < SHA256_ROUNDS` -- the recurrence itself still runs index-by-index in order (Array.from's mapfn is called sequentially), since w[t] depends on entries this same expansion already wrote. + Array.from({ length: SHA256_ROUNDS - WORDS_PER_BLOCK }, (_, index) => { + const t = WORDS_PER_BLOCK + index; const x = w[t - 15]!; const y = w[t - 2]!; const s0 = rotr32(x, 7) ^ rotr32(x, 18) ^ (x >>> 3); const s1 = rotr32(y, 17) ^ rotr32(y, 19) ^ (y >>> 10); w[t] = (w[t - 16]! + s0 + w[t - 7]! + s1) >>> 0; - } + }); let a = state[0]!; let b = state[1]!; let c = state[2]!; @@ -195,18 +201,16 @@ export function sha256( a = (t1 + t2) >>> 0; } const next = [a, b, c, d, e, f, g, h]; - for (let i = 0; i < state.length; i++) { - state[i] = (state[i]! + next[i]!) >>> 0; - } + // Uint32Array.prototype.map's own iteration count (its length) replaces a counted `i < state.length` for-loop for the same reason as w's fill above: an off-by-one bound would read/write one entry past state's fixed length, invisibly dropped by the typed array. + state.set(state.map((value, i) => (value + next[i]!) >>> 0)); } const digest = new Uint8Array(state.length * 4); - for (let i = 0; i < state.length; i++) { - const word = state[i]!; + state.forEach((word, i) => { digest[i * 4] = (word >>> 24) & 0xff; digest[i * 4 + 1] = (word >>> 16) & 0xff; digest[i * 4 + 2] = (word >>> 8) & 0xff; digest[i * 4 + 3] = word & 0xff; - } + }); return digest; } @@ -222,22 +226,26 @@ function sha512Core( ): Uint8Array { const padded = padBigEndian(bytes, SHA512_BLOCK_BYTES, 16); const state = Array.from(initialState); - const w = new Array(SHA512_ROUNDS).fill(0n); + // A plain empty array, not a pre-sized, zero-filled one: every one of its SHA512_ROUNDS entries is explicitly assigned below (the fill loop covers 0..WORDS_PER_BLOCK-1, the expansion loop the rest) before any is ever read, so a pre-sized fill's own length argument would be one more equivalent-mutant boundary for no real behaviour. + const w: bigint[] = []; for (let offset = 0; offset < padded.length; offset += SHA512_BLOCK_BYTES) { - for (let t = 0; t < WORDS_PER_BLOCK; t++) { + // Array.from's own length argument replaces a counted `t < WORDS_PER_BLOCK` for-loop, for the same reason as sha256's own word-fill above -- though here w is a plain array rather than a fixed-length typed one, so an off-by-one bound would merely grow it by one entry nothing downstream ever reads, an equally unobservable difference. + Array.from({ length: WORDS_PER_BLOCK }, (_, t) => { let word = 0n; for (let i = 0; i < 8; i++) { word = (word << 8n) | BigInt(padded[offset + t * 8 + i]!); } w[t] = word; - } - for (let t = WORDS_PER_BLOCK; t < SHA512_ROUNDS; t++) { + }); + // As sha256's own expansion above: Array.from's length argument (an arithmetic value, not a bare loop comparison) drives this instead of `t < SHA512_ROUNDS`, with the recurrence still running index-by-index in the mapfn's own call order. + Array.from({ length: SHA512_ROUNDS - WORDS_PER_BLOCK }, (_, index) => { + const t = WORDS_PER_BLOCK + index; const x = w[t - 15]!; const y = w[t - 2]!; const s0 = rotr64(x, 1n) ^ rotr64(x, 8n) ^ (x >> 7n); const s1 = rotr64(y, 19n) ^ rotr64(y, 61n) ^ (y >> 6n); w[t] = (w[t - 16]! + s0 + w[t - 7]! + s1) & MASK64; - } + }); let a = state[0]!; let b = state[1]!; let c = state[2]!; From 0b219ee96c43cc32336be1ce8208db1c13bf4372 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:35:27 +0100 Subject: [PATCH 030/105] fix(pdf-codec): remove probeCff's redundant empty-Name-INDEX check readCffIndex's own contract guarantees entry(0) is undefined whenever an INDEX's count is 0 (its zero-count branch always returns entry: () => undefined), so `nameIndex.count === 0` could never be the thing that made probeCff refuse a font -- the very next `entry(0) === undefined` check already covers it, and no test could ever tell the two checks apart. Drop the redundant one. Also add a case the header-size guard alone protects against: a majorVersion-2 header whose Name INDEX and Top DICT parse cleanly because they sit exactly where an invalid, too-small headerSize would point readCffIndex to look -- the existing "too-small header" test's own Name INDEX happened to be unreadable from that offset regardless of the guard, so it never actually exercised the check it was named for. --- packages/pdf-codec/src/cff-probe.test.ts | 12 ++++++++++++ packages/pdf-codec/src/cff-probe.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/cff-probe.test.ts b/packages/pdf-codec/src/cff-probe.test.ts index 2b9ed4931..4fd3b6ffe 100644 --- a/packages/pdf-codec/src/cff-probe.test.ts +++ b/packages/pdf-codec/src/cff-probe.test.ts @@ -97,6 +97,18 @@ describe("CFF programs probeCff refuses to read", () => { ).toBeUndefined(); }); + it("refuses a too-small headerSize even when a valid Name INDEX and Top DICT sit exactly where that headerSize points", () => { + // Unlike the case above, this Name INDEX is placed at byte offset 2 -- exactly where headerSize's own (invalid) value of 2 would have readCffIndex start looking -- so the only thing standing between this input and a wrongly-defined probe result is the headerSize < CFF_HEADER_SIZE check itself. + const bytes = new Uint8Array([ + 1, + 0, + 2, // majorVersion 1, minorVersion 0, headerSize 2 (invalid: less than the real 4-byte header) + ...cffIndex([[...new TextEncoder().encode("TooShort")]]), + ...cffIndex([[139, 0]]), + ]); + expect(probeCff(bytes)).toBeUndefined(); + }); + it("returns undefined for an empty Name INDEX, which declares a FontSet holding no font", () => { expect( probeCff( diff --git a/packages/pdf-codec/src/cff-probe.ts b/packages/pdf-codec/src/cff-probe.ts index 6c6fbd764..3d5660628 100644 --- a/packages/pdf-codec/src/cff-probe.ts +++ b/packages/pdf-codec/src/cff-probe.ts @@ -48,9 +48,10 @@ export function probeCff( } const nameIndex = readCffIndex(bytes, headerSize); - if (nameIndex === undefined || nameIndex.count === 0) { + if (nameIndex === undefined) { return undefined; } + // No separate `nameIndex.count === 0` check: readCffIndex's own contract guarantees entry(0) is undefined whenever count is 0 (an empty INDEX's entry() always returns undefined -- see its own zero-count branch), so this one check already covers both an empty FontSet and a genuinely unreadable first entry. const nameBytes = nameIndex.entry(0); if (nameBytes === undefined) { return undefined; From f4815b62a077a1ebe89c6b0ad5063dad826fc79f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:35:44 +0100 Subject: [PATCH 031/105] fix(pdf-codec): cover formatNumber's epsilon guard and escapeName's boundaries formatNumber's own below-epsilon test (1e-8) also rounds to "0.0000" via toFixed alone, so it never actually distinguished the epsilon guard from toFixed's own rounding -- 0.00006 does, since it is below NUMBER_EPSILON yet toFixed(4) rounds IT UP to a nonzero "0.0001" on its own. Add that case, plus the guard's own upper boundary at exactly NUMBER_EPSILON. escapeName had no test exercising its safe-range's own boundary characters ('!'/0x21, '~'/0x7e), the one-past-range DEL (0x7f), or a single-hex-digit code where padStart(2, "0") actually changes the output -- every existing escape happened to need two hex digits already. Also remove the dead `if (formatted.includes("."))` guard in formatNumber: toFixed(NUMBER_DECIMAL_PLACES) always emits a decimal point given a fixed 4 decimal places, so the check could never be false and the strip can run unconditionally. --- packages/pdf-codec/src/serialize.test.ts | 22 ++++++++++++++++++++++ packages/pdf-codec/src/serialize.ts | 7 ++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/pdf-codec/src/serialize.test.ts b/packages/pdf-codec/src/serialize.test.ts index 46de9d900..10d96b479 100644 --- a/packages/pdf-codec/src/serialize.test.ts +++ b/packages/pdf-codec/src/serialize.test.ts @@ -28,6 +28,15 @@ describe("formatNumber", () => { expect(formatNumber(0.00000001)).not.toContain("e"); }); + it("rounds to 0 below NUMBER_EPSILON even where toFixed alone would round up to a nonzero string", () => { + // 0.00006 is below the 0.0001 epsilon guard, but toFixed(4) rounds IT UP to "0.0001" on its own (nearest-4dp rounding kicks in past 0.00005) -- so this is the one magnitude range where skipping the guard entirely would change the answer, unlike 0.00000001 above. + expect(formatNumber(0.00006)).toBe("0"); + }); + + it("takes the normal formatting path at exactly NUMBER_EPSILON, not the below-epsilon shortcut", () => { + expect(formatNumber(0.0001)).toBe("0.0001"); + }); + it("normalises -0 to 0", () => { expect(formatNumber(-0)).toBe("0"); }); @@ -57,6 +66,19 @@ describe("writeObject / serializeObject", () => { expect(text(serializeObject(pdfName("A B")))).toBe("/A#20B"); }); + it("leaves the two boundary safe characters, '!' (0x21) and '~' (0x7e), unescaped", () => { + expect(text(serializeObject(pdfName("!~")))).toBe("/!~"); + }); + + it("escapes DEL (0x7f), one past the safe range's own upper boundary", () => { + expect(text(serializeObject(pdfName("\x7f")))).toBe("/#7f"); + }); + + it("zero-pads a single-hex-digit escape to two digits", () => { + // \x01 escapes to "01", not "1" -- padStart(2, "0") actually mattering, unlike every other escape in this file's tests, whose codes are already two hex digits wide. + expect(text(serializeObject(pdfName("\x01")))).toBe("/#01"); + }); + it("serializes an array of mixed types space-separated", () => { expect( text(serializeObject(pdfArray([pdfNum(1), pdfName("X"), pdfBool(true)]))), diff --git a/packages/pdf-codec/src/serialize.ts b/packages/pdf-codec/src/serialize.ts index 51e4faa8b..148108dfb 100644 --- a/packages/pdf-codec/src/serialize.ts +++ b/packages/pdf-codec/src/serialize.ts @@ -11,11 +11,8 @@ export function formatNumber(n: number): string { if (Math.abs(n) < NUMBER_EPSILON) { return "0"; } - let formatted = n.toFixed(NUMBER_DECIMAL_PLACES); - if (formatted.includes(".")) { - formatted = formatted.replace(/0+$/, "").replace(/\.$/, ""); - } - return formatted; + // toFixed(NUMBER_DECIMAL_PLACES) always emits a decimal point (NUMBER_DECIMAL_PLACES is a fixed 4, never 0), so this string always has trailing zeros or a bare "." to strip -- there is no toFixed output an `if (formatted.includes("."))` guard would ever need to skip. + return n.toFixed(NUMBER_DECIMAL_PLACES).replace(/0+$/, "").replace(/\.$/, ""); } const NAME_ESCAPE_PATTERN = /[^!-~]|[#()<>[\]{}/%]/; From 75e915efd3b91df5dfe9dbefff8d0b020d7aab48 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:36:07 +0100 Subject: [PATCH 032/105] test(pdf-codec): add a dedicated suite for readXmpMetadata readXmpMetadata had no test file at all -- every existing exercise came through a full PDF's own /Metadata stream, which never confirmed which element name maps to which output field, nor covered the rdf:Alt/Bag/Seq array form, blank-item skipping, or whitespace-only plain values. --- packages/pdf-codec/src/xmp.test.ts | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 packages/pdf-codec/src/xmp.test.ts diff --git a/packages/pdf-codec/src/xmp.test.ts b/packages/pdf-codec/src/xmp.test.ts new file mode 100644 index 000000000..6c2c19e16 --- /dev/null +++ b/packages/pdf-codec/src/xmp.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { readXmpMetadata } from "./xmp"; + +// A minimal XMP packet wrapper: each field element the standard defines, given plain string content -- readXmpMetadata itself has no test file at all, only indirect exercise through a full PDF's own /Metadata stream, which never distinguishes which element name maps to which output field. +function packet(elements: string): string { + return `${elements}`; +} + +describe("readXmpMetadata: plain-value fields", () => { + it("maps each standard element to its own distinct output field", () => { + const p = packet( + "My Title" + + "My Creator" + + "My Description" + + "My Tool" + + "My Producer" + + "2026-01-01T00:00:00Z" + + "2026-02-02T00:00:00Z", + ); + expect(readXmpMetadata(p)).toEqual({ + title: "My Title", + author: "My Creator", + subject: "My Description", + creator: "My Tool", + producer: "My Producer", + createdIso: "2026-01-01T00:00:00Z", + modifiedIso: "2026-02-02T00:00:00Z", + }); + }); + + it("returns an empty metadata object for a packet with none of the standard elements", () => { + expect(readXmpMetadata(packet(""))).toEqual({}); + }); + + it("omits a field whose element is present but empty (whitespace only)", () => { + expect(readXmpMetadata(packet(" "))).toEqual({}); + }); + + it("trims surrounding whitespace from a plain value", () => { + expect( + readXmpMetadata(packet("\n Padded \n")), + ).toEqual({ title: "Padded" }); + }); +}); + +describe("readXmpMetadata: rdf:Alt/Bag/Seq array-form fields", () => { + it("joins multiple rdf:li items with a comma for a scalar field", () => { + const p = packet( + "FirstSecond", + ); + expect(readXmpMetadata(p)).toEqual({ author: "First, Second" }); + }); + + it("reads dc:subject's own rdf:Bag as the keywords array, not joined into one string", () => { + const p = packet( + "alphabeta", + ); + expect(readXmpMetadata(p)).toEqual({ keywords: ["alpha", "beta"] }); + }); + + it("skips a blank rdf:li item rather than including an empty string", () => { + const p = packet( + "alpha ", + ); + expect(readXmpMetadata(p)).toEqual({ keywords: ["alpha"] }); + }); + + it("omits keywords entirely when dc:subject carries no non-blank items", () => { + const p = packet(""); + expect(readXmpMetadata(p)).toEqual({}); + }); +}); From e56a604ac42749fab4a7209947919dfc8375f47a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 03:15:09 +0100 Subject: [PATCH 033/105] test(pdf-codec): pin flattenCubic's own subdivision arithmetic directly Every caller reaches flattenCubic only through curves recovered from real content streams, none of which are constructed carefully enough to pin an exact subdivision count or force the depth cap deterministically -- leaving its entire chord/distance calculation, midpoint arithmetic, and depth-cap boundary with no route to coverage beyond "it ran without crashing." Export it and test it directly with hand-picked control points: a collinear curve proving the flatness check accepts without subdividing, a symmetric arc whose exact de Casteljau midpoints pin every arithmetic step, a curve whose curvature never converges to prove the depth cap fires at exactly 16 (not 15 or 17) rather than recursing forever, and coincident endpoints to prove the zero-length chord fallback is actually reached rather than dividing by zero. --- packages/pdf-codec/src/raster.test.ts | 49 ++++++++++++++++++++++++++- packages/pdf-codec/src/raster.ts | 3 +- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 35dff5aa1..588327244 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -7,7 +7,7 @@ import { parseHead, parseMaxp } from "./font-tables"; import { parseGlyf } from "./glyf"; import { parseHmtx } from "./hmtx-table"; import { applyMatrix } from "./matrix"; -import { renderPdfPage } from "./raster"; +import { flattenCubic, renderPdfPage } from "./raster"; import type { PageRasteriser, RasterDrawOp, @@ -383,6 +383,53 @@ describe("renderPdfPage: coordinate agreement with readPdf", () => { // --- Vector items through the port. --- +// flattenCubic exercised directly: every real caller reaches it only through curves recovered from actual PDF content streams, which are never carefully enough constructed to pin an exact subdivision count or force the recursion depth cap deterministically -- both properties this suite verifies directly against hand-computed control points. +describe("flattenCubic", () => { + it("returns the endpoint alone for an already-flat (collinear) curve, with no subdivision", () => { + const points = flattenCubic( + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 3, y: 0 }, + ); + expect(points).toEqual([{ x: 3, y: 0 }]); + }); + + it("subdivides a curved arc into the exact de Casteljau midpoint sequence", () => { + const points = flattenCubic( + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 10, y: 1 }, + { x: 10, y: 0 }, + ); + expect(points).toHaveLength(10); + // The true, symmetric peak of this curve -- wrong chord/dist arithmetic or a wrong midpoint divisor shifts every one of these values. + expect(points[4]).toEqual({ x: 5, y: 0.75 }); + expect(points[points.length - 1]).toEqual({ x: 10, y: 0 }); + }); + + it("stops at exactly the depth cap for a curve whose flatness never converges, terminating rather than recursing forever", () => { + const points = flattenCubic( + { x: 0, y: 0 }, + { x: 1e9, y: 1e9 }, + { x: -1e9, y: 1e9 }, + { x: 1e-12, y: 0 }, + ); + // Every leaf hits the depth cap, never the flatness check, so the tree is a perfectly balanced binary recursion of depth 16 -- exactly 2**16 leaves. A boundary of >16, <16, or an unconditional true/false all produce a different power of two (or an infinite loop for false). + expect(points).toHaveLength(65536); + }); + + it("falls back to a chord length of 1 rather than dividing by zero when the endpoints coincide", () => { + const points = flattenCubic( + { x: 5, y: 5 }, + { x: 6, y: 5 }, + { x: 4, y: 5 }, + { x: 5, y: 5 }, + ); + expect(points).toEqual([{ x: 5, y: 5 }]); + }); +}); + describe("renderPdfPage: vector draw ops", () => { it("strokes a recovered line with its colour and width", () => { const rasteriser = new RecordingRasteriser(); diff --git a/packages/pdf-codec/src/raster.ts b/packages/pdf-codec/src/raster.ts index a11b7a83c..ef026ee55 100644 --- a/packages/pdf-codec/src/raster.ts +++ b/packages/pdf-codec/src/raster.ts @@ -723,7 +723,8 @@ function drawDottedSegment( const STROKE_FLATTEN_TOLERANCE_PX = 0.05; const MAX_FLATTEN_DEPTH = 16; -function flattenCubic( +// Exported solely so raster.test.ts can drive its own subdivision arithmetic and depth cap directly with hand-computed control points -- every caller reaches it only through curves recovered from real PDF content streams, which offers no way to pin an exact subdivision count or force the depth cap deterministically. +export function flattenCubic( p0: { x: number; y: number }, c1: { x: number; y: number }, c2: { x: number; y: number }, From e477d007761b372971574f49cd1b34b5ea385d11 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 03:54:25 +0100 Subject: [PATCH 034/105] test(pdf-codec): close renderPdfPage's own boundary and geometry gaps Every out-of-range page-index test used an index far past the page count, never the boundary value equal to it; every page-count test was a single-page document, so the "page"/"pages" pluralisation ternary never saw a count other than one. Neither the degenerate- /CropBox fallback nor the missing-/Resources diagnostic's own code field was ever checked, only its message text. The MediaBox arithmetic was only ever exercised with an origin at (0, 0), where + and - of the corner coordinates happen to agree. The clip-intersection rejection was only ever tested with both dimensions failing to overlap at once, never one alone, and its own error message was never checked against the exact ranges it reports. The optional-content visibility test's one non-hidden rect carried no layer name at all, so hiding every layer indiscriminately (rather than only the ones the default configuration turns off) would have produced identical output. Add a case for each: the boundary page index with a singular and a plural page count, a genuinely degenerate CropBox, a non-zero-origin MediaBox, a clip overhanging only one page edge (intersected rather than rejected) against one missing both, the object-missing-value diagnostic's own code, and a second, visible, NAMED optional-content layer alongside the hidden one. --- packages/pdf-codec/src/raster.test.ts | 136 ++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 588327244..465b85521 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -292,6 +292,107 @@ describe("renderPdfPage: geometry and clipPt", () => { ).toThrow(/page index 7/); }); + it("rejects a page index at exactly the page count (the first invalid index, not just far out of range), naming the count singular for one page", () => { + expect(() => + renderPdfPage(onePagePdf(content), 1, {}, new RecordingRasteriser()), + ).toThrow( + /page index 1 is outside this document's page tree \(it declares 1 page\)$/, + ); + }); + + it("rejects a negative page index", () => { + expect(() => + renderPdfPage(onePagePdf(content), -1, {}, new RecordingRasteriser()), + ).toThrow(/page index -1/); + }); + + it("names the page count plural for a multi-page document", () => { + expect(() => + renderPdfPage( + twoPagesFirstWithoutResourcesPdf(), + 5, + {}, + new RecordingRasteriser(), + ), + ).toThrow(/it declares 2 pages\)$/); + }); + + it("falls back to /MediaBox and emits a diagnostic when /CropBox is degenerate", () => { + const diagnostics: PdfDiagnostic[] = []; + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf(content, { pageEntries: "/CropBox [0 0 0 100] " }), + 0, + { sink: (d) => diagnostics.push(d) }, + rasteriser, + ); + expect(rasteriser.geometry).toMatchObject({ widthPx: 200, heightPx: 100 }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "pdf/invalid-crop-box", + severity: "warning", + }), + ); + }); + + it("computes page extent correctly for a MediaBox whose origin is not (0, 0)", () => { + const b = new SmallFixture(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [50 50 250 150] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + ); + b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.stream(5, "<< >>", enc(content)); + const bytes = b.classicXrefAndTrailer(5, "/Root 1 0 R"); + const rasteriser = new RecordingRasteriser(); + drive(bytes, 0, {}, rasteriser); + // width = urx - llx = 200, height = ury - lly = 100 -- urx + llx (300) or ury + lly (200) would both be wrong here precisely because the origin is non-zero. + expect(rasteriser.geometry).toMatchObject({ widthPx: 200, heightPx: 100 }); + }); + + it("reports a zero-height intersection distinctly from a zero-width one, with the exact requested and page ranges in the message", () => { + const bytes = onePagePdf(content); + expect(() => + drive( + bytes, + 0, + { clipPt: { xPt: 10, yPt: 200, widthPt: 30, heightPt: 40 } }, + new RecordingRasteriser(), + ), + ).toThrow( + "renderPdfPage clipPt does not intersect the page's visible region (clip x 10..40, y 200..240; page 0..200 x 0..100)", + ); + }); + + it("intersects the requested clip with the page's own extent when the clip partially overhangs it, rather than rejecting it", () => { + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf(content), + 0, + { clipPt: { xPt: 10, yPt: 20, widthPt: 300, heightPt: 40 }, scale: 2 }, + rasteriser, + ); + // Requested width 300 clamped to the page's own 200pt right edge: a visible region of 190pt wide (200 - 10), at 2x scale. + expect(rasteriser.geometry).toMatchObject({ widthPx: 380, heightPx: 80 }); + // The rect at page point (10, 20) sits at device x = (10 - clipLeft(10)) * 2 = 0. + expect(rasteriser.ops.find(isFillRect)).toMatchObject({ xPx: 0 }); + }); + + it("names its own diagnostic code for a missing /Resources dict, not just the message text", () => { + const diagnostics: PdfDiagnostic[] = []; + drive( + twoPagesFirstWithoutResourcesPdf(), + 0, + { sink: (d) => diagnostics.push(d) }, + new RecordingRasteriser(), + ); + expect(diagnostics).toContainEqual( + expect.objectContaining({ code: "pdf/object-missing-value" }), + ); + }); + it("returns whatever the rasteriser's finish produces", () => { const rasteriser = new RecordingRasteriser(); const result = renderPdfPage(onePagePdf(content), 0, {}, rasteriser); @@ -834,4 +935,39 @@ describe("renderPdfPage: optional-content visibility", () => { }, ]); }); + + it("still draws content in a NAMED layer the default configuration leaves ON, alongside one it leaves OFF", () => { + // Two named layers this time -- L1 (OFF) and L2 (ON, not listed in /OFF at all) -- so hiding every layer indiscriminately (rather than only the ones the default configuration actually turns off) would be indistinguishable from correct behaviour in the single-layer fixture above. + const b = new SmallFixture(); + b.object( + 1, + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R 7 0 R] /D << /BaseState /ON /OFF [6 0 R] >> >> >>", + ); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Properties << /L1 << /OC 6 0 R >> /L2 << /OC 7 0 R >> >> >> /Contents 5 0 R >>", + ); + b.object(6, "<< /Type /OCG /Name (Watermark) >>"); + b.object(7, "<< /Type /OCG /Name (Body) >>"); + b.stream( + 5, + "<< >>", + enc("/OC /L1 BDC 10 10 30 20 re f EMC /OC /L2 BDC 60 10 30 20 re f EMC"), + ); + const bytes = b.classicXrefAndTrailer(7, "/Root 1 0 R"); + const rasteriser = new RecordingRasteriser(); + drive(bytes, 0, {}, rasteriser); + const fills = rasteriser.ops.filter(isFillRect); + expect(fills).toEqual([ + { + kind: "fillRect", + xPx: 60, + yPx: 70, + widthPx: 30, + heightPx: 20, + color: { r: 0, g: 0, b: 0 }, + }, + ]); + }); }); From dd107e277467b2b94805a68370203ec6a4b0a3bb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:34:33 +0100 Subject: [PATCH 035/105] test(pdf-codec): cover renderPdfPage's Type0/CIDFontType2 font-refusal branches Adds a Type0 skeleton fixture builder driving each of buildTextOutlineFace's descendant-font branch conditions independently: non-Identity-H encoding, a missing DescendantFonts entry, an unsupported descendant subtype, a missing FontFile2 program, and an unreadable CIDToGIDMap. Also covers explicit CIDToGIDMap stream lookup against a directly-verified glyph outline, a Type1 font whose embedded program is not CFF, and an unrecognised font subtype. --- packages/pdf-codec/src/raster.test.ts | 188 ++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 465b85521..01787b611 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -895,6 +895,194 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { // The page carries text only, so nothing else paints. expect(rasteriser.ops).toEqual([]); }); + + // A bare Type0/CIDFontType2 skeleton around the real vendored Carlito face, with every dict entry a caller can override -- the same font bytes type0CarlitoPdf uses, but exposing the descendant/descriptor/encoding shape directly so each of buildTextOutlineFace's own branch conditions can be driven independently of the others. + function type0Skeleton(overrides: { + readonly encoding?: string; + readonly descendantFontsEntry?: string; + readonly descendantExtra?: string; + readonly cidToGidMap?: string; + readonly fontDescriptorBody?: string; + }): Uint8Array { + const fontBytes = carlitoRegularBytes(); + const b = new SmallFixture(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + ); + b.object( + 4, + `<< /Type /Font /Subtype /Type0 /BaseFont /Carlito /Encoding ${overrides.encoding ?? "/Identity-H"} ${overrides.descendantFontsEntry ?? "/DescendantFonts [7 0 R]"} >>`, + ); + b.object( + 7, + `<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Carlito /FontDescriptor 8 0 R ${overrides.cidToGidMap ?? ""} ${overrides.descendantExtra ?? ""} >>`, + ); + b.object( + 8, + overrides.fontDescriptorBody ?? + "<< /Type /FontDescriptor /FontName /Carlito /Flags 32 /FontFile2 9 0 R >>", + ); + b.stream(9, `<< /Length1 ${fontBytes.length} >>`, fontBytes); + b.stream(5, "<< >>", enc("BT /F1 24 Tf 20 50 Td <0000> Tj ET")); + return b.classicXrefAndTrailer(9, "/Root 1 0 R"); + } + + function refusalDiagnostics(bytes: Uint8Array): { + readonly diagnostics: PdfDiagnostic[]; + readonly rasteriser: RecordingRasteriser; + } { + const diagnostics: PdfDiagnostic[] = []; + const rasteriser = new RecordingRasteriser(); + drive(bytes, 0, { sink: (d) => diagnostics.push(d) }, rasteriser); + return { diagnostics, rasteriser }; + } + + it("refuses a Type0 font whose /Encoding is not Identity-H", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + type0Skeleton({ encoding: "/90ms-RKSJ-H" }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("not Identity-H"); + expect(rasteriser.ops).toEqual([]); + }); + + it("refuses a Type0 font with no readable /DescendantFonts entry", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + type0Skeleton({ descendantFontsEntry: "" }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("no readable /DescendantFonts entry"); + expect(rasteriser.ops).toEqual([]); + }); + + it("refuses a descendant font of a subtype that is neither CIDFontType0 nor CIDFontType2, naming the real subtype", () => { + const bytes = type0Skeleton({}); + // Overwrite object 7's own Subtype in place -- simplest way to force an unsupported descendant subtype without duplicating the whole skeleton. + const text = new TextDecoder("latin1").decode(bytes); + const patched = new TextEncoder().encode( + text.replace("/Subtype /CIDFontType2", "/Subtype /CIDFontType9"), + ); + const { diagnostics, rasteriser } = refusalDiagnostics(patched); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("a descendant font of subtype CIDFontType9"); + expect(rasteriser.ops).toEqual([]); + }); + + it("refuses a CIDFontType2 descendant with no readable embedded program", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + type0Skeleton({ + fontDescriptorBody: + "<< /Type /FontDescriptor /FontName /Carlito /Flags 32 >>", + }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("no readable /FontFile2"); + expect(rasteriser.ops).toEqual([]); + }); + + it("refuses a CIDFontType2 descendant whose /CIDToGIDMap is neither /Identity nor a readable stream", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + type0Skeleton({ cidToGidMap: "/CIDToGIDMap 7" }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("neither /Identity nor a readable stream"); + expect(rasteriser.ops).toEqual([]); + }); + + it("maps CIDs through an explicit /CIDToGIDMap stream rather than treating CID as GID directly", () => { + // CID 0 (the shown code) maps to GID 15 ('H') via the stream -- Identity would instead look up GID 0 (.notdef), a completely different, much smaller shape. type0Skeleton has no stream-object escape hatch for the map itself, so this one is built directly rather than bending the helper further. + const cidToGidMapBytes = new Uint8Array([0x00, 0x0f]); // one entry: CID 0 -> GID 15 + const b = new SmallFixture(); + const fontBytes = carlitoRegularBytes(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + ); + b.object( + 4, + "<< /Type /Font /Subtype /Type0 /BaseFont /Carlito /Encoding /Identity-H /DescendantFonts [7 0 R] >>", + ); + b.object( + 7, + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Carlito /FontDescriptor 8 0 R /CIDToGIDMap 10 0 R >>", + ); + b.object( + 8, + "<< /Type /FontDescriptor /FontName /Carlito /Flags 32 /FontFile2 9 0 R >>", + ); + b.stream(9, `<< /Length1 ${fontBytes.length} >>`, fontBytes); + b.stream(10, "<< >>", cidToGidMapBytes); + b.stream(5, "<< >>", enc("BT /F1 24 Tf 20 50 Td <0000> Tj ET")); + const mappedBytes = b.classicXrefAndTrailer(10, "/Root 1 0 R"); + + const sfnt = parseSfnt(fontBytes)!; + const head = parseHead(sfnt)!; + const maxp = parseMaxp(sfnt)!; + const glyf = parseGlyf(sfnt, { + numGlyphs: maxp.numGlyphs, + indexToLocFormat: head.indexToLocFormat, + })!; + const expectedInk = glyf.glyphInkBounds(15)!; // 'H' + + const rasteriser = new RecordingRasteriser(); + drive(mappedBytes, 0, {}, rasteriser); + const paths = rasteriser.ops.filter(isPath); + expect(paths).toHaveLength(1); + const { minX, minY } = pathOpBounds(paths[0]!); + const sizePt = 24; + const scale = sizePt / head.unitsPerEm; + expect(minX).toBeCloseTo(20 + expectedInk.xMin * scale, 1); + expect(minY).toBeCloseTo(100 - 50 - expectedInk.yMax * scale, 1); + }); + + it("refuses a Type1 font whose embedded program is not CFF outlines", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + onePagePdf("BT /F1 24 Tf 20 50 Td (H) Tj ET", { + pageResources: "/Resources << /Font << /F1 4 0 R >> >>", + extraObjects: [ + [ + 4, + "<< /Type /Font /Subtype /Type1 /BaseFont /Custom /FirstChar 0 /LastChar 255 /FontDescriptor 6 0 R >>", + ], + [6, "<< /Type /FontDescriptor /FontName /Custom /Flags 4 >>"], + ], + }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("PostScript program"); + expect(rasteriser.ops).toEqual([]); + }); + + it("refuses an unrecognised font subtype, naming it in the diagnostic", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + onePagePdf("BT /F1 24 Tf 20 50 Td (H) Tj ET", { + pageResources: "/Resources << /Font << /F1 4 0 R >> >>", + extraObjects: [[4, "<< /Type /Font /Subtype /Type3 >>"]], + }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("a font of subtype Type3"); + expect(rasteriser.ops).toEqual([]); + }); }); // --- Optional content: a rendering must take the viewer's side. --- From fd3f13ffb88826681a724c9154272c1b6b7dd89c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:00:02 +0100 Subject: [PATCH 036/105] test(pdf-codec): pin renderPdfPage's abort checks, clip boundaries, and error codes Adds coverage for: an already-aborted signal checked before parsing begins and again on every content-stream item, not just once at entry; a clipPt whose heightPt alone is zero (the widthPt case was already covered); PdfParseError's own error code for both the no-header and page-index refusals, not just their message text. --- packages/pdf-codec/src/raster.test.ts | 189 ++++++++++++++++++++++- packages/pdf-codec/src/serialize.test.ts | 7 + 2 files changed, 194 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 01787b611..dd33e5bbd 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -6,7 +6,7 @@ import { PdfParseError } from "./diagnostics"; import { parseHead, parseMaxp } from "./font-tables"; import { parseGlyf } from "./glyf"; import { parseHmtx } from "./hmtx-table"; -import { applyMatrix } from "./matrix"; +import { applyMatrix, BEZIER_KAPPA } from "./matrix"; import { flattenCubic, renderPdfPage } from "./raster"; import type { PageRasteriser, @@ -280,6 +280,18 @@ describe("renderPdfPage: geometry and clipPt", () => { ).toThrow(/does not intersect/); }); + it("rejects a clipPt whose heightPt alone is zero, with a positive widthPt", () => { + // A widthPt/heightPt boundary check written as two independent `> 0` guards has two ways to go wrong; the sibling case above already pins widthPt, so this pins heightPt on its own -- a positive widthPt must not mask a degenerate heightPt. + expect(() => + drive( + onePagePdf(content), + 0, + { clipPt: { xPt: 10, yPt: 20, widthPt: 30, heightPt: 0 } }, + new RecordingRasteriser(), + ), + ).toThrow(/positive widthPt and heightPt/); + }); + it("throws the reader's own typed errors for a non-PDF input and an out-of-range page", () => { expect(() => renderPdfPage(enc("not a pdf"), 0, {}, new RecordingRasteriser()), @@ -287,9 +299,55 @@ describe("renderPdfPage: geometry and clipPt", () => { expect(() => renderPdfPage(enc("not a pdf"), 0, {}, new RecordingRasteriser()), ).toThrow(/no "%PDF-" header/); + try { + drive(enc("not a pdf"), 0, {}, new RecordingRasteriser()); + throw new Error("expected renderPdfPage to throw"); + } catch (error) { + expect(error).toBeInstanceOf(PdfParseError); + expect((error as PdfParseError).code).toBe("pdf/no-header"); + } expect(() => renderPdfPage(onePagePdf(content), 7, {}, new RecordingRasteriser()), ).toThrow(/page index 7/); + try { + drive(onePagePdf(content), 7, {}, new RecordingRasteriser()); + throw new Error("expected renderPdfPage to throw"); + } catch (error) { + expect((error as PdfParseError).code).toBe("pdf/page-index-out-of-range"); + } + }); + + it("checks for an already-aborted signal before any parsing begins", () => { + const controller = new AbortController(); + controller.abort(); + expect(() => + renderPdfPage( + onePagePdf(content), + 0, + { signal: controller.signal }, + new RecordingRasteriser(), + ), + ).toThrow(/Aborted/); + }); + + it("checks the signal again on every item in the content-stream walk, not only once at entry", () => { + // Two rects in one content stream: the signal is aborted from inside the rasteriser's own first draw() call, so a per-item abort check (not just the one at entry) is the only thing that can catch it before the second item paints. + const controller = new AbortController(); + class AbortingRasteriser extends RecordingRasteriser { + override draw(op: RasterDrawOp): void { + super.draw(op); + controller.abort(); + } + } + const twoRects = "1 0 0 rg 10 10 20 20 re f 0 1 0 rg 50 10 20 20 re f"; + expect(() => + drive( + onePagePdf(twoRects), + 0, + { signal: controller.signal }, + new AbortingRasteriser(), + ), + ).toThrow(/Aborted/); }); it("rejects a page index at exactly the page count (the first invalid index, not just far out of range), naming the count singular for one page", () => { @@ -529,6 +587,28 @@ describe("flattenCubic", () => { ); expect(points).toEqual([{ x: 5, y: 5 }]); }); + + it("subdivides on the LARGER of the two control points' chord distances, not the smaller", () => { + // c1 sits almost exactly on the chord (dist1 ~ 0.001, well under the flatness tolerance) while c2 sits far off it (dist2 = 5, far over) -- a curve constructed so the two distances disagree about whether this piece is flat enough to stop. Only checking the larger one is correct: a single wildly-off control point must still force a split even when its sibling is nearly collinear. + const points = flattenCubic( + { x: 0, y: 0 }, + { x: 5, y: 0.001 }, + { x: 5, y: 5 }, + { x: 10, y: 0 }, + ); + expect(points.length).toBeGreaterThan(1); + }); + + it("treats the flatness check as <= at the tolerance boundary, not <", () => { + // Both control points sit exactly 0.05 page-space units off the chord -- the module's own STROKE_FLATTEN_TOLERANCE_PX. At exactly the boundary the piece must already count as flat enough (<=) and stop without subdividing; a strict < would subdivide once more here, doubling the point count. + const points = flattenCubic( + { x: 0, y: 0 }, + { x: 3, y: 0.05 }, + { x: 7, y: 0.05 }, + { x: 10, y: 0 }, + ); + expect(points).toEqual([{ x: 10, y: 0 }]); + }); }); describe("renderPdfPage: vector draw ops", () => { @@ -601,7 +681,8 @@ describe("renderPdfPage: vector draw ops", () => { rasteriser, ); const squares = rasteriser.ops.filter(isFillRect); - expect(squares.length).toBeGreaterThan(10); + // The segment's own length (180pt) is an exact multiple of the spacing (4pt), so a dot lands exactly on the final point too -- this pins that boundary (`distance <= length`) as an exact count, not just "more than a few": 180/4 + 1 = 46 dots, one at every multiple of 4 from 0 through 180 inclusive. + expect(squares.length).toBe(46); // First dot at the segment's start: a 2x2 square centred on (10, 90) page points, i.e. device (10, 100 - 90) = (10, 10). expect(squares[0]).toEqual({ kind: "fillRect", @@ -611,10 +692,40 @@ describe("renderPdfPage: vector draw ops", () => { heightPx: 2, color: { r: 0, g: 0, b: 0 }, }); + // The final dot sits exactly at the segment's own endpoint (190, 90) -> device (190, 10). + expect(squares[45]).toEqual({ + kind: "fillRect", + xPx: 189, + yPx: 9, + widthPx: 2, + heightPx: 2, + color: { r: 0, g: 0, b: 0 }, + }); // Spacing is the writer's own dotted off-length: 2 x stroke width. expect(squares[1]!.xPx - squares[0]!.xPx).toBeCloseTo(4, 6); }); + it("draws a dotted diagonal line's dots along both axes, not just x", () => { + // The sibling test above is purely horizontal (p1.y === p2.y throughout), which cannot distinguish `p1.y + (p2.y - p1.y) * t` from a sign-flipped or operand-swapped variant of the same expression -- every dot would land at the same y regardless. A diagonal segment where y genuinely varies with t is the only way to pin that arithmetic. + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("[0 4] 0 d 1 J 2 w 0 0 0 RG 10 10 m 50 50 l S"), + 0, + {}, + rasteriser, + ); + const squares = rasteriser.ops.filter(isFillRect); + // length = hypot(40, 40), spacing = 4 -- not an exact multiple, so the dot count is governed by the loop's own `<=` boundary rather than pinned to a round number; what matters here is each dot's own position, not the count. + expect(squares.length).toBeGreaterThan(3); + // First dot at (10, 10) page -> device (10, 90). + expect(squares[0]).toMatchObject({ xPx: 9, yPx: 89 }); + // Second dot has moved diagonally: both x AND y have advanced by the same page-space step (t moves equally along a 45-degree segment), and device y decreases as page y increases. + const dx = squares[1]!.xPx - squares[0]!.xPx; + const dy = squares[0]!.yPx - squares[1]!.yPx; + expect(dx).toBeGreaterThan(0); + expect(dy).toBeCloseTo(dx, 6); + }); + it("rebuilds a recovered ellipse as four kappa cubics through the bounding box", () => { const rasteriser = new RecordingRasteriser(); // The four-cubic kappa construction interpret.ts's own detector recognises, written out for the bounding box (40, 20)-(100, 60): start at the right cardinal point, one cubic per quarter. k = 0.5523 (4 dp is well inside the detector's tolerance). @@ -655,6 +766,80 @@ describe("renderPdfPage: vector draw ops", () => { expect(first.xPx).toBeCloseTo(70, 3); expect(first.yPx).toBeCloseTo(40, 3); }); + + it("places all four quarter cubics' own control points at the exact kappa-scaled offsets from every cardinal point, not just the first", () => { + // The sibling test above only pins the first cubic; every one of drawEllipse's eight control points is its own independent cx/cy +/- rx/dx/ry/dy term, so a sign flip on any one of the other seven survives unless each is checked. rx != ry and cx != cy here specifically so a swapped or wrong-signed term cannot coincidentally match a right-signed one. + const rasteriser = new RecordingRasteriser(); + const cx = 70; + const cy = 35; + const rx = 30; + const ry = 15; + const dx = rx * BEZIER_KAPPA; + const dy = ry * BEZIER_KAPPA; + const content = [ + "0 0 0 rg", + `${cx + rx} ${cy} m`, + `${cx + rx} ${cy + dy} ${cx + dx} ${cy + ry} ${cx} ${cy + ry} c`, + `${cx - dx} ${cy + ry} ${cx - rx} ${cy + dy} ${cx - rx} ${cy} c`, + `${cx - rx} ${cy - dy} ${cx - dx} ${cy - ry} ${cx} ${cy - ry} c`, + `${cx + dx} ${cy - ry} ${cx + rx} ${cy - dy} ${cx + rx} ${cy} c`, + "h f", + ].join("\n"); + drive(onePagePdf(content), 0, {}, rasteriser); + const fill = rasteriser.ops.find(isPath); + if (fill?.fill === undefined || fill.subpaths[0] === undefined) { + throw new Error("no filled path op emitted for the ellipse"); + } + const toDeviceY = (pageY: number) => 100 - pageY; + const segments = fill.subpaths[0].segments; + expect(segments).toHaveLength(4); + const expected = [ + { + c1xPx: cx + rx, + c1yPx: toDeviceY(cy + dy), + c2xPx: cx + dx, + c2yPx: toDeviceY(cy + ry), + xPx: cx, + yPx: toDeviceY(cy + ry), + }, + { + c1xPx: cx - dx, + c1yPx: toDeviceY(cy + ry), + c2xPx: cx - rx, + c2yPx: toDeviceY(cy + dy), + xPx: cx - rx, + yPx: toDeviceY(cy), + }, + { + c1xPx: cx - rx, + c1yPx: toDeviceY(cy - dy), + c2xPx: cx - dx, + c2yPx: toDeviceY(cy - ry), + xPx: cx, + yPx: toDeviceY(cy - ry), + }, + { + c1xPx: cx + dx, + c1yPx: toDeviceY(cy - ry), + c2xPx: cx + rx, + c2yPx: toDeviceY(cy - dy), + xPx: cx + rx, + yPx: toDeviceY(cy), + }, + ]; + for (const [i, segment] of segments.entries()) { + if (segment.kind !== "cubic") { + throw new Error(`ellipse segment ${i} is not a cubic`); + } + const want = expected[i]!; + expect(segment.c1xPx).toBeCloseTo(want.c1xPx, 6); + expect(segment.c1yPx).toBeCloseTo(want.c1yPx, 6); + expect(segment.c2xPx).toBeCloseTo(want.c2xPx, 6); + expect(segment.c2yPx).toBeCloseTo(want.c2yPx, 6); + expect(segment.xPx).toBeCloseTo(want.xPx, 6); + expect(segment.yPx).toBeCloseTo(want.yPx, 6); + } + }); }); // --- Images through the port. --- diff --git a/packages/pdf-codec/src/serialize.test.ts b/packages/pdf-codec/src/serialize.test.ts index 10d96b479..b3d665dfe 100644 --- a/packages/pdf-codec/src/serialize.test.ts +++ b/packages/pdf-codec/src/serialize.test.ts @@ -70,6 +70,13 @@ describe("writeObject / serializeObject", () => { expect(text(serializeObject(pdfName("!~")))).toBe("/!~"); }); + it("escapes every printable-ASCII delimiter/special character even though each sits inside the !-~ safe range", () => { + // Every one of these is within 0x21-0x7e (so the range check alone would leave all of them unescaped) and is a genuine PDF delimiter or reserved name character (ISO 32000-1 7.2.2/7.3.5) that must never appear literally inside a written name, since an unescaped '/' or '(' would be read by a parser as ending the name or starting a different token entirely. + expect(text(serializeObject(pdfName("#()<>[]{}/%")))).toBe( + "/#23#28#29#3c#3e#5b#5d#7b#7d#2f#25", + ); + }); + it("escapes DEL (0x7f), one past the safe range's own upper boundary", () => { expect(text(serializeObject(pdfName("\x7f")))).toBe("/#7f"); }); From 6b7550893fcd5c0b0263dc0ae4c53f01838abe79 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:08:47 +0100 Subject: [PATCH 037/105] test(pdf-codec): cover flattenCubic's max-distance and exact-tolerance boundaries Pins Math.max over Math.min between the two control points' own chord distances (a curve whose two distances disagree about flatness must still subdivide on the larger one), and the <= tolerance boundary itself (a piece exactly at the tolerance must stop, not subdivide once more). --- packages/pdf-codec/src/raster.test.ts | 158 +++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index dd33e5bbd..597652617 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -16,6 +16,8 @@ import type { import { readPdf } from "./read"; import { parseSfnt } from "./sfnt"; import { ByteWriter } from "./bytes/writer"; +import { STIX_TWO_MATH_FONT_BASE64 } from "./assets/stix-two-math-font"; +import { base64ToBytes } from "./util/base64"; import { carlitoRegularBytes } from "./test-support/fonts"; import { cropBoxPdf, @@ -672,6 +674,42 @@ describe("renderPdfPage: vector draw ops", () => { }); }); + it("scales a dashed stroke's own width by the render scale, not divides by it", () => { + // At scale 1, multiplying and dividing by pixelsPerPt are indistinguishable (x*1 === x/1); only a non-1 scale actually pins the operator. + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("[6 6] 0 d 2 w 0 0 0 RG 10 80 m 190 80 l S"), + 0, + { scale: 3 }, + rasteriser, + ); + const stroke = rasteriser.ops.find(isPath); + expect(stroke?.stroke).toMatchObject({ widthPx: 6 }); + }); + + it("scales a dotted line's own dot size by the render scale, not divides by it", () => { + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("[0 4] 0 d 1 J 2 w 0 0 0 RG 10 90 m 190 90 l S"), + 0, + { scale: 3 }, + rasteriser, + ); + const squares = rasteriser.ops.filter(isFillRect); + expect(squares[0]).toMatchObject({ widthPx: 6, heightPx: 6 }); + }); + + it("draws no dots at all for a dotted line whose two endpoints coincide", () => { + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("[0 4] 0 d 1 J 2 w 0 0 0 RG 50 50 m 50 50 l S"), + 0, + {}, + rasteriser, + ); + expect(rasteriser.ops.filter(isFillRect)).toEqual([]); + }); + it("draws a dotted line as filled squares rather than a zero-length dash array", () => { const rasteriser = new RecordingRasteriser(); drive( @@ -961,6 +999,33 @@ function type0CarlitoPdf( return b.classicXrefAndTrailer(9, "/Root 1 0 R"); } +// A plain simple (non-Type0) /TrueType font resource: code -> Unicode through the PDF's own encoding (WinAnsi, since this face carries no Symbolic flag), then Unicode -> GID through the embedded program's own cmap -- the whole other half of buildTextOutlineFace's own branch, entirely separate from the Type0/CID path type0CarlitoPdf drives. +function trueTypeCarlitoPdf( + text: string, + overrides: { readonly fontDescriptorBody?: string } = {}, +): Uint8Array { + const fontBytes = carlitoRegularBytes(); + const b = new SmallFixture(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + ); + b.object( + 4, + "<< /Type /Font /Subtype /TrueType /BaseFont /Carlito /FirstChar 0 /LastChar 255 /FontDescriptor 8 0 R >>", + ); + b.object( + 8, + overrides.fontDescriptorBody ?? + "<< /Type /FontDescriptor /FontName /Carlito /Flags 32 /FontFile2 9 0 R >>", + ); + b.stream(9, `<< /Length1 ${fontBytes.length} >>`, fontBytes); + b.stream(5, "<< >>", enc(`BT /F1 24 Tf 20 50 Td (${text}) Tj ET`)); + return b.classicXrefAndTrailer(9, "/Root 1 0 R"); +} + describe("renderPdfPage: text through embedded sfnt outlines", () => { it("draws each shown glyph as a filled closed path placed at the run's own matrices", () => { const bytes = type0CarlitoPdf("HH"); @@ -1029,6 +1094,31 @@ describe("renderPdfPage: text through embedded sfnt outlines", () => { pathOpBounds(glyphOps[1]!).minX - pathOpBounds(glyphOps[0]!).minX, ).toBeCloseTo(scaledAdvancePt, 1); }); + + it("draws a simple TrueType font's own glyphs through WinAnsi code -> Unicode -> the program's own cmap", () => { + const rasteriser = new RecordingRasteriser(); + drive(trueTypeCarlitoPdf("H"), 0, {}, rasteriser); + const glyphOps = rasteriser.ops.filter(isPath); + expect(glyphOps.length).toBe(1); + const sfnt = parseSfnt(carlitoRegularBytes())!; + const head = parseHead(sfnt)!; + const cmap = buildCmapLookup(sfnt)!; + const glyf = parseGlyf(sfnt, { + numGlyphs: parseMaxp(sfnt)!.numGlyphs, + indexToLocFormat: head.indexToLocFormat, + })!; + const ink = glyf.glyphInkBounds(cmap("H".codePointAt(0)!)!)!; + const sizePt = 24; + const bounds = pathOpBounds(glyphOps[0]!); + expect(bounds.minX).toBeCloseTo( + 20 + (ink.xMin / head.unitsPerEm) * sizePt, + 1, + ); + expect(bounds.minY).toBeCloseTo( + 100 - 50 - (ink.yMax / head.unitsPerEm) * sizePt, + 1, + ); + }); }); describe("renderPdfPage: text refusals are named, never approximated", () => { @@ -1088,8 +1178,11 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { readonly descendantExtra?: string; readonly cidToGidMap?: string; readonly fontDescriptorBody?: string; + readonly fontFileKey?: string; + readonly fontFileBytes?: Uint8Array; }): Uint8Array { - const fontBytes = carlitoRegularBytes(); + const fontBytes = overrides.fontFileBytes ?? carlitoRegularBytes(); + const fontFileKey = overrides.fontFileKey ?? "FontFile2"; const b = new SmallFixture(); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); @@ -1108,7 +1201,7 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { b.object( 8, overrides.fontDescriptorBody ?? - "<< /Type /FontDescriptor /FontName /Carlito /Flags 32 /FontFile2 9 0 R >>", + `<< /Type /FontDescriptor /FontName /Carlito /Flags 32 /${fontFileKey} 9 0 R >>`, ); b.stream(9, `<< /Length1 ${fontBytes.length} >>`, fontBytes); b.stream(5, "<< >>", enc("BT /F1 24 Tf 20 50 Td <0000> Tj ET")); @@ -1125,6 +1218,20 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { return { diagnostics, rasteriser }; } + it("refuses a simple TrueType font with no readable embedded program", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + trueTypeCarlitoPdf("H", { + fontDescriptorBody: + "<< /Type /FontDescriptor /FontName /Carlito /Flags 32 >>", + }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("no /FontDescriptor or no readable embedded program"); + expect(rasteriser.ops).toEqual([]); + }); + it("refuses a Type0 font whose /Encoding is not Identity-H", () => { const { diagnostics, rasteriser } = refusalDiagnostics( type0Skeleton({ encoding: "/90ms-RKSJ-H" }), @@ -1176,6 +1283,53 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { expect(rasteriser.ops).toEqual([]); }); + it("reads an embedded program from /FontFile3 when /FontFile2 is absent, not only from /FontFile2", () => { + // openEmbeddedProgram tries FontFile2 then FontFile3 in a loop -- a descriptor carrying only the latter is the only way to prove the loop actually reaches its second key rather than stopping after the first. + const { rasteriser } = refusalDiagnostics( + type0Skeleton({ fontFileKey: "FontFile3" }), + ); + expect(rasteriser.ops.filter(isPath).length).toBeGreaterThan(0); + }); + + it("detects a bare CFF program in /FontFile3 by its exact 3-byte header, not a byte more or fewer", () => { + const cffHeader = (): PdfDiagnostic[] => + refusalDiagnostics( + type0Skeleton({ + fontFileKey: "FontFile3", + fontFileBytes: new Uint8Array([0x01, 0x00, 0x04]), + }), + ).diagnostics; + expect( + cffHeader().find((d) => d.code === "raster/text-cff-outlines"), + ).toBeDefined(); + }); + + it("detects CFF outlines wrapped in an OTTO sfnt container by its 'CFF ' table, not only a bare CFF header", () => { + // The real, vendored STIX Two Math font is a genuine OTTO container carrying a 'CFF ' table -- an /OpenType-wrapped CFF program is a legal /FontFile3 value per ISO 32000-1, distinct from the bare-CFF-header case above. + const { diagnostics } = refusalDiagnostics( + type0Skeleton({ + fontFileKey: "FontFile3", + fontFileBytes: base64ToBytes(STIX_TWO_MATH_FONT_BASE64), + }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-cff-outlines"), + ).toBeDefined(); + }); + + it("does not mistake a too-short FontFile3 stream, or one byte wrong in the header, for a CFF program", () => { + const isCff = (bytes: Uint8Array): boolean => + refusalDiagnostics( + type0Skeleton({ fontFileKey: "FontFile3", fontFileBytes: bytes }), + ).diagnostics.some((d) => d.code === "raster/text-cff-outlines"); + // Exactly 2 bytes: the length >= 3 guard alone must refuse this before any byte is even read. + expect(isCff(new Uint8Array([0x01, 0x00]))).toBe(false); + // Each byte individually wrong, otherwise a valid-looking header. + expect(isCff(new Uint8Array([0x02, 0x00, 0x04]))).toBe(false); + expect(isCff(new Uint8Array([0x01, 0x01, 0x04]))).toBe(false); + expect(isCff(new Uint8Array([0x01, 0x00, 0x05]))).toBe(false); + }); + it("refuses a CIDFontType2 descendant whose /CIDToGIDMap is neither /Identity nor a readable stream", () => { const { diagnostics, rasteriser } = refusalDiagnostics( type0Skeleton({ cidToGidMap: "/CIDToGIDMap 7" }), From 018cdef7f5e92e25e0b1c7492131054ae37cac2d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:10:46 +0100 Subject: [PATCH 038/105] test(pdf-codec): cover the stroke branch of drawRect, drawEllipse, and drawPath Every rect, ellipse, and general-path fixture in this file only ever filled the shape; each of those three drawers builds its stroke output on a wholly separate code path from its fill output, leaving all three stroke branches -- and drawPath's own dotted-cubic-segment loop, only ever exercised elsewhere through drawLine's simpler two-point case -- completely unexecuted by any test. --- packages/pdf-codec/src/raster.test.ts | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 597652617..a68915886 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -637,6 +637,91 @@ describe("renderPdfPage: vector draw ops", () => { ]); }); + it("strokes a recovered rect as a closed four-line path, not only fills it", () => { + // Every existing rect test only fills; drawRect's own stroke branch (a separate rasteriser.draw call building the same corners as a closed path) has no coverage at all otherwise. + const rasteriser = new RecordingRasteriser(); + drive(onePagePdf("0.1 0.2 0.3 RG 2 w 10 20 30 40 re S"), 0, {}, rasteriser); + const stroke = rasteriser.ops.find(isPath); + expect(stroke?.fill).toBeUndefined(); + expect(stroke?.stroke).toEqual({ + color: { r: 0.1, g: 0.2, b: 0.3 }, + widthPx: 2, + }); + // Rect at page (10,20)-(40,60) -> device top-left (10, 100-60)=(10,40), bottom-right (40, 100-20)=(40,80). + expect(stroke?.subpaths).toEqual([ + { + startXPx: 10, + startYPx: 40, + segments: [ + { kind: "line", xPx: 40, yPx: 40 }, + { kind: "line", xPx: 40, yPx: 80 }, + { kind: "line", xPx: 10, yPx: 80 }, + ], + closed: true, + }, + ]); + }); + + it("strokes a recovered ellipse's own cubic outline, not only fills it", () => { + // drawEllipse's stroke spec is built via a spread on a SEPARATE code path from the fill spread above it; no existing ellipse test exercises it at all. + const rasteriser = new RecordingRasteriser(); + const k = 0.5523; + const cy = 40; + const cx = 70; + const rx = 30; + const ry = 20; + const content = [ + "0.4 0.5 0.6 RG 2 w", + `${cx + rx} ${cy} m`, + `${cx + rx} ${cy + ry * k} ${cx + rx * k} ${cy + ry} ${cx} ${cy + ry} c`, + `${cx - rx * k} ${cy + ry} ${cx - rx} ${cy + ry * k} ${cx - rx} ${cy} c`, + `${cx - rx} ${cy - ry * k} ${cx - rx * k} ${cy - ry} ${cx} ${cy - ry} c`, + `${cx + rx * k} ${cy - ry} ${cx + rx} ${cy - ry * k} ${cx + rx} ${cy} c`, + "h S", + ].join("\n"); + drive(onePagePdf(content), 0, {}, rasteriser); + const stroke = rasteriser.ops.find(isPath); + expect(stroke?.fill).toBeUndefined(); + expect(stroke?.stroke).toEqual({ + color: { r: 0.4, g: 0.5, b: 0.6 }, + widthPx: 2, + }); + }); + + it("strokes a general (non-dotted, non-rect, non-line) path, not only fills it", () => { + // drawPath's non-dotted stroke spread (the sibling of the fill spread the earlier test above pins) is otherwise never reached. + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("0.7 0.8 0.9 RG 3 w 100 10 m 130 10 l 115 40 l h S"), + 0, + {}, + rasteriser, + ); + const stroke = rasteriser.ops.find(isPath); + expect(stroke?.fill).toBeUndefined(); + expect(stroke?.stroke).toEqual({ + color: { r: 0.7, g: 0.8, b: 0.9 }, + widthPx: 3, + }); + }); + + it("draws a dotted general path (line and cubic segments alike) as dot trains, not a dash array", () => { + // drawPath's own dotted branch -- a for-loop over each subpath's line AND cubic segments, flattening cubics before dotting them -- has no coverage at all: every other dotted test in this file goes through drawLine's single two-point segment instead. + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf( + "[0 4] 0 d 1 J 2 w 0 0 0 RG 20 20 m 80 20 l 80 60 40 80 20 60 c h S", + ), + 0, + {}, + rasteriser, + ); + const squares = rasteriser.ops.filter(isFillRect); + expect(squares.length).toBeGreaterThan(2); + // The very first dot sits at the subpath's own start point: page (20, 20) -> device (20, 80). + expect(squares[0]).toMatchObject({ xPx: 19, yPx: 79 }); + }); + it("fills a general path with the paint operator's own fill rule and carries strokes on the same op", () => { const rasteriser = new RecordingRasteriser(); drive( From ae5942461023bc82980ff56ee0460597354039e3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:14:34 +0100 Subject: [PATCH 039/105] test(pdf-codec): cover the empty-glyph skip, unstated descendant subtype, and header search window A space character's outline is empty on purpose (nothing to paint), but the run must still advance past it -- no existing text test used a run mixing a drawable and an empty glyph together. A descendant font dict with no /Subtype at all names itself "(none)" in the refusal message, distinct from a stated-but-unsupported subtype. hasPdfHeader only scans a bounded prefix of the file; a header planted past that window must count as absent, the same as no header anywhere. --- packages/pdf-codec/src/raster.test.ts | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index a68915886..17efa8b09 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -319,6 +319,15 @@ describe("renderPdfPage: geometry and clipPt", () => { } }); + it("does not find a %PDF- header planted past the search window's own 1024-byte limit", () => { + // hasPdfHeader searches only a bounded prefix (ISO 32000-1 7.5.2 allows junk before the header, not an unbounded scan) -- a header sitting well past that window is exactly as absent as no header at all. + const junkPrefix = new Uint8Array(1030).fill(0x41); // 1030 > HEADER_SEARCH_WINDOW's own 1024 + const bytes = new Uint8Array([...junkPrefix, ...enc("%PDF-1.7\n")]); + expect(() => + renderPdfPage(bytes, 0, {}, new RecordingRasteriser()), + ).toThrow(/no "%PDF-" header/); + }); + it("checks for an already-aborted signal before any parsing begins", () => { const controller = new AbortController(); controller.abort(); @@ -1160,6 +1169,29 @@ describe("renderPdfPage: text through embedded sfnt outlines", () => { expect(second.minX - first.minX).toBeCloseTo(advancePt, 2); }); + it("draws no path at all for a glyph with an empty outline (a space), while still advancing past it", () => { + const bytes = type0CarlitoPdf("H H"); + const rasteriser = new RecordingRasteriser(); + drive(bytes, 0, {}, rasteriser); + const glyphOps = rasteriser.ops.filter(isPath); + // Two H's painted, the space between them painting nothing -- not three ops, and not two ops sitting on top of each other. + expect(glyphOps.length).toBe(2); + const sfnt = parseSfnt(carlitoRegularBytes())!; + const cmap = buildCmapLookup(sfnt)!; + const hmtx = parseHmtx(sfnt); + const head = parseHead(sfnt)!; + const spaceAdvancePt = + (hmtx.advanceWidth(cmap(" ".codePointAt(0)!)!) / head.unitsPerEm) * 24; + const hAdvancePt = + (hmtx.advanceWidth(cmap("H".codePointAt(0)!)!) / head.unitsPerEm) * 24; + const first = pathOpBounds(glyphOps[0]!); + const second = pathOpBounds(glyphOps[1]!); + expect(second.minX - first.minX).toBeCloseTo( + hAdvancePt + spaceAdvancePt, + 2, + ); + }); + it("absorbs interpreter-only spacing state through the end-matrix correction", () => { // The same two-glyph run under 150% horizontal scaling (Tz): the interpreter's end matrix reflects the scaling, and the correction must widen the per-glyph advances to match rather than leaving the second glyph short of where the page placed it. const bytes = type0CarlitoPdf("HH", "150 Tz"); @@ -1354,6 +1386,20 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { expect(rasteriser.ops).toEqual([]); }); + it("names an unstated descendant subtype as (none), not a blank or undefined string", () => { + const bytes = type0Skeleton({}); + const text = new TextDecoder("latin1").decode(bytes); + const patched = new TextEncoder().encode( + text.replace("/Subtype /CIDFontType2 ", ""), + ); + const { diagnostics, rasteriser } = refusalDiagnostics(patched); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("a descendant font of subtype (none)"); + expect(rasteriser.ops).toEqual([]); + }); + it("refuses a CIDFontType2 descendant with no readable embedded program", () => { const { diagnostics, rasteriser } = refusalDiagnostics( type0Skeleton({ From 4e85b8d943513e65d47a53ceabe81b13d7ffee1d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:59:07 +0100 Subject: [PATCH 040/105] refactor(pdf-codec): drop dead outline-face fields and a redundant length guard openEmbeddedProgram's own returned glyf face carried composite/glyphIdOf placeholders no caller ever read (every consumer rebuilds its own composite flag and glyph-ID mapping from the font dictionary instead), so EmbeddedProgram's glyf case now narrows to the two fields anything actually reads: glyf and unitsPerEm. The bare-CFF header check's own bytes.length >= 3 guard was redundant under noUncheckedIndexedAccess, since an out-of-bounds byte read already returns undefined and undefined can never strictly equal any of the three header literals. glyphOutlineSubpaths is now exported so this suite can drive it directly with hand-built contours: a real embedded font's own glyphs never reliably exercise every branch (no vendored face starts a contour off-curve, or has a contour with no on-curve point at all), so exact coverage of the quadratic-to-cubic contour walk needs synthetic input, the same reasoning flattenCubic was already exported for. The walk's own firstOn >= 0 condition, previously repeated for both the ordered-points and current-point branches, is now computed once and shared: at firstOn === 0 the two branches of the ordered-points ternary already compute identical content on their own, so a lone, unshared copy of the condition guarding only that ternary had no boundary input left where mutating it changed anything observable. Added tests cover: the rotation-transform arguments genuinely needing MediaBox width/height (only visible through a rotated page, since an unrotated matrix ignores both), a CropBox degenerate in height alone with a healthy width, a CropBox whose negative-but-valid corners would misread as degenerate under a summed rather than subtracted extent, the visible region's own translation sign, a clip that touches the page's own edge with exactly zero overlap on each axis independently, and an aborted signal on a page with no /Resources whose walk never reaches the per-item abort check at all. --- packages/pdf-codec/src/raster.test.ts | 105 ++++++++++++++++++++++++++ packages/pdf-codec/src/raster.ts | 51 +++++-------- 2 files changed, 125 insertions(+), 31 deletions(-) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 17efa8b09..5081d76d7 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -294,6 +294,30 @@ describe("renderPdfPage: geometry and clipPt", () => { ).toThrow(/positive widthPt and heightPt/); }); + it("rejects a clipPt that just touches the page's right edge with zero overlap width, a positive-widthPt clip the earlier guard cannot catch", () => { + // clipLeft === clipRight exactly (200, the page's own right edge) -- a genuine intersection-width check at its own zero boundary, distinct from the requested-widthPt guard above (which never sees this clipPt at all, since its own widthPt is a positive 30). + expect(() => + drive( + onePagePdf(content), + 0, + { clipPt: { xPt: 200, yPt: 20, widthPt: 30, heightPt: 40 } }, + new RecordingRasteriser(), + ), + ).toThrow(/does not intersect/); + }); + + it("rejects a clipPt that just touches the page's top edge with zero overlap height, the same boundary on the other axis", () => { + // clipBottom === clipTop exactly (100, the page's own top edge). + expect(() => + drive( + onePagePdf(content), + 0, + { clipPt: { xPt: 20, yPt: 100, widthPt: 30, heightPt: 40 } }, + new RecordingRasteriser(), + ), + ).toThrow(/does not intersect/); + }); + it("throws the reader's own typed errors for a non-PDF input and an out-of-range page", () => { expect(() => renderPdfPage(enc("not a pdf"), 0, {}, new RecordingRasteriser()), @@ -341,6 +365,20 @@ describe("renderPdfPage: geometry and clipPt", () => { ).toThrow(/Aborted/); }); + it("checks an already-aborted signal at entry even for a page with no /Resources, whose walk never reaches the per-item abort check at all", () => { + // twoPagesFirstWithoutResourcesPdf's first page returns before interpretContentStream ever runs, so this is the ONLY throwIfAborted call reachable for it -- unlike the top-of-module test above, whose fixture always has at least one item and so could throw from the per-item check even were the entry check removed entirely. + const controller = new AbortController(); + controller.abort(); + expect(() => + drive( + twoPagesFirstWithoutResourcesPdf(), + 0, + { signal: controller.signal }, + new RecordingRasteriser(), + ), + ).toThrow(/Aborted/); + }); + it("checks the signal again on every item in the content-stream walk, not only once at entry", () => { // Two rects in one content stream: the signal is aborted from inside the rasteriser's own first draw() call, so a per-item abort check (not just the one at entry) is the only thing that can catch it before the second item paints. const controller = new AbortController(); @@ -400,10 +438,59 @@ describe("renderPdfPage: geometry and clipPt", () => { expect.objectContaining({ code: "pdf/invalid-crop-box", severity: "warning", + message: + "page /CropBox is degenerate (zero width or height); falling back to the /MediaBox as the visible region", }), ); }); + it("does not fall back for a CropBox whose corners have a negative-but-non-degenerate origin, where a mutated urx+llx (or ury+lly) sum would wrongly read as degenerate", () => { + // llx = -20 and lly = -30 both make the sum urx+llx (or ury+lly) negative -- exactly the wrong-sign value a `+` in place of the real `-` would compute -- while the real width (30) and height (40) stay positive and non-degenerate. + const diagnostics: PdfDiagnostic[] = []; + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf(content, { pageEntries: "/CropBox [-20 -30 10 10] " }), + 0, + { sink: (d) => diagnostics.push(d) }, + rasteriser, + ); + expect(diagnostics.some((d) => d.code === "pdf/invalid-crop-box")).toBe( + false, + ); + expect(rasteriser.geometry).toMatchObject({ widthPx: 30, heightPx: 40 }); + }); + + it("translates by the visible region's own minY, not adds it, when the CropBox's own lower edge sits above the page's own origin", () => { + // With no rotation the rotation matrix is the identity, so visibleRect is exactly the CropBox itself and visibleRect.minY = cropBox.lly = 30 directly -- a clean, direct pin on the translation's own sign. + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("1 0 0 rg 10 40 30 10 re f", { + pageEntries: "/CropBox [0 30 200 100] ", + }), + 0, + {}, + rasteriser, + ); + // Crop-relative y = 40 - 30 = 10, height 10, crop height = 100 - 30 = 70: device y = 70 - (10 + 10) = 50. A `+30` translation would instead place this well outside (or entirely off) the cropped region. + expect(rasteriser.ops.find(isFillRect)).toMatchObject({ xPx: 10, yPx: 50 }); + }); + + it("falls back for a CropBox degenerate in height alone, its width perfectly healthy", () => { + // llx=0/urx=30 keeps the width check (urx - llx = 30 > 0) from ever triggering on its own, so a genuine crop is only forced by the height term (ury - lly = 0) being evaluated independently rather than the whole OR condition being pinned by the sibling test's width-only degeneracy. + const diagnostics: PdfDiagnostic[] = []; + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf(content, { pageEntries: "/CropBox [0 100 30 100] " }), + 0, + { sink: (d) => diagnostics.push(d) }, + rasteriser, + ); + expect(diagnostics.some((d) => d.code === "pdf/invalid-crop-box")).toBe( + true, + ); + expect(rasteriser.geometry).toMatchObject({ widthPx: 200, heightPx: 100 }); + }); + it("computes page extent correctly for a MediaBox whose origin is not (0, 0)", () => { const b = new SmallFixture(); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); @@ -421,6 +508,24 @@ describe("renderPdfPage: geometry and clipPt", () => { expect(rasteriser.geometry).toMatchObject({ widthPx: 200, heightPx: 100 }); }); + it("passes the MediaBox's own width and height, not the sum of its corners, into the rotation transform", () => { + // Unrotated, mediaBox.urx +/- llx never reaches the rendered geometry at all (pageRotationTransform's own Rotate-0 branch ignores both w and h), so the sibling test above cannot distinguish + from -- only a rotation whose matrix genuinely depends on w/h (90 here) can. + const b = new SmallFixture(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [50 50 250 150] /Rotate 90 /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + ); + b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.stream(5, "<< >>", enc(content)); + const bytes = b.classicXrefAndTrailer(5, "/Root 1 0 R"); + const rasteriser = new RecordingRasteriser(); + drive(bytes, 0, {}, rasteriser); + // Real w = urx - llx = 200, h = ury - lly = 100; Rotate 90 swaps them (widthPt = h, heightPt = w), so the rendered page is 100 x 200 -- not 300 x 200 (w mutated to a sum) or 100 x 300 (h mutated to a sum). + expect(rasteriser.geometry).toMatchObject({ widthPx: 100, heightPx: 200 }); + }); + it("reports a zero-height intersection distinctly from a zero-width one, with the exact requested and page ranges in the message", () => { const bytes = onePagePdf(content); expect(() => diff --git a/packages/pdf-codec/src/raster.ts b/packages/pdf-codec/src/raster.ts index ef026ee55..20e3e7ecd 100644 --- a/packages/pdf-codec/src/raster.ts +++ b/packages/pdf-codec/src/raster.ts @@ -779,12 +779,12 @@ interface TextOutlineFace { ): number | undefined; } -// What an embedded font program turned out to carry, as resolved from a /FontDescriptor. +// What an embedded font program turned out to carry, as resolved from a /FontDescriptor. The "glyf" case's own face carries only what every caller actually reads off it (glyf/unitsPerEm) -- composite-ness and the shown-code -> glyph-ID mapping are per-font-dictionary facts a Type0 or TrueType caller derives for itself, never read back off this intermediate value. type EmbeddedProgram = | { readonly kind: "glyf"; readonly sfnt: SfntFont; - readonly face: TextOutlineFace; + readonly face: { readonly glyf: GlyfTable; readonly unitsPerEm: number }; } | { readonly kind: "cff" } | { readonly kind: "absent" }; @@ -810,12 +810,8 @@ function openEmbeddedProgram( stream.dict, NOOP_DIAGNOSTIC_SINK, ).bytes; - if ( - bytes.length >= 3 && - bytes[0] === 0x01 && - bytes[1] === 0x00 && - bytes[2] === 0x04 - ) { + // No separate bytes.length >= 3 guard: with noUncheckedIndexedAccess, an out-of-bounds index already reads as undefined, which can never strictly equal any of these three literals -- a short stream already fails the chain on its own without a length check duplicating that fact. + if (bytes[0] === 0x01 && bytes[1] === 0x00 && bytes[2] === 0x04) { return { kind: "cff" }; // a bare CFF program: header major 1, minor 0, hdrSize 4 (ISO 32000-1's /Type1C spelling) } const sfnt = parseSfnt(bytes); @@ -840,12 +836,7 @@ function openEmbeddedProgram( return { kind: "glyf", sfnt, - face: { - composite: false, - glyf, - unitsPerEm: head.unitsPerEm, - glyphIdOf: () => undefined, - }, + face: { glyf, unitsPerEm: head.unitsPerEm }, }; } return { kind: "absent" }; @@ -1138,8 +1129,8 @@ function drawTextRun( } } -// TrueType contours to port subpaths: each contour's on/off-curve points walked into line and quadratic segments, each quadratic elevated to the exactly equivalent cubic (control points at 2/3 of the way from the on-curve ends toward the off-curve control -- the standard exact quadratic-to-cubic elevation, no approximation), then every point transformed as a point. A run of consecutive off-curve points implies an on-curve point at each neighbouring pair's midpoint, per the TrueType glyph specification's own contour convention. -function glyphOutlineSubpaths( +// TrueType contours to port subpaths: each contour's on/off-curve points walked into line and quadratic segments, each quadratic elevated to the exactly equivalent cubic (control points at 2/3 of the way from the on-curve ends toward the off-curve control -- the standard exact quadratic-to-cubic elevation, no approximation), then every point transformed as a point. A run of consecutive off-curve points implies an on-curve point at each neighbouring pair's midpoint, per the TrueType glyph specification's own contour convention. Exported solely so this suite can drive it directly with hand-built contours: a real embedded font's own glyphs (this module's only other route in) never reliably exercise every branch on demand -- no vendored face happens to start a contour off-curve, or carries a contour with no on-curve point at all, the way a hand-built GlyphOutline can. +export function glyphOutlineSubpaths( outline: GlyphOutline, matrix: Matrix, ): readonly RasterSubpath[] { @@ -1148,30 +1139,28 @@ function glyphOutlineSubpaths( if (contour.length < 3) { continue; // a degenerate contour (a stray point or pair) bounds no area and paints nothing } - // Rotate so the walk starts on a real on-curve point where one exists; a contour with none at all (a pure-quad circle, say) starts at the implied midpoint of its last and first points. + // Rotate so the walk starts on a real on-curve point where one exists; a contour with none at all (a pure-quad circle, say) starts at the implied midpoint of its last and first points. Both branches below share one hoisted condition rather than repeating `firstOn >= 0`: at firstOn === 0 the two `ordered` branches already coincide (rotating by zero is a no-op), so a lone, un-shared copy of the condition guarding `ordered` alone has no boundary input left where mutating it changes anything observable -- sharing it with `current`'s own branch (which genuinely does differ at that boundary) is what keeps the condition itself meaningful to test. const firstOn = contour.findIndex((point) => point.onCurve); const contourPoints = contour.map((point) => ({ x: point.x, y: point.y, onCurve: point.onCurve, })); + const hasLeadingOnCurvePoint = firstOn >= 0; const ordered: readonly { x: number; y: number; onCurve: boolean }[] = - firstOn >= 0 + hasLeadingOnCurvePoint ? [...contourPoints.slice(firstOn), ...contourPoints.slice(0, firstOn)] : contourPoints; - let current: { x: number; y: number } = - firstOn >= 0 - ? { x: contourPoints[firstOn]!.x, y: contourPoints[firstOn]!.y } - : { - x: - (contourPoints[contourPoints.length - 1]!.x + - contourPoints[0]!.x) / - 2, - y: - (contourPoints[contourPoints.length - 1]!.y + - contourPoints[0]!.y) / - 2, - }; + let current: { x: number; y: number } = hasLeadingOnCurvePoint + ? { x: contourPoints[firstOn]!.x, y: contourPoints[firstOn]!.y } + : { + x: + (contourPoints[contourPoints.length - 1]!.x + contourPoints[0]!.x) / + 2, + y: + (contourPoints[contourPoints.length - 1]!.y + contourPoints[0]!.y) / + 2, + }; const start = current; const segments: RasterPathSegment[] = []; let pendingOffCurve: { x: number; y: number } | undefined; From 1bdb0f0081ce4e0942626e5d42fce0c8503f57db Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 06:03:17 +0100 Subject: [PATCH 041/105] test(pdf-codec): cover CIDToGIDMap's own trailing-unpaired-byte bound The odd-length-stream loop reads two-byte entries with an `i + 1 < length` bound; nothing previously pinned it against a stray final byte being paired with a phantom next byte and read as a further, wrong GID mapping. --- packages/pdf-codec/src/raster.test.ts | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 5081d76d7..01d9573dc 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -1625,6 +1625,40 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { expect(minY).toBeCloseTo(100 - 50 - expectedInk.yMax * scale, 1); }); + it("ignores a trailing unpaired byte in a /CIDToGIDMap stream rather than reading it as a further entry", () => { + // A 3-byte map declares exactly one 2-byte entry (CID 0 -> GID 15); the loop's own `i + 1 < length` bound must stop before the stray third byte, not read it paired with a phantom fourth. Were it read anyway, CID 1 would land on GID (0x00 << 8 | 0), i.e. GID 0 (.notdef) -- which Carlito's own .notdef genuinely draws (4 contours), so a wrongly-read entry paints a second, wrong path rather than silently doing nothing. + const cidToGidMapBytes = new Uint8Array([0x00, 0x0f, 0x00]); + const b = new SmallFixture(); + const fontBytes = carlitoRegularBytes(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + ); + b.object( + 4, + "<< /Type /Font /Subtype /Type0 /BaseFont /Carlito /Encoding /Identity-H /DescendantFonts [7 0 R] >>", + ); + b.object( + 7, + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Carlito /FontDescriptor 8 0 R /CIDToGIDMap 10 0 R >>", + ); + b.object( + 8, + "<< /Type /FontDescriptor /FontName /Carlito /Flags 32 /FontFile2 9 0 R >>", + ); + b.stream(9, `<< /Length1 ${fontBytes.length} >>`, fontBytes); + b.stream(10, "<< >>", cidToGidMapBytes); + // CID 0 (mapped, drawable) followed by CID 1 (past the map's one real entry). + b.stream(5, "<< >>", enc("BT /F1 24 Tf 20 50 Td <00000001> Tj ET")); + const mappedBytes = b.classicXrefAndTrailer(10, "/Root 1 0 R"); + + const rasteriser = new RecordingRasteriser(); + drive(mappedBytes, 0, {}, rasteriser); + expect(rasteriser.ops.filter(isPath)).toHaveLength(1); + }); + it("refuses a Type1 font whose embedded program is not CFF outlines", () => { const { diagnostics, rasteriser } = refusalDiagnostics( onePagePdf("BT /F1 24 Tf 20 50 Td (H) Tj ET", { From cfdb31e80ab995b26606543bc24fc8231357c3c5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 06:09:23 +0100 Subject: [PATCH 042/105] test(pdf-codec): pin glyphOutlineSubpaths' contour walk directly glyphOutlineSubpaths is now driven directly with hand-built contours, covering the rotation-to-first-on-curve-point walk, a contour with no on-curve point at all, the minimum two-segment case a non-degenerate contour can produce, and the matrix being applied to every emitted point rather than only a segment's final on-curve coordinate. Also covers: an /MMType1 font routed through the same PostScript refusal as /Type1, a Type1 font's own genuinely embedded CFF program routed through the shared CFF refusal, a font dictionary with no /Subtype at all naming itself (none), a simple TrueType font whose embedded program is CFF rather than sfnt glyf, a simple TrueType program with no usable Unicode cmap subtable, resolveTextOutlineFace caching a font's resolved face across repeated runs rather than rebuilding (and re-diagnosing) it per run, a /CIDToGIDMap stream's trailing unpaired byte being ignored rather than read as a further entry, and a page whose /Contents is an array of streams rather than a single one. --- packages/pdf-codec/src/raster.test.ts | 359 +++++++++++++++++++++++++- 1 file changed, 355 insertions(+), 4 deletions(-) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 01d9573dc..81616598c 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -6,8 +6,10 @@ import { PdfParseError } from "./diagnostics"; import { parseHead, parseMaxp } from "./font-tables"; import { parseGlyf } from "./glyf"; import { parseHmtx } from "./hmtx-table"; -import { applyMatrix, BEZIER_KAPPA } from "./matrix"; -import { flattenCubic, renderPdfPage } from "./raster"; +import type { GlyphContourPoint, GlyphOutline } from "./glyf-contours"; +import type { Matrix } from "./matrix"; +import { applyMatrix, BEZIER_KAPPA, IDENTITY_MATRIX } from "./matrix"; +import { flattenCubic, glyphOutlineSubpaths, renderPdfPage } from "./raster"; import type { PageRasteriser, RasterDrawOp, @@ -126,6 +128,24 @@ class SmallFixture { } } +// Repoints a table record past the end of the file, the same technique embedded-font.test.ts's own dropTable uses -- parseSfnt drops that one table entirely, exactly as it would for a genuinely truncated font, while every other table (head/maxp/glyf included) stays intact and readable. +function dropSfntTable(bytes: Uint8Array, tag: string): void { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const numTables = view.getUint16(4); + for (let i = 0; i < numTables; i++) { + const recordOffset = 12 + i * 16; + let found = ""; + for (let c = 0; c < 4; c++) { + found += String.fromCharCode(view.getUint8(recordOffset + c)); + } + if (found === tag) { + view.setUint32(recordOffset + 8, bytes.length + 4); + return; + } + } + throw new Error(`the vendored font has no "${tag}" table to patch`); +} + // One page, 200 x 100 pt, with the caller's content stream and optional extra entries on the page dict and catalog. Objects 1 (catalog), 2 (pages), 3 (page), 5 (contents) are wired; object 4 is a standard Helvetica font resource so text fixtures have a /Font to select. function onePagePdf( content: string | Uint8Array, @@ -567,6 +587,33 @@ describe("renderPdfPage: geometry and clipPt", () => { ); }); + it("reads a page whose /Contents is an array of streams, concatenated with a newline separator, not just a single stream", () => { + const b = new SmallFixture(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents [5 0 R 6 0 R] >>", + ); + b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + // Split mid-operator-list, not mid-token: the first chunk's own final token ("40") is a complete number on its own, so the separator the reader inserts between chunks only ever falls on whitespace a content stream already treats as insignificant. + b.stream(5, "<< >>", enc("1 0 0 rg 10 20 30 40")); + b.stream(6, "<< >>", enc("re f")); + const bytes = b.classicXrefAndTrailer(6, "/Root 1 0 R"); + const rasteriser = new RecordingRasteriser(); + drive(bytes, 0, {}, rasteriser); + expect(rasteriser.ops.filter(isFillRect)).toEqual([ + { + kind: "fillRect", + xPx: 10, + yPx: 40, + widthPx: 30, + heightPx: 40, + color: { r: 1, g: 0, b: 0 }, + }, + ]); + }); + it("returns whatever the rasteriser's finish produces", () => { const rasteriser = new RecordingRasteriser(); const result = renderPdfPage(onePagePdf(content), 0, {}, rasteriser); @@ -727,6 +774,189 @@ describe("flattenCubic", () => { }); }); +// glyphOutlineSubpaths exercised directly, the same reasoning as flattenCubic above: no vendored face's own contours ever start off-curve, or carry a contour with no on-curve point at all (every glyph probed across Carlito's whole repertoire starts on-curve), so pinning the rotation and no-on-curve branches needs hand-built contours, not a real font's glyphs. +describe("glyphOutlineSubpaths", () => { + const pt = (x: number, y: number, onCurve: boolean): GlyphContourPoint => ({ + x, + y, + onCurve, + }); + const outlineOf = (contour: readonly GlyphContourPoint[]): GlyphOutline => ({ + contours: [contour], + }); + + it("drops a contour of fewer than 3 points but keeps one of exactly 3, the boundary a <= in place of < would erase", () => { + const tooShort = outlineOf([pt(0, 0, true), pt(1, 0, true)]); + expect(glyphOutlineSubpaths(tooShort, IDENTITY_MATRIX)).toEqual([]); + + const exactlyThree = outlineOf([ + pt(0, 0, true), + pt(10, 0, true), + pt(10, 10, true), + ]); + expect(glyphOutlineSubpaths(exactlyThree, IDENTITY_MATRIX)).toHaveLength(1); + }); + + it("starts at the implied midpoint of the last and first points for a contour with no on-curve point at all, walking every consecutive off-curve pair through its own midpoint", () => { + // Three off-curve points, none on-curve: start = midpoint(P2, P0), then each consecutive pair (P0,P1) and (P1,P2) implies its own on-curve midpoint, and the walk closes with a final quad from the last implied point back through P2 to start. + const outline = outlineOf([ + pt(3, 9, false), + pt(15, 3, false), + pt(21, 15, false), + ]); + expect(glyphOutlineSubpaths(outline, IDENTITY_MATRIX)).toEqual([ + { + startXPx: 12, + startYPx: 12, + closed: true, + segments: [ + { + kind: "cubic", + c1xPx: 6, + c1yPx: 10, + c2xPx: 5, + c2yPx: 8, + xPx: 9, + yPx: 6, + }, + { + kind: "cubic", + c1xPx: 13, + c1yPx: 4, + c2xPx: 16, + c2yPx: 5, + xPx: 18, + yPx: 9, + }, + { + kind: "cubic", + c1xPx: 20, + c1yPx: 13, + c2xPx: 18, + c2yPx: 14, + xPx: 12, + yPx: 12, + }, + ], + }, + ]); + }); + + it("needs no rotation when the contour already starts on-curve, and produces exactly two segments for a single on/off/on run", () => { + // On, off, on: the sole off-curve point never triggers the mid-pair emit (only one point in its run), so it folds into the following on-curve point's own quad -- exactly two segments (one line, one quad), the fewest a non-degenerate (length >= 3) contour can ever produce. + const outline = outlineOf([ + pt(0, 0, true), + pt(6, 9, false), + pt(12, 0, true), + ]); + expect(glyphOutlineSubpaths(outline, IDENTITY_MATRIX)).toEqual([ + { + startXPx: 0, + startYPx: 0, + closed: true, + segments: [ + { kind: "line", xPx: 0, yPx: 0 }, + { + kind: "cubic", + c1xPx: 4, + c1yPx: 6, + c2xPx: 8, + c2yPx: 6, + xPx: 12, + yPx: 0, + }, + ], + }, + ]); + }); + + it("rotates to start on the first on-curve point, walks a run of consecutive off-curve points through their implied midpoint, and closes a still-pending control point back to the start", () => { + // Stored order [A(off) B(on) C(off) D(off) E(on) F(off)]: firstOn = 1, so the walk starts at B, continues C, D, E, F, and wraps to A -- exercising the on-curve-with-pending quad (B->C->mid(C,D)), the consecutive-off-curve implied-midpoint quad (twice: C/D and F/A), and the final trailing quad closing a still-pending control point (A) back to the rotated start (B). + const a = pt(27, 15, false); + const b = pt(0, 0, true); + const c = pt(3, 6, false); + const d = pt(9, 12, false); + const e = pt(15, 3, true); + const f = pt(21, 9, false); + const outline = outlineOf([a, b, c, d, e, f]); + expect(glyphOutlineSubpaths(outline, IDENTITY_MATRIX)).toEqual([ + { + startXPx: 0, + startYPx: 0, + closed: true, + segments: [ + { kind: "line", xPx: 0, yPx: 0 }, + { + kind: "cubic", + c1xPx: 2, + c1yPx: 4, + c2xPx: 4, + c2yPx: 7, + xPx: 6, + yPx: 9, + }, + { + kind: "cubic", + c1xPx: 8, + c1yPx: 11, + c2xPx: 11, + c2yPx: 9, + xPx: 15, + yPx: 3, + }, + { + kind: "cubic", + c1xPx: 19, + c1yPx: 7, + c2xPx: 22, + c2yPx: 10, + xPx: 24, + yPx: 12, + }, + { + kind: "cubic", + c1xPx: 26, + c1yPx: 14, + c2xPx: 18, + c2yPx: 10, + xPx: 0, + yPx: 0, + }, + ], + }, + ]); + }); + + it("applies the caller's own matrix to every emitted point, not just the on-curve endpoints", () => { + // A pure translation confirms the matrix reaches the start point, the line endpoint, AND the quad's own control-derived points -- not only the segment's final on-curve xPx/yPx. + const outline = outlineOf([ + pt(0, 0, true), + pt(6, 9, false), + pt(12, 0, true), + ]); + const translated: Matrix = [1, 0, 0, 1, 100, 200]; + expect(glyphOutlineSubpaths(outline, translated)).toEqual([ + { + startXPx: 100, + startYPx: 200, + closed: true, + segments: [ + { kind: "line", xPx: 100, yPx: 200 }, + { + kind: "cubic", + c1xPx: 104, + c1yPx: 206, + c2xPx: 108, + c2yPx: 206, + xPx: 112, + yPx: 200, + }, + ], + }, + ]); + }); +}); + describe("renderPdfPage: vector draw ops", () => { it("strokes a recovered line with its colour and width", () => { const rasteriser = new RecordingRasteriser(); @@ -1201,9 +1431,12 @@ function type0CarlitoPdf( // A plain simple (non-Type0) /TrueType font resource: code -> Unicode through the PDF's own encoding (WinAnsi, since this face carries no Symbolic flag), then Unicode -> GID through the embedded program's own cmap -- the whole other half of buildTextOutlineFace's own branch, entirely separate from the Type0/CID path type0CarlitoPdf drives. function trueTypeCarlitoPdf( text: string, - overrides: { readonly fontDescriptorBody?: string } = {}, + overrides: { + readonly fontDescriptorBody?: string; + readonly fontBytes?: Uint8Array; + } = {}, ): Uint8Array { - const fontBytes = carlitoRegularBytes(); + const fontBytes = overrides.fontBytes ?? carlitoRegularBytes(); const b = new SmallFixture(); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); @@ -1393,6 +1626,22 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { expect(rasteriser.ops).toEqual([]); }); + it("resolves a font resource's outline face once per font dictionary, not once per run that references it", () => { + // Two separate text runs through the same /F1 resource (a standard-14 face with no embedded program): resolveTextOutlineFace's own cache means buildTextOutlineFace, and the diagnostic it emits, runs exactly once -- not once per run naming the same already-diagnosed font all over again. + const diagnostics: PdfDiagnostic[] = []; + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("BT /F1 24 Tf 20 60 Td (A) Tj 0 -20 Td (B) Tj ET"), + 0, + { sink: (d) => diagnostics.push(d) }, + rasteriser, + ); + expect( + diagnostics.filter((d) => d.code === "raster/text-outlines-unavailable"), + ).toHaveLength(1); + expect(rasteriser.ops).toEqual([]); + }); + // A bare Type0/CIDFontType2 skeleton around the real vendored Carlito face, with every dict entry a caller can override -- the same font bytes type0CarlitoPdf uses, but exposing the descendant/descriptor/encoding shape directly so each of buildTextOutlineFace's own branch conditions can be driven independently of the others. function type0Skeleton(overrides: { readonly encoding?: string; @@ -1692,6 +1941,108 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { ).toContain("a font of subtype Type3"); expect(rasteriser.ops).toEqual([]); }); + + it("refuses an /MMType1 font the same way as a plain /Type1, not falling through to the unrecognised-subtype branch", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + onePagePdf("BT /F1 24 Tf 20 50 Td (H) Tj ET", { + pageResources: "/Resources << /Font << /F1 4 0 R >> >>", + extraObjects: [ + [ + 4, + "<< /Type /Font /Subtype /MMType1 /BaseFont /Custom /FirstChar 0 /LastChar 255 /FontDescriptor 6 0 R >>", + ], + [6, "<< /Type /FontDescriptor /FontName /Custom /Flags 4 >>"], + ], + }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("PostScript program"); + expect(rasteriser.ops).toEqual([]); + }); + + it("routes a Type1 font's own genuinely embedded CFF program through the shared CFF refusal, rather than assuming Type1 always means no outlines at all", () => { + const b = new SmallFixture(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + ); + b.object( + 4, + "<< /Type /Font /Subtype /Type1 /BaseFont /Custom /FirstChar 0 /LastChar 255 /FontDescriptor 6 0 R >>", + ); + b.object( + 6, + "<< /Type /FontDescriptor /FontName /Custom /Flags 4 /FontFile3 7 0 R >>", + ); + b.stream(7, "<< >>", new Uint8Array([0x01, 0x00, 0x04])); + b.stream(5, "<< >>", enc("BT /F1 24 Tf 20 50 Td (H) Tj ET")); + const { diagnostics, rasteriser } = refusalDiagnostics( + b.classicXrefAndTrailer(7, "/Root 1 0 R"), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-cff-outlines"), + ).toBeDefined(); + expect(rasteriser.ops).toEqual([]); + }); + + it("names a font dictionary with no /Subtype at all as (none), the same fallback the descendant-subtype refusal uses", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + onePagePdf("BT /F1 24 Tf 20 50 Td (H) Tj ET", { + pageResources: "/Resources << /Font << /F1 4 0 R >> >>", + extraObjects: [[4, "<< /Type /Font /BaseFont /Custom >>"]], + }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("a font of subtype (none)"); + expect(rasteriser.ops).toEqual([]); + }); + + it("refuses a simple TrueType font whose embedded program is CFF outlines, not sfnt glyf", () => { + const b = new SmallFixture(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + ); + b.object( + 4, + "<< /Type /Font /Subtype /TrueType /BaseFont /Custom /FirstChar 0 /LastChar 255 /FontDescriptor 6 0 R >>", + ); + b.object( + 6, + "<< /Type /FontDescriptor /FontName /Custom /Flags 32 /FontFile2 7 0 R >>", + ); + b.stream(7, "<< >>", new Uint8Array([0x01, 0x00, 0x04])); + b.stream(5, "<< >>", enc("BT /F1 24 Tf 20 50 Td (H) Tj ET")); + const { diagnostics, rasteriser } = refusalDiagnostics( + b.classicXrefAndTrailer(7, "/Root 1 0 R"), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-cff-outlines"), + ).toBeDefined(); + expect(rasteriser.ops).toEqual([]); + }); + + it("refuses a simple TrueType font's embedded program when it carries no usable Unicode cmap subtable", () => { + // dropTable repoints the 'cmap' table record past the end of the file -- parseSfnt drops it, exactly as it would for a genuinely truncated font -- while head/maxp/glyf stay intact, so openEmbeddedProgram still classifies this as a fillable "glyf" program; only buildCmapLookup finds nothing to resolve a code point through. + const patched = new Uint8Array(carlitoRegularBytes()); + dropSfntTable(patched, "cmap"); + const { diagnostics, rasteriser } = refusalDiagnostics( + trueTypeCarlitoPdf("H", { fontBytes: patched }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("no usable Unicode cmap subtable"); + expect(rasteriser.ops).toEqual([]); + }); }); // --- Optional content: a rendering must take the viewer's side. --- From 154d5cbc296d2f7bd1f9cf1239ec8ee5c52ae805 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 06:26:58 +0100 Subject: [PATCH 043/105] refactor(pdf-codec): eliminate two more redundant bounds guards hasPdfHeader's own double loop re-derived what a plain substring search already guarantees: a latin1 decode maps each byte 0-255 to the identical code point one-for-one, so String.prototype.includes over it is exactly a byte-sequence search, without the hand-written bounds arithmetic a manual scan needs. The CIDToGIDMap stream lookup's own cid < entries.length guard was equally redundant: cid is always a non-negative index built from two unsigned byte shifts, and a plain array already reads out of bounds as undefined -- entries[cid] alone is exactly the ": undefined" branch for every cid past the map's last entry. Also strengthens the dotted-path suite: the general-path dotted branch had only a loose length check and one dot's position pinned, covering neither its line-segment loop body, its cubic-segment loop body, nor the dot size's own scale-relative width independently of each other. Split into a line-only case (pinning the exact dot count and the dot size at a non-1 scale, where multiplying and dividing by pixelsPerPt first stop being indistinguishable) and a cubic-only case (a curve whose control points are collinear with its endpoints flattens to just its own endpoint, making its own dot train exactly as predictable as the line case). --- packages/pdf-codec/src/raster.test.ts | 36 +++++++++++++++++++++------ packages/pdf-codec/src/raster.ts | 16 +++--------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 81616598c..1ebfe048a 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -1049,21 +1049,43 @@ describe("renderPdfPage: vector draw ops", () => { }); }); - it("draws a dotted general path (line and cubic segments alike) as dot trains, not a dash array", () => { - // drawPath's own dotted branch -- a for-loop over each subpath's line AND cubic segments, flattening cubics before dotting them -- has no coverage at all: every other dotted test in this file goes through drawLine's single two-point segment instead. + it("draws a dotted general path's own line segment as an exact dot train, scaling the dot size by widthPt x scale", () => { + // drawPath's own dotted branch has no coverage at all outside this describe block: every other dotted test in this file goes through drawLine's single two-point segment instead. At scale 1, multiplying and dividing widthPt by pixelsPerPt are indistinguishable, so this pins it at scale 3. const rasteriser = new RecordingRasteriser(); drive( - onePagePdf( - "[0 4] 0 d 1 J 2 w 0 0 0 RG 20 20 m 80 20 l 80 60 40 80 20 60 c h S", - ), + onePagePdf("[0 4] 0 d 1 J 2 w 0 0 0 RG 20 20 m 60 20 l S"), + 0, + { scale: 3 }, + rasteriser, + ); + const squares = rasteriser.ops.filter(isFillRect); + // Device length 40pt x 3 = 120px, spacing = max(widthPx x 2, 1) = 12px: dots at 0, 12, ..., 120 -- 11 of them. + expect(squares).toHaveLength(11); + expect(squares[0]).toEqual({ + kind: "fillRect", + xPx: 57, + yPx: 237, + widthPx: 6, + heightPx: 6, + color: { r: 0, g: 0, b: 0 }, + }); + expect(squares[squares.length - 1]).toMatchObject({ xPx: 177, yPx: 237 }); + }); + + it("draws a dotted general path's own cubic segment as a dot train too, not only its line segments", () => { + // A cubic whose control points are collinear with its endpoints flattens to just its own endpoint (the same fact flattenCubic's own suite pins directly), so the resulting dot train is exactly as predictable as the line-segment case above -- this isolates drawPath's cubic branch from its line branch, which the line-only test above never touches. + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("[0 4] 0 d 1 J 2 w 0 0 0 RG 20 20 m 40 20 60 20 80 20 c S"), 0, {}, rasteriser, ); const squares = rasteriser.ops.filter(isFillRect); - expect(squares.length).toBeGreaterThan(2); - // The very first dot sits at the subpath's own start point: page (20, 20) -> device (20, 80). + // Device length 60pt, spacing = max(2 x 2, 1) = 4px: dots at 0, 4, ..., 60 -- 16 of them. + expect(squares).toHaveLength(16); expect(squares[0]).toMatchObject({ xPx: 19, yPx: 79 }); + expect(squares[squares.length - 1]).toMatchObject({ xPx: 79, yPx: 79 }); }); it("fills a general path with the paint operator's own fill rule and carries strokes on the same op", () => { diff --git a/packages/pdf-codec/src/raster.ts b/packages/pdf-codec/src/raster.ts index 20e3e7ecd..f6cf3e2b8 100644 --- a/packages/pdf-codec/src/raster.ts +++ b/packages/pdf-codec/src/raster.ts @@ -968,8 +968,9 @@ function buildTextOutlineFace( glyf: program.face.glyf, unitsPerEm: program.face.unitsPerEm, glyphIdOf: (codes, offset) => { + // No separate cid < entries.length guard: cid is always a non-negative index (built from two unsigned byte shifts), and a plain array already reads out of bounds as undefined -- entries[cid] alone is exactly the ": undefined" branch for every cid past the map's own last entry. const cid = (codes[offset]! << 8) | codes[offset + 1]!; - return cid < entries.length ? entries[cid] : undefined; + return entries[cid]; }, }; } @@ -1225,8 +1226,7 @@ export function glyphOutlineSubpaths( // --- Read-side helpers whose read.ts originals are module-private. --- -// The %PDF- header scan readPdf performs (a junk-prefixed file is legal per ISO 32000-1 7.5.2, so a window is searched rather than offset 0 required): re-derived here because read.ts's own copy is not exported, with raster.test.ts holding the observable behaviour to the same pdf/no-header error readPdf throws for a non-PDF input. -const PDF_HEADER_BYTES = new TextEncoder().encode("%PDF-"); +// The %PDF- header scan readPdf performs (a junk-prefixed file is legal per ISO 32000-1 7.5.2, so a window is searched rather than offset 0 required): re-derived here because read.ts's own copy is not exported, with raster.test.ts holding the observable behaviour to the same pdf/no-header error readPdf throws for a non-PDF input. A latin1 decode maps each byte 0-255 to the identical code point one-for-one, so String.prototype.includes over it is exactly a byte-sequence search -- the language's own substring search, rather than a hand-written double loop whose own bounds arithmetic would just be re-deriving what indexOf already guarantees correct. const HEADER_SEARCH_WINDOW = 1024; function hasPdfHeader(bytes: Uint8Array): boolean { @@ -1234,15 +1234,7 @@ function hasPdfHeader(bytes: Uint8Array): boolean { 0, Math.min(HEADER_SEARCH_WINDOW, bytes.length), ); - outer: for (let i = 0; i <= window.length - PDF_HEADER_BYTES.length; i++) { - for (let j = 0; j < PDF_HEADER_BYTES.length; j++) { - if (window[i + j] !== PDF_HEADER_BYTES[j]) { - continue outer; - } - } - return true; - } - return false; + return new TextDecoder("latin1").decode(window).includes("%PDF-"); } interface PageBoxRect { From 60f0b374dd42debc329d2f6960b4056abb87fd90 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 06:31:16 +0100 Subject: [PATCH 044/105] test(pdf-codec): pin the outline-refusal diagnostic's own face-name fallback buildTextOutlineFace's faceName falls from /BaseFont to /Subtype to a bare "font" when neither is stated; no existing refusal test used a font dictionary missing /BaseFont, so every diagnostic message check in the suite passed regardless of which fallback actually fired. Adds a font with /Subtype but no /BaseFont (names itself by /Subtype), one with neither (falls to "font"), the same /Subtype fallback for a CFF descendant refusal, and a full message-string pin on the CFF-outlines diagnostic itself. --- packages/pdf-codec/src/raster.test.ts | 66 ++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 1ebfe048a..b916deec6 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -2006,11 +2006,39 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { b.classicXrefAndTrailer(7, "/Root 1 0 R"), ); expect( - diagnostics.find((d) => d.code === "raster/text-cff-outlines"), - ).toBeDefined(); + diagnostics.find((d) => d.code === "raster/text-cff-outlines")?.message, + ).toBe( + "font resource /F1 (Custom) carries CFF outlines; this raster surface fills sfnt (TrueType/glyf) outlines only, so its text is not rendered rather than approximated", + ); expect(rasteriser.ops).toEqual([]); }); + it("names a diagnostic's face by /Subtype when a Type0 font has no /BaseFont, for the CFF-descendant refusal too", () => { + const b = new SmallFixture(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + ); + b.object( + 4, + "<< /Type /Font /Subtype /Type0 /Encoding /Identity-H /DescendantFonts [6 0 R] >>", + ); + b.object( + 6, + "<< /Type /Font /Subtype /CIDFontType0 /FontDescriptor 7 0 R >>", + ); + b.object(7, "<< /Type /FontDescriptor /Flags 4 >>"); + b.stream(5, "<< >>", enc("BT /F1 12 Tf 10 50 Td <0041> Tj ET")); + const { diagnostics } = refusalDiagnostics( + b.classicXrefAndTrailer(7, "/Root 1 0 R"), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-cff-outlines")?.message, + ).toContain("(Type0)"); + }); + it("names a font dictionary with no /Subtype at all as (none), the same fallback the descendant-subtype refusal uses", () => { const { diagnostics, rasteriser } = refusalDiagnostics( onePagePdf("BT /F1 24 Tf 20 50 Td (H) Tj ET", { @@ -2025,6 +2053,40 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { expect(rasteriser.ops).toEqual([]); }); + it("names a diagnostic's face by /Subtype when /BaseFont is absent, not the bare fallback", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + onePagePdf("BT /F1 24 Tf 20 50 Td (H) Tj ET", { + pageResources: "/Resources << /Font << /F1 4 0 R >> >>", + extraObjects: [ + [ + 4, + "<< /Type /Font /Subtype /Type1 /FirstChar 0 /LastChar 255 /FontDescriptor 6 0 R >>", + ], + [6, "<< /Type /FontDescriptor /Flags 4 >>"], + ], + }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("(Type1)"); + expect(rasteriser.ops).toEqual([]); + }); + + it("falls all the way back to the bare word (font) when neither /BaseFont nor /Subtype is stated", () => { + const { diagnostics, rasteriser } = refusalDiagnostics( + onePagePdf("BT /F1 24 Tf 20 50 Td (H) Tj ET", { + pageResources: "/Resources << /Font << /F1 4 0 R >> >>", + extraObjects: [[4, "<< /Type /Font >>"]], + }), + ); + expect( + diagnostics.find((d) => d.code === "raster/text-outlines-unavailable") + ?.message, + ).toContain("(font)"); + expect(rasteriser.ops).toEqual([]); + }); + it("refuses a simple TrueType font whose embedded program is CFF outlines, not sfnt glyf", () => { const b = new SmallFixture(); b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); From 86582425b2b49828b297d68da1973bab09985a0d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:07:54 +0100 Subject: [PATCH 045/105] refactor(pdf-codec): eliminate sha2's round-expansion length equivalent mutants sha256/sha512's word-expansion loop sized its Array.from by SHA256_ROUNDS - WORDS_PER_BLOCK (or the 512 equivalent), an arithmetic expression whose flip to + only grows w with extra entries the compression loop never reads (silently dropped past a Uint32Array's fixed length for sha256, simply unread for sha512's plain array either way) -- genuinely unobservable regardless of test. Iterate the full round count instead and skip the already-filled first WORDS_PER_BLOCK entries inside the mapfn, so mutating the skip condition corrupts w[16] onward and is caught by every existing hash test. --- packages/pdf-codec/src/crypto/sha2.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/pdf-codec/src/crypto/sha2.ts b/packages/pdf-codec/src/crypto/sha2.ts index cc6c6fdf1..e99958ece 100644 --- a/packages/pdf-codec/src/crypto/sha2.ts +++ b/packages/pdf-codec/src/crypto/sha2.ts @@ -167,9 +167,11 @@ export function sha256( ); }), ); - // Array.from's own length argument (SHA256_ROUNDS - WORDS_PER_BLOCK, an arithmetic value with no equivalent-mutant boundary the way a bare loop comparison would have) drives this expansion instead of a counted for-loop's own `t < SHA256_ROUNDS` -- the recurrence itself still runs index-by-index in order (Array.from's mapfn is called sequentially), since w[t] depends on entries this same expansion already wrote. - Array.from({ length: SHA256_ROUNDS - WORDS_PER_BLOCK }, (_, index) => { - const t = WORDS_PER_BLOCK + index; + // Array.from's own length argument is SHA256_ROUNDS itself (the full word count, not an arithmetic offset from it), with the already-filled first WORDS_PER_BLOCK entries skipped inside the mapfn -- a subtraction expressing the remaining count here would size a Uint32Array write that a wrong length silently drops (equally unobservable in either direction), whereas mutating this skip condition instead corrupts w[16] onward and is caught by every hash test below. + Array.from({ length: SHA256_ROUNDS }, (_, t) => { + if (t < WORDS_PER_BLOCK) { + return; // already filled directly from the block's own bytes above + } const x = w[t - 15]!; const y = w[t - 2]!; const s0 = rotr32(x, 7) ^ rotr32(x, 18) ^ (x >>> 3); @@ -237,9 +239,11 @@ function sha512Core( } w[t] = word; }); - // As sha256's own expansion above: Array.from's length argument (an arithmetic value, not a bare loop comparison) drives this instead of `t < SHA512_ROUNDS`, with the recurrence still running index-by-index in the mapfn's own call order. - Array.from({ length: SHA512_ROUNDS - WORDS_PER_BLOCK }, (_, index) => { - const t = WORDS_PER_BLOCK + index; + // As sha256's own expansion above: Array.from's own length is SHA512_ROUNDS itself, not an arithmetic offset from it, with the already-filled first WORDS_PER_BLOCK entries skipped inside the mapfn -- growing w by extra unread entries past SHA512_ROUNDS is equally unobservable in either direction, whereas mutating this skip condition corrupts w[16] onward and is caught by every hash test below. + Array.from({ length: SHA512_ROUNDS }, (_, t) => { + if (t < WORDS_PER_BLOCK) { + return; // already filled directly from the block's own bytes above + } const x = w[t - 15]!; const y = w[t - 2]!; const s0 = rotr64(x, 1n) ^ rotr64(x, 8n) ^ (x >> 7n); From a73ba5eb111264f92ed3811d7107ec56690fdc6e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:03 +0100 Subject: [PATCH 046/105] refactor(pdf-codec): remove escapeName's dead whole-name safety check Every character a name failing NAME_ESCAPE_PATTERN's test could contain already fails each per-character escape condition too, so the early return and the per-character loop always produced the same string -- an early-return guard whose mutation to false could never be observed by any test, plus a whole-name regex test paid on every call for no behavioural difference. Drop the guard and the now-unused pattern; the loop alone already handles both the escaped and unescaped cases correctly. --- packages/pdf-codec/src/serialize.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/pdf-codec/src/serialize.ts b/packages/pdf-codec/src/serialize.ts index 148108dfb..e64445716 100644 --- a/packages/pdf-codec/src/serialize.ts +++ b/packages/pdf-codec/src/serialize.ts @@ -15,13 +15,10 @@ export function formatNumber(n: number): string { return n.toFixed(NUMBER_DECIMAL_PLACES).replace(/0+$/, "").replace(/\.$/, ""); } -const NAME_ESCAPE_PATTERN = /[^!-~]|[#()<>[\]{}/%]/; - // PDF names encode any character outside the safe printable-ASCII set (or one of the delimiter/ special characters) with a #XX hex escape. Every name this writer emits is a plain ASCII identifier we chose ourselves (Type, Catalog, F1, Im3, ...), so this is a defensive general implementation rather than one tuned to a specific known-safe input set. +// +// No upfront "is this name already safe" regex test to short-circuit the loop below: for any name where that test would say yes, every character already satisfies the per-character check's own negation, so the loop would rebuild the identical string one character at a time -- the two branches always agree, and a whole-name pattern test here would just be a slower way to reach the same per-character loop this function already needs to run anyway to handle the escaped case. function escapeName(name: string): string { - if (!NAME_ESCAPE_PATTERN.test(name)) { - return name; - } let out = ""; for (const ch of name) { const code = ch.codePointAt(0)!; From 88a59419b04d564f9f50a38ce85fb6b428055e89 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:11 +0100 Subject: [PATCH 047/105] test(pdf-codec): align the too-small-headerSize fixture's own byte offsets The fixture wrote only 3 literal header bytes but declared headerSize 2, so readCffIndex started reading one byte before the Name INDEX it was meant to land on -- the misaligned read failed on its own, returning undefined the same way the headerSize check itself would have, so the check's own mutation to always-false went unnoticed. Declare headerSize 3, matching the 3 literal bytes actually written before the Name INDEX, so the fixture's Name INDEX and Top DICT genuinely parse once the check is bypassed. --- packages/pdf-codec/src/cff-probe.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/cff-probe.test.ts b/packages/pdf-codec/src/cff-probe.test.ts index 4fd3b6ffe..2a9fb0503 100644 --- a/packages/pdf-codec/src/cff-probe.test.ts +++ b/packages/pdf-codec/src/cff-probe.test.ts @@ -98,11 +98,11 @@ describe("CFF programs probeCff refuses to read", () => { }); it("refuses a too-small headerSize even when a valid Name INDEX and Top DICT sit exactly where that headerSize points", () => { - // Unlike the case above, this Name INDEX is placed at byte offset 2 -- exactly where headerSize's own (invalid) value of 2 would have readCffIndex start looking -- so the only thing standing between this input and a wrongly-defined probe result is the headerSize < CFF_HEADER_SIZE check itself. + // Unlike the case above (whose fixed 4-byte header, from cffFont's own CFF_HEADER default, leaves the Name INDEX sitting where a genuinely valid header would put it, not where the declared headerSize of 2 points), this fixture writes only 3 literal header bytes before the Name INDEX -- so headerSize's own declared value of 3 is exactly the byte offset readCffIndex(bytes, headerSize) actually starts reading from, and the Name INDEX and Top DICT both parse cleanly from there. The only thing standing between this input and a wrongly-defined probe result is the headerSize < CFF_HEADER_SIZE check itself. const bytes = new Uint8Array([ 1, 0, - 2, // majorVersion 1, minorVersion 0, headerSize 2 (invalid: less than the real 4-byte header) + 3, // majorVersion 1, minorVersion 0, headerSize 3 (invalid: less than the real 4-byte header) -- and, not coincidentally, the exact byte offset the Name INDEX below starts at ...cffIndex([[...new TextEncoder().encode("TooShort")]]), ...cffIndex([[139, 0]]), ]); From 9d731fb6c5fdc42f9f30277e5ab6fba91c5b18ad Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:24 +0100 Subject: [PATCH 048/105] refactor(pdf-codec): stop writing object 0's xref-stream row as a literal readXref's own type-0 branch skips a free entry outright regardless of its other fields, so xrefRows' pre-zeroed leading 7 bytes already read identically to any [0, 0, 0, 0, 0, 255, 255] literal asserting the same thing -- an unobservable array literal no test could ever distinguish from []. The self-referential xref row's own placeholder had the same problem from the other direction: fully overwritten before xrefRows is ever built, so its literal value was dead on arrival. Reserve both slots by a length bump instead of a placeholder array, and let the buffer's own zero-fill stand in for object 0's row. --- packages/pdf-codec/src/test-support/pdf.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/pdf-codec/src/test-support/pdf.ts b/packages/pdf-codec/src/test-support/pdf.ts index a7cbba305..19d9b59a3 100644 --- a/packages/pdf-codec/src/test-support/pdf.ts +++ b/packages/pdf-codec/src/test-support/pdf.ts @@ -182,9 +182,8 @@ export function xrefStreamWithObjectStreamPdf(): Uint8Array { ); b.stream(5, EMPTY_DICT, enc(HELLO_CONTENT)); - // /W [1 4 2]: 1-byte type, 4-byte second field, 2-byte third field -- type 2 (compressed) rows store the containing ObjStm's object number and the index within it; type 1 (uncompressed) rows store a plain byte offset and generation. + // /W [1 4 2]: 1-byte type, 4-byte second field, 2-byte third field -- type 2 (compressed) rows store the containing ObjStm's object number and the index within it; type 1 (uncompressed) rows store a plain byte offset and generation. Object 0's own row is never written explicitly: readXref's `type === 0` branch skips a free entry outright regardless of its other field values, so xrefRows' pre-zeroed leading 7 bytes (type 0, offset/gen both 0) already read exactly the same as any other free-list-head content a literal here could assert. const rows: number[][] = [ - [0, 0, 0, 0, 0, 255, 255], // object 0: the conventional free-list head [2, 0, 0, 0, 4, 0, 0], // object 1 (Catalog): in ObjStm 4, index 0 [2, 0, 0, 0, 4, 0, 1], // object 2 (Pages): index 1 [2, 0, 0, 0, 4, 0, 2], // object 3 (Page): index 2 @@ -194,21 +193,22 @@ export function xrefStreamWithObjectStreamPdf(): Uint8Array { rows.push([1, ...be4(objStmOffset), 0, 0]); rows.push([1, ...be4(contentOffset), 0, 0]); const xrefObjNum = 6; - // The xref stream's own row references its own not-yet-written offset -- known in advance because FixtureBuilder assigns it the moment `stream()` is called, before any bytes are written. + // The xref stream's own row references its own not-yet-written offset -- known in advance because FixtureBuilder assigns it the moment `stream()` is called, before any bytes are written. Reserved by a length bump rather than a placeholder row literal: any placeholder value here is fully overwritten below before `xrefRows` is ever built from it, so a literal would only assert bytes nothing downstream can observe. const xrefOffsetPlaceholderIndex = rows.length; - rows.push([1, 0, 0, 0, 0, 0, 0]); // patched below once the real offset is known + rows.length += 1; const xrefOffset = b.length; // object 6 (the xref stream) starts here, matching what stream(6, ...) is about to record rows[xrefOffsetPlaceholderIndex] = [1, ...be4(xrefOffset), 0, 0]; - const xrefRows = new Uint8Array(rows.length * 7); + const totalRows = rows.length + 1; // + object 0's own implicit free-list-head row, never written explicitly (see the comment on `rows` above) + const xrefRows = new Uint8Array(totalRows * 7); rows.forEach((row, i) => { - xrefRows.set(row, i * 7); + xrefRows.set(row, (i + 1) * 7); }); const xrefCompressed = zlibSync(xrefRows); b.stream( xrefObjNum, - `<< /Type /XRef /Size ${rows.length} /W [1 4 2] /Index [0 ${rows.length}] /Root 1 0 R /Filter /FlateDecode >>`, + `<< /Type /XRef /Size ${totalRows} /W [1 4 2] /Index [0 ${totalRows}] /Root 1 0 R /Filter /FlateDecode >>`, xrefCompressed, ); b.raw(`startxref\n${xrefOffset}\n%%EOF`); From 15738563ab4406e0bfbae455920a9d9181727a61 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:32 +0100 Subject: [PATCH 049/105] test(pdf-codec): pin header()'s default version and the first xref revision's own byte layout header() with no argument had no test at all, leaving its "1.7" default free to mutate unnoticed. incrementalUpdatePdf's own hand-rolled first xref section likewise had no assertion on its offset padding or its trailing startxref/%%EOF, unlike the second section's regex check that already pins both. --- .../pdf-codec/src/test-support/pdf.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/pdf-codec/src/test-support/pdf.test.ts b/packages/pdf-codec/src/test-support/pdf.test.ts index 30a16eb18..0e16ce180 100644 --- a/packages/pdf-codec/src/test-support/pdf.test.ts +++ b/packages/pdf-codec/src/test-support/pdf.test.ts @@ -177,6 +177,22 @@ describe("incrementalUpdatePdf", () => { "[0 0 200 100]", ); }); + + it("pads every offset in the first revision's own xref section to exactly 10 digits, matching the second revision's", () => { + const text = decode(incrementalUpdatePdf()); + const firstXrefIdx = text.indexOf("xref\n0 6\n"); + const section = text.slice(firstXrefIdx, text.indexOf("trailer")); + expect(section.match(/\d{10} 00000 n /g)).toHaveLength(5); + }); + + it("closes the first revision's own trailer with a self-contained, well-formed startxref and %%EOF", () => { + const text = decode(incrementalUpdatePdf()); + const firstXrefIdx = text.indexOf("xref\n0 6\n"); + const firstTrailerIdx = text.indexOf("trailer", firstXrefIdx); + expect(text.slice(firstTrailerIdx)).toMatch( + /^trailer\n<< \/Size 6 \/Root 1 0 R >>\nstartxref\n\d+\n%%EOF\n3 0 obj/, + ); + }); }); describe("unsupportedSecurityHandlerPdf", () => { @@ -306,6 +322,11 @@ describe("symbolFontProgramPdf", () => { // FixtureBuilder itself, exercised directly: the exported fixture functions above only ever feed it well-formed dicts and object numbers that genuinely exist, so its own /Length-insertion regex, misuse guard, and xref-padding arithmetic have no route to coverage except a test that deliberately probes their edge cases. describe("FixtureBuilder", () => { + it("defaults header() to version 1.7 when called with no argument", () => { + const text = decode(new FixtureBuilder().header().bytes()); + expect(text).toBe("%PDF-1.7\n"); + }); + it("inserts /Length at the dict's own true end, not at the first nested '>>' it happens to find", () => { const bytes = new FixtureBuilder() .stream(1, "<< /Sub << /X 1 >> >>", new TextEncoder().encode("abc")) From 78f38ed2396604d947e8d5ee29be911f3aa9e5e4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:40 +0100 Subject: [PATCH 050/105] test(pdf-codec): read a pageless document on the unaborted path The only existing pagelessPdf() test aborts its signal before readPdf ever runs, so the fixture's own trailer content -- the /Root reference resolving the catalog at all -- was never actually exercised by a successful parse. --- packages/pdf-codec/src/read.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/pdf-codec/src/read.test.ts b/packages/pdf-codec/src/read.test.ts index b6f68fc7b..11e862d28 100644 --- a/packages/pdf-codec/src/read.test.ts +++ b/packages/pdf-codec/src/read.test.ts @@ -280,6 +280,10 @@ describe("readPdf: cancellation", () => { ).toThrow(); }); + it("reads an unaborted pageless document normally, resolving its catalog to zero pages", () => { + expect(readPdf(pagelessPdf()).pages).toHaveLength(0); + }); + // The abort contract's real granularity (ExaDev/documents.js#585): the signal is consulted once per page-loop iteration, so a signal aborted WHILE page 1 is being read (here: the sink fires on page 1's missing-/Resources warning and aborts) stops the parse before page 2 is ever interpreted, rather than running to completion. it("honours an aborted signal between pages, not only before reading begins", () => { const controller = new AbortController(); From c9d79bc65e2b88f8ec02b472e9d422bdc2504cf4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:18:22 +0100 Subject: [PATCH 051/105] test(pdf-codec): pin the dedup annotation's own parse and the manifest stream's bytes The second /FileAttachment annotation (object 11, the dedup case) contributed nothing to the final attachments list whether it parsed correctly or was outright malformed, since its filespec name always duplicates the name tree's own entry -- nothing distinguished "parsed and deduped" from "failed to parse and was skipped". Capture diagnostics and assert none, so a malformed object 11 is caught by its own unexpected warnings. manifest.json's own stream content was never read back, only its missing /Desc; assert its decoded bytes too. --- packages/pdf-codec/src/attachments.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/attachments.test.ts b/packages/pdf-codec/src/attachments.test.ts index 68de34ae4..1f3534208 100644 --- a/packages/pdf-codec/src/attachments.test.ts +++ b/packages/pdf-codec/src/attachments.test.ts @@ -22,7 +22,10 @@ describe("readPdf: embedded files", () => { }); it("collects a /FileAttachment annotation's filespec and a catalog /AF entry, deduplicated against the name tree by name", () => { - const doc = readPdf(embeddedFilesPdf()); + const diagnostics: PdfDiagnostic[] = []; + const doc = readPdf(embeddedFilesPdf(), { + sink: (d) => diagnostics.push(d), + }); const names = doc.attachments?.map((a) => a.name); expect(names).toEqual(["notes.txt", "logo.bin", "manifest.json"]); const logo = doc.attachments?.find((a) => a.name === "logo.bin"); @@ -30,6 +33,11 @@ describe("readPdf: embedded files", () => { expect(logo?.mimeType).toBeUndefined(); const manifest = doc.attachments?.find((a) => a.name === "manifest.json"); expect(manifest?.description).toBeUndefined(); + expect(manifest?.base64).toBe(b64("{}")); + // The only diagnostic expected is the deliberately-broken /AF entry (object 16) tested separately below -- the second /FileAttachment annotation (object 11, the dedup case) must itself parse cleanly rather than merely happening to contribute nothing because it is malformed. + expect(diagnostics).toEqual([ + expect.objectContaining({ code: "pdf/embedded-file-missing-stream" }), + ]); }); it("warns on and drops a filespec whose /EF resolves but has neither an /F nor a /UF stream", () => { From 4a1998985db4596037dc961c4bb105ad5d294a73 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:18:32 +0100 Subject: [PATCH 052/105] test(pdf-codec): assert the raw XMP residue matches byte-for-byte The residue test only checked doc.source.xmp.xml contained two substrings ("dc:title", "pdf:Producer"), leaving every wrapper tag, namespace declaration, and the join separator between lines unverified -- a fixture missing any of those still contained both substrings. Compare against a full expected string instead, kept as its own literal rather than imported from the fixture: reusing the fixture's own source string as the expected value would make the assertion agree with itself under any mutation to that shared string. --- .../pdf-codec/src/document-residue.test.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/document-residue.test.ts b/packages/pdf-codec/src/document-residue.test.ts index a9a95873e..c5a34abe9 100644 --- a/packages/pdf-codec/src/document-residue.test.ts +++ b/packages/pdf-codec/src/document-residue.test.ts @@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest"; import { readPdf } from "./read"; import { metadataResiduePdf, minimalClassicXrefPdf } from "./test-support/pdf"; +// A separately-typed copy of metadataResiduePdf's own XMP packet, not imported from the fixture: comparing the raw residue against the fixture's own source string would make the assertion trivially true under any change to that shared string, since both sides would mutate together. Independent duplication here is what lets the check actually verify byte-for-byte preservation rather than tautologically agreeing with itself. +const EXPECTED_METADATA_RESIDUE_XMP = [ + '', + '', + '', + '', + 'From XMP', + 'The XMP description', + "xmpmetadata", + "XMP Author", + "XMP Producer 9.9", + "", + "", + "", + '', +].join("\n"); + // The metadata/residue cluster (#721 phase 6): catalog /Lang as the document language, the XMP /Metadata stream split into a semantic Dublin Core mirror (filling only fields /Info does not carry -- in a PDF/A file these live ONLY in XMP) and a raw-packet residue entry, and the package-level residue rows for the catalog and trailer facts no content node owns (viewer/session behaviour, output intents, private/application data, the trailer /ID). describe("readPdf: document language and XMP", () => { @@ -25,8 +42,7 @@ describe("readPdf: document language and XMP", () => { it("keeps the whole raw XMP packet as package-level residue", () => { const doc = readPdf(metadataResiduePdf()); expect(doc.source?.xmp?.format).toBe("pdf"); - expect(doc.source?.xmp?.xml).toContain("dc:title"); - expect(doc.source?.xmp?.xml).toContain("pdf:Producer"); + expect(doc.source?.xmp?.xml).toBe(EXPECTED_METADATA_RESIDUE_XMP); }); }); From 09c2f3126c8261b8df431cb76ac56ee954770d4c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:22:22 +0100 Subject: [PATCH 053/105] test(pdf-codec): read the metadata fixture's own page alongside its metadata Every other test against metadataResiduePdf() checked metadata and residue facts only, never that its own page dict actually parses -- a corrupted page dict left every existing assertion in this file passing regardless. --- packages/pdf-codec/src/document-residue.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/pdf-codec/src/document-residue.test.ts b/packages/pdf-codec/src/document-residue.test.ts index c5a34abe9..49de7cbe3 100644 --- a/packages/pdf-codec/src/document-residue.test.ts +++ b/packages/pdf-codec/src/document-residue.test.ts @@ -22,6 +22,10 @@ const EXPECTED_METADATA_RESIDUE_XMP = [ // The metadata/residue cluster (#721 phase 6): catalog /Lang as the document language, the XMP /Metadata stream split into a semantic Dublin Core mirror (filling only fields /Info does not carry -- in a PDF/A file these live ONLY in XMP) and a raw-packet residue entry, and the package-level residue rows for the catalog and trailer facts no content node owns (viewer/session behaviour, output intents, private/application data, the trailer /ID). describe("readPdf: document language and XMP", () => { + it("reads the fixture's own single page alongside its metadata and residue facts", () => { + expect(readPdf(metadataResiduePdf()).pages).toHaveLength(1); + }); + it("reads catalog /Lang as metadata.language", () => { const doc = readPdf(metadataResiduePdf()); expect(doc.metadata.language).toBe("en-GB"); From a652701072a13b3397b99c502952d324f97da94b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:22:26 +0100 Subject: [PATCH 054/105] test(pdf-codec): pin equalCropBoxPdf's own declared CropBox bytes An explicit CropBox equal to the MediaBox and no CropBox at all are indistinguishable through readPdf's output alone -- both leave the visible region at the MediaBox and generate no residue row -- so nothing distinguished this fixture actually declaring one from omitting it entirely. Check the raw bytes for the literal /CropBox entry the fixture's own name and comment say it declares. --- packages/pdf-codec/src/page-boundaries.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/page-boundaries.test.ts b/packages/pdf-codec/src/page-boundaries.test.ts index d4a886a3d..443149e69 100644 --- a/packages/pdf-codec/src/page-boundaries.test.ts +++ b/packages/pdf-codec/src/page-boundaries.test.ts @@ -96,7 +96,10 @@ describe("readPdf: page-boundary residue", () => { }); it("records nothing when the declared boxes carry no fact beyond the visible one", () => { - const doc = readPdf(equalCropBoxPdf()); + const bytes = equalCropBoxPdf(); + // An equal CropBox and no CropBox at all are indistinguishable through readPdf's own output (both leave the visible region at the MediaBox and generate no residue row), so this checks the fixture's own raw bytes genuinely declare one rather than merely omitting it -- the fixture's whole point is the equal-box case, not the no-box one. + expect(new TextDecoder().decode(bytes)).toContain("/CropBox [0 0 200 100]"); + const doc = readPdf(bytes); expect(doc.pages[0]).toMatchObject({ widthPt: 200, heightPt: 100 }); expect(textItems(doc.pages[0]!.items).length).toBeGreaterThan(0); expect(doc.source?.["page-boxes"]).toBeUndefined(); From 3ff18a620e7b38de406c96ce0a7e6a7fd4e6f773 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:29:52 +0100 Subject: [PATCH 055/105] test(pdf-codec): pin taggedFormPdf's struct elements and both fixtures' raw MCID spans doc.structure was never checked at all for taggedFormPdf, leaving both of its struct elements (including the one exercising the /Stm- qualified numbering channel) unverified. Separately, wrapping a form invocation or a paragraph's own text in a page-level MCID span it never resolves against produces the exact same `structure`-free item whether the span exists or not, in both taggedFormPdf's FmB case and parentTreeMissingEntryPdf's inconsistent- mapping case -- add raw content-stream checks for the spans each fixture's own documentation says it declares. --- packages/pdf-codec/src/structure.test.ts | 26 ++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/structure.test.ts b/packages/pdf-codec/src/structure.test.ts index 973410956..42cb17d7c 100644 --- a/packages/pdf-codec/src/structure.test.ts +++ b/packages/pdf-codec/src/structure.test.ts @@ -108,7 +108,8 @@ describe("readPdf: marked-content association", () => { }); it("carries the enclosing page MCID onto content a form XObject paints, but not into a form that numbers its own MCIDs", () => { - const doc = readPdf(taggedFormPdf()); + const bytes = taggedFormPdf(); + const doc = readPdf(bytes); const textItem = (text: string) => doc.pages[0]!.items.find((i) => i.kind === "text" && i.text === text); // FmA is invoked inside the /P <> span and declares no /StructParents of its own, so its text paints that span's content item. @@ -117,11 +118,28 @@ describe("readPdf: marked-content association", () => { }); // FmB declares /StructParents 3 and marks its own MCID 0 under that key -- the /Stm-qualified channel, which must not resolve against the page's numbering even though FmB is invoked inside the /P <> span. expect(textItem("Self-marked form text")).not.toHaveProperty("structure"); + // Wrapping FmB's own Do in a page-level MCID span it never inherits from is exactly the point of this fixture, but that also makes the wrapper invisible to every assertion above (the item ends up with no `structure` property whether the span is there or not) -- check the raw content streams directly for the spans the fixture's own name and comment claim it declares. + const text = new TextDecoder().decode(bytes); + expect(text).toContain( + "/P << /MCID 0 >> BDC\n/FmA Do\nEMC\n/P << /MCID 1 >> BDC\n/FmB Do\nEMC", + ); + expect(text).toContain( + "/Span << /MCID 0 >> BDC\nBT /F1 12 Tf 10 10 Td (Self-marked form text) Tj ET\nEMC", + ); + }); + + it("reads both of taggedFormPdf's own struct elements from its /K walk", () => { + const doc = readPdf(taggedFormPdf()); + expect(doc.structure).toEqual([ + { id: "struct1", type: "P", title: "Carried span", children: [] }, + { id: "struct2", type: "P", title: "Own numbering", children: [] }, + ]); }); it("reports a diagnostic when a page declares /StructParents the parent tree does not carry", () => { + const bytes = parentTreeMissingEntryPdf(); const diagnostics: PdfDiagnostic[] = []; - const doc = readPdf(parentTreeMissingEntryPdf(), { + const doc = readPdf(bytes, { sink: (d) => diagnostics.push(d), }); expect( @@ -129,5 +147,9 @@ describe("readPdf: marked-content association", () => { ).toBe(true); // The tree's key 0 names an owner for MCID 0, but the page declares /StructParents 4: no owner, and no accidental lookup through the position-shaped key either. expect(doc.pages[0]!.items[0]).not.toHaveProperty("structure"); + // An item genuinely marked but resolving to no owner and an item never marked at all produce the identical `structure`-free result above, so this checks the fixture's own raw content stream genuinely wraps the text in the /P <> span its own name and comment describe. + expect(new TextDecoder().decode(bytes)).toContain( + "/P << /MCID 0 >> BDC\nBT /F1 12 Tf 10 100 Td (Owned by nothing) Tj ET\nEMC", + ); }); }); From 64711f9cb7701b7be1e468ce1c33b47fed76b5fd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:33:47 +0100 Subject: [PATCH 056/105] test(pdf-codec): pin parentTreeMissingEntryPdf's own struct element Its struct element (with no /T title of its own) had no assertion on doc.structure at all, leaving the whole dict unverified alongside the diagnostic and per-item checks already in place. --- packages/pdf-codec/src/structure.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pdf-codec/src/structure.test.ts b/packages/pdf-codec/src/structure.test.ts index 42cb17d7c..16d20dbd2 100644 --- a/packages/pdf-codec/src/structure.test.ts +++ b/packages/pdf-codec/src/structure.test.ts @@ -151,5 +151,7 @@ describe("readPdf: marked-content association", () => { expect(new TextDecoder().decode(bytes)).toContain( "/P << /MCID 0 >> BDC\nBT /F1 12 Tf 10 100 Td (Owned by nothing) Tj ET\nEMC", ); + // The struct element itself is the tree's only content (nothing else references its own dict), so this checks it independently of the page-association behaviour above. + expect(doc.structure).toEqual([{ id: "struct1", type: "P", children: [] }]); }); }); From cccfb0a0a2b9a353ddd15cf9fcd509040c142088 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:41:57 +0100 Subject: [PATCH 057/105] refactor(pdf-codec): stop computing an unread MediaBox width/height for the rotation matrix renderPdfPage and readPage both fed the MediaBox's own width and height into pageRotationTransform purely to build its matrix, then immediately renormalized the origin from the crop box's own rotated bounds -- a translation that provably cancels whatever w/h value produced it, since only the matrix's rotation component depends on rotation at all and the translation component is subtracted straight back out. Confirmed directly against an asymmetric MediaBox/CropBox pair under every rotation, not merely the aligned case: passing 0 for both arguments produces byte-identical geometry and item positions. Pass 0 for both, removing an arithmetic expression whose result never reaches any output, and drop the raster.ts test that asserted the old (never actually observable) width/height distinction. --- packages/pdf-codec/src/raster.test.ts | 18 ------------------ packages/pdf-codec/src/raster.ts | 7 ++----- packages/pdf-codec/src/read.ts | 9 +++------ 3 files changed, 5 insertions(+), 29 deletions(-) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index b916deec6..bbe9c8fda 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -528,24 +528,6 @@ describe("renderPdfPage: geometry and clipPt", () => { expect(rasteriser.geometry).toMatchObject({ widthPx: 200, heightPx: 100 }); }); - it("passes the MediaBox's own width and height, not the sum of its corners, into the rotation transform", () => { - // Unrotated, mediaBox.urx +/- llx never reaches the rendered geometry at all (pageRotationTransform's own Rotate-0 branch ignores both w and h), so the sibling test above cannot distinguish + from -- only a rotation whose matrix genuinely depends on w/h (90 here) can. - const b = new SmallFixture(); - b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); - b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); - b.object( - 3, - "<< /Type /Page /Parent 2 0 R /MediaBox [50 50 250 150] /Rotate 90 /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", - ); - b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); - b.stream(5, "<< >>", enc(content)); - const bytes = b.classicXrefAndTrailer(5, "/Root 1 0 R"); - const rasteriser = new RecordingRasteriser(); - drive(bytes, 0, {}, rasteriser); - // Real w = urx - llx = 200, h = ury - lly = 100; Rotate 90 swaps them (widthPt = h, heightPt = w), so the rendered page is 100 x 200 -- not 300 x 200 (w mutated to a sum) or 100 x 300 (h mutated to a sum). - expect(rasteriser.geometry).toMatchObject({ widthPx: 100, heightPx: 200 }); - }); - it("reports a zero-height intersection distinctly from a zero-width one, with the exact requested and page ranges in the message", () => { const bytes = onePagePdf(content); expect(() => diff --git a/packages/pdf-codec/src/raster.ts b/packages/pdf-codec/src/raster.ts index f6cf3e2b8..c4f29e004 100644 --- a/packages/pdf-codec/src/raster.ts +++ b/packages/pdf-codec/src/raster.ts @@ -224,11 +224,8 @@ export function renderPdfPage( cropBox = mediaBox; } const rotation = normalizeRotation(asNumber(dictGet(page, "Rotate"))); - const rotationResult = pageRotationTransform( - rotation, - mediaBox.urx - mediaBox.llx, - mediaBox.ury - mediaBox.lly, - ); + // Only rotationResult.matrix is used below, never its own widthPt/heightPt fields -- and the matrix's rotation/reflection component (a, b, c, d) never depends on the w/h arguments at all, only its translation component (e, f) does. That translation is provably canceled by the origin renormalization two lines down (translationMatrix(-visibleRect.minX, -visibleRect.minY) subtracts out exactly the offset any w/h value would have introduced), so the real mediaBox width/height computed here would produce a byte-identical pageMatrix and visibleRect to passing 0 for both -- confirmed directly against an asymmetric MediaBox/CropBox pair under every rotation, not merely the aligned case. Passing 0 rather than the real (but unobservable) mediaBox dimensions removes an arithmetic expression whose result genuinely never reaches any output. + const rotationResult = pageRotationTransform(rotation, 0, 0); const visibleRect = rotatedRectBounds(cropBox, rotationResult.matrix); const pageWidthPt = visibleRect.maxX - visibleRect.minX; const pageHeightPt = visibleRect.maxY - visibleRect.minY; diff --git a/packages/pdf-codec/src/read.ts b/packages/pdf-codec/src/read.ts index e5d35c707..c19ce7c1b 100644 --- a/packages/pdf-codec/src/read.ts +++ b/packages/pdf-codec/src/read.ts @@ -505,12 +505,9 @@ function readPage( cropBox = mediaBox; } const rotation = normalizeRotation(asNumber(dictGet(page, "Rotate"))); - const rotationResult = pageRotationTransform( - rotation, - mediaBox.urx - mediaBox.llx, - mediaBox.ury - mediaBox.lly, - ); - // The crop rect rotated into output space, then used as the origin: every item position is relative to the visible region's own lower-left corner, exactly as a viewer presents it. With no declared /CropBox this reproduces the media-box pipeline bit for bit -- the rotation matrix's translation already maps the media box to the first quadrant, so the rotated media rect's min corner is the origin the old shift-by-(-llx, -lly) produced. + // Only rotationResult.matrix is used below, never its own widthPt/heightPt fields -- and the matrix's rotation/reflection component (a, b, c, d) never depends on the w/h arguments at all, only its translation component (e, f) does. That translation is provably canceled by the origin renormalization two lines down (translationMatrix(-visibleRect.minX, -visibleRect.minY) subtracts out exactly the offset any w/h value would have introduced), so the real mediaBox width/height computed here would produce a byte-identical pageMatrix and visibleRect to passing 0 for both -- confirmed directly against an asymmetric MediaBox/CropBox pair under every rotation, not merely the aligned case. Passing 0 rather than the real (but unobservable) mediaBox dimensions removes an arithmetic expression whose result genuinely never reaches any output. + const rotationResult = pageRotationTransform(rotation, 0, 0); + // The crop rect rotated into output space, then used as the origin: every item position is relative to the visible region's own lower-left corner, exactly as a viewer presents it. const visibleRect = rotatedRectBounds(cropBox, rotationResult.matrix); const widthPt = visibleRect.maxX - visibleRect.minX; const heightPt = visibleRect.maxY - visibleRect.minY; From 7b082f8834d82610e392fa8e4ed8f92828225163 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:05:41 +0100 Subject: [PATCH 058/105] refactor(pdf-codec): extract drawGlyphOutline for direct coverage of its own empty-subpaths branch Every one of a real vendored face's own glyphs with at least one contour flattens to at least one subpath (glyphOutlineSubpaths' own suite already establishes a contour under three points contributes none), so drawTextRun's "outline had contours but produced no subpaths anyway" branch had no route to coverage through any real embedded font. Factor the per-glyph draw-or-skip decision into its own exported function, matching glyphOutlineSubpaths' and flattenCubic's own established pattern in this file, and drive it directly with the same too-short contour those functions' own tests already use. Separately, fix the misleading "passes drawLine's dotted branch" comment on an existing test whose own two-point path actually collapses to an ExtractedLine (interpret.ts's own detectLine), never reaching drawPath at all, and add the two-segment fixture that genuinely does. --- packages/pdf-codec/src/raster.test.ts | 42 +++++++++++++++++++++++++-- packages/pdf-codec/src/raster.ts | 28 ++++++++++++------ 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index bbe9c8fda..224d9b590 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -9,7 +9,12 @@ import { parseHmtx } from "./hmtx-table"; import type { GlyphContourPoint, GlyphOutline } from "./glyf-contours"; import type { Matrix } from "./matrix"; import { applyMatrix, BEZIER_KAPPA, IDENTITY_MATRIX } from "./matrix"; -import { flattenCubic, glyphOutlineSubpaths, renderPdfPage } from "./raster"; +import { + drawGlyphOutline, + flattenCubic, + glyphOutlineSubpaths, + renderPdfPage, +} from "./raster"; import type { PageRasteriser, RasterDrawOp, @@ -779,6 +784,19 @@ describe("glyphOutlineSubpaths", () => { expect(glyphOutlineSubpaths(exactlyThree, IDENTITY_MATRIX)).toHaveLength(1); }); + it("draws nothing for a non-empty outline whose only contour is still too short to produce a subpath", () => { + // decodeGlyphOutline's own contract only guarantees a non-empty contours array, not that every contour individually clears the 3-point floor -- the same tooShort shape above, but driven through drawGlyphOutline's own draw-or-skip decision rather than glyphOutlineSubpaths directly. + const tooShort = outlineOf([pt(0, 0, true), pt(1, 0, true)]); + const rasteriser = new RecordingRasteriser(); + drawGlyphOutline( + tooShort, + IDENTITY_MATRIX, + { r: 0, g: 0, b: 0 }, + rasteriser, + ); + expect(rasteriser.ops).toEqual([]); + }); + it("starts at the implied midpoint of the last and first points for a contour with no on-curve point at all, walking every consecutive off-curve pair through its own midpoint", () => { // Three off-curve points, none on-curve: start = midpoint(P2, P0), then each consecutive pair (P0,P1) and (P1,P2) implies its own on-curve midpoint, and the walk closes with a final quad from the last implied point back through P2 to start. const outline = outlineOf([ @@ -1031,8 +1049,8 @@ describe("renderPdfPage: vector draw ops", () => { }); }); - it("draws a dotted general path's own line segment as an exact dot train, scaling the dot size by widthPt x scale", () => { - // drawPath's own dotted branch has no coverage at all outside this describe block: every other dotted test in this file goes through drawLine's single two-point segment instead. At scale 1, multiplying and dividing widthPt by pixelsPerPt are indistinguishable, so this pins it at scale 3. + it("draws a dotted two-point path as an exact dot train, scaling the dot size by widthPt x scale", () => { + // A single "m ... l S" open segment is exactly the shape detectLine reduces to an ExtractedLine (interpret.ts), so this actually drives drawLine's own dotted branch, not drawPath's -- drawPath's dotted branch needs a path detectLine won't collapse, which the two-segment test below covers. At scale 1, multiplying and dividing widthPt by pixelsPerPt are indistinguishable, so this pins it at scale 3. const rasteriser = new RecordingRasteriser(); drive( onePagePdf("[0 4] 0 d 1 J 2 w 0 0 0 RG 20 20 m 60 20 l S"), @@ -1054,6 +1072,24 @@ describe("renderPdfPage: vector draw ops", () => { expect(squares[squares.length - 1]).toMatchObject({ xPx: 177, yPx: 237 }); }); + it("draws a dotted general path's own line segment as a dot train, not only through drawLine's single-segment shape", () => { + // Two straight segments in one open subpath: detectLine only ever collapses a subpath of exactly one segment, so this one stays an ExtractedPath and genuinely drives drawPath's own "line" kind branch -- the sibling test above, despite drawing a straight line, never reaches this branch at all. + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("[0 4] 0 d 1 J 2 w 0 0 0 RG 20 20 m 60 20 l 60 60 l S"), + 0, + {}, + rasteriser, + ); + const squares = rasteriser.ops.filter(isFillRect); + // Two 40pt segments, spacing = max(2 x 2, 1) = 4px: 11 dots each (0, 4, ..., 40), 22 total -- including the shared corner point drawn once by each segment's own end/start. + expect(squares).toHaveLength(22); + expect(squares[0]).toMatchObject({ xPx: 19, yPx: 79 }); + expect(squares[10]).toMatchObject({ xPx: 59, yPx: 79 }); + expect(squares[11]).toMatchObject({ xPx: 59, yPx: 79 }); + expect(squares[squares.length - 1]).toMatchObject({ xPx: 59, yPx: 39 }); + }); + it("draws a dotted general path's own cubic segment as a dot train too, not only its line segments", () => { // A cubic whose control points are collinear with its endpoints flattens to just its own endpoint (the same fact flattenCubic's own suite pins directly), so the resulting dot train is exactly as predictable as the line-segment case above -- this isolates drawPath's cubic branch from its line branch, which the line-only test above never touches. const rasteriser = new RecordingRasteriser(); diff --git a/packages/pdf-codec/src/raster.ts b/packages/pdf-codec/src/raster.ts index c4f29e004..90bab3fd1 100644 --- a/packages/pdf-codec/src/raster.ts +++ b/packages/pdf-codec/src/raster.ts @@ -1115,18 +1115,28 @@ function drawTextRun( glyphScale, multiplyMatrices(trm, interpretToDeviceMatrix), ); - const subpaths = glyphOutlineSubpaths(outline, glyphMatrix); - if (subpaths.length === 0) { - continue; - } - rasteriser.draw({ - kind: "path", - subpaths, - fill: { color: item.color, fillRule: "nonzero" }, - }); + drawGlyphOutline(outline, glyphMatrix, item.color, rasteriser); } } +// One glyph's outline drawn as a single filled path, factored out of the per-glyph loop above solely so raster.test.ts can drive it directly with a hand-built outline: every one of a real vendored face's own glyphs with at least one contour flattens to at least one subpath (glyphOutlineSubpaths' own suite already establishes that a contour under three points contributes none), so the "a non-empty outline still produced no subpaths" branch below has no route to coverage through any real embedded font. +export function drawGlyphOutline( + outline: GlyphOutline, + glyphMatrix: Matrix, + color: LayoutColor, + rasteriser: PageRasteriser, +): void { + const subpaths = glyphOutlineSubpaths(outline, glyphMatrix); + if (subpaths.length === 0) { + return; + } + rasteriser.draw({ + kind: "path", + subpaths, + fill: { color, fillRule: "nonzero" }, + }); +} + // TrueType contours to port subpaths: each contour's on/off-curve points walked into line and quadratic segments, each quadratic elevated to the exactly equivalent cubic (control points at 2/3 of the way from the on-curve ends toward the off-curve control -- the standard exact quadratic-to-cubic elevation, no approximation), then every point transformed as a point. A run of consecutive off-curve points implies an on-curve point at each neighbouring pair's midpoint, per the TrueType glyph specification's own contour convention. Exported solely so this suite can drive it directly with hand-built contours: a real embedded font's own glyphs (this module's only other route in) never reliably exercise every branch on demand -- no vendored face happens to start a contour off-curve, or carries a contour with no on-curve point at all, the way a hand-built GlyphOutline can. export function glyphOutlineSubpaths( outline: GlyphOutline, From 5dc2352faf882d64237d27fa7712c88ea1cda9ea Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:23:20 +0100 Subject: [PATCH 059/105] refactor(pdf-codec): remove drawTextRun's dead glyphAdvance fallback resolveTextOutlineFace and fontResolver.metrics.glyphAdvance resolve item.fontResourceName against item.resources through the identical dictGet(resources, "Font") -> dictGet(fontsDict, fontResourceName) lookup, and the metrics side always returns a populated result once that dict exists. Since drawTextRun already returns early when the face itself fails to resolve, glyphAdvance can never return undefined by the time the placement loop calls it with the same two values -- the widthPer1000/byteLengthConsumed fallbacks, and the composite field feeding one of them, were unreachable. Assert the result non-null with a comment recording why, and drop composite from TextOutlineFace and its three call sites now that nothing reads it. --- packages/pdf-codec/src/raster.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/pdf-codec/src/raster.ts b/packages/pdf-codec/src/raster.ts index 90bab3fd1..94583675f 100644 --- a/packages/pdf-codec/src/raster.ts +++ b/packages/pdf-codec/src/raster.ts @@ -157,9 +157,6 @@ export interface RenderPdfPageOptions { readonly signal?: AbortSignal; } -// Mirrors interpret.ts's own fallback width for a glyph whose advance cannot be resolved -- the interpreter's advance walk has already diagnosed the miss through the sink by the time the raster walk re-asks, and using the same constant keeps the two walks accumulating identical fallbacks rather than silently disagreeing about a run's internal placement. -const FALLBACK_GLYPH_WIDTH_PER_1000 = 500; - // Canvas dimensions round UP, so the region's whole point extent always covers its last pixel row/column (a round-half rule could drop a right-edge sliver), and a hairline-but-valid clip that scales below one pixel still yields a one-pixel canvas rather than a zero-sized PNG no encoder accepts. The epsilon absorbs float fuzz (an exact 100pt at scale 2 computing 200.00000000000003 must be 200, not 201). function regionPixels(extentPt: number, scale: number): number { return Math.max(1, Math.ceil(extentPt * scale - 1e-9)); @@ -766,8 +763,6 @@ export function flattenCubic( // Everything the glyph walk needs from one font resource: the parsed 'glyf', the design-grid size its coordinates live in, and the shown-code -> glyph-ID mapping the PDF's own font dictionary states (Identity-H's CID arithmetic, or a simple font's program cmap). interface TextOutlineFace { - // Whether shown codes are 2-byte CIDs (a Type0/Identity-H composite font) or 1-byte simple-font codes -- the fallback advance width the glyph walk consumes per code when the metrics port cannot resolve one. - readonly composite: boolean; readonly glyf: GlyfTable; readonly unitsPerEm: number; glyphIdOf( @@ -961,7 +956,6 @@ function buildTextOutlineFace( entries.push((decodedBytes[i]! << 8) | decodedBytes[i + 1]!); } return { - composite: true, glyf: program.face.glyf, unitsPerEm: program.face.unitsPerEm, glyphIdOf: (codes, offset) => { @@ -973,7 +967,6 @@ function buildTextOutlineFace( } // /Identity (or unstated, which defaults to Identity per 9.7.4.2): GID == CID. return { - composite: true, glyf: program.face.glyf, unitsPerEm: program.face.unitsPerEm, glyphIdOf: (codes, offset) => (codes[offset]! << 8) | codes[offset + 1]!, @@ -999,7 +992,6 @@ function buildTextOutlineFace( return; } return { - composite: false, glyf: program.face.glyf, unitsPerEm: program.face.unitsPerEm, glyphIdOf: (codes, offset) => { @@ -1049,8 +1041,7 @@ function drawTextRun( if (face === undefined) { return; // the diagnostic naming why has already gone to the sink } - const composite = face.composite; - // Per-glyph advances exactly as the interpreter accumulated them (same port, same fallback constants), without the Tc/Tw/Tz text-state adjustments that live inside the interpreter -- those are absorbed by the end-matrix correction below. + // Per-glyph advances exactly as the interpreter accumulated them (same port), without the Tc/Tw/Tz text-state adjustments that live inside the interpreter -- those are absorbed by the end-matrix correction below. const placements: { readonly glyphId: number | undefined; readonly advance: number; @@ -1058,14 +1049,15 @@ function drawTextRun( let offset = 0; let cumulative = 0; while (offset < item.codes.length) { + // Never undefined: resolveTextOutlineFace above already resolved item.fontResourceName against item.resources to a real font dict (returning early otherwise), and fontResolver.metrics.glyphAdvance's own resolution does the identical dictGet(resources, "Font") -> dictGet(fontsDict, fontResourceName) lookup against the same two values, then always returns a populated result once that dict exists -- there is no way for this call to find no font once the one above already did. const advance = fontResolver.metrics.glyphAdvance( item.fontResourceName, item.resources, item.codes, offset, - ); - const widthPer1000 = advance?.widthPer1000 ?? FALLBACK_GLYPH_WIDTH_PER_1000; - const byteLength = advance?.byteLengthConsumed ?? (composite ? 2 : 1); + )!; + const widthPer1000 = advance.widthPer1000; + const byteLength = advance.byteLengthConsumed; placements.push({ glyphId: face.glyphIdOf(item.codes, offset), advance: cumulative, From dc8c04e7678f4ef2ecbbb214ce6979289906ef14 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:33:40 +0100 Subject: [PATCH 060/105] test(pdf-codec): pin drawPath's own dotted-stroke width scaling Every existing test against drawPath's dotted branch ran at the default scale (1), where multiplying and dividing widthPt by pixelsPerPt are indistinguishable, mirroring the same gap drawLine's own sibling test already closed for its branch. --- packages/pdf-codec/src/raster.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 224d9b590..87882f5d3 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -1090,6 +1090,19 @@ describe("renderPdfPage: vector draw ops", () => { expect(squares[squares.length - 1]).toMatchObject({ xPx: 59, yPx: 39 }); }); + it("scales drawPath's own dotted dot size by the render scale, not divides by it", () => { + // At scale 1 (every test above), multiplying and dividing widthPt by pixelsPerPt are indistinguishable; only a non-1 scale pins the operator drawPath's own dotted branch uses, as the sibling drawLine test above already does for its own branch. + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("[0 4] 0 d 1 J 2 w 0 0 0 RG 20 20 m 60 20 l 60 60 l S"), + 0, + { scale: 3 }, + rasteriser, + ); + const squares = rasteriser.ops.filter(isFillRect); + expect(squares[0]).toMatchObject({ widthPx: 6, heightPx: 6 }); + }); + it("draws a dotted general path's own cubic segment as a dot train too, not only its line segments", () => { // A cubic whose control points are collinear with its endpoints flattens to just its own endpoint (the same fact flattenCubic's own suite pins directly), so the resulting dot train is exactly as predictable as the line-segment case above -- this isolates drawPath's cubic branch from its line branch, which the line-only test above never touches. const rasteriser = new RecordingRasteriser(); From a4f6c5af6f93361023f5f07637d548e7d312fa85 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:38:24 +0100 Subject: [PATCH 061/105] refactor(pdf-codec): remove glyphOutlineSubpaths' redundant segment-count guard The contour.length < 3 continue above already guarantees the walk below emits at least two segments: every on-curve point emits exactly one, and among off-curve points only the very first one encountered after a clear state can defer without emitting, so a contour of n >= 3 points can defer at most once and the trailing flush emits one more for any pair left open -- the count can never drop below n - 1. Push the subpath unconditionally now that the guard could never be false. --- packages/pdf-codec/src/raster.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/pdf-codec/src/raster.ts b/packages/pdf-codec/src/raster.ts index 94583675f..9232bab61 100644 --- a/packages/pdf-codec/src/raster.ts +++ b/packages/pdf-codec/src/raster.ts @@ -1210,15 +1210,14 @@ export function glyphOutlineSubpaths( if (pendingOffCurve !== undefined) { emitQuad(current, pendingOffCurve, start); } - if (segments.length >= 2) { - const startPx = applyMatrix(matrix, start); - subpaths.push({ - startXPx: startPx.x, - startYPx: startPx.y, - segments, - closed: true, - }); - } + // No separate segments.length guard: the contour.length < 3 continue above already guarantees at least two segments here. Walking a contour of n >= 3 points emits exactly one segment per point that isn't the first half of a still-open off-curve pair (an on-curve point always emits, and only the very first off-curve point encountered after a clear state emits none) -- for n >= 3 points that can defer at most one single emission this way, and the loop's own trailing flush emits one more for a pair left open at the end, so the count can never drop below n - 1, i.e. never below 2. + const startPx = applyMatrix(matrix, start); + subpaths.push({ + startXPx: startPx.x, + startYPx: startPx.y, + segments, + closed: true, + }); } return subpaths; } From a6b37493c56d2030ed63256d02f0e6ef6ac0b5e3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:41:36 +0100 Subject: [PATCH 062/105] test(pdf-codec): pin the /Contents array's own inter-chunk separator byte The existing multi-stream test splits after a number, already a complete token on its own, so the reader's inserted separator only ever lands on whitespace the content stream already treats as insignificant. Add a fixture that splits directly between two bare keywords instead, where omitting the separator concatenates "re" and "f" into the single unrecognised keyword "ref" and the rect is never filled. --- packages/pdf-codec/src/raster.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 87882f5d3..4a8dc6506 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -601,6 +601,33 @@ describe("renderPdfPage: geometry and clipPt", () => { ]); }); + it("keeps the array's own two keyword tokens apart across the chunk boundary, not merged into one unrecognised keyword", () => { + // Unlike the sibling test above (whose split falls after a number, already a complete token on its own), this one splits directly between two bare keywords -- "re" ending one chunk, "f" starting the next. Without a separator the two concatenate into the single unrecognised keyword "ref", and the rect is never actually filled. + const b = new SmallFixture(); + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.object( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents [5 0 R 6 0 R] >>", + ); + b.object(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + b.stream(5, "<< >>", enc("1 0 0 rg 10 20 30 40 re")); + b.stream(6, "<< >>", enc("f")); + const bytes = b.classicXrefAndTrailer(6, "/Root 1 0 R"); + const rasteriser = new RecordingRasteriser(); + drive(bytes, 0, {}, rasteriser); + expect(rasteriser.ops.filter(isFillRect)).toEqual([ + { + kind: "fillRect", + xPx: 10, + yPx: 40, + widthPx: 30, + heightPx: 40, + color: { r: 1, g: 0, b: 0 }, + }, + ]); + }); + it("returns whatever the rasteriser's finish produces", () => { const rasteriser = new RecordingRasteriser(); const result = renderPdfPage(onePagePdf(content), 0, {}, rasteriser); From 32cc71f4e891423fc716d01ad13f135092ee4964 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:58:15 +0100 Subject: [PATCH 063/105] refactor(pdf-codec): remove drawTextRun's redundant empty-contours check An outline with zero contours (or, per decodeGlyphOutline's own contract, one whose only contour is too short to keep) already produces zero subpaths through glyphOutlineSubpaths, which drawGlyphOutline's own subpaths.length === 0 check already turns into a no-op draw. The outer contours.length === 0 check duplicated a skip that already happens one call downstream, for no different outcome. --- packages/pdf-codec/src/raster.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/raster.ts b/packages/pdf-codec/src/raster.ts index 9232bab61..81815c486 100644 --- a/packages/pdf-codec/src/raster.ts +++ b/packages/pdf-codec/src/raster.ts @@ -1096,9 +1096,10 @@ function drawTextRun( continue; // a code with no glyph in this face: no ink (the reader's own extraction diagnostics cover the mapping gap) } const outline = decodeGlyphOutline(face.glyf, placement.glyphId); - if (outline === undefined || outline.contours.length === 0) { - continue; // an empty glyph (a space) or an undecodable one: nothing to draw + if (outline === undefined) { + continue; // an undecodable glyph: nothing to draw } + // No separate outline.contours.length === 0 guard here: an empty glyph (a space) decodes to zero contours, and glyphOutlineSubpaths already turns zero contours into zero subpaths on its own (the same emptiness drawGlyphOutline's own subpaths.length === 0 check below catches), so a dedicated check for it here would only ever duplicate a skip that already happens one call downstream. const trm = multiplyMatrices( translationMatrix(placement.advance * correction, 0), item.startMatrix, From 19019fadeeb5091a968675a7de8ccb37a8b35f11 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:24:59 +0100 Subject: [PATCH 064/105] test(pdf-codec): drive cff-bounds.ts's charstring interpreter with hand-built programs The vendored STIX Two Math font is a well-formed program from a real font toolchain, so its charstrings never reach execute()'s or executeEscaped()'s own interpreter limits and malformed-input paths: subroutine nesting past the spec's own depth limit, a glyph whose operator count runs past the per-glyph ceiling, an operand stack overrun, a hintmask whose mask bytes run past the end of the charstring, a reserved operator byte, callsubr/callgsubr with no index on the stack or no matching subroutine, and endchar's own four-argument seac-like form. Add cffFontWithCharstrings, a fixture builder that wraps caller-supplied charstrings (and an optional Private DICT with a Local Subrs INDEX) in an otherwise real CFF program, and drive every one of those paths directly, plus the success path of a glyph drawn through a real local subroutine and an implicit vstem list ahead of a hintmask. --- packages/pdf-codec/src/cff-bounds.test.ts | 159 +++++++++++++++++++++ packages/pdf-codec/src/test-support/cff.ts | 59 ++++++++ 2 files changed, 218 insertions(+) diff --git a/packages/pdf-codec/src/cff-bounds.test.ts b/packages/pdf-codec/src/cff-bounds.test.ts index ef4dc2379..5f094f90a 100644 --- a/packages/pdf-codec/src/cff-bounds.test.ts +++ b/packages/pdf-codec/src/cff-bounds.test.ts @@ -7,6 +7,7 @@ import { CFF_HEADER, ROS_OPERANDS_AND_OPERATOR, cffFont, + cffFontWithCharstrings, cffIndex, stixMathCffBytes, } from "./test-support/cff"; @@ -217,3 +218,161 @@ describe("CFF programs parseCffGlyphBounds refuses to walk", () => { ).toBeUndefined(); }); }); + +// Every charstring below is hand-written specifically to reach an interpreter limit or a malformed-input path in execute()/executeEscaped(): the vendored STIX Two Math font is a well-formed program from a real font toolchain, so none of these ever arise from walking it -- a subroutine nesting past the spec's own limit, an operator count run away by a degenerate charstring, an operand stack overrun, a truncated hintmask, a reserved operator byte, and a call to a subroutine that does not exist are all things a real font's own charstrings simply never do. +describe("parseCffGlyphBounds's charstring interpreter, driven by hand-built charstrings", () => { + const OP_CALLSUBR = 10; + const OP_CALLGSUBR = 29; + const OP_HSTEM = 1; + const OP_VSTEM = 3; + const OP_HINTMASK = 19; + const OP_ENDCHAR = 14; + const RESERVED_OPERATOR = 13; + const ZERO_OPERAND = 139; // the single-byte small-integer encoding of 0 (bias 139) + const MAX_SUBR_DEPTH = 10; + const MAX_OPERAND_STACK = 48; + const MAX_OPERATIONS_PER_GLYPH = 100_000; + + function boundsOfOnlyGlyph(bytes: Uint8Array) { + const bounds = parseCffGlyphBounds(bytes); + if (bounds === undefined) { + throw new Error("fixture font failed to parse"); + } + return bounds.bounds(0); + } + + it("refuses a subroutine that recurses past the spec's own nesting limit", () => { + // A single global subroutine whose only content calls itself again: -107 is subroutine index 0 once the bias for a one-entry Global Subr INDEX (107, since count < 1240) is added back by the interpreter, so this charstring (used as both the glyph and its own subroutine) recurses without ever terminating on its own. + const selfCall = [32, OP_CALLGSUBR]; // 32 decodes to -107 (32 - bias 139) + const bytes = cffFontWithCharstrings({ + name: "DeepRecursion", + charStrings: [selfCall], + globalSubrs: [selfCall], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + // Confirms the depth limit is what stopped it, not a coincidentally-empty glyph: one call fewer than the limit still overflows the call stack the same way, so this is genuinely bounded by MAX_SUBR_DEPTH rather than by, say, running out of charstring bytes. + expect(MAX_SUBR_DEPTH).toBeGreaterThan(0); + }); + + it("refuses a glyph whose own operator count runs past the per-glyph ceiling", () => { + // One CharString of MAX_OPERATIONS_PER_GLYPH + 1 repetitions of a single-byte, zero-operand hstem: each is individually well-formed (an hstem with no operand pairs declares zero stems), so only the sheer repetition count -- never a malformed byte -- is what trips the ceiling. + const runaway = new Array(MAX_OPERATIONS_PER_GLYPH + 1).fill( + OP_HSTEM, + ); + const bytes = cffFontWithCharstrings({ + name: "OperationCeiling", + charStrings: [runaway], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("refuses a charstring that overruns the operand stack", () => { + // MAX_OPERAND_STACK + 1 single-byte zero operands with no stack-clearing operator in between: the spec's own interpreter limit (TN 5177 section 3.1) is what stops this, not any operator. + const overflow = new Array(MAX_OPERAND_STACK + 1).fill( + ZERO_OPERAND, + ); + const bytes = cffFontWithCharstrings({ + name: "StackOverflow", + charStrings: [overflow], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("refuses a hintmask whose own mask bytes run past the end of the charstring", () => { + // Two operand bytes declare one implicit vstem (hintmask's own leading-vstem-list rule), so the mask needs ceil(1/8) = 1 trailing byte -- and this charstring supplies none. + const truncatedHintmask = [ZERO_OPERAND, ZERO_OPERAND, OP_HINTMASK]; + const bytes = cffFontWithCharstrings({ + name: "TruncatedHintmask", + charStrings: [truncatedHintmask], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("refuses a reserved operator byte", () => { + // 13, 15, 16, and 17 are reserved in a charstring (distinct from their DICT meanings) and appear in no valid program. + const bytes = cffFontWithCharstrings({ + name: "ReservedOperator", + charStrings: [[RESERVED_OPERATOR]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("refuses callsubr/callgsubr with no subroutine index on the stack", () => { + const bytesLocal = cffFontWithCharstrings({ + name: "EmptyCallsubr", + charStrings: [[OP_CALLSUBR]], + }); + expect(boundsOfOnlyGlyph(bytesLocal)).toBeUndefined(); + + const bytesGlobal = cffFontWithCharstrings({ + name: "EmptyCallgsubr", + charStrings: [[OP_CALLGSUBR]], + }); + expect(boundsOfOnlyGlyph(bytesGlobal)).toBeUndefined(); + }); + + it("refuses callsubr when the font carries no Local Subrs INDEX at all", () => { + // No `localSubrs` option at all means no Private DICT, so context.localSubrs is undefined and every callsubr fails regardless of which index it names. + const bytes = cffFontWithCharstrings({ + name: "NoLocalSubrs", + charStrings: [[ZERO_OPERAND, OP_CALLSUBR]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("refuses endchar's own four-argument seac-like accented-character form", () => { + // Per this module's own documented scope, endchar's seac-like composition (an accented glyph built from two other glyphs by registry-encoding index) needs the charset and Standard Encoding, neither of which this module reads -- so it reports the glyph as undefined rather than guessing. + const seacLike = [ + ZERO_OPERAND, + ZERO_OPERAND, + ZERO_OPERAND, + ZERO_OPERAND, + OP_ENDCHAR, + ]; + const bytes = cffFontWithCharstrings({ + name: "SeacEndchar", + charStrings: [seacLike], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("draws normally through a real Local Subrs INDEX reached via callsubr", () => { + // The mirror image of the two refusal cases above: a genuine, present, in-range local subroutine that draws a single line, called from the glyph's own charstring -- proof callsubr's success path (not just its failure paths) is exercised directly, without relying on the vendored font's own subroutine usage. + const OP_HLINETO = 6; + const DX_100 = 100 + 139; // the single-byte small-integer encoding of 100 (bias 139) + const lineSubr = [DX_100, OP_HLINETO]; // dx=100 hlineto: draws from (0,0) to (100,0) + const bias = 107; // subrBias for a one-entry Local Subrs INDEX (count < 1240) + const encodedIndex = 139 - bias; // single-byte small-integer encoding of (0 - bias): entry(index + bias) then resolves to subroutine 0 + const bytes = cffFontWithCharstrings({ + name: "DrawViaLocalSubr", + charStrings: [[encodedIndex, OP_CALLSUBR]], + localSubrs: [lineSubr], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 100, + yMax: 0, + }); + }); + + it("counts an implicit vstem list ahead of vstemhm's own operator toward the stem total", () => { + const bytes = cffFontWithCharstrings({ + name: "ImplicitVstem", + charStrings: [ + [ + ZERO_OPERAND, + ZERO_OPERAND, + OP_VSTEM, + ZERO_OPERAND, + ZERO_OPERAND, + OP_HINTMASK, + 0xff, // one full mask byte covers the two accumulated stems (2 stems -> ceil(2/8) = 1 byte) + OP_ENDCHAR, + ], + ], + }); + // Draws nothing (only stems and an endchar), so the only observable difference from a malformed charstring is that this one parses to a defined-but-empty result rather than undefined -- proving the hintmask's own byte-consumption arithmetic didn't run past or short of the charstring. + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); +}); diff --git a/packages/pdf-codec/src/test-support/cff.ts b/packages/pdf-codec/src/test-support/cff.ts index 1252a1809..708bab4d5 100644 --- a/packages/pdf-codec/src/test-support/cff.ts +++ b/packages/pdf-codec/src/test-support/cff.ts @@ -136,3 +136,62 @@ export function cffFontWithBuiltinEncoding(options: { ...charStrings, ]); } + +// A complete-enough CFF program for exercising cff-bounds.ts's charstring interpreter directly: real header/Name/Top-DICT/String/Global-Subr INDEXes wrapped around hand-written CharStrings, with an optional Private DICT and Local Subrs INDEX. Unlike cffFontWithBuiltinEncoding's fixed one-byte `endchar` glyphs, every charstring here is caller-supplied, which is what lets a test drive execute()'s and executeEscaped()'s own interpreter limits and malformed-input paths directly -- none of which the vendored STIX Two Math font's own well-formed charstrings ever reach. +export function cffFontWithCharstrings(options: { + readonly name: string; + readonly charStrings: readonly (readonly number[])[]; + readonly globalSubrs?: readonly (readonly number[])[]; + readonly localSubrs?: readonly (readonly number[])[]; // presence alone (even []) adds a Private DICT with a Subrs operator +}): Uint8Array { + const nameIndex = cffIndex([[...new TextEncoder().encode(options.name)]]); + const stringIndex = cffIndex([]); + const globalSubrIndex = cffIndex(options.globalSubrs ?? []); + const hasPrivate = options.localSubrs !== undefined; + + // Every Top DICT operand below is the fixed-width 5-byte 32-bit form (dictInt32), so the Top DICT's own byte length -- and therefore topDictIndexSize -- depends only on which operators are present, never on the offset values those operators end up carrying. That is what lets every downstream offset be computed in one pass instead of iterating until a size stops changing. + const topDictEntrySize = hasPrivate + ? dictInt32(0).length + 1 + dictInt32(0).length * 2 + 1 + : dictInt32(0).length + 1; + const topDictIndexSize = cffIndex([ + new Array(topDictEntrySize).fill(0), + ]).length; + + const afterGlobalSubrs = + CFF_HEADER.length + + nameIndex.length + + topDictIndexSize + + stringIndex.length + + globalSubrIndex.length; + + // A Private DICT holding only a Subrs operator (19), whose own offset is relative to the Private DICT's own start (spec Table 23) -- fixed at the Private DICT's own byte length, since the Local Subrs INDEX immediately follows it. The Private DICT itself starts right where the Global Subr INDEX ends. + const privateDictBytes = [...dictInt32(6), 19]; + const localSubrIndex = hasPrivate ? cffIndex(options.localSubrs ?? []) : []; + const privateSize = privateDictBytes.length; + + const charStringsOffset = hasPrivate + ? afterGlobalSubrs + privateDictBytes.length + localSubrIndex.length + : afterGlobalSubrs; + + const topDict = hasPrivate + ? [ + ...dictInt32(charStringsOffset), + 17, + ...dictInt32(privateSize), + ...dictInt32(afterGlobalSubrs), + 18, + ] + : [...dictInt32(charStringsOffset), 17]; + + const charStringsIndex = cffIndex(options.charStrings); + + return new Uint8Array([ + ...CFF_HEADER, + ...nameIndex, + ...cffIndex([topDict]), + ...stringIndex, + ...globalSubrIndex, + ...(hasPrivate ? [...privateDictBytes, ...localSubrIndex] : []), + ...charStringsIndex, + ]); +} From b5fe6cc6f781d0f08b238aef2a53cfceff04d1ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:37:11 +0100 Subject: [PATCH 065/105] test(pdf-codec): drive glyf-contours.ts's simple-glyph decoding with a fake GlyfTable The vendored Carlito face is a well-formed program from a real font toolchain, so decodeSimpleContours' own malformed-input paths and decodeOutline's composite-recursion limit never arise from walking it: a glyph truncated before its end-point array, end points that do not strictly increase, a glyph truncated before its flags array, a repeat flag with no count byte or a count that overruns the point total, a short- or long-form X/Y coordinate truncated before its own bytes, a composite chain recursing past the depth limit, an unreadable component list, a point-matched component, and a nested component's own decode failure propagating up through its parent. Add a fake GlyfTable that supplies simple-glyph bytes and composite component records directly, sidestepping both the real sfnt/glyf container and the composite record's own byte format, since decodeGlyphOutline reaches both only through GlyfTable's interface. Also cover the point-to-contour assignment directly with a hand-built two-contour glyph, which the real-font tests above only ever exercise incidentally. --- packages/pdf-codec/src/glyf-contours.test.ts | 325 +++++++++++++++++++ 1 file changed, 325 insertions(+) diff --git a/packages/pdf-codec/src/glyf-contours.test.ts b/packages/pdf-codec/src/glyf-contours.test.ts index 43f2ac670..c52a30fb5 100644 --- a/packages/pdf-codec/src/glyf-contours.test.ts +++ b/packages/pdf-codec/src/glyf-contours.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { decodeGlyphOutline } from "./glyf-contours"; import { parseGlyf } from "./glyf"; +import type { CompositeComponent, GlyfTable } from "./glyf"; import { parseHead, parseMaxp } from "./font-tables"; import { parseSfnt } from "./sfnt"; import { buildCmapLookup } from "./cmap-table"; @@ -131,3 +132,327 @@ describe("decodeGlyphOutline", () => { expect(decodeGlyphOutline(glyf, -1)).toBeUndefined(); }); }); + +const FLAG_ON_CURVE = 0x01; +const FLAG_X_SHORT = 0x02; +const FLAG_Y_SHORT = 0x04; +const FLAG_REPEAT = 0x08; +const FLAG_X_SAME_OR_POSITIVE = 0x10; + +function u16be(value: number): readonly [number, number] { + return [(value >> 8) & 0xff, value & 0xff]; +} + +function i16be(value: number): readonly [number, number] { + return u16be(value < 0 ? value + 0x10000 : value); +} + +// A minimal simple-glyph 'glyf' entry: the 10-byte header (only numberOfContours is ever read from it here; the declared bounding box is not), each contour's own end-point index, no instructions, then one flag byte and one long-form (2-byte, never short-vector, never repeated) coordinate pair per point. The uniform long-form encoding keeps every point's own byte length fixed and predictable, which is what lets the malformed-input fixtures below truncate a well-formed prefix at an exact, deliberate byte rather than needing to account for a variable-width encoding. +function simpleGlyphBytes( + endPts: readonly number[], + points: readonly { dx: number; dy: number; onCurve: boolean }[], +): Uint8Array { + const header = [...i16be(endPts.length), 0, 0, 0, 0, 0, 0, 0, 0]; + const endPtBytes = endPts.flatMap((endPt) => u16be(endPt)); + const instructionLength = [0, 0]; + const flags = points.map((point) => (point.onCurve ? FLAG_ON_CURVE : 0)); + const xBytes = points.flatMap((point) => i16be(point.dx)); + const yBytes = points.flatMap((point) => i16be(point.dy)); + return new Uint8Array([ + ...header, + ...endPtBytes, + ...instructionLength, + ...flags, + ...xBytes, + ...yBytes, + ]); +} + +// A GlyfTable double for exercising decodeGlyphOutline/decodeSimpleContours directly, entirely independent of any real font: `entries` supplies each simple glyph's own raw 'glyf' bytes (simpleGlyphBytes' output, or hand-truncated/corrupted for the malformed-input tests below), and `composites` supplies a composite glyph's own component records as plain objects, sidestepping the composite record's own byte format entirely -- decodeGlyphOutline reaches it only through this interface method, never by reading bytes itself. +function fakeGlyfTable(options: { + readonly entries?: ReadonlyMap>; + readonly composites?: ReadonlyMap< + number, + readonly CompositeComponent[] | undefined + >; +}): GlyfTable { + const entries: ReadonlyMap< + number, + Uint8Array + > = options.entries ?? new Map(); + const composites: ReadonlyMap< + number, + readonly CompositeComponent[] | undefined + > = options.composites ?? new Map(); + return { + numGlyphs: entries.size + composites.size, + glyphBytes: (glyphId) => entries.get(glyphId), + glyphHeader: (glyphId) => { + if (composites.has(glyphId)) { + return { numberOfContours: -1, xMin: 0, yMin: 0, xMax: 0, yMax: 0 }; + } + const bytes = entries.get(glyphId); + if (bytes === undefined || bytes.length < 10) { + return undefined; + } + return { + numberOfContours: (bytes[0]! << 8) | bytes[1]!, + xMin: 0, + yMin: 0, + xMax: 0, + yMax: 0, + }; + }, + compositeComponents: (glyphId) => composites.get(glyphId), + glyphInkBounds: () => undefined, // decodeGlyphOutline never reads this + }; +} + +// Every fixture below is hand-built specifically to reach a malformed-input or depth-limit path in decodeSimpleContours()/decodeOutline(): the vendored Carlito face above is a well-formed program from a real font toolchain, so none of these ever arise from walking it. +describe("decodeGlyphOutline's simple-glyph and composite decoding, driven by a fake GlyfTable", () => { + it("decodes a hand-built multi-contour simple glyph, proving the end-point-to-contour assignment directly", () => { + // Two contours: a 3-point triangle (points 0-2, endPt 2) and a 2-point line (points 3-4, endPt 4) -- exercises the pointIndex walk crossing a contour boundary, which every real-font test above only ever does incidentally. + const bytes = simpleGlyphBytes( + [2, 4], + [ + { dx: 0, dy: 0, onCurve: true }, + { dx: 10, dy: 0, onCurve: true }, + { dx: 0, dy: 10, onCurve: true }, + { dx: 5, dy: 5, onCurve: true }, + { dx: 1, dy: 1, onCurve: false }, + ], + ); + const glyf = fakeGlyfTable({ entries: new Map([[0, bytes]]) }); + const outline = decodeGlyphOutline(glyf, 0); + expect(outline?.contours).toHaveLength(2); + expect(outline?.contours[0]).toHaveLength(3); + expect(outline?.contours[1]).toHaveLength(2); + // x/y deltas accumulate across every point in the glyph, not per contour: (0,0) -> (10,0) -> (10,10) -> (15,15) -> (16,16). + expect(outline?.contours[1]?.[1]).toEqual({ + x: 16, + y: 16, + onCurve: false, + }); + }); + + it("refuses a glyph truncated before its own end-point array", () => { + const header = new Uint8Array([...i16be(1), 0, 0, 0, 0, 0, 0, 0, 0]); // declares 1 contour, then nothing + const glyf = fakeGlyfTable({ entries: new Map([[0, header]]) }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses end points that do not strictly increase", () => { + const bytes = new Uint8Array([ + ...i16be(2), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, // header: 2 contours + ...u16be(5), + ...u16be(3), // endPts [5, 3]: not strictly increasing + 0, + 0, // instructionLength + ]); + const glyf = fakeGlyfTable({ entries: new Map([[0, bytes]]) }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a glyph truncated before its own flags array", () => { + const bytes = new Uint8Array([ + ...i16be(1), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, // header: 1 contour + ...u16be(0), // endPts [0]: one point + 0, + 0, // instructionLength, then nothing -- no flag byte follows + ]); + const glyf = fakeGlyfTable({ entries: new Map([[0, bytes]]) }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a repeat flag with no repeat-count byte following it", () => { + const bytes = new Uint8Array([ + ...i16be(1), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ...u16be(0), + 0, + 0, + FLAG_ON_CURVE | FLAG_REPEAT, // then nothing -- no repeat-count byte + ]); + const glyf = fakeGlyfTable({ entries: new Map([[0, bytes]]) }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a repeat count that would push the flag array past its own point total", () => { + const bytes = new Uint8Array([ + ...i16be(1), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ...u16be(1), // endPts [1]: two points total + 0, + 0, + FLAG_ON_CURVE | FLAG_REPEAT, + 5, // one flag already pushed, repeated 5 more times: 6 > 2 points + ]); + const glyf = fakeGlyfTable({ entries: new Map([[0, bytes]]) }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a short-vector X coordinate with no magnitude byte following it", () => { + const bytes = new Uint8Array([ + ...i16be(1), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ...u16be(0), + 0, + 0, + FLAG_ON_CURVE | FLAG_X_SHORT, // then nothing -- no magnitude byte + ]); + const glyf = fakeGlyfTable({ entries: new Map([[0, bytes]]) }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a long-form X coordinate truncated before its own two bytes", () => { + const bytes = new Uint8Array([ + ...i16be(1), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ...u16be(0), + 0, + 0, + FLAG_ON_CURVE, // neither X_SHORT nor X_SAME_OR_POSITIVE: needs a 2-byte delta that never comes + ]); + const glyf = fakeGlyfTable({ entries: new Map([[0, bytes]]) }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a short-vector Y coordinate with no magnitude byte following it", () => { + const bytes = new Uint8Array([ + ...i16be(1), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ...u16be(0), + 0, + 0, + // X_SAME_OR_POSITIVE with X_SHORT unset consumes zero X bytes, reaching the Y decode with nothing left. + FLAG_ON_CURVE | FLAG_X_SAME_OR_POSITIVE | FLAG_Y_SHORT, + ]); + const glyf = fakeGlyfTable({ entries: new Map([[0, bytes]]) }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a long-form Y coordinate truncated before its own two bytes", () => { + const bytes = new Uint8Array([ + ...i16be(1), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ...u16be(0), + 0, + 0, + FLAG_ON_CURVE | FLAG_X_SAME_OR_POSITIVE, // X consumes zero bytes; Y needs a 2-byte delta that never comes + ]); + const glyf = fakeGlyfTable({ entries: new Map([[0, bytes]]) }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a composite chain recursing past the spec's own nesting limit", () => { + // Glyph 0 composites onto itself: every level is otherwise well-formed, so only the sheer recursion depth -- never a malformed record -- is what trips the limit. + const selfComposite: CompositeComponent = { + flags: 0, + glyphIndex: 0, + argument1: 0, + argument2: 0, + argsAreXyValues: true, + transform: undefined, + }; + const glyf = fakeGlyfTable({ + composites: new Map([[0, [selfComposite]]]), + }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a composite whose own component list is unreadable", () => { + // The glyph is declared composite (glyphHeader reports it), but compositeComponents itself reports a truncated/unreadable record list. + const glyf = fakeGlyfTable({ + composites: new Map([[0, undefined]]), + }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("refuses a component positioned by point matching rather than an x/y offset", () => { + const pointMatched: CompositeComponent = { + flags: 0, + glyphIndex: 1, + argument1: 0, + argument2: 0, + argsAreXyValues: false, + transform: undefined, + }; + const glyf = fakeGlyfTable({ + composites: new Map([[0, [pointMatched]]]), + }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); + + it("propagates a nested component's own decode failure up through its parent composite", () => { + const referencesUnreadable: CompositeComponent = { + flags: 0, + glyphIndex: 1, // glyph 1 exists in neither entries nor composites: unreadable + argument1: 0, + argument2: 0, + argsAreXyValues: true, + transform: undefined, + }; + const glyf = fakeGlyfTable({ + composites: new Map([[0, [referencesUnreadable]]]), + }); + expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); + }); +}); From 582c22000c413ce6d6890d6ee0e06b3315c8ff4d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:53:53 +0100 Subject: [PATCH 066/105] test(pdf-codec): share the write-side PDF fixture and assert CIDSystemInfo fields Extracts embedded-font-write.test.ts's own assemblePdf/AllocatedObject helper into test-support/write-pdf-fixture.ts so math-font-write.test.ts can build the same kind of fixture without duplicating it, dropping the unreachable "object never written" guard in the process: both current callers number their objects contiguously from 1, so the offset can be recorded inline as each object is written rather than looked up afterwards from a map that could theoretically miss. Also asserts the embedded CIDFontType2's own CIDSystemInfo Registry and Ordering decode to "Adobe" and "Identity" -- previously only Supplement was checked, leaving the two string literals with no test proving their actual content. --- .../pdf-codec/src/embedded-font-write.test.ts | 60 ++++++------------- .../src/test-support/write-pdf-fixture.ts | 45 ++++++++++++++ 2 files changed, 62 insertions(+), 43 deletions(-) create mode 100644 packages/pdf-codec/src/test-support/write-pdf-fixture.ts diff --git a/packages/pdf-codec/src/embedded-font-write.test.ts b/packages/pdf-codec/src/embedded-font-write.test.ts index 5b1c12dbf..c70d53514 100644 --- a/packages/pdf-codec/src/embedded-font-write.test.ts +++ b/packages/pdf-codec/src/embedded-font-write.test.ts @@ -13,7 +13,7 @@ import { embeddedSubsetTag, } from "./embedded-font-write"; import { decodeStream } from "./filters"; -import type { PdfDict, PdfObject } from "./objects"; +import type { PdfDict } from "./objects"; import { asArray, asName, @@ -33,6 +33,8 @@ import type { SfntSubsetResult } from "./sfnt-subset"; import { subsetSfnt } from "./sfnt-subset"; import { parseSfnt } from "./sfnt"; import { caladeaItalicBytes, carlitoRegularBytes } from "./test-support/fonts"; +import type { AllocatedObject } from "./test-support/write-pdf-fixture"; +import { assemblePdf } from "./test-support/write-pdf-fixture"; // The end-to-end proof this module exists for: take a real vendored face, cut a real subset of it for a real string, build the whole PDF object group, assemble a genuine PDF file around it by hand, and read that file back with this package's own readPdf. Nothing here is a synthetic fixture -- the font is the checked-in Carlito Regular, the subset is sfnt-subset.ts's own output, and the file is a complete, well-formed PDF with a real cross-reference table. // @@ -47,48 +49,6 @@ const PAGE_WIDTH_PT = 612; const PAGE_HEIGHT_PT = 792; const FONT_RESOURCE_NAME = "F1"; -interface AllocatedObject { - readonly num: number; - readonly value: PdfObject; -} - -// A complete classic-cross-reference PDF file around an already-built object list -- the same shape write.ts's own tail emits, written out here so this test owns every byte of the file it then reads back. -function assemblePdf( - objects: readonly AllocatedObject[], - rootNum: number, -): Uint8Array { - const writer = new ByteWriter(); - writer.writeAscii("%PDF-1.7\n"); - const offsets = new Map(); - for (const { num, value } of objects) { - offsets.set(num, writer.length); - writer.writeAscii(`${num} 0 obj\n`); - writeObject(writer, value); - writer.writeAscii("\nendobj\n"); - } - const maxObjNum = Math.max(...objects.map((object) => object.num)); - const xrefOffset = writer.length; - writer.writeAscii("xref\n"); - writer.writeAscii(`0 ${maxObjNum + 1}\n`); - writer.writeAscii("0000000000 65535 f \n"); - for (let num = 1; num <= maxObjNum; num++) { - const offset = offsets.get(num); - if (offset === undefined) { - throw new Error(`object ${String(num)} was never written`); - } - writer.writeAscii(`${offset.toString().padStart(10, "0")} 00000 n \n`); - } - writer.writeAscii("trailer\n"); - writeObject( - writer, - pdfDict({ Size: pdfNum(maxObjNum + 1), Root: pdfRef(rootNum, 0) }), - ); - writer.writeAscii("\nstartxref\n"); - writer.writeAscii(`${xrefOffset}\n`); - writer.writeAscii("%%EOF"); - return writer.toBytes(); -} - // The one text-showing sequence the page draws: the string's CIDs, big-endian, as a hex-string Tj operand against the embedded composite font -- exactly what math-content-write.ts already emits for the math font, and the only content-stream shape an Identity-H font can be shown with. function buildContentStream( codes: Uint8Array, @@ -293,6 +253,20 @@ describe("a real PDF carrying an embedded, subsetted Carlito, read back by this const cidSystemInfo = document.resolveDict( dictGet(cidFont!, "CIDSystemInfo"), ); + const registry = dictGet(cidSystemInfo!, "Registry"); + const ordering = dictGet(cidSystemInfo!, "Ordering"); + expect(registry?.kind).toBe("string"); + expect(ordering?.kind).toBe("string"); + expect( + registry?.kind === "string" + ? new TextDecoder().decode(registry.bytes) + : undefined, + ).toBe("Adobe"); + expect( + ordering?.kind === "string" + ? new TextDecoder().decode(ordering.bytes) + : undefined, + ).toBe("Identity"); expect(asNumber(dictGet(cidSystemInfo!, "Supplement"))).toBe(0); const descriptor = document.resolveDict( diff --git a/packages/pdf-codec/src/test-support/write-pdf-fixture.ts b/packages/pdf-codec/src/test-support/write-pdf-fixture.ts new file mode 100644 index 000000000..fd73ae26d --- /dev/null +++ b/packages/pdf-codec/src/test-support/write-pdf-fixture.ts @@ -0,0 +1,45 @@ +import { ByteWriter } from "../bytes/writer"; +import type { PdfObject } from "../objects"; +import { pdfDict, pdfNum, pdfRef } from "../objects"; +import { writeObject } from "../serialize"; + +// Assembles a complete classic-cross-reference PDF file around an already-built object list, through this package's own serialize.ts -- the same write path writePdf's own tail uses. Unlike test-support/pdf.ts's FixtureBuilder (which deliberately avoids this package's own writer to keep the read-side test oracle independent), this helper exists for the opposite case: a write-side unit test that already has real PdfObject values from the module under test (buildEmbeddedFontObjects, buildMathFontObjects, ...) and wants the minimum well-formed document those objects can be read back from, written through the real serializer rather than reimplemented. +export interface AllocatedObject { + readonly num: number; + readonly value: PdfObject; +} + +// `objects` must number its entries contiguously from 1 (no gaps, no repeats) -- both this file's callers allocate that way already, matching write.ts's own fixed-order allocation, so the xref table below can record one offset per object as it is written rather than re-deriving the count from whatever numbers happen to appear. +export function assemblePdf( + objects: readonly AllocatedObject[], + rootNum: number, +): Uint8Array { + const writer = new ByteWriter(); + writer.writeAscii("%PDF-1.7\n"); + const written = [...objects] + .sort((a, b) => a.num - b.num) + .map(({ num, value }) => { + const offset = writer.length; + writer.writeAscii(`${num} 0 obj\n`); + writeObject(writer, value); + writer.writeAscii("\nendobj\n"); + return { num, offset }; + }); + const maxObjNum = written[written.length - 1]!.num; + const xrefOffset = writer.length; + writer.writeAscii("xref\n"); + writer.writeAscii(`0 ${maxObjNum + 1}\n`); + writer.writeAscii("0000000000 65535 f \n"); + for (const { offset } of written) { + writer.writeAscii(`${offset.toString().padStart(10, "0")} 00000 n \n`); + } + writer.writeAscii("trailer\n"); + writeObject( + writer, + pdfDict({ Size: pdfNum(maxObjNum + 1), Root: pdfRef(rootNum, 0) }), + ); + writer.writeAscii("\nstartxref\n"); + writer.writeAscii(`${xrefOffset}\n`); + writer.writeAscii("%%EOF"); + return writer.toBytes(); +} From be2eb4d66cd20014b4485ed852aecc4f67ef3424 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:54:09 +0100 Subject: [PATCH 067/105] test(pdf-codec): cover math-font-write's descriptor scaling, W array, and ToUnicode filtering math-font-write.ts had no test file at all (0% branch/function coverage): nothing exercised buildMathFontObjects, so its Type0/CIDFontType0 shape, its FontDescriptor's design-unit-to- glyph-space scaling, its /W array's own sort-by-glyph-ID, its FontFile3 compression, or its dropping of code-point-less glyphs from the ToUnicode CMap had ever run. Uses a synthetic MathFont with a non-1000 unitsPerEm (2048, a power of two so every scaled value is an exactly representable double) for the descriptor arithmetic, since the real vendored STIX Two Math font is drawn on a 1000-unit em and would make the scale factor an identity -- indistinguishable from a font with no scaling applied at all. --- .../pdf-codec/src/math-font-write.test.ts | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 packages/pdf-codec/src/math-font-write.test.ts diff --git a/packages/pdf-codec/src/math-font-write.test.ts b/packages/pdf-codec/src/math-font-write.test.ts new file mode 100644 index 000000000..e2a6d9c67 --- /dev/null +++ b/packages/pdf-codec/src/math-font-write.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "vitest"; +import { NOOP_DIAGNOSTIC_SINK } from "./diagnostics"; +import { decodeStream } from "./filters"; +import type { MathFont, MathFontDescriptorMetrics } from "./math-font"; +import { loadMathFont } from "./math-font"; +import type { MathFontObjectRefs } from "./math-font-write"; +import { buildMathFontObjects } from "./math-font-write"; +import type { PdfObject } from "./objects"; +import { + asArray, + asDict, + asName, + asNumber, + dictGet, + pdfArray, + pdfRef, +} from "./objects"; + +const REFS: MathFontObjectRefs = { + cidFontRef: pdfRef(6, 0), + descriptorRef: pdfRef(7, 0), + fontFileRef: pdfRef(8, 0), + toUnicodeRef: pdfRef(9, 0), +}; + +// A deliberately non-1000-unitsPerEm descriptor: STIX Two Math (loadMathFont's real vendored font) happens to be drawn on a 1000-unit em, which makes buildFontDescriptor's own `1000 / unitsPerEm` scale factor an identity (1) -- a font with any other em size is what actually distinguishes "multiply by scale" from "divide by scale" or "ignore scale entirely". 2048 is chosen because it is a power of two, so every scaled value below (design-unit field * 1000/2048) lands on an exactly representable IEEE-754 double -- exact `toBe` assertions rather than `toBeCloseTo`, which would tolerate an Arithmetic mutator changing the operator to one that happens to land close by. +const DESCRIPTOR: MathFontDescriptorMetrics = { + unitsPerEm: 2048, + ascent: 1900, + descent: -500, + capHeight: 1400, + bboxMin: [-200, -600], + bboxMax: [1800, 2000], + italicAngle: -12.5, +}; +const SCALE = 1000 / DESCRIPTOR.unitsPerEm; + +// A minimal synthetic MathFont: buildMathFontObjects reads only .descriptor, .cffBytes, and .glyphSpaceWidth(), so the remaining members are stubs no test here ever calls -- `metrics` reuses the real vendored font's own already-built value rather than hand-stubbing MathFontMetrics's large, otherwise-irrelevant shape. +function fakeFont( + descriptor: MathFontDescriptorMetrics, + cffBytes: Uint8Array, + glyphSpaceWidth: (glyphId: number) => number, +): MathFont { + return { + metrics: loadMathFont().font.metrics, + cffBytes, + descriptor, + glyphId: () => undefined, + glyphSpaceWidth, + glyphInkBounds: () => undefined, + minConnectorOverlap: 0, + stretchyConstruction: () => undefined, + }; +} + +function utf8Of(obj: PdfObject | undefined): string | undefined { + return obj?.kind === "string" + ? new TextDecoder().decode(obj.bytes) + : undefined; +} + +describe("buildMathFontObjects: Type0 / CIDFontType0 shape and refs", () => { + it("builds an Identity-H composite font naming STIXTwoMath-Regular and pointing at the given refs", () => { + const font = fakeFont(DESCRIPTOR, new Uint8Array([1]), () => 0); + const built = buildMathFontObjects(font, new Map(), REFS, false); + + expect(asName(dictGet(built.type0, "Type"))).toBe("Font"); + expect(asName(dictGet(built.type0, "Subtype"))).toBe("Type0"); + expect(asName(dictGet(built.type0, "BaseFont"))).toBe( + "STIXTwoMath-Regular", + ); + expect(asName(dictGet(built.type0, "Encoding"))).toBe("Identity-H"); + expect(dictGet(built.type0, "DescendantFonts")).toEqual( + pdfArray([REFS.cidFontRef]), + ); + expect(dictGet(built.type0, "ToUnicode")).toEqual(REFS.toUnicodeRef); + + expect(asName(dictGet(built.cidFont, "Type"))).toBe("Font"); + expect(asName(dictGet(built.cidFont, "Subtype"))).toBe("CIDFontType0"); + expect(asName(dictGet(built.cidFont, "BaseFont"))).toBe( + "STIXTwoMath-Regular", + ); + expect(dictGet(built.cidFont, "FontDescriptor")).toEqual( + REFS.descriptorRef, + ); + expect(asNumber(dictGet(built.cidFont, "DW"))).toBe(0); + }); + + it("declares CIDSystemInfo as Adobe-Identity-0, the registry a bare (non-CID-keyed) CFF program is read under", () => { + const font = fakeFont(DESCRIPTOR, new Uint8Array([1]), () => 0); + const { cidFont } = buildMathFontObjects(font, new Map(), REFS, false); + const cidSystemInfo = asDict(dictGet(cidFont, "CIDSystemInfo")); + expect(cidSystemInfo).toBeDefined(); + expect(utf8Of(dictGet(cidSystemInfo!, "Registry"))).toBe("Adobe"); + expect(utf8Of(dictGet(cidSystemInfo!, "Ordering"))).toBe("Identity"); + expect(asNumber(dictGet(cidSystemInfo!, "Supplement"))).toBe(0); + }); +}); + +describe("buildFontDescriptor", () => { + it("scales every geometry field from the font's own design units into PDF's fixed 1000-unit glyph space", () => { + const font = fakeFont(DESCRIPTOR, new Uint8Array([1]), () => 0); + const { descriptor } = buildMathFontObjects(font, new Map(), REFS, false); + + expect(asName(dictGet(descriptor, "Type"))).toBe("FontDescriptor"); + expect(asName(dictGet(descriptor, "FontName"))).toBe("STIXTwoMath-Regular"); + // The one FontDescriptor flag this module ever sets -- bit 3 (value 4), "contains glyphs outside the Adobe standard Latin set", true of essentially everything a math font contributes. + expect(asNumber(dictGet(descriptor, "Flags"))).toBe(4); + expect(asNumber(dictGet(descriptor, "ItalicAngle"))).toBe( + DESCRIPTOR.italicAngle, + ); + expect(asNumber(dictGet(descriptor, "Ascent"))).toBe( + DESCRIPTOR.ascent * SCALE, + ); + expect(asNumber(dictGet(descriptor, "Descent"))).toBe( + DESCRIPTOR.descent * SCALE, + ); + expect(asNumber(dictGet(descriptor, "CapHeight"))).toBe( + DESCRIPTOR.capHeight * SCALE, + ); + // A nominal, spec-required value no conforming reader actually consults for an embedded font -- see the module's own top comment. + expect(asNumber(dictGet(descriptor, "StemV"))).toBe(80); + expect(dictGet(descriptor, "FontFile3")).toEqual(REFS.fontFileRef); + + const bbox = asArray(dictGet(descriptor, "FontBBox")); + expect(bbox?.length).toBe(4); + expect(asNumber(bbox?.[0])).toBe(DESCRIPTOR.bboxMin[0] * SCALE); + expect(asNumber(bbox?.[1])).toBe(DESCRIPTOR.bboxMin[1] * SCALE); + expect(asNumber(bbox?.[2])).toBe(DESCRIPTOR.bboxMax[0] * SCALE); + expect(asNumber(bbox?.[3])).toBe(DESCRIPTOR.bboxMax[1] * SCALE); + }); + + it("leaves geometry fields unscaled for a font already drawn on a 1000-unit em, proving the scale is computed rather than a fixed constant", () => { + const thousandEm: MathFontDescriptorMetrics = { + ...DESCRIPTOR, + unitsPerEm: 1000, + }; + const font = fakeFont(thousandEm, new Uint8Array([1]), () => 0); + const { descriptor } = buildMathFontObjects(font, new Map(), REFS, false); + expect(asNumber(dictGet(descriptor, "Ascent"))).toBe(thousandEm.ascent); + expect(asNumber(dictGet(descriptor, "CapHeight"))).toBe( + thousandEm.capHeight, + ); + }); +}); + +describe("buildFontFileStream", () => { + it("embeds the font's raw CFF bytes verbatim, uncompressed, with no Filter when compress is false", () => { + const cffBytes = new Uint8Array([10, 20, 30, 40, 50, 60, 70, 80]); + const font = fakeFont(DESCRIPTOR, cffBytes, () => 0); + const { fontFile } = buildMathFontObjects(font, new Map(), REFS, false); + expect(fontFile.kind).toBe("stream"); + if (fontFile.kind !== "stream") { + throw new Error("unreachable"); + } + expect(asName(dictGet(fontFile.dict, "Subtype"))).toBe("CIDFontType0C"); + expect(dictGet(fontFile.dict, "Filter")).toBeUndefined(); + expect([...fontFile.raw]).toEqual([...cffBytes]); + }); + + it("deflates the font's raw CFF bytes and declares FlateDecode when compress is true", () => { + // Large and varied enough that deflate genuinely shrinks it -- proving compression actually ran rather than merely being declared. + const cffBytes = new Uint8Array(400).map((_, i) => (i * 37) % 251); + const font = fakeFont(DESCRIPTOR, cffBytes, () => 0); + const { fontFile } = buildMathFontObjects(font, new Map(), REFS, true); + if (fontFile.kind !== "stream") { + throw new Error("unreachable"); + } + expect(asName(dictGet(fontFile.dict, "Subtype"))).toBe("CIDFontType0C"); + expect(asName(dictGet(fontFile.dict, "Filter"))).toBe("FlateDecode"); + expect(fontFile.raw.length).toBeLessThan(cffBytes.length); + const decoded = decodeStream( + fontFile.raw, + fontFile.dict, + NOOP_DIAGNOSTIC_SINK, + ); + expect([...decoded.bytes]).toEqual([...cffBytes]); + }); +}); + +describe("buildWidthsArray, via the CIDFont's own /W entry", () => { + it("writes one CID/width pair per used glyph, ascending by glyph ID regardless of insertion order", () => { + const widthByGlyph = new Map([ + [50, 500], + [7, 70], + [200, 2000], + ]); + const font = fakeFont(DESCRIPTOR, new Uint8Array([1]), (glyphId) => + widthByGlyph.get(glyphId)!, + ); + // Inserted deliberately out of ascending order -- the output is sorted by the function under test, not by whatever order happened to reach it. + const usedGlyphs = new Map([ + [50, undefined], + [7, undefined], + [200, undefined], + ]); + const { cidFont } = buildMathFontObjects(font, usedGlyphs, REFS, false); + const w = asArray(dictGet(cidFont, "W")); + expect(w?.length).toBe(6); + expect(asNumber(w?.[0])).toBe(7); + expect(asNumber(asArray(w?.[1])?.[0])).toBe(70); + expect(asNumber(w?.[2])).toBe(50); + expect(asNumber(asArray(w?.[3])?.[0])).toBe(500); + expect(asNumber(w?.[4])).toBe(200); + expect(asNumber(asArray(w?.[5])?.[0])).toBe(2000); + }); + + it("writes an empty /W array when no glyph is used", () => { + const font = fakeFont(DESCRIPTOR, new Uint8Array([1]), () => 0); + const { cidFont } = buildMathFontObjects(font, new Map(), REFS, false); + expect(asArray(dictGet(cidFont, "W"))).toEqual([]); + }); +}); + +describe("toUnicodeEntries, via the built ToUnicode CMap", () => { + // A real STIX glyph ID standing in for an assembly-piece placement with no code point of its own (see math-content-write.ts's own collectUsedGlyphs): what this module does with such a glyph is independent of which glyph ID it is, so any glyph ID the font doesn't otherwise use is representative. + const UNMAPPED_GLYPH = 4862; + + function cmapTextOf(toUnicode: PdfObject): string { + expect(toUnicode.kind).toBe("stream"); + if (toUnicode.kind !== "stream") { + throw new Error("unreachable"); + } + // buildToUnicodeCMap never compresses its own output -- plain UTF-8 text, readable with no filter decoding. + return new TextDecoder().decode(toUnicode.raw); + } + + it("maps a glyph with a code point, in a bfchar entry naming that code point", () => { + const font = fakeFont(DESCRIPTOR, new Uint8Array([1]), () => 0); + const usedGlyphs = new Map([[65, 0x41]]); + const { toUnicode } = buildMathFontObjects(font, usedGlyphs, REFS, false); + const text = cmapTextOf(toUnicode); + expect(text).toContain("1 beginbfchar"); + expect(text).toContain("<0041> <0041>"); + }); + + it("drops a glyph with no code point from the CMap entirely, rather than mapping it to nothing", () => { + const font = fakeFont(DESCRIPTOR, new Uint8Array([1]), () => 0); + const usedGlyphs = new Map([ + [UNMAPPED_GLYPH, undefined], + ]); + const { toUnicode } = buildMathFontObjects(font, usedGlyphs, REFS, false); + const text = cmapTextOf(toUnicode); + // No bfchar block at all: the only glyph in the map has nothing to map to. + expect(text).not.toContain("beginbfchar"); + }); + + it("keeps the code-point-bearing glyph and drops the code-point-less one when both are used together", () => { + const font = fakeFont(DESCRIPTOR, new Uint8Array([1]), () => 0); + const usedGlyphs = new Map([ + [65, 0x41], + [UNMAPPED_GLYPH, undefined], + ]); + const { toUnicode } = buildMathFontObjects(font, usedGlyphs, REFS, false); + const text = cmapTextOf(toUnicode); + expect(text).toContain("1 beginbfchar"); + expect(text).toContain("<0041> <0041>"); + expect(text).not.toContain( + `<${UNMAPPED_GLYPH.toString(16).padStart(4, "0")}>`, + ); + }); +}); + +describe("buildMathFontObjects, against the real vendored STIX Two Math font", () => { + it("produces a widths array whose entries match the real font's own glyph-space measurements", () => { + const font = loadMathFont().font; + const latinX = font.glyphId(0x78); + expect(latinX).toBeDefined(); + const usedGlyphs = new Map([[latinX!, 0x78]]); + const { cidFont } = buildMathFontObjects(font, usedGlyphs, REFS, true); + const w = asArray(dictGet(cidFont, "W")); + expect(w?.length).toBe(2); + expect(asNumber(w?.[0])).toBe(latinX); + expect(asNumber(asArray(w?.[1])?.[0])).toBe(font.glyphSpaceWidth(latinX!)); + }); + + it("embeds the real font's own CFF table, byte for byte, compressed", () => { + const font = loadMathFont().font; + const { fontFile } = buildMathFontObjects(font, new Map(), REFS, true); + if (fontFile.kind !== "stream") { + throw new Error("unreachable"); + } + const decoded = decodeStream( + fontFile.raw, + fontFile.dict, + NOOP_DIAGNOSTIC_SINK, + ); + expect([...decoded.bytes]).toEqual([...font.cffBytes]); + }); +}); From 919ad359c28e578190d8a3335e597d84cf96b818 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:54:24 +0100 Subject: [PATCH 068/105] test(pdf-codec): cover writeFormulaContentStream's glyph-run, rule, and stroke items The existing suite only ever exercised the "assembled-glyphs" item kind (54% statement coverage, 0% of writeGlyphRun/writeRule/writeStroke). Adds direct coverage of the other three MathLayoutItem kinds: an ordinary glyph run's own CID encoding (including skipping a character with no glyph in the font's cmap, and emitting nothing when every character is unmapped), a filled rule's top-left-to-bottom-edge re-anchoring, and a stroke's moveto/lineto sequence (including the under-two-points no-op case). --- .../pdf-codec/src/math-content-write.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/packages/pdf-codec/src/math-content-write.test.ts b/packages/pdf-codec/src/math-content-write.test.ts index 525a849df..701a19cb5 100644 --- a/packages/pdf-codec/src/math-content-write.test.ts +++ b/packages/pdf-codec/src/math-content-write.test.ts @@ -227,3 +227,174 @@ describe("collectUsedGlyphs", () => { ).toBe(0x239d); }); }); + +const RED = { r: 0.25, g: 0.5, b: 0.75 }; +// No cmap entry in STIX Two Math (a Supplementary Private Use Area-B code point, never assigned by any font's own cmap) -- standing in for "this character has no glyph", the branch encodeGlyphRunToCids skips over rather than crashing on. +const UNMAPPED_CODE_POINT = 0x10fffd; + +describe("writeFormulaContentStream, an ordinary glyph run", () => { + it("shows the run's own CIDs at its own computed size, color, and position", () => { + const font = loadMathFont().font; + const aId = font.glyphId(0x41)!; + const bId = font.glyphId(0x42)!; + expect(aId).toBeDefined(); + expect(bId).toBeDefined(); + const content = write( + positioned( + box( + [ + { + kind: "glyphs", + xPt: 5, + yPt: 20, + text: "AB", + sizePt: 16, + color: RED, + }, + ], + 50, + ), + ), + ); + // Box-local (5, 20) against a 50pt box placed with its own bottom-left at page (100, 200): x is a plain offset (105); y is re-anchored from "20pt down from the box's own top" to "30pt up from its bottom", landing at page y = 230. + expect(content).toBe( + "BT\n" + + `/${RESOURCE} 16 Tf\n` + + "0.25 0.5 0.75 rg\n" + + "1 0 0 1 105 230 Tm\n" + + `<${aId.toString(16).padStart(4, "0")}${bId.toString(16).padStart(4, "0")}> Tj\n` + + "ET\n", + ); + }); + + it("skips a character with no glyph in the font's cmap, rather than crashing or emitting a bogus CID", () => { + const font = loadMathFont().font; + expect(font.glyphId(UNMAPPED_CODE_POINT)).toBeUndefined(); + const aId = font.glyphId(0x41)!; + const content = write( + positioned( + box( + [ + { + kind: "glyphs", + xPt: 0, + yPt: 0, + text: `A${String.fromCodePoint(UNMAPPED_CODE_POINT)}A`, + sizePt: 12, + color: BLACK, + }, + ], + 50, + ), + ), + ); + // Two 'A's worth of CIDs, not three code points' worth: the unmapped middle character contributed nothing. + const hex = `${aId.toString(16).padStart(4, "0")}${aId.toString(16).padStart(4, "0")}`; + expect(content).toContain(`<${hex}> Tj`); + }); + + it("emits nothing at all when every character in the run is unmapped", () => { + const content = write( + positioned( + box( + [ + { + kind: "glyphs", + xPt: 0, + yPt: 0, + text: String.fromCodePoint(UNMAPPED_CODE_POINT), + sizePt: 12, + color: BLACK, + }, + ], + 50, + ), + ), + ); + expect(content).toBe(""); + }); +}); + +describe("writeFormulaContentStream, a rule", () => { + it("fills an axis-aligned rectangle from the rule's own top-left corner and size, re-anchored to page space", () => { + const content = write( + positioned( + box( + [ + { + kind: "rule", + xPt: 10, + yPt: 5, + widthPt: 30, + heightPt: 2, + color: RED, + }, + ], + 50, + ), + ), + ); + // xPt=10 -> page x 110. topY = box-local yPt=5 re-anchored to page y 245 (200 + 50 - 5); the filled rect's own y is its BOTTOM edge, topY - heightPt = 243. + expect(content).toBe("0.25 0.5 0.75 rg\n" + "110 243 30 2 re\n" + "f\n"); + }); +}); + +describe("writeFormulaContentStream, a stroke", () => { + it("draws an open polyline through every point, moveto first then lineto the rest", () => { + const content = write( + positioned( + box( + [ + { + kind: "stroke", + points: [ + { xPt: 0, yPt: 0 }, + { xPt: 4, yPt: 10 }, + { xPt: 8, yPt: 0 }, + ], + widthPt: 1.5, + color: RED, + }, + ], + 50, + ), + ), + ); + expect(content).toBe( + "0.25 0.5 0.75 RG\n" + + "1.5 w\n" + + "100 250 m\n" + // (0,0) box-local -> page (100, 250) + "104 240 l\n" + // (4,10) -> page (104, 240) + "108 250 l\n" + // (8,0) -> page (108, 250) + "S\n", + ); + }); + + it("draws nothing for a stroke with fewer than two points", () => { + const content = write( + positioned( + box( + [ + { + kind: "stroke", + points: [{ xPt: 0, yPt: 0 }], + widthPt: 1, + color: RED, + }, + ], + 50, + ), + ), + ); + expect(content).toBe(""); + }); + + it("draws nothing for a stroke with no points at all", () => { + const content = write( + positioned( + box([{ kind: "stroke", points: [], widthPt: 1, color: RED }], 50), + ), + ); + expect(content).toBe(""); + }); +}); From 7fe27da55c732a008de5c8b550713e898cc908f1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:54:39 +0100 Subject: [PATCH 069/105] test(pdf-codec): cover parseDestination's view types and the outline cycle guard navigation.ts's own destination and outline logic (74% statement, 53% branch coverage) had several branches no fixture-based test ever reached: five of the eight display types (FitH/FitV/FitR/FitB/FitBH/FitBV), a bare non-negative-integer page number, a page element the page-index lookup can't place, an unrecognised or missing display type name, a duplicate name in the /Names /Dests tree specifically (as opposed to the old-style /Dests dictionary), the dest1/dest2/dest3 minting collision loop, an outline item's own missing /Title, its /A /GoTo destination path, and -- most importantly -- the outline cycle guard, never exercised at all. Calls parseDestination/createDestinationRegistry/readOutline directly against hand-built PdfObject values and a small ref-table resolver, rather than growing the existing FixtureBuilder-based PDF fixture to cover every one of these combinations by hand. --- packages/pdf-codec/src/navigation.test.ts | 521 ++++++++++++++++++++++ 1 file changed, 521 insertions(+) diff --git a/packages/pdf-codec/src/navigation.test.ts b/packages/pdf-codec/src/navigation.test.ts index c33a3b273..0d939852b 100644 --- a/packages/pdf-codec/src/navigation.test.ts +++ b/packages/pdf-codec/src/navigation.test.ts @@ -1,4 +1,23 @@ import { describe, expect, it } from "vitest"; +import type { PdfDiagnostic, PdfDiagnosticSink } from "./diagnostics"; +import type { PdfObjectResolver } from "./interpret"; +import type { PageIndexLookup } from "./navigation"; +import { + createDestinationRegistry, + parseDestination, + readOutline, +} from "./navigation"; +import type { PdfDict, PdfObject } from "./objects"; +import { + asDict, + pdfArray, + pdfDict, + pdfLiteralString, + pdfName, + pdfNull, + pdfNum, + pdfRef, +} from "./objects"; import { readPdf } from "./read"; import { navigationClusterPdf } from "./test-support/pdf"; @@ -109,3 +128,505 @@ describe("readPdf: internal link annotations", () => { ); }); }); + +function collectDiagnostics(): { + sink: PdfDiagnosticSink; + diagnostics: PdfDiagnostic[]; +} { + const diagnostics: PdfDiagnostic[] = []; + return { sink: (d) => diagnostics.push(d), diagnostics }; +} + +// A resolver over a plain ref-number -> object table -- every object in these tests is either direct or a `pdfRef` into this map, matching interpret.test.ts's own makeResolver. Returning the SAME map entry on every resolve is what lets the cycle-detection tests below recognise a repeated node by object identity. +function makeResolver( + objects = new Map(), +): PdfObjectResolver { + const resolve = (obj: PdfObject | undefined): PdfObject | undefined => + obj?.kind === "ref" ? objects.get(obj.num) : obj; + const resolveDict = (obj: PdfObject | undefined): PdfDict | undefined => + asDict(resolve(obj)); + return { resolve, resolveDict }; +} + +// A page-index lookup that resolves any ref to its own object number -- arbitrary but deterministic, and distinct enough from a small page count that a test asserting `pageIndex: N` can't be confused with a coincidental default. +const pageIndexByRefNum: PageIndexLookup = (obj) => + obj?.kind === "ref" ? obj.num : undefined; + +function str(text: string): PdfObject { + return pdfLiteralString(new TextEncoder().encode(text)); +} + +describe("parseDestination", () => { + const resolver = makeResolver(); + + it("is invalid when the value does not resolve to an array at all", () => { + const { sink, diagnostics } = collectDiagnostics(); + expect( + parseDestination(pdfNum(5), resolver, pageIndexByRefNum, sink), + ).toBeUndefined(); + expect(diagnostics[0]?.code).toBe("pdf/destination-invalid"); + }); + + it("is invalid when the array has fewer than two elements", () => { + const { sink, diagnostics } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(3, 0)]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toBeUndefined(); + expect(diagnostics[0]?.code).toBe("pdf/destination-invalid"); + }); + + it("accepts a bare non-negative integer page number (the PDF 2.0 spelling)", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfNum(3), pdfName("Fit")]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ pageIndex: 3, target: { kind: "fit" } }); + }); + + it("rejects a non-integer bare page number rather than truncating it", () => { + const { sink, diagnostics } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfNum(1.5), pdfName("Fit")]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toBeUndefined(); + expect(diagnostics[0]?.message).toContain( + "not in the document's page tree", + ); + }); + + it("rejects a negative bare page number", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfNum(-1), pdfName("Fit")]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toBeUndefined(); + }); + + it("resolves a non-number page element through the page-index lookup", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(7, 0), pdfName("Fit")]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ pageIndex: 7, target: { kind: "fit" } }); + }); + + it("is invalid when the page-index lookup cannot place the page element", () => { + const { sink, diagnostics } = collectDiagnostics(); + const neverFound: PageIndexLookup = () => undefined; + expect( + parseDestination( + pdfArray([pdfRef(7, 0), pdfName("Fit")]), + resolver, + neverFound, + sink, + ), + ).toBeUndefined(); + expect(diagnostics[0]?.code).toBe("pdf/destination-invalid"); + }); + + it("reads XYZ coordinates, and drops a null coordinate rather than defaulting it to 0", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([ + pdfRef(0, 0), + pdfName("XYZ"), + pdfNum(12), + pdfNull(), + pdfNum(2), + ]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ + pageIndex: 0, + target: { kind: "xyz", leftPt: 12, zoom: 2 }, + }); + }); + + it("reads FitH's own single top coordinate", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(0, 0), pdfName("FitH"), pdfNum(99)]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ pageIndex: 0, target: { kind: "fitH", topPt: 99 } }); + }); + + it("reads FitH with no coordinate at all", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(0, 0), pdfName("FitH")]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ pageIndex: 0, target: { kind: "fitH" } }); + }); + + it("reads FitV's own single left coordinate", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(0, 0), pdfName("FitV"), pdfNum(44)]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ pageIndex: 0, target: { kind: "fitV", leftPt: 44 } }); + }); + + it("reads FitR's own four-coordinate rectangle", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([ + pdfRef(0, 0), + pdfName("FitR"), + pdfNum(1), + pdfNum(2), + pdfNum(3), + pdfNum(4), + ]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ + pageIndex: 0, + target: { kind: "fitR", leftPt: 1, bottomPt: 2, rightPt: 3, topPt: 4 }, + }); + }); + + it("reads FitB with no coordinates", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(0, 0), pdfName("FitB")]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ pageIndex: 0, target: { kind: "fitB" } }); + }); + + it("reads FitBH's own single top coordinate", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(0, 0), pdfName("FitBH"), pdfNum(7)]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ pageIndex: 0, target: { kind: "fitBH", topPt: 7 } }); + }); + + it("reads FitBV's own single left coordinate", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(0, 0), pdfName("FitBV"), pdfNum(8)]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toEqual({ pageIndex: 0, target: { kind: "fitBV", leftPt: 8 } }); + }); + + it("is invalid for an unrecognised display type, naming it in the diagnostic", () => { + const { sink, diagnostics } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(0, 0), pdfName("Bogus")]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toBeUndefined(); + expect(diagnostics[0]?.message).toContain("/Bogus"); + }); + + it("names the type as /? when the array carries no type name at all", () => { + const { sink, diagnostics } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(0, 0), pdfNull()]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toBeUndefined(); + expect(diagnostics[0]?.message).toContain("/?"); + }); +}); + +describe("createDestinationRegistry", () => { + it("keeps only the first entry when the /Dests dictionary declares the same name twice", () => { + const { sink, diagnostics } = collectDiagnostics(); + const catalog = pdfDict({ + Dests: pdfDict({ + dup: pdfArray([pdfRef(0, 0), pdfName("Fit")]), + }), + }); + // A single-entry Map can't itself hold a duplicate key, so this exercises the duplicate check by calling the registry with a catalog whose /Dests dict entries iteration naturally yields "dup" once -- the duplicate branch itself is proven by the name-tree case below, which genuinely can repeat a name. This case instead confirms the ordinary non-duplicate path leaves the sink untouched. + createDestinationRegistry(catalog, makeResolver(), pageIndexByRefNum, sink); + expect(diagnostics).toEqual([]); + }); + + it("warns and keeps the first entry when the /Names /Dests tree repeats a name", () => { + const { sink, diagnostics } = collectDiagnostics(); + const catalog = pdfDict({ + Names: pdfDict({ + Dests: pdfDict({ + Names: pdfArray([ + str("dup"), + pdfArray([pdfRef(0, 0), pdfName("Fit")]), + str("dup"), + pdfArray([pdfRef(1, 0), pdfName("FitB")]), + ]), + }), + }), + }); + const registry = createDestinationRegistry( + catalog, + makeResolver(), + pageIndexByRefNum, + sink, + ); + expect(registry.entries).toHaveLength(1); + expect(registry.entries[0]).toEqual({ + name: "dup", + pageIndex: 0, + target: { kind: "fit" }, + }); + expect( + diagnostics.some((d) => d.code === "pdf/destination-duplicate"), + ).toBe(true); + }); + + it("skips an unparseable destination in the /Dests dictionary rather than adding a broken entry", () => { + const { sink, diagnostics } = collectDiagnostics(); + const catalog = pdfDict({ Dests: pdfDict({ broken: pdfNull() }) }); + const registry = createDestinationRegistry( + catalog, + makeResolver(), + pageIndexByRefNum, + sink, + ); + expect(registry.entries).toEqual([]); + expect(diagnostics.some((d) => d.code === "pdf/destination-invalid")).toBe( + true, + ); + }); + + it("mints dest1, dest2, dest3 in order, skipping over already-taken names", () => { + const { sink } = collectDiagnostics(); + // A pre-existing named destination that happens to occupy the FIRST name the minter would otherwise pick, forcing it to skip past dest1. + const catalog = pdfDict({ + Dests: pdfDict({ + dest1: pdfArray([pdfRef(0, 0), pdfName("Fit")]), + }), + }); + const registry = createDestinationRegistry( + catalog, + makeResolver(), + pageIndexByRefNum, + sink, + ); + const first = registry.intern(pdfArray([pdfRef(1, 0), pdfName("Fit")])); + const second = registry.intern(pdfArray([pdfRef(2, 0), pdfName("Fit")])); + expect(first).toBe("dest2"); + expect(second).toBe("dest3"); + }); + + describe("intern", () => { + it("returns undefined, with no diagnostic, for a value that resolves to neither a string nor an array", () => { + const { sink, diagnostics } = collectDiagnostics(); + const registry = createDestinationRegistry( + pdfDict({}), + makeResolver(), + pageIndexByRefNum, + sink, + ); + expect(registry.intern(pdfNum(5))).toBeUndefined(); + expect(registry.intern(undefined)).toBeUndefined(); + expect(diagnostics).toEqual([]); + }); + + it("warns and returns undefined for a named destination no /Dests or name tree entry declares", () => { + const { sink, diagnostics } = collectDiagnostics(); + const registry = createDestinationRegistry( + pdfDict({}), + makeResolver(), + pageIndexByRefNum, + sink, + ); + expect(registry.intern(str("nowhere"))).toBeUndefined(); + expect(diagnostics[0]?.code).toBe("pdf/destination-unresolved"); + expect(diagnostics[0]?.message).toContain("nowhere"); + }); + + it("resolves a name already in the table with no diagnostic", () => { + const { sink, diagnostics } = collectDiagnostics(); + const catalog = pdfDict({ + Dests: pdfDict({ here: pdfArray([pdfRef(0, 0), pdfName("Fit")]) }), + }); + const registry = createDestinationRegistry( + catalog, + makeResolver(), + pageIndexByRefNum, + sink, + ); + expect(registry.intern(str("here"))).toBe("here"); + expect(diagnostics).toEqual([]); + }); + }); +}); + +describe("readOutline", () => { + const resolver = makeResolver(); + + it("returns no items when the catalog has no /Outlines at all", () => { + const { sink } = collectDiagnostics(); + const registry = createDestinationRegistry( + pdfDict({}), + resolver, + pageIndexByRefNum, + sink, + ); + expect(readOutline(pdfDict({}), registry, resolver, sink)).toEqual([]); + }); + + it("returns no items when /Outlines has no /First", () => { + const { sink } = collectDiagnostics(); + const registry = createDestinationRegistry( + pdfDict({}), + resolver, + pageIndexByRefNum, + sink, + ); + const catalog = pdfDict({ Outlines: pdfDict({}) }); + expect(readOutline(catalog, registry, resolver, sink)).toEqual([]); + }); + + it("titles an item with no /Title as an empty string rather than omitting it", () => { + const { sink } = collectDiagnostics(); + const registry = createDestinationRegistry( + pdfDict({}), + resolver, + pageIndexByRefNum, + sink, + ); + const objects = new Map([[1, pdfDict({})]]); + const catalog = pdfDict({ Outlines: pdfDict({ First: pdfRef(1, 0) }) }); + expect(readOutline(catalog, registry, makeResolver(objects), sink)).toEqual( + [{ title: "", children: [] }], + ); + }); + + it("resolves a destination through /A /GoTo when there is no direct /Dest", () => { + const { sink } = collectDiagnostics(); + const interned: PdfObject[] = []; + const registry = { + entries: [], + intern: (obj: PdfObject | undefined) => { + if (obj !== undefined) { + interned.push(obj); + } + return "wherever"; + }, + }; + const action = pdfDict({ S: pdfName("GoTo"), D: str("target") }); + const objects = new Map([ + [1, pdfDict({ Title: str("Node"), A: pdfRef(2, 0) })], + [2, action], + ]); + const catalog = pdfDict({ Outlines: pdfDict({ First: pdfRef(1, 0) }) }); + const items = readOutline(catalog, registry, makeResolver(objects), sink); + expect(items).toEqual([ + { title: "Node", destination: "wherever", children: [] }, + ]); + expect(interned).toEqual([str("target")]); + }); + + it("ignores an /A action whose /S is not /GoTo", () => { + const { sink } = collectDiagnostics(); + const registry = { + entries: [], + intern: () => "should-not-be-called", + }; + const action = pdfDict({ S: pdfName("URI"), URI: str("https://x") }); + const objects = new Map([ + [1, pdfDict({ Title: str("Node"), A: pdfRef(2, 0) })], + [2, action], + ]); + const catalog = pdfDict({ Outlines: pdfDict({ First: pdfRef(1, 0) }) }); + const items = readOutline(catalog, registry, makeResolver(objects), sink); + expect(items).toEqual([{ title: "Node", children: [] }]); + }); + + it("leaves destination unset for a node with neither /Dest nor /A", () => { + const { sink } = collectDiagnostics(); + const registry = createDestinationRegistry( + pdfDict({}), + resolver, + pageIndexByRefNum, + sink, + ); + const objects = new Map([ + [1, pdfDict({ Title: str("Node") })], + ]); + const catalog = pdfDict({ Outlines: pdfDict({ First: pdfRef(1, 0) }) }); + expect(readOutline(catalog, registry, makeResolver(objects), sink)).toEqual( + [{ title: "Node", children: [] }], + ); + }); + + it("stops a chain at a repeated node and warns, with the shared visited set spanning parent and child recursion", () => { + const { sink, diagnostics } = collectDiagnostics(); + const registry = createDestinationRegistry( + pdfDict({}), + resolver, + pageIndexByRefNum, + sink, + ); + // A's own child is B, and B's /Next points back to A -- a cycle across the parent/child boundary, not merely a self-loop within one sibling chain. + const objects = new Map([ + [1, pdfDict({ Title: str("A"), First: pdfRef(2, 0) })], + [2, pdfDict({ Title: str("B"), Next: pdfRef(1, 0) })], + ]); + const catalog = pdfDict({ Outlines: pdfDict({ First: pdfRef(1, 0) }) }); + const items = readOutline(catalog, registry, makeResolver(objects), sink); + expect(items).toEqual([ + { title: "A", children: [{ title: "B", children: [] }] }, + ]); + expect(diagnostics.some((d) => d.code === "pdf/outline-cycle")).toBe(true); + }); +}); From dac7d388c7a7f96647e72ea45b3370e9bdc7e7ef Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 12:28:30 +0100 Subject: [PATCH 070/105] refactor(pdf-codec): remove the unreachable duplicate-name check in the /Dests dictionary loop destsDict.entries is a Map, whose own key uniqueness already guarantees every name the old-style /Dests dictionary loop sees is distinct within that loop -- a dictionary literal's own duplicate keys, if the source bytes had any, were already collapsed to last-wins by the parser that built this Map, long before createDestinationRegistry ever runs. The duplicate check that loop carried could never observe a true duplicate, unlike the /Names /Dests name-tree walk immediately after it, which genuinely can encounter the same name from two different leaf nodes. Also strengthens the surrounding tests: several assertions only checked a diagnostic's code, not its message, letting a StringLiteral mutation on the message text survive; several others used toEqual against an object where an optional field's absence versus an explicit undefined value are the exact thing under test, which toEqual treats as equal and toStrictEqual does not. --- packages/pdf-codec/src/navigation.test.ts | 79 ++++++++++++++--------- packages/pdf-codec/src/navigation.ts | 10 +-- 2 files changed, 50 insertions(+), 39 deletions(-) diff --git a/packages/pdf-codec/src/navigation.test.ts b/packages/pdf-codec/src/navigation.test.ts index 0d939852b..390908b36 100644 --- a/packages/pdf-codec/src/navigation.test.ts +++ b/packages/pdf-codec/src/navigation.test.ts @@ -165,6 +165,9 @@ describe("parseDestination", () => { parseDestination(pdfNum(5), resolver, pageIndexByRefNum, sink), ).toBeUndefined(); expect(diagnostics[0]?.code).toBe("pdf/destination-invalid"); + expect(diagnostics[0]?.message).toBe( + "a destination is not a display destination array; skipping it", + ); }); it("is invalid when the array has fewer than two elements", () => { @@ -178,6 +181,9 @@ describe("parseDestination", () => { ), ).toBeUndefined(); expect(diagnostics[0]?.code).toBe("pdf/destination-invalid"); + expect(diagnostics[0]?.message).toBe( + "a destination is not a display destination array; skipping it", + ); }); it("accepts a bare non-negative integer page number (the PDF 2.0 spelling)", () => { @@ -189,7 +195,19 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ pageIndex: 3, target: { kind: "fit" } }); + ).toStrictEqual({ pageIndex: 3, target: { kind: "fit" } }); + }); + + it("accepts page index 0, distinguishing >= 0 from > 0", () => { + const { sink } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfNum(0), pdfName("Fit")]), + resolver, + pageIndexByRefNum, + sink, + ), + ).toStrictEqual({ pageIndex: 0, target: { kind: "fit" } }); }); it("rejects a non-integer bare page number rather than truncating it", () => { @@ -228,7 +246,7 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ pageIndex: 7, target: { kind: "fit" } }); + ).toStrictEqual({ pageIndex: 7, target: { kind: "fit" } }); }); it("is invalid when the page-index lookup cannot place the page element", () => { @@ -260,7 +278,7 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ + ).toStrictEqual({ pageIndex: 0, target: { kind: "xyz", leftPt: 12, zoom: 2 }, }); @@ -275,7 +293,7 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ pageIndex: 0, target: { kind: "fitH", topPt: 99 } }); + ).toStrictEqual({ pageIndex: 0, target: { kind: "fitH", topPt: 99 } }); }); it("reads FitH with no coordinate at all", () => { @@ -287,7 +305,7 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ pageIndex: 0, target: { kind: "fitH" } }); + ).toStrictEqual({ pageIndex: 0, target: { kind: "fitH" } }); }); it("reads FitV's own single left coordinate", () => { @@ -299,7 +317,7 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ pageIndex: 0, target: { kind: "fitV", leftPt: 44 } }); + ).toStrictEqual({ pageIndex: 0, target: { kind: "fitV", leftPt: 44 } }); }); it("reads FitR's own four-coordinate rectangle", () => { @@ -318,7 +336,7 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ + ).toStrictEqual({ pageIndex: 0, target: { kind: "fitR", leftPt: 1, bottomPt: 2, rightPt: 3, topPt: 4 }, }); @@ -333,7 +351,7 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ pageIndex: 0, target: { kind: "fitB" } }); + ).toStrictEqual({ pageIndex: 0, target: { kind: "fitB" } }); }); it("reads FitBH's own single top coordinate", () => { @@ -345,7 +363,7 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ pageIndex: 0, target: { kind: "fitBH", topPt: 7 } }); + ).toStrictEqual({ pageIndex: 0, target: { kind: "fitBH", topPt: 7 } }); }); it("reads FitBV's own single left coordinate", () => { @@ -357,7 +375,7 @@ describe("parseDestination", () => { pageIndexByRefNum, sink, ), - ).toEqual({ pageIndex: 0, target: { kind: "fitBV", leftPt: 8 } }); + ).toStrictEqual({ pageIndex: 0, target: { kind: "fitBV", leftPt: 8 } }); }); it("is invalid for an unrecognised display type, naming it in the diagnostic", () => { @@ -370,6 +388,7 @@ describe("parseDestination", () => { sink, ), ).toBeUndefined(); + expect(diagnostics[0]?.code).toBe("pdf/destination-invalid"); expect(diagnostics[0]?.message).toContain("/Bogus"); }); @@ -388,18 +407,6 @@ describe("parseDestination", () => { }); describe("createDestinationRegistry", () => { - it("keeps only the first entry when the /Dests dictionary declares the same name twice", () => { - const { sink, diagnostics } = collectDiagnostics(); - const catalog = pdfDict({ - Dests: pdfDict({ - dup: pdfArray([pdfRef(0, 0), pdfName("Fit")]), - }), - }); - // A single-entry Map can't itself hold a duplicate key, so this exercises the duplicate check by calling the registry with a catalog whose /Dests dict entries iteration naturally yields "dup" once -- the duplicate branch itself is proven by the name-tree case below, which genuinely can repeat a name. This case instead confirms the ordinary non-duplicate path leaves the sink untouched. - createDestinationRegistry(catalog, makeResolver(), pageIndexByRefNum, sink); - expect(diagnostics).toEqual([]); - }); - it("warns and keeps the first entry when the /Names /Dests tree repeats a name", () => { const { sink, diagnostics } = collectDiagnostics(); const catalog = pdfDict({ @@ -426,9 +433,13 @@ describe("createDestinationRegistry", () => { pageIndex: 0, target: { kind: "fit" }, }); - expect( - diagnostics.some((d) => d.code === "pdf/destination-duplicate"), - ).toBe(true); + const duplicateWarning = diagnostics.find( + (d) => d.code === "pdf/destination-duplicate", + ); + expect(duplicateWarning).toBeDefined(); + expect(duplicateWarning?.message).toBe( + 'destination name "dup" is declared more than once; keeping the first', + ); }); it("skips an unparseable destination in the /Dests dictionary rather than adding a broken entry", () => { @@ -589,7 +600,9 @@ describe("readOutline", () => { ]); const catalog = pdfDict({ Outlines: pdfDict({ First: pdfRef(1, 0) }) }); const items = readOutline(catalog, registry, makeResolver(objects), sink); - expect(items).toEqual([{ title: "Node", children: [] }]); + // Strict, not just structural equality: the item must have no `destination` KEY at all, not merely one whose value happens to be undefined -- the conditional spread this proves is genuinely conditional. + expect(items).toStrictEqual([{ title: "Node", children: [] }]); + expect(Object.hasOwn(items[0]!, "destination")).toBe(false); }); it("leaves destination unset for a node with neither /Dest nor /A", () => { @@ -604,9 +617,9 @@ describe("readOutline", () => { [1, pdfDict({ Title: str("Node") })], ]); const catalog = pdfDict({ Outlines: pdfDict({ First: pdfRef(1, 0) }) }); - expect(readOutline(catalog, registry, makeResolver(objects), sink)).toEqual( - [{ title: "Node", children: [] }], - ); + const items = readOutline(catalog, registry, makeResolver(objects), sink); + expect(items).toStrictEqual([{ title: "Node", children: [] }]); + expect(Object.hasOwn(items[0]!, "destination")).toBe(false); }); it("stops a chain at a repeated node and warns, with the shared visited set spanning parent and child recursion", () => { @@ -627,6 +640,12 @@ describe("readOutline", () => { expect(items).toEqual([ { title: "A", children: [{ title: "B", children: [] }] }, ]); - expect(diagnostics.some((d) => d.code === "pdf/outline-cycle")).toBe(true); + const cycleWarning = diagnostics.find( + (d) => d.code === "pdf/outline-cycle", + ); + expect(cycleWarning).toBeDefined(); + expect(cycleWarning?.message).toBe( + "the outline contains a cycle; stopping the sibling chain at the repeated item", + ); }); }); diff --git a/packages/pdf-codec/src/navigation.ts b/packages/pdf-codec/src/navigation.ts index fc5c0d181..0deed827e 100644 --- a/packages/pdf-codec/src/navigation.ts +++ b/packages/pdf-codec/src/navigation.ts @@ -166,18 +166,10 @@ export function createDestinationRegistry( byName.set(name, entry); }; - // The old-style dictionary (PDF 1.1, still widely emitted): name -> destination array, as direct dict entries. + // The old-style dictionary (PDF 1.1, still widely emitted): name -> destination array, as direct dict entries. No duplicate-name check here (unlike the name-tree walk below): destsDict.entries is a Map, whose own key uniqueness already guarantees every `name` this loop sees is distinct -- a dictionary literal's own duplicate keys, if the source bytes had any, were already collapsed to last-wins by the parser that built this Map, long before this function ever sees it. const destsDict = resolver.resolveDict(dictGet(catalog, "Dests")); if (destsDict !== undefined) { for (const [name, value] of destsDict.entries) { - if (byName.has(name)) { - sink({ - code: "pdf/destination-duplicate", - severity: "warning", - message: `destination name "${name}" is declared more than once; keeping the first`, - }); - continue; - } const parsed = parseDestination(value, resolver, pageIndex, sink); if (parsed !== undefined) { add(name, parsed); From a31a490a7dae10c4cf06e82390e5ccba5fc92b83 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 12:28:46 +0100 Subject: [PATCH 071/105] test(pdf-codec): pick characters that actually distinguish math-content-write's byte packing Several survived mutants traced back to test fixtures whose chosen glyph IDs or code units happened to share a zero byte with the mutated one, making the mutation invisible: a two-Latin- letter glyph run where both CIDs fit under 0xff never exercises the high-byte offset for a second CID, and a surrogate pair whose low surrogate's own low byte is 0x00 never exercises the low-byte offset for a second UTF-16 code unit. Swaps in characters whose bytes are actually non-zero at the positions under test, and adds a two-point stroke (the boundary a "fewer than two points" check must not also exclude) and a synthetic font that deliberately collides two code points onto one glyph ID (proving collectUsedGlyphs' first-write-wins guard, which the real font's own injective cmap can never exercise). --- .../pdf-codec/src/math-content-write.test.ts | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/math-content-write.test.ts b/packages/pdf-codec/src/math-content-write.test.ts index 701a19cb5..dc7515401 100644 --- a/packages/pdf-codec/src/math-content-write.test.ts +++ b/packages/pdf-codec/src/math-content-write.test.ts @@ -8,6 +8,7 @@ import { collectUsedGlyphs, writeFormulaContentStream, } from "./math-content-write"; +import type { MathFont } from "./math-font"; import { loadMathFont } from "./math-font"; const BLACK = { r: 0, g: 0, b: 0 }; @@ -88,10 +89,32 @@ describe("writeFormulaContentStream, assembled stretchy glyphs", () => { // UTF-16BE with the byte-order mark that marks a PDF text string as Unicode: FEFF then U+0028. expect(content).toContain("/Span < >> BDC\n"); expect(content.endsWith("EMC\n")).toBe(true); + expect(content).toContain("ET\n"); // a hard containment check first: indexOf("ET") is -1 (still "less than" any real EMC index) if this string were ever blanked out expect(content.indexOf("BDC")).toBeLessThan(content.indexOf("BT")); expect(content.indexOf("ET")).toBeLessThan(content.indexOf("EMC")); }); + it("encodes a two-character operator's /ActualText with each character's own low byte, not just its high byte", () => { + // Two ordinary BMP characters, not a surrogate pair: proves utf16BeWithBom packs the SECOND code unit's own low byte at the right offset too, which a surrogate pair (whose low surrogate happens to end 0x00) can't distinguish from a dropped write. + const content = write( + positioned( + box( + [ + { + kind: "assembled-glyphs", + text: "AB", + sizePt: 12, + color: BLACK, + placements: [{ glyphId: LOWER_HOOK, xPt: 0, yPt: 0 }], + }, + ], + 50, + ), + ), + ); + expect(content).toContain("/Span < >> BDC\n"); + }); + it("encodes a supplementary-plane operator in /ActualText as a real surrogate pair", () => { const content = write( positioned( @@ -226,6 +249,39 @@ describe("collectUsedGlyphs", () => { ).get(hook!), ).toBe(0x239d); }); + + it("keeps the first code point a glyph resolved to, never overwriting it with a later one", () => { + // A synthetic font, not the real STIX Two Math one: the real font's cmap is injective (its own module comment states this explicitly, and it holds for every code point actually probed), so no pair of distinct real code points ever reaches this guard with an already-resolved glyph. A font is built here that deliberately violates that invariant, to prove the guard itself -- first write wins -- rather than relying on real font data that can never exercise it. + const COLLIDING_GLYPH = 999; + const realFont = loadMathFont().font; // for the members this test never exercises, so nothing here needs its own hand-stubbed values + const collidingFont: MathFont = { + ...realFont, + glyphId: (codePoint: number) => + codePoint === 0x41 || codePoint === 0x42 ? COLLIDING_GLYPH : undefined, + }; + const used = collectUsedGlyphs( + [ + positioned( + box( + [ + { + kind: "glyphs", + xPt: 0, + yPt: 0, + text: "AB", + sizePt: 12, + color: BLACK, + }, + ], + 50, + ), + ), + ], + collidingFont, + ); + expect(used.size).toBe(1); + expect(used.get(COLLIDING_GLYPH)).toBe(0x41); // 'A' was seen first; 'B' resolves to the same glyph but must not overwrite it + }); }); const RED = { r: 0.25, g: 0.5, b: 0.75 }; @@ -236,9 +292,11 @@ describe("writeFormulaContentStream, an ordinary glyph run", () => { it("shows the run's own CIDs at its own computed size, color, and position", () => { const font = loadMathFont().font; const aId = font.glyphId(0x41)!; - const bId = font.glyphId(0x42)!; + // The integral sign, not a second Latin letter: its glyph ID (0x6a2) has a non-zero HIGH byte, which a plain ASCII pair (every Latin glyph ID here sits under 256) would never exercise -- proving encodeGlyphRunToCids packs (gid >> 8) at the right byte offset for the second CID, not just the first. + const bId = font.glyphId(0x222b)!; expect(aId).toBeDefined(); expect(bId).toBeDefined(); + expect(bId).toBeGreaterThan(0xff); const content = write( positioned( box( @@ -247,7 +305,7 @@ describe("writeFormulaContentStream, an ordinary glyph run", () => { kind: "glyphs", xPt: 5, yPt: 20, - text: "AB", + text: "A∫", sizePt: 16, color: RED, }, @@ -370,6 +428,30 @@ describe("writeFormulaContentStream, a stroke", () => { ); }); + it("draws a stroke at exactly the two-point minimum, the boundary a fewer-than-two check must not also exclude", () => { + const content = write( + positioned( + box( + [ + { + kind: "stroke", + points: [ + { xPt: 0, yPt: 0 }, + { xPt: 6, yPt: 6 }, + ], + widthPt: 1, + color: RED, + }, + ], + 50, + ), + ), + ); + expect(content).toBe( + "0.25 0.5 0.75 RG\n" + "1 w\n" + "100 250 m\n" + "106 244 l\n" + "S\n", + ); + }); + it("draws nothing for a stroke with fewer than two points", () => { const content = write( positioned( From 7ffded032b6e0065826095bd3354673cf3a9ecf2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 12:29:00 +0100 Subject: [PATCH 072/105] test(pdf-codec): cover embedded-font-write's serif flag, subset tag arithmetic, and dict keys FLAG_SERIF was never set in any test (every vendored face used elsewhere is a sans family); adds a dedicated case using the real, vendored Caladea (a genuine serif face). The subset tag's own comma separator and its base-26 letter-extraction direction had no test able to tell a comma-joined glyph list from a concatenated one, or floor-division from multiplication -- adds a collision pair for the former and an independently-computed expected tag (via the package's own already-tested crc32()) for the latter. The FontDescriptor's own /Type key, /StemV, and the CIDFontType2 dict's own /Type key were never read back at all; the /FontBBox check used optional chaining that let a blanked-out key vacuously pass with the array read as undefined instead of failing. --- .../pdf-codec/src/embedded-font-write.test.ts | 80 +++++++++++++++++-- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/pdf-codec/src/embedded-font-write.test.ts b/packages/pdf-codec/src/embedded-font-write.test.ts index c70d53514..157f342ee 100644 --- a/packages/pdf-codec/src/embedded-font-write.test.ts +++ b/packages/pdf-codec/src/embedded-font-write.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { ByteWriter } from "./bytes/writer"; +import { crc32 } from "./bytes/crc32"; import { NOOP_DIAGNOSTIC_SINK } from "./diagnostics"; import { openPdfDocument } from "./document"; import { @@ -32,7 +33,11 @@ import { writeObject } from "./serialize"; import type { SfntSubsetResult } from "./sfnt-subset"; import { subsetSfnt } from "./sfnt-subset"; import { parseSfnt } from "./sfnt"; -import { caladeaItalicBytes, carlitoRegularBytes } from "./test-support/fonts"; +import { + caladeaItalicBytes, + caladeaRegularBytes, + carlitoRegularBytes, +} from "./test-support/fonts"; import type { AllocatedObject } from "./test-support/write-pdf-fixture"; import { assemblePdf } from "./test-support/write-pdf-fixture"; @@ -382,19 +387,33 @@ describe("a real PDF carrying an embedded, subsetted Carlito, read back by this 4, ); expect(asNumber(dictGet(descriptor!, "ItalicAngle"))).toBe(0); + expect(asNumber(dictGet(descriptor!, "StemV"))).toBe(80); // NOMINAL_STEM_V -- a nominal, spec-required value no conforming reader actually consults + expect(asName(dictGet(descriptor!, "Type"))).toBe("FontDescriptor"); // Every geometry field is in 1000-unit glyph space, not Carlito's own 2048-unit design grid -- so the bounding box read back here is roughly half the raw head-table one. - asArray(dictGet(descriptor!, "FontBBox"))?.forEach((entry, index) => { + // + // A hard length assertion first, not just the forEach below: FontBBox is read through optional chaining because it's read from an already-round-tripped PDF dict (a genuinely absent key is a real, distinct outcome from an empty array), so a mutant blanking out the FontBBox key would otherwise leave the forEach body silently unrun and this test vacuously green. + const bbox = asArray(dictGet(descriptor!, "FontBBox")); + expect(bbox).toBeDefined(); + expect(bbox).toHaveLength(4); + bbox?.forEach((entry, index) => { expect(asNumber(entry)).toBeCloseTo( face.metrics.bboxGlyphSpace[index]!, 4, ); }); - expect(asNumber(asArray(dictGet(descriptor!, "FontBBox"))?.[2])).not.toBe( - 2351, - ); + expect(asNumber(bbox?.[2])).not.toBe(2351); // NONSYMBOLIC only: Carlito is a sans design (no SERIF bit) drawn upright (no ITALIC bit). expect(asNumber(dictGet(descriptor!, "Flags"))).toBe(32); }); + + it("names the CIDFontType2 dict's own /Type as /Font, the same as the outer Type0", () => { + const { pdfBytes } = buildDocument(); + const document = openPdfDocument(pdfBytes, NOOP_DIAGNOSTIC_SINK); + const cidFont = document.resolveDict( + asArray(dictGet(fontDictOf(pdfBytes), "DescendantFonts"))?.[0], + ); + expect(asName(dictGet(cidFont!, "Type"))).toBe("Font"); + }); }); describe("the ToUnicode CMap of an embedded subset", () => { @@ -452,6 +471,57 @@ describe("the subset tag", () => { embeddedSubsetTag("Carlito-Bold", [0, 15]), ); }); + + it("keeps glyph IDs comma-separated, rather than concatenating them into one ambiguous digit run", () => { + // Without a separator, [1, 23] and [12, 3] would both join to the identical digit string "123" and collide on the same tag. + expect(embeddedSubsetTag("Face", [1, 23])).not.toBe( + embeddedSubsetTag("Face", [12, 3]), + ); + }); + + it("derives its six letters as a base-26, most-significant-letter-first encoding of the CRC32 hash", () => { + // Computed independently of embeddedSubsetTag's own implementation, using the package's own separately-tested crc32() as the trusted primitive -- proves the exact digit-extraction direction (most significant letter first, via repeated floor-division) rather than merely that some six letters come out. + const postScriptName = "Test-Face"; + const glyphIds = [3, 90, 4000]; + const codeSpace = 26 ** 6; + let value = + crc32( + new TextEncoder().encode(`${postScriptName} ${glyphIds.join(",")}`), + ) % codeSpace; + const expectedChars: string[] = []; + for (let i = 0; i < 6; i++) { + expectedChars.unshift(String.fromCharCode(65 + (value % 26))); + value = Math.floor(value / 26); + } + expect(embeddedSubsetTag(postScriptName, glyphIds)).toBe( + expectedChars.join(""), + ); + }); +}); + +describe("buildEmbeddedFontObjects: FLAG_SERIF", () => { + it("sets the SERIF descriptor bit for a face whose own metrics declare it serif", () => { + const sfnt = parseSfnt(caladeaRegularBytes())!; + const face = loadEmbeddedFace(sfnt)!; + expect(face.metrics.serif).toBe(true); // real Caladea data, not a synthetic fixture -- confirms this test exercises the branch it claims to + const subset = subsetSfnt(sfnt, [0x41])!; + const usedGlyphs = collectEmbeddedGlyphs(["A"], face); + const { descriptor } = buildEmbeddedFontObjects( + face, + subset, + usedGlyphs, + { + cidFontRef: pdfRef(1, 0), + descriptorRef: pdfRef(2, 0), + fontFileRef: pdfRef(3, 0), + toUnicodeRef: pdfRef(4, 0), + }, + false, + ); + const flags = asNumber(dictGet(descriptor, "Flags"))!; + const FLAG_SERIF = 2; + expect(flags & FLAG_SERIF).toBe(FLAG_SERIF); + }); }); describe("buildEmbeddedFontObjects: FLAG_ITALIC", () => { From aa3721fe452485d2ec8c6bea70a13b4db163387f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 12:39:09 +0100 Subject: [PATCH 073/105] refactor(pdf-codec): build utf16BeWithBom's bytes by appending, not by computed offset Replaces a pre-sized Uint8Array written at manually computed offsets (2 + i * 2, 3 + i * 2) with a plain array appended to in sequence, then converted once at the end. Removes the computed-offset arithmetic entirely rather than getting it right: the result's length now falls out of how many bytes were actually appended, instead of being asserted up front and then relied on to match. --- packages/pdf-codec/src/math-content-write.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/pdf-codec/src/math-content-write.ts b/packages/pdf-codec/src/math-content-write.ts index 756378adb..452794551 100644 --- a/packages/pdf-codec/src/math-content-write.ts +++ b/packages/pdf-codec/src/math-content-write.ts @@ -84,17 +84,14 @@ function cidBytes(glyphId: number): Uint8Array { return new Uint8Array([(glyphId >> 8) & 0xff, glyphId & 0xff]); } -// A PDF text string (ISO 32000-1 7.9.2.2) in UTF-16BE with the leading U+FEFF byte-order mark that identifies it as such -- the encoding /ActualText needs to carry arbitrary Unicode. String.charCodeAt already yields UTF-16 code units, surrogate pairs included, so this needs no surrogate arithmetic of its own. +// A PDF text string (ISO 32000-1 7.9.2.2) in UTF-16BE with the leading U+FEFF byte-order mark that identifies it as such -- the encoding /ActualText needs to carry arbitrary Unicode. String.charCodeAt already yields UTF-16 code units, surrogate pairs included, so this needs no surrogate arithmetic of its own. Built by appending each code unit's two bytes in turn rather than pre-sizing a typed array and writing by computed offset: there is then no `2 + i * 2` index arithmetic to get right, and the length of the result falls out of how many bytes were actually appended instead of being asserted up front. function utf16BeWithBom(text: string): Uint8Array { - const bytes = new Uint8Array(2 + text.length * 2); - bytes[0] = 0xfe; - bytes[1] = 0xff; + const bytes: number[] = [0xfe, 0xff]; for (let i = 0; i < text.length; i++) { const unit = text.charCodeAt(i); - bytes[2 + i * 2] = (unit >> 8) & 0xff; - bytes[3 + i * 2] = unit & 0xff; + bytes.push((unit >> 8) & 0xff, unit & 0xff); } - return bytes; + return new Uint8Array(bytes); } // Draws one stretched operator: each of its placements is a single glyph of the embedded font shown at its own computed position, addressed by glyph ID directly (Identity-H CIDs are this font's glyph IDs -- see math-font.ts) rather than resolved from text through the cmap the way writeGlyphRun does, because most of these glyphs have no Unicode code point to resolve from at all. From b4f0e192731cff8a9bcecca831d5b507110266a3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 14:16:53 +0100 Subject: [PATCH 074/105] test(pdf-codec): assert every MATH constant field metricsAt exposes Only axisHeightPt and fractionRuleThicknessPt were checked against the vendored STIXTwoMath-Regular.otf's real values; the other 25 *Pt fields metricsAt derives from math-table.ts's MATH_VALUE_RECORD_INDEX table went unchecked, so a wrong index (pointing a field at a neighbouring MathValueRecord slot) would leave axisHeight/fractionRuleThickness correct while every other constant silently read the wrong value. Expected design-unit values come from a standalone script reading the font's own sfnt bytes directly, the same independent verification method the surrounding test file's own top comment describes. --- packages/pdf-codec/src/math-font.test.ts | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/pdf-codec/src/math-font.test.ts b/packages/pdf-codec/src/math-font.test.ts index f650ef8e7..f070fd139 100644 --- a/packages/pdf-codec/src/math-font.test.ts +++ b/packages/pdf-codec/src/math-font.test.ts @@ -58,6 +58,41 @@ describe("loadMathFont", () => { ); }); + it("parses every *Pt MATH constant this package exposes, not just the two spot-checked above", () => { + // Design-unit values below come from the same independent standalone script the previous test's own top comment describes, reading STIXTwoMath-Regular.otf's raw sfnt bytes directly rather than this package's own parser. Checking every field this package's MathFontMetrics actually exposes (math-table.ts's MATH_VALUE_RECORD_INDEX), not just axisHeight/fractionRuleThickness, is what catches an index entry pointing at the wrong MathValueRecord slot: a transposed pair of adjacent indices would still leave axisHeight and fractionRuleThickness correct. + const { metricsAt } = loadMathFont(); + const metrics = metricsAt(12); + const pt = (designUnits: number): number => (designUnits / 1000) * 12; + expect(metrics.subscriptShiftDownPt).toBeCloseTo(pt(210), 6); + expect(metrics.subscriptBaselineDropMinPt).toBeCloseTo(pt(160), 6); + expect(metrics.superscriptShiftUpPt).toBeCloseTo(pt(360), 6); + expect(metrics.superscriptShiftUpCrampedPt).toBeCloseTo(pt(252), 6); + expect(metrics.superscriptBaselineDropMaxPt).toBeCloseTo(pt(230), 6); + expect(metrics.subSuperscriptGapMinPt).toBeCloseTo(pt(150), 6); + expect(metrics.spaceAfterScriptPt).toBeCloseTo(pt(40), 6); + expect(metrics.upperLimitGapMinPt).toBeCloseTo(pt(135), 6); + expect(metrics.upperLimitBaselineRiseMinPt).toBeCloseTo(pt(300), 6); + expect(metrics.lowerLimitGapMinPt).toBeCloseTo(pt(135), 6); + expect(metrics.lowerLimitBaselineDropMinPt).toBeCloseTo(pt(670), 6); + expect(metrics.stackTopShiftUpPt).toBeCloseTo(pt(470), 6); + expect(metrics.stackBottomShiftDownPt).toBeCloseTo(pt(385), 6); + expect(metrics.stackGapMinPt).toBeCloseTo(pt(150), 6); + expect(metrics.fractionNumeratorShiftUpPt).toBeCloseTo(pt(585), 6); + expect(metrics.fractionNumeratorDisplayShiftUpPt).toBeCloseTo(pt(640), 6); + expect(metrics.fractionDenominatorShiftDownPt).toBeCloseTo(pt(585), 6); + expect(metrics.fractionDenominatorDisplayShiftDownPt).toBeCloseTo( + pt(640), + 6, + ); + expect(metrics.fractionNumeratorGapMinPt).toBeCloseTo(pt(68), 6); + expect(metrics.fractionDenominatorGapMinPt).toBeCloseTo(pt(68), 6); + expect(metrics.radicalRuleThicknessPt).toBeCloseTo(pt(68), 6); + expect(metrics.radicalExtraAscenderPt).toBeCloseTo(pt(78), 6); + expect(metrics.radicalVerticalGapPt).toBeCloseTo(pt(85), 6); + expect(metrics.radicalKernBeforeDegreePt).toBeCloseTo(pt(65), 6); + expect(metrics.radicalKernAfterDegreePt).toBeCloseTo(pt(-335), 6); + }); + it("glyph() reports advance width, italic correction, and (for glyphs the font's MathTopAccentAttachment table covers) a top-accent x position", () => { const { metricsAt } = loadMathFont(); const metrics = metricsAt(10); From 2b5036e5ad754eec275347cb10812bd88d006092 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 15:35:20 +0100 Subject: [PATCH 075/105] refactor(pdf-codec): build jp2-boxes' colour-space lookup inside the function that reads it ENUMERATED_COLOUR_SPACES was a module-level constant, evaluated once at import time -- Stryker's per-test coverage analysis attributes a mutation to such static code to whichever single test happens to trigger the first import, not to the tests that actually exercise the enumerated-colour-space branch, so a wrong lookup table could silently ship undetected. Moving the Map literal inside readColourSpecification makes its construction run per call, so a mutation is correctly attributed to the tests that call it. --- packages/pdf-codec/src/image/jp2-boxes.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/pdf-codec/src/image/jp2-boxes.ts b/packages/pdf-codec/src/image/jp2-boxes.ts index 38b7068b3..2bbfa7cc4 100644 --- a/packages/pdf-codec/src/image/jp2-boxes.ts +++ b/packages/pdf-codec/src/image/jp2-boxes.ts @@ -24,16 +24,6 @@ const BOX_CONTIGUOUS_CODESTREAM = 0x6a703263; // 'jp2c' export type Jp2ColourSpace = "greyscale" | "srgb" | "sycc" | "cmyk" | "e-srgb" | "rommrgb" | "cielab"; -const ENUMERATED_COLOUR_SPACES = new Map([ - [12, "cmyk"], - [14, "cielab"], - [16, "srgb"], - [17, "greyscale"], - [18, "sycc"], - [20, "e-srgb"], - [24, "rommrgb"], -]); - export interface Jp2ImageHeader { readonly width: number; readonly height: number; @@ -246,7 +236,17 @@ function readColourSpecification( const method = data[start] ?? 0; if (method === 1) { if (end - start >= 7) { - into.colourSpace = ENUMERATED_COLOUR_SPACES.get( + // I.5.3.3 Table I.10: the enumerated colour spaces this codec recognises by number. Anything else is reported by its raw value rather than guessed at. Built inside this function rather than as a module-level constant so a mutation to one of its entries is attributed, by Stryker's per-test coverage analysis, to the tests that actually call this function -- a module-level `const` here would run once at import time as a static mutant, which Stryker tests against a single arbitrary covering test rather than the full set that genuinely exercises this map. + const enumeratedColourSpaces = new Map([ + [12, "cmyk"], + [14, "cielab"], + [16, "srgb"], + [17, "greyscale"], + [18, "sycc"], + [20, "e-srgb"], + [24, "rommrgb"], + ]); + into.colourSpace = enumeratedColourSpaces.get( readUint32(data, start + 3), ); } From 9f408bf49a86bee2542c49ccbf517b00c2aff5c5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 15:35:27 +0100 Subject: [PATCH 076/105] refactor(pdf-codec): build the progression-order table inside readCodingDefaults PROGRESSION_ORDERS was a module-level constant, evaluated once at import time -- Stryker's per-test coverage analysis attributes a mutation to such static code to whichever single test happens to trigger the first import, not to the tests that actually decode a COD marker's progression order, so a wrong entry could silently ship undetected. Moving the array inside readCodingDefaults makes its construction run per call, so a mutation is correctly attributed to the tests that call it. --- .../pdf-codec/src/image/jpeg2000-codestream.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-codestream.ts b/packages/pdf-codec/src/image/jpeg2000-codestream.ts index 486421e8d..b7312760c 100644 --- a/packages/pdf-codec/src/image/jpeg2000-codestream.ts +++ b/packages/pdf-codec/src/image/jpeg2000-codestream.ts @@ -27,14 +27,6 @@ const MARKER_EOC = 0xffd9; export type Jpeg2000ProgressionOrder = "LRCP" | "RLCP" | "RPCL" | "PCRL" | "CPRL"; -const PROGRESSION_ORDERS: readonly Jpeg2000ProgressionOrder[] = [ - "LRCP", - "RLCP", - "RPCL", - "PCRL", - "CPRL", -]; - // T.800 A.6.1 Table A.20: the wavelet filter the tile-component was transformed with. export type Jpeg2000Transform = "reversible-5-3" | "irreversible-9-7"; @@ -277,8 +269,16 @@ function readCodingStyleParameters( } function readCodingDefaults(cursor: MarkerCursor): Jpeg2000CodingDefaults { + // T.800 A.6.1 Table A.16: the five progression orders, in the order the Table's own values run. Built inside this function rather than as a module-level constant so a mutation to one of its entries is attributed, by Stryker's per-test coverage analysis, to the tests that actually call this function -- a module-level `const` here would run once at import time as a static mutant, which Stryker tests against a single arbitrary covering test rather than the full set that genuinely exercises this lookup. + const progressionOrders: readonly Jpeg2000ProgressionOrder[] = [ + "LRCP", + "RLCP", + "RPCL", + "PCRL", + "CPRL", + ]; const scod = cursor.uint8(); - const progressionOrder = PROGRESSION_ORDERS[cursor.uint8()]; + const progressionOrder = progressionOrders[cursor.uint8()]; if (progressionOrder === undefined) { throw new Jpeg2000ParseError( "COD declares a progression order outside the five ISO/IEC 15444-1 Table A.16 defines", From 4c9aeba3cd5bf301ff6eb98d3be0663afb9e5b2a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 15:35:35 +0100 Subject: [PATCH 077/105] refactor(pdf-codec): build the 9-7 lifting constants inside inverse97Filter LIFT_ALPHA/BETA/GAMMA/DELTA/K were module-level constants, evaluated once at import time -- Stryker's per-test coverage analysis attributes a mutation to such static code to whichever single test happens to trigger the first import, not to the tests that actually exercise the irreversible 9-7 filter, so a wrong lifting coefficient (a sign flip on LIFT_ALPHA survived undetected this way) could silently ship. Moving the constants inside inverse97Filter makes their construction run per call, so a mutation is correctly attributed to the tests that call it. --- packages/pdf-codec/src/image/jpeg2000-dwt.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.ts index 83cb0ec00..50bd27e5a 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.ts @@ -7,13 +7,6 @@ // The widest read in either filter is the 9-7's own scaling step, whose loop (F-9) runs two lifting indices -- four samples -- past each end of the signal. Six samples of symmetric extension covers that with room to spare, and covers the 5-3's narrower reach as well. const EXTENSION_MARGIN = 6; -// F.3.8.2 Table F.4: the four lifting parameters of the 9-7 analysis filter and its normalisation constant. The synthesis below applies each in reverse order with the opposite sign, which is what makes lifting invertible at all. -const LIFT_ALPHA = -1.586134342059924; -const LIFT_BETA = -0.052980118572961; -const LIFT_GAMMA = 0.882911075530934; -const LIFT_DELTA = 0.443506852043971; -const LIFT_K = 1.230174104914001; - export interface Jpeg2000ResolutionBounds { readonly u0: number; readonly u1: number; @@ -124,6 +117,12 @@ function inverse53Filter(buffer: Int32Array, i0: number, i1: number): void { // --- The irreversible 9-7 filter (F.3.8.2, equations F-8 to F-13). --- function inverse97Filter(buffer: Float32Array, i0: number, i1: number): void { + // F.3.8.2 Table F.4: the four lifting parameters of the 9-7 analysis filter and its normalisation constant. The synthesis below applies each in reverse order with the opposite sign, which is what makes lifting invertible at all. Built inside this function rather than as module-level constants so a mutation to one of them is attributed, by Stryker's per-test coverage analysis, to the tests that actually call this function -- module-level `const`s here would run once at import time as static mutants, which Stryker tests against a single arbitrary covering test rather than the full set that genuinely exercises the 9-7 filter. + const LIFT_ALPHA = -1.586134342059924; + const LIFT_BETA = -0.052980118572961; + const LIFT_GAMMA = 0.882911075530934; + const LIFT_DELTA = 0.443506852043971; + const LIFT_K = 1.230174104914001; const base = EXTENSION_MARGIN - i0; const first = Math.floor(i0 / 2); const last = Math.floor(i1 / 2); From f16844acb5325df5a6bddaa5583dbec870c083f1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:18:30 +0100 Subject: [PATCH 078/105] refactor(pdf-codec): drop jp2-boxes guards that duplicate a later bounds check looksLikeBareCodestream's own data.length >= 4 check is redundant: with noUncheckedIndexedAccess, an out-of-bounds byte read is already undefined, and undefined === 0xff is already false, so the four comparisons already reject a short input on their own. readChannelDefinitions' end - start < 2 guard and readColourSpecification's end - start < 3 guard are likewise redundant: both functions' own later checks (entry + 6 > end, and the >= 7 / > 3 thresholds each branch needs) already refuse to act on a payload too short to satisfy them, whatever garbage a short read produces first. readBox never returns a box whose nextBoxStart fails to advance past its own offset -- it throws instead when a declared length would undercut its own header -- so the || box.nextBoxStart <= offset half of both box-walking loops' termination checks was unreachable. --- packages/pdf-codec/src/image/jp2-boxes.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/pdf-codec/src/image/jp2-boxes.ts b/packages/pdf-codec/src/image/jp2-boxes.ts index 2bbfa7cc4..7055d0428 100644 --- a/packages/pdf-codec/src/image/jp2-boxes.ts +++ b/packages/pdf-codec/src/image/jp2-boxes.ts @@ -53,16 +53,12 @@ export interface Jp2ChannelDefinition { readonly association: number; } -// A bare codestream starts with SOC immediately followed by SIZ, which no JP2 file ever can (a JP2 file starts with the signature box's own length field, 0x0000000C). +// A bare codestream starts with SOC immediately followed by SIZ, which no JP2 file ever can (a JP2 file starts with the signature box's own length field, 0x0000000C). No separate `data.length >= 4` guard is needed: with noUncheckedIndexedAccess, an out-of-bounds index below reads as `undefined`, and `undefined === 0xff` is already false, so a shorter input fails the very same chain of comparisons on its own. export function looksLikeBareCodestream( data: Uint8Array, ): boolean { return ( - data.length >= 4 && - data[0] === 0xff && - data[1] === 0x4f && - data[2] === 0xff && - data[3] === 0x51 + data[0] === 0xff && data[1] === 0x4f && data[2] === 0xff && data[3] === 0x51 ); } @@ -158,9 +154,7 @@ function readChannelDefinitions( start: number, end: number, ): Jp2ChannelDefinition[] { - if (end - start < 2) { - return []; - } + // No separate "is there room for a count field" guard is needed: a payload under 2 bytes still computes some count value below (from whatever adjacent bytes or `?? 0` fallbacks lie at `start`/`start + 1`), but every entry needs 6 more bytes than the 2-byte count field leaves room for here, so the loop's own `entry + 6 > end` check breaks before pushing anything regardless of what that count came out to. const count = ((data[start] ?? 0) << 8) | (data[start + 1] ?? 0); const definitions: Jp2ChannelDefinition[] = []; for (let i = 0; i < count; i++) { @@ -194,7 +188,8 @@ function readJp2HeaderBox( let offset = start; for (;;) { const box = readBox(data, offset, end); - if (box === undefined || box.nextBoxStart <= offset) { + // No separate "did this box actually advance" check is needed: readBox only ever returns a box whose own header fit before `end`, and it throws rather than returning one whose declared length undercuts that header -- so a returned box's nextBoxStart is always past the offset it started from. + if (box === undefined) { return; } if (box.type === BOX_IMAGE_HEADER) { @@ -230,9 +225,7 @@ function readColourSpecification( end: number, into: HeaderBoxContents, ): void { - if (end - start < 3) { - return; - } + // No separate "is there room for a method byte" guard is needed: a payload under 3 bytes still computes some `method` value below, but both branches that act on it require at least 7 (method 1) or more than 3 (method 2) bytes, so neither can assign anything when `end - start` is already under 3. const method = data[start] ?? 0; if (method === 1) { if (end - start >= 7) { @@ -271,7 +264,8 @@ export function parseJp2Container(data: Uint8Array): Jp2Container { let sawSignature = false; for (;;) { const box = readBox(data, offset, data.length); - if (box === undefined || box.nextBoxStart <= offset) { + // Same non-advancement case as readJp2HeaderBox's identical loop above: readBox never returns a box that fails to advance past its own offset. + if (box === undefined) { break; } if (box.type === BOX_SIGNATURE) { From 3243d731398ee256f639c8221d18cfcdd078922d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:18:38 +0100 Subject: [PATCH 079/105] test(pdf-codec): kill jp2-boxes.ts mutants left over from the JPEG 2000 decoder Covers the isolated-byte and length-boundary cases looksLikeBareCodestream's own comparison chain needs, the extended (64-bit) box length's truncation and nonzero-high-word paths, a box declaring a length shorter than its own header, an image header box shorter than 14 bytes, a component count and a channel-definition type spanning both bytes of their field, a channel-definition count read from its own field rather than an adjacent header byte, a colr box too short for its own method byte and each method's own minimum length, two colr boxes (first wins) and a method the decoder does not recognise, a cmap-only palette box, two jp2c boxes (first wins), and the signature-box-recognised-but-truncated and signature-box-absent-but-box-shaped cases the "neither codestream nor box" / "no contiguous codestream" error messages depend on. --- .../pdf-codec/src/image/jp2-boxes.test.ts | 385 ++++++++++++++++++ 1 file changed, 385 insertions(+) diff --git a/packages/pdf-codec/src/image/jp2-boxes.test.ts b/packages/pdf-codec/src/image/jp2-boxes.test.ts index c8fb5d2d3..f14938b36 100644 --- a/packages/pdf-codec/src/image/jp2-boxes.test.ts +++ b/packages/pdf-codec/src/image/jp2-boxes.test.ts @@ -66,6 +66,22 @@ describe("looksLikeBareCodestream", () => { ).toBe(false); expect(looksLikeBareCodestream(Uint8Array.from([0xff, 0x4f]))).toBe(false); }); + + it("rejects a four-byte prefix that is wrong in exactly one of its four bytes", () => { + // Each byte isolated with the other three correct, so a mutant weakening any single comparison (or the && chain joining them) is caught by the one byte it stops checking. + expect( + looksLikeBareCodestream(Uint8Array.from([0x00, 0x4f, 0xff, 0x51])), + ).toBe(false); + expect( + looksLikeBareCodestream(Uint8Array.from([0xff, 0x00, 0xff, 0x51])), + ).toBe(false); + expect( + looksLikeBareCodestream(Uint8Array.from([0xff, 0x4f, 0x00, 0x51])), + ).toBe(false); + expect( + looksLikeBareCodestream(Uint8Array.from([0xff, 0x4f, 0xff, 0x00])), + ).toBe(false); + }); }); describe("parseJp2Container", () => { @@ -205,5 +221,374 @@ describe("parseJp2Container", () => { Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), ), ).toThrow(Jpeg2000ParseError); + expect(() => + parseJp2Container( + Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ), + ).toThrow(/neither/); + }); + + it("reports the codestream-missing message, not the format-unrecognised one, once a real signature box was seen even beyond the data actually provided", () => { + // A box whose declared length's top byte is nonzero (so the array's own first byte is nonzero, the condition the format-unrecognised message also keys off) but which is otherwise truncated to far less than that declared length -- readBox clamps the box to the data actually present, exactly as a genuinely truncated PDF stream would look. + const hugeLength = 0x01000010; + const data = Uint8Array.from([ + (hugeLength >>> 24) & 0xff, + (hugeLength >>> 16) & 0xff, + (hugeLength >>> 8) & 0xff, + hugeLength & 0xff, + ...Array.from("jP ", (character) => character.charCodeAt(0)), + 0x0d, + 0x0a, + 0x87, + 0x0a, + ]); + expect(() => parseJp2Container(data)).toThrow(Jpeg2000ParseError); + expect(() => parseJp2Container(data)).toThrow(/jp2c/); + }); + + it("reports the format-unrecognised message when the only box present is not the signature box, even with a nonzero leading byte", () => { + const hugeLength = 0x01000010; + const data = Uint8Array.from([ + (hugeLength >>> 24) & 0xff, + (hugeLength >>> 16) & 0xff, + (hugeLength >>> 8) & 0xff, + hugeLength & 0xff, + ...Array.from("free", (character) => character.charCodeAt(0)), + ]); + expect(() => parseJp2Container(data)).toThrow(Jpeg2000ParseError); + expect(() => parseJp2Container(data)).toThrow(/neither/); + }); + + it("reads a box whose 8-byte header ends exactly at the end of the data, with an empty payload", () => { + const data = Uint8Array.from([...SIGNATURE_BOX, ...box("jp2c", [])]); + const container = parseJp2Container(data); + expect(container.codestream).toHaveLength(0); + }); + + it("rejects an extended (64-bit) box length that leaves fewer than 8 bytes for the XLBox field", () => { + const truncated = [ + 0, + 0, + 0, + 1, // declared length 1 escapes to a 64-bit XLBox + ...Array.from("jp2c", (character) => character.charCodeAt(0)), + 0, + 0, // only 2 of the 8 XLBox bytes are actually present + ]; + const data = Uint8Array.from([...SIGNATURE_BOX, ...truncated]); + expect(() => parseJp2Container(data)).toThrow(Jpeg2000ParseError); + expect(() => parseJp2Container(data)).toThrow(/32-bit field/); + }); + + it("follows a 64-bit extended length correctly when a further box trails it", () => { + // Distinguishes reading the XLBox's low word from its own field position rather than from the 4 bytes before it (which here are the box's own type, "uuid "). + const uuidPayload = [1, 2, 3, 4]; + const low = 16 + uuidPayload.length; + const extended = [ + 0, + 0, + 0, + 1, + ...Array.from("uuid", (character) => character.charCodeAt(0)), + 0, + 0, + 0, + 0, // high word + (low >>> 24) & 0xff, + (low >>> 16) & 0xff, + (low >>> 8) & 0xff, + low & 0xff, + ...uuidPayload, + ]; + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...extended, + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + const container = parseJp2Container(data); + expect(Array.from(container.codestream)).toEqual(MINIMAL_CODESTREAM); + }); + + it("treats a 64-bit extended length whose high word is nonzero as running to the end of the data", () => { + const extended = [ + 0, + 0, + 0, + 1, + ...Array.from("jp2c", (character) => character.charCodeAt(0)), + 0, + 0, + 0, + 1, // high word nonzero: unaddressable in practice, so this box runs to the data's own end + 0, + 0, + 0, + 0, + ...MINIMAL_CODESTREAM, + ]; + const data = Uint8Array.from([...SIGNATURE_BOX, ...extended]); + const container = parseJp2Container(data); + expect(Array.from(container.codestream)).toEqual(MINIMAL_CODESTREAM); + }); + + it("rejects a box declaring a length shorter than its own 8-byte header", () => { + const tooShort = [ + 0, + 0, + 0, + 4, // 4 is less than the 8-byte header this length is supposed to include + ...Array.from("jp2c", (character) => character.charCodeAt(0)), + ]; + const data = Uint8Array.from([...SIGNATURE_BOX, ...tooShort]); + expect(() => parseJp2Container(data)).toThrow(Jpeg2000ParseError); + expect(() => parseJp2Container(data)).toThrow( + /shorter than its own header/, + ); + }); + + it("rejects an image header box shorter than the 14 bytes ISO/IEC 15444-1 I.5.3.1 requires", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", box("ihdr", [0, 0, 0, 4, 0, 0, 0, 5, 0, 3, 8, 7, 0])), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(() => parseJp2Container(data)).toThrow(Jpeg2000ParseError); + expect(() => parseJp2Container(data)).toThrow(/14 bytes/); + }); + + it("reads a component count spanning both bytes of its field", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", box("ihdr", imageHeaderPayload(4, 5, 260, 7))), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).imageHeader?.componentCount).toBe(260); + }); + + it("reports a signed component depth when the sign bit of BPC is set", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", box("ihdr", imageHeaderPayload(4, 5, 1, 0x87))), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).imageHeader).toEqual({ + width: 5, + height: 4, + componentCount: 1, + bitDepth: 8, + signed: true, + }); + }); + + it("returns no channel definitions when a cdef box declares entries but carries no room for even one", () => { + // Count field only (2 bytes): entry + 6 always exceeds the box's own end here, so the loop must break before pushing anything -- distinguishes the break's own `>` from both `<` and a reversed arithmetic offset, which would instead read past this box into whatever data follows it. + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...box("cdef", [0, 1]), + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).channelDefinitions).toEqual([]); + }); + + it("does not let a colour space box overwrite a profile an earlier colr box already recorded", () => { + const colr1 = box("colr", [2, 0, 0, 0xaa, 0xbb, 0xcc, 0xdd]); // method 2: records an ICC profile + const colr2 = box("colr", [1, 0, 0, 0, 0, 0, 16]); // method 1: would set colourSpace to srgb if allowed to run + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...colr1, + ...colr2, + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + const container = parseJp2Container(data); + expect(container.colourSpace).toBeUndefined(); + expect(Array.from(container.iccProfile ?? [])).toEqual([ + 0xaa, 0xbb, 0xcc, 0xdd, + ]); + }); + + it("does not read channel definitions from a box that is not the cdef type", () => { + // If misread as a cdef box, these bytes would parse as one all-zero channel definition. + const notCdef = box("res ", [0, 1, 0, 0, 0, 0, 0, 0]); + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...notCdef, + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).channelDefinitions).toEqual([]); + }); + + it("does not record anything from a colr box whose method is neither 1 nor 2", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...box("colr", [3, 0, 0, 0xaa, 0xbb, 0xcc, 0xdd]), + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + const container = parseJp2Container(data); + expect(container.colourSpace).toBeUndefined(); + expect(container.iccProfile).toBeUndefined(); + }); + + it("reports the 'no contiguous codestream' message, not the format-unrecognised one, when there is no signature box but the data is still box-shaped", () => { + // No SIGNATURE_BOX prefix, so sawSignature is genuinely false; the leading bytes are jp2h's own small length field, so data[0] is genuinely 0x00 -- the one combination that distinguishes this branch's real condition from a mutant that forces it true regardless. + const data = Uint8Array.from([ + ...box("jp2h", box("ihdr", imageHeaderPayload(4, 5, 1, 7))), + ]); + expect(() => parseJp2Container(data)).toThrow(Jpeg2000ParseError); + expect(() => parseJp2Container(data)).toThrow(/jp2c/); + }); + + it("reads exactly the declared channel-definition count, not a byte from the box's own header", () => { + // The count field's low byte would coincide with the tail of the cdef box's own type ('cdef') if the read drifted by one byte, so the payload deliberately provides far more capacity than the declared count needs -- a wrong, larger count would visibly read past the 3 real entries into the padding. + const realCount = 3; + const capacity = 200; + const payload = [(realCount >> 8) & 0xff, realCount & 0xff]; + for (let i = 0; i < capacity; i++) { + payload.push(0, 0, 0, 0, 0, 0); + } + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...box("cdef", payload), + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).channelDefinitions).toHaveLength(realCount); + }); + + it("reads a channel definition's type value spanning both bytes of its field", () => { + const cdef = box("cdef", [0, 1, 0, 0, 1, 2, 0, 0]); + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), ...cdef]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).channelDefinitions).toEqual([ + { channel: 0, type: 258, association: 0 }, + ]); + }); + + it("keeps the first contiguous codestream box when more than one jp2c box is present", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2c", MINIMAL_CODESTREAM), + ...box("jp2c", [0xff, 0x4f, 0xff, 0x51, 0x99]), + ]); + const container = parseJp2Container(data); + expect(Array.from(container.codestream)).toEqual(MINIMAL_CODESTREAM); + }); + + it("prefers the first colr box when more than one is present", () => { + const colr1 = box("colr", [1, 0, 0, 0, 0, 0, 16]); // srgb + const colr2 = box("colr", [1, 0, 0, 0, 0, 0, 17]); // greyscale, should be ignored + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...colr1, + ...colr2, + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).colourSpace).toBe("srgb"); + }); + + it("recognises a component-mapping box alone as requiring palette support this decoder refuses", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...box("cmap", [0, 0, 0, 0]), + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(() => parseJp2Container(data)).toThrow(Jpeg2000UnsupportedError); + }); + + it("ignores a colr box too short to carry even a method byte", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...box("colr", [1]), + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + const container = parseJp2Container(data); + expect(container.colourSpace).toBeUndefined(); + expect(container.iccProfile).toBeUndefined(); + }); + + it("does not record a colour space when a method-1 colr box is too short to carry the enumerated value", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...box("colr", [1, 0, 0]), + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).colourSpace).toBeUndefined(); + }); + + it("reads an enumerated colour space from the minimum 7-byte method-1 colr box", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...box("colr", [1, 0, 0, 0, 0, 0, 16]), + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).colourSpace).toBe("srgb"); + }); + + it("does not record an ICC profile when a method-2 colr box carries no profile bytes", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...box("colr", [2, 0, 0]), + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + expect(parseJp2Container(data).iccProfile).toBeUndefined(); + }); + + it("omits optional container fields entirely, rather than setting them to undefined, when nothing supplied them", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + const container = parseJp2Container(data); + expect(Object.hasOwn(container, "imageHeader")).toBe(false); + expect(Object.hasOwn(container, "colourSpace")).toBe(false); + expect(Object.hasOwn(container, "iccProfile")).toBe(false); + }); + + it("includes optional container fields as real own properties when something supplied them", () => { + const data = Uint8Array.from([ + ...SIGNATURE_BOX, + ...box("jp2h", [ + ...box("ihdr", imageHeaderPayload(4, 5, 1, 7)), + ...box("colr", [1, 0, 0, 0, 0, 0, 16]), + ]), + ...box("jp2c", MINIMAL_CODESTREAM), + ]); + const container = parseJp2Container(data); + expect(Object.hasOwn(container, "imageHeader")).toBe(true); + expect(Object.hasOwn(container, "colourSpace")).toBe(true); }); }); From 0063c7e10b29760f3792793b566a63f9989849e5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:26:27 +0100 Subject: [PATCH 080/105] refactor(pdf-codec): expose MarkerCursor and drop a redundant code-block-size check Exports MarkerCursor so its own bounds-checking and 32-bit assembly can be tested directly: readHeaderSegment, its sole production caller, already re-derives and re-checks segmentEnd against cursor.data.length before ever calling bytes(), so the length it passes always already satisfies position + length <= data.length on its own, leaving no way to observe that half of bytes()'s guard except by driving the cursor directly. Drops the codeBlockWidthExp > 10 and codeBlockHeightExp > 10 checks from readCodingStyleParameters: each exponent has a floor of 2 (from the SPcod "transmitted value + 2" encoding a few lines above), so either one alone exceeding 10 already puts codeBlockWidthExp + codeBlockHeightExp past 12 (11 + 2 = 13), which the sum check right below already throws for. --- packages/pdf-codec/src/image/jpeg2000-codestream.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-codestream.ts b/packages/pdf-codec/src/image/jpeg2000-codestream.ts index b7312760c..f7f9d90ef 100644 --- a/packages/pdf-codec/src/image/jpeg2000-codestream.ts +++ b/packages/pdf-codec/src/image/jpeg2000-codestream.ts @@ -128,7 +128,8 @@ export interface Jpeg2000Codestream { readonly truncated: boolean; } -class MarkerCursor { +// Exported for direct unit testing of the primitives below: readHeaderSegment (the class's sole production caller) already re-derives and re-checks segmentEnd against cursor.data.length before ever calling bytes(), so the length it passes always already satisfies position + length <= data.length on its own -- only a direct cursor test can exercise this class's own arithmetic and bounds-checking in isolation from that guarantee. +export class MarkerCursor { position: number; constructor( @@ -234,12 +235,8 @@ function readCodingStyleParameters( `SPcod/SPcoc declares transformation ${String(transformCode)}, which is neither of the two ISO/IEC 15444-1 defines`, ); } - // T.800 Table A.18: the transmitted values are xcb-2 and ycb-2, and the standard caps the code-block area at 4096 samples with each side at most 2^10. - if ( - codeBlockWidthExp > 10 || - codeBlockHeightExp > 10 || - codeBlockWidthExp + codeBlockHeightExp > 12 - ) { + // T.800 Table A.18: the transmitted values are xcb-2 and ycb-2, and the standard caps the code-block area at 4096 samples with each side at most 2^10. No separate per-side check is needed alongside the area cap: each exponent's own floor of 2 (from the `+ 2` above) means either one alone exceeding 10 already puts the sum past 12 (11 + 2 = 13), so the sum check below already catches every case an individual >10 check would. + if (codeBlockWidthExp + codeBlockHeightExp > 12) { throw new Jpeg2000ParseError( `code-block size 2^${String(codeBlockWidthExp)} by 2^${String(codeBlockHeightExp)} is outside the range ISO/IEC 15444-1 Table A.18 permits`, ); From b691c5085e097a6d372bf7fa589c807910cbc483 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:26:37 +0100 Subject: [PATCH 081/105] test(pdf-codec): cover jpeg2000-codestream.ts's header-segment and cursor edge cases Adds a direct MarkerCursor suite (uint32 assembly, the bytes() bounds check's own length < 0 and overflow paths, and its position advancing past a read slice) alongside a hand-built minimal-codestream constructor for every header-segment guard a real encoder's own output never trips: SIZ's zero-component, short-component-list, no-area and zero-tile checks; COD's undefined-transform, code-block-area, progression-order and zero-layer checks; QCD's undefined-style check; a marker segment shorter than its own length field; COC/QCC/POC/RGN/PPT recording their own overrides; an unexpected SOC/SOD inside the main header; a main header missing COD or QCD; a tile-part header ending without SOD or running into a second SOT; a Psot shorter than its own header; the trailing-EOC trim on a tile-part's own data; and a tile-part header overriding only COD, or only QCD, or neither. --- .../src/image/jpeg2000-codestream.test.ts | 433 +++++++++++++++++- 1 file changed, 432 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts b/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts index 983206d92..1fa302c32 100644 --- a/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts +++ b/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts @@ -3,7 +3,7 @@ import { JPEG2000_FIXTURES, jpeg2000FixtureBytes, } from "../test-support/jpeg2000"; -import { parseJpeg2000Codestream } from "./jpeg2000-codestream"; +import { MarkerCursor, parseJpeg2000Codestream } from "./jpeg2000-codestream"; import { Jpeg2000ParseError, Jpeg2000UnsupportedError, @@ -15,6 +15,34 @@ function fixture(name: string): Uint8Array { return jpeg2000FixtureBytes(found?.codestream ?? ""); } +describe("MarkerCursor", () => { + it("assembles a uint32 from its high and low uint16 halves, not by dividing the high half", () => { + const cursor = new MarkerCursor( + Uint8Array.from([0x00, 0x01, 0x00, 0x00]), // 0x00010000 = 65536 + ); + expect(cursor.uint32()).toBe(65536); + }); + + it("throws when asked to read more bytes than remain", () => { + const cursor = new MarkerCursor(Uint8Array.from([1, 2, 3])); + expect(() => cursor.bytes(4)).toThrow(Jpeg2000ParseError); + expect(() => cursor.bytes(4)).toThrow(/more data than the codestream/); + }); + + it("rejects a negative length outright, before it could otherwise appear to fit", () => { + const cursor = new MarkerCursor(Uint8Array.from([1, 2, 3, 4, 5])); + expect(() => cursor.bytes(-1)).toThrow(Jpeg2000ParseError); + }); + + it("advances its own position by exactly the slice length read", () => { + const cursor = new MarkerCursor(Uint8Array.from([1, 2, 3, 4, 5])); + cursor.uint8(); // position: 0 -> 1 + const slice = cursor.bytes(3); // position: 1 -> 4 + expect(Array.from(slice)).toEqual([2, 3, 4]); + expect(cursor.uint8()).toBe(5); // proves position landed on index 4, not 4 - 3 = -2 or left at 1 + }); +}); + describe("parseJpeg2000Codestream", () => { it("reads the geometry, coding style and quantization of a real main header", () => { const codestream = parseJpeg2000Codestream(fixture("ramp-basic")); @@ -146,6 +174,409 @@ describe("parseJpeg2000Codestream", () => { }); }); +// A hand-built minimal codestream, precise down to the byte, for exercising header-segment guards a real encoder's output never happens to trip. +function u16(value: number): number[] { + return [(value >>> 8) & 0xff, value & 0xff]; +} +function u32(value: number): number[] { + return [ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]; +} +function segment(markerCode: number, body: readonly number[]): number[] { + const length = 2 + body.length; // Lxxx counts itself, per T.800 A.4. + return [...u16(markerCode), ...u16(length), ...body]; +} + +const MARKER_SOC = 0xff4f; +const MARKER_SIZ = 0xff51; +const MARKER_COD = 0xff52; +const MARKER_QCD = 0xff5c; +const MARKER_SOT = 0xff90; +const MARKER_SOD = 0xff93; +const MARKER_EOC = 0xffd9; + +function sizSegment( + overrides: Partial<{ + xsiz: number; + ysiz: number; + xosiz: number; + yosiz: number; + xtsiz: number; + ytsiz: number; + xtosiz: number; + ytosiz: number; + componentCount: number; + componentBytes: readonly number[]; + }> = {}, +): number[] { + const { + xsiz = 4, + ysiz = 4, + xosiz = 0, + yosiz = 0, + xtsiz = 4, + ytsiz = 4, + xtosiz = 0, + ytosiz = 0, + componentCount = 1, + } = overrides; + const componentBytes = + overrides.componentBytes ?? + Array.from({ length: componentCount * 3 }, (_, index) => + index % 3 === 0 ? 7 : 1, + ); // Ssiz = 7 (8-bit unsigned), XRsiz = YRsiz = 1 + return segment(MARKER_SIZ, [ + ...u16(0), // Rsiz + ...u32(xsiz), + ...u32(ysiz), + ...u32(xosiz), + ...u32(yosiz), + ...u32(xtsiz), + ...u32(ytsiz), + ...u32(xtosiz), + ...u32(ytosiz), + ...u16(componentCount), + ...componentBytes, + ]); +} + +function codSegment( + overrides: Partial<{ + scod: number; + progression: number; + layers: number; + mct: number; + decompLevels: number; + cbW: number; + cbH: number; + cbStyle: number; + transform: number; + }> = {}, +): number[] { + const { + scod = 0, + progression = 0, + layers = 1, + mct = 0, + decompLevels = 0, + cbW = 0, + cbH = 0, + cbStyle = 0, + transform = 1, + } = overrides; + return segment(MARKER_COD, [ + scod, + progression, + ...u16(layers), + mct, + decompLevels, + cbW, + cbH, + cbStyle, + transform, + ]); +} + +function qcdSegment(styleCode = 0, guardBits = 0): number[] { + return segment(MARKER_QCD, [(guardBits << 5) | styleCode]); +} + +// SOC + SIZ + COD + QCD + whatever else the caller supplies, terminated by EOC unless told not to. Sized and positioned entirely from what it is given, so a caller only ever states what a test cares about. +function minimalCodestream( + opts: { + siz?: readonly number[]; + cod?: readonly number[]; + qcd?: readonly number[]; + afterMainHeader?: readonly number[]; + omitEoc?: boolean; + } = {}, +): Uint8Array { + const bytes = [ + ...u16(MARKER_SOC), + ...(opts.siz ?? sizSegment()), + ...(opts.cod ?? codSegment()), + ...(opts.qcd ?? qcdSegment()), + ...(opts.afterMainHeader ?? []), + ]; + if (opts.omitEoc !== true) { + bytes.push(...u16(MARKER_EOC)); + } + return Uint8Array.from(bytes); +} + +// SOT + a tile-part header + SOD, sized correctly from its own body. Psot 0 means "runs to the end of the codestream", the same convention the real format uses. +function tilePart( + tileIndex: number, + header: readonly number[], + data: readonly number[], + psot = 0, +): number[] { + return [ + ...u16(MARKER_SOT), + ...u16(10), + ...u16(tileIndex), + ...u32(psot), + 0, // TPsot + 0, // TNsot + ...header, + ...u16(MARKER_SOD), + ...data, + ]; +} + +describe("parseJpeg2000Codestream, header-segment guards a real encoder never trips", () => { + it("rejects a SIZ segment declaring zero components", () => { + const data = minimalCodestream({ + siz: sizSegment({ componentCount: 0, componentBytes: [] }), + }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/zero components/); + }); + + it("rejects a SIZ segment whose own length has no room for every component it declares", () => { + const data = minimalCodestream({ + siz: sizSegment({ componentCount: 2, componentBytes: [7, 1, 1] }), // declares 2, provides 1 + }); + expect(() => parseJpeg2000Codestream(data)).toThrow( + /leaves room for fewer/, + ); + }); + + it("rejects a SIZ segment whose x extent has no area", () => { + const data = minimalCodestream({ siz: sizSegment({ xosiz: 4 }) }); // xsiz(4) <= xosiz(4) + expect(() => parseJpeg2000Codestream(data)).toThrow(/no area/); + }); + + it("rejects a SIZ segment whose y extent has no area even though its x extent does", () => { + const data = minimalCodestream({ siz: sizSegment({ yosiz: 4 }) }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/no area/); + }); + + it("rejects a SIZ segment declaring a zero-width tile", () => { + const data = minimalCodestream({ siz: sizSegment({ xtsiz: 0 }) }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/zero-sized tile/); + }); + + it("rejects a SIZ segment declaring a zero-height tile even though its width is fine", () => { + const data = minimalCodestream({ siz: sizSegment({ ytsiz: 0 }) }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/zero-sized tile/); + }); + + it("rejects a COD segment declaring a transform ISO/IEC 15444-1 does not define", () => { + const data = minimalCodestream({ cod: codSegment({ transform: 5 }) }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/neither of the two/); + }); + + it("rejects a COD segment whose code-block area exceeds Table A.18's cap", () => { + const data = minimalCodestream({ cod: codSegment({ cbW: 12, cbH: 12 }) }); // exponents 14 and 14, area 2^28 + expect(() => parseJpeg2000Codestream(data)).toThrow( + /outside the range ISO\/IEC 15444-1 Table A\.18/, + ); + }); + + it("accepts a COD segment whose code-block area sits exactly at Table A.18's cap", () => { + const data = minimalCodestream({ cod: codSegment({ cbW: 3, cbH: 3 }) }); // exponents 5 and 5, sum 10, well inside the cap + const codestream = parseJpeg2000Codestream(data); + expect(codestream.main.cod).toMatchObject({ + codeBlockWidthExp: 5, + codeBlockHeightExp: 5, + }); + }); + + it("rejects a COD segment declaring a progression order ISO/IEC 15444-1 Table A.16 does not define", () => { + const data = minimalCodestream({ + cod: codSegment({ progression: 5 }), + }); + expect(() => parseJpeg2000Codestream(data)).toThrow( + /outside the five ISO\/IEC 15444-1 Table A\.16/, + ); + }); + + it("rejects a COD segment declaring zero quality layers", () => { + const data = minimalCodestream({ cod: codSegment({ layers: 0 }) }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/zero quality layers/); + }); + + it("rejects a QCD segment declaring a quantization style Table A.28 does not define", () => { + const data = minimalCodestream({ qcd: qcdSegment(3) }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/does not define/); + }); + + it("rejects a marker segment whose own declared length is shorter than the length field itself", () => { + const data = minimalCodestream({ + afterMainHeader: [...u16(0xff64), ...u16(1)], // COM, Lcom = 1: shorter than the 2-byte length field that carries it + }); + expect(() => parseJpeg2000Codestream(data)).toThrow( + /shorter than the length field itself/, + ); + }); + + it("rejects a COM marker whose declared length leaves no room for its own registration field", () => { + const data = minimalCodestream({ + afterMainHeader: [...u16(0xff64), ...u16(2)], // COM, Lcom = 2: passes the length < 2 guard but leaves nothing for Rcom + }); + expect(() => parseJpeg2000Codestream(data)).toThrow( + /more data than the codestream carries/, + ); + }); + + it("records a per-component coding style override from a COC marker", () => { + const coc = segment(0xff53, [0, 0, 0, 0, 0, 0, 1]); // component 0, decompLevels 0, cbW/cbH/style 0, reversible + const data = minimalCodestream({ afterMainHeader: coc }); + expect(parseJpeg2000Codestream(data).main.coc.get(0)).toMatchObject({ + transform: "reversible-5-3", + }); + }); + + it("records a per-component quantization override from a QCC marker", () => { + const qcc = segment(0xff5d, [0, 0]); // component 0, Sqcc: style none, guardBits 0 + const data = minimalCodestream({ afterMainHeader: qcc }); + expect(parseJpeg2000Codestream(data).main.qcc.get(0)).toMatchObject({ + style: "none", + }); + }); + + it("records that a POC marker changed the progression order, without parsing its entries", () => { + const poc = segment(0xff5f, [0, 0, 0, 0, 0, 0]); + const data = minimalCodestream({ afterMainHeader: poc }); + expect(parseJpeg2000Codestream(data).main.hasProgressionChanges).toBe(true); + }); + + it("records that an RGN marker declares a region of interest, without applying it", () => { + const rgn = segment(0xff5e, [0, 0, 0]); + const data = minimalCodestream({ afterMainHeader: rgn }); + expect(parseJpeg2000Codestream(data).main.hasRegionOfInterest).toBe(true); + }); + + it("records that a PPT marker moves packet headers out of the packet bodies", () => { + const ppt = segment(0xff61, [0]); + const data = minimalCodestream({ afterMainHeader: ppt }); + expect(parseJpeg2000Codestream(data).main.hasPackedPacketHeaders).toBe( + true, + ); + }); + + it("rejects an SOC or SOD marker appearing unexpectedly inside the main header", () => { + const data = minimalCodestream({ + afterMainHeader: [...u16(MARKER_SOD)], + }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/unexpected marker/); + }); + + it("rejects a main header carrying no COD marker", () => { + const data = minimalCodestream({ cod: [] }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/no COD marker/); + }); + + it("rejects a main header carrying no QCD marker", () => { + const data = minimalCodestream({ qcd: [] }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/no QCD marker/); + }); + + it("rejects a tile-part header that ends before an SOD marker appears", () => { + const data = minimalCodestream({ + afterMainHeader: [ + ...u16(MARKER_SOT), + ...u16(10), + ...u16(0), + ...u32(0), + 0, + 0, + ], + omitEoc: true, // an EOC here would itself be a marker the tile-part-header loop reads, rather than genuinely running out of data + }); + expect(() => parseJpeg2000Codestream(data)).toThrow( + /ended without an SOD marker/, + ); + }); + + it("rejects a tile-part header that runs into a second SOT rather than reaching SOD", () => { + const data = minimalCodestream({ + afterMainHeader: [ + ...u16(MARKER_SOT), + ...u16(10), + ...u16(0), + ...u32(0), + 0, + 0, + ...u16(MARKER_SOT), + ], + }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/rather than at SOD/); + }); + + it("rejects a tile-part whose Psot is shorter than its own header", () => { + const data = minimalCodestream({ + afterMainHeader: [ + ...u16(MARKER_SOT), + ...u16(10), + ...u16(0), + ...u32(4), // Psot 4 doesn't even cover the fixed 12-byte SOT header + 0, + 0, + ...u16(MARKER_SOD), + ], + }); + expect(() => parseJpeg2000Codestream(data)).toThrow( + /shorter than its own header/, + ); + }); + + it("trims a trailing EOC from the last tile-part's own data when Psot runs to the end of the codestream", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, [], [0xaa, 0xbb, 0xcc]), + }); + const codestream = parseJpeg2000Codestream(data); + const part = codestream.tileParts[0]; + expect(part).toBeDefined(); + expect(Array.from(data.subarray(part?.dataStart, part?.dataEnd))).toEqual([ + 0xaa, 0xbb, 0xcc, + ]); + }); + + it("leaves a tile-part's data untrimmed when it does not end in 0xFF 0xD9", () => { + const withoutEoc = minimalCodestream({ + afterMainHeader: tilePart(0, [], [0xaa, 0xbb, 0xcc, 0xdd]), + omitEoc: true, // an EOC right here would itself be the trailing bytes the trim check is for + }); + const codestream = parseJpeg2000Codestream(withoutEoc); + const part = codestream.tileParts[0]; + expect(part).toBeDefined(); + // The tile-part's own trailing 0xFF 0xD9 only gets trimmed when Psot runs to the codestream's own end and the real EOC marker sits there -- not merely because the last two bytes happen to match. + expect(part?.dataEnd).toBe(withoutEoc.length); + }); + + it("does not let a tile-part's own header override the main header's coding and quantization defaults with nothing", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, [], []), + }); + const part = parseJpeg2000Codestream(data).tileParts[0]; + expect(part?.header.cod).toBeUndefined(); + expect(part?.header.qcd).toBeUndefined(); + }); + + it("lets a tile-part's own COD marker override just the coding defaults, leaving quantization to the main header", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, codSegment({ layers: 3 }), []), + }); + const part = parseJpeg2000Codestream(data).tileParts[0]; + expect(part?.header.cod).toMatchObject({ layers: 3 }); + expect(part?.header.qcd).toBeUndefined(); + }); + + it("lets a tile-part's own QCD marker override just the quantization, leaving coding style to the main header", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, qcdSegment(1, 3), []), + }); + const part = parseJpeg2000Codestream(data).tileParts[0]; + expect(part?.header.qcd).toMatchObject({ style: "derived", guardBits: 3 }); + expect(part?.header.cod).toBeUndefined(); + }); +}); + // Walks the main header's marker segments to the first occurrence of `marker`, returning the offset of the marker itself. Used instead of a fixed offset because a COM segment's length varies with the encoder's own version string. function findMarker(data: Uint8Array, marker: number): number { let position = 4 + ((data[4] ?? 0) << 8) + (data[5] ?? 0); From ccf6789948f4f606df8bc28507755f0e9a87624a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:38:03 +0100 Subject: [PATCH 082/105] refactor(pdf-codec): expose interleave, mirrorIndex and synthesiseLine for testing Each of these three functions has a real, meaningful contract of its own (coordinate placement, symmetric extension, one-dimensional synthesis), but every one of their guards against a degenerate input -- mirrorIndex's length <= 1, synthesiseLine's length <= 0 -- is already unreachable through their sole production callers: inverseDwt53Level/97Level's own width <= 0 || height <= 0 guard returns before either ever gets called with i1 - i0 that small. Exporting them lets a direct test drive that input rather than removing the guard a future caller might still need. --- packages/pdf-codec/src/image/jpeg2000-dwt.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.ts index 50bd27e5a..2600285ea 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.ts @@ -41,8 +41,8 @@ export function subbandBounds( }; } -// F.3.4's whole-sample symmetric extension: outside [i0, i1) the signal is mirrored about its own two end samples, so index i0 - k reads as i0 + k and index i1 - 1 + k as i1 - 1 - k, repeating with period 2(n - 1). -function mirrorIndex(position: number, i0: number, i1: number): number { +// F.3.4's whole-sample symmetric extension: outside [i0, i1) the signal is mirrored about its own two end samples, so index i0 - k reads as i0 + k and index i1 - 1 + k as i1 - 1 - k, repeating with period 2(n - 1). Exported for direct unit testing: synthesiseLine, this function's sole production caller, only ever reaches its loop (the one place mirrorIndex is called) once it has already special-cased length 0 and length 1 itself, so no length <= 1 input ever reaches mirrorIndex through that path -- only a direct call can exercise this function's own guard against it. +export function mirrorIndex(position: number, i0: number, i1: number): number { const length = i1 - i0; if (length <= 1) { return i0; @@ -56,14 +56,15 @@ function mirrorIndex(position: number, i0: number, i1: number): number { } // The interleave of F.3.3, written generically over "read a subband sample" / "write an interleaved sample" so the reversible and irreversible paths share one copy of the coordinate arithmetic -- the part most likely to be got wrong, and the part that is identical between them. -interface InterleaveSource { +// Exported for direct unit testing of the loop bounds below: the reversible and irreversible reconstructions this function serves both immediately overwrite whatever it writes with a filtered value (a single-sample degenerate case aside, in which the raw interleaved value survives untouched but every call site's own subband is already known-flat there), so no caller-level test can distinguish an interleave loop running one iteration long or short from its output alone. +export interface InterleaveSource { readonly ll: (u: number, v: number) => number; readonly hl: (u: number, v: number) => number; readonly lh: (u: number, v: number) => number; readonly hh: (u: number, v: number) => number; } -function interleave( +export function interleave( source: InterleaveSource, bounds: Jpeg2000ResolutionBounds, write: (u: number, v: number, value: number) => void, @@ -160,8 +161,8 @@ function inverse97Filter(buffer: Float32Array, i0: number, i1: number): void { } } -// F.3.7 1D_SR: the one-dimensional synthesis of an interleaved signal spanning [i0, i1). `read` supplies sample `index` and `write` receives the reconstructed one, both in absolute coordinates, so the same routine serves rows and columns without transposing anything. -function synthesiseLine( +// F.3.7 1D_SR: the one-dimensional synthesis of an interleaved signal spanning [i0, i1). `read` supplies sample `index` and `write` receives the reconstructed one, both in absolute coordinates, so the same routine serves rows and columns without transposing anything. Exported for direct unit testing: inverseDwt53Level/97Level, this function's only production callers, already refuse to call it at all once their own width <= 0 || height <= 0 guard has returned, so i1 - i0 is always positive by the time either caller's loop reaches it -- only a direct call can exercise this function's own length <= 0 and length === 1 branches in isolation. +export function synthesiseLine( read: (index: number) => number, write: (index: number, value: number) => void, i0: number, From 9a171e123146f044c4df7526e9994afeed826ebe Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:38:13 +0100 Subject: [PATCH 083/105] test(pdf-codec): cover jpeg2000-dwt.ts's zero-size, boundary and index-arithmetic cases Adds direct interleave/mirrorIndex/synthesiseLine suites for the guards and loop bounds only reachable that way (see the sibling refactor commit), zero-width/zero-height cases for both inverseDwt53Level and inverseDwt97Level, and two non-square, nonzero-origin reconstructions (one flat, one a single high-pass sample at an odd row and column) that distinguish an output-index mutant adding an axis origin back in from one correctly subtracting it -- indistinguishable from a flat signal or a zero origin alone, which every existing test before this one used. --- .../pdf-codec/src/image/jpeg2000-dwt.test.ts | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts index 16073da63..60b4179e5 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; import { + interleave, + type InterleaveSource, inverseDwt53Level, inverseDwt97Level, + mirrorIndex, subbandBounds, + synthesiseLine, } from "./jpeg2000-dwt"; // The whole-image fixtures in jpeg2000.test.ts already pin this transform against real encoder output at every size and origin the fixture set covers. What follows pins the pieces those cannot isolate: the exact integers the 5-3 lifting produces for a signal short enough to compute by hand from the specification's own equations, the DC gain that makes a flat image survive, and the coordinate split a caller has to size its subband buffers by. @@ -127,4 +131,274 @@ describe("inverseDwt97Level", () => { ); expect(Array.from(result)).toEqual([5.5]); }); + + it("returns an empty array for a resolution level with zero width or zero height", () => { + const emptyBands = { + ll: new Float32Array(0), + hl: new Float32Array(0), + lh: new Float32Array(0), + hh: new Float32Array(0), + }; + expect( + Array.from(inverseDwt97Level(emptyBands, { u0: 3, u1: 3, v0: 0, v1: 4 })), + ).toEqual([]); + expect( + Array.from(inverseDwt97Level(emptyBands, { u0: 0, u1: 4, v0: 2, v1: 2 })), + ).toEqual([]); + }); + + it("applies the single-sample gain at the correct absolute row when the vertical origin is nonzero", () => { + // u0/v0 both odd this time (band hh), and v0 = 3 rather than 0, so an (index - v0) mutant that instead adds v0 would write the vertical pass's result to output[6] (out of this length-1 buffer) rather than back to output[0], leaving the horizontal pass's own result unhalved. + const bounds = { u0: 1, u1: 2, v0: 3, v1: 4 }; + const result = inverseDwt97Level( + { + ll: new Float32Array(0), + hl: new Float32Array(0), + lh: new Float32Array(0), + hh: Float32Array.from([11]), + }, + bounds, + ); + // Both u0 and v0 are odd, so the lone sample's synthesis gain of two is undone once by the horizontal pass and again by the vertical one: 11 / 2 / 2. + expect(Array.from(result)).toEqual([2.75]); + }); + + it("places a distinguishable value at the correct row and column of a non-square level with a nonzero origin", () => { + // u0/v0 both nonzero so an index-arithmetic mutant that adds the origin instead of subtracting it (or vice versa) lands the impulse at the wrong output cell rather than coincidentally the right one. + const bounds = { u0: 1, u1: 5, v0: 2, v1: 5 }; // width 4, height 3 + const result = inverseDwt97Level( + { + ll: Float32Array.from([9, 9, 9, 9]), + hl: new Float32Array(4), + lh: new Float32Array(2), + hh: new Float32Array(2), + }, + bounds, + ); + expect(result).toHaveLength(12); + for (const value of result) { + expect(value).toBeCloseTo(9, 3); + } + }); +}); + +describe("inverseDwt53Level, zero-size and non-square cases", () => { + it("returns an empty array for a resolution level with zero width or zero height", () => { + const emptyBands = { + ll: new Int32Array(0), + hl: new Int32Array(0), + lh: new Int32Array(0), + hh: new Int32Array(0), + }; + expect( + Array.from(inverseDwt53Level(emptyBands, { u0: 3, u1: 3, v0: 0, v1: 4 })), + ).toEqual([]); + expect( + Array.from(inverseDwt53Level(emptyBands, { u0: 0, u1: 4, v0: 2, v1: 2 })), + ).toEqual([]); + }); + + it("reconstructs a flat signal correctly across a non-square level with a nonzero origin", () => { + const bounds = { u0: 1, u1: 5, v0: 2, v1: 5 }; // width 4, height 3 + const result = inverseDwt53Level( + { + ll: Int32Array.from([9, 9, 9, 9]), + hl: new Int32Array(4), + lh: new Int32Array(2), + hh: new Int32Array(2), + }, + bounds, + ); + expect(result).toHaveLength(12); + expect(Array.from(result)).toEqual(new Array(12).fill(9)); + }); +}); + +describe("interleave", () => { + it("visits exactly the coordinate range each of the four subbands owns, and no more", () => { + // Distinct llWidth (3), hWidth (2), llHeight (2), hHeight (1) so every one of the four loops' own upper bound is individually observable in the recorded call set. + const bounds = { u0: 0, u1: 5, v0: 0, v1: 3 }; + const calls: string[] = []; + const source: InterleaveSource = { + ll: (u, v) => { + calls.push(`ll(${String(u)},${String(v)})`); + return 0; + }, + hl: (u, v) => { + calls.push(`hl(${String(u)},${String(v)})`); + return 0; + }, + lh: (u, v) => { + calls.push(`lh(${String(u)},${String(v)})`); + return 0; + }, + hh: (u, v) => { + calls.push(`hh(${String(u)},${String(v)})`); + return 0; + }, + }; + interleave(source, bounds, () => { + // The write callback's own arguments are covered by inverseDwt53Level/97Level's own output-placement tests above; this test is solely about which (u, v) each subband gets asked for. + }); + expect(calls.sort()).toEqual( + [ + "ll(0,0)", + "ll(1,0)", + "ll(2,0)", + "ll(0,1)", + "ll(1,1)", + "ll(2,1)", + "hl(0,0)", + "hl(1,0)", + "hl(0,1)", + "hl(1,1)", + "lh(0,0)", + "lh(1,0)", + "lh(2,0)", + "hh(0,0)", + "hh(1,0)", + ].sort(), + ); + }); +}); + +describe("synthesiseLine", () => { + it("calls neither read nor write for a degenerate (i1 <= i0) range", () => { + const read = () => { + throw new Error("read should not be called"); + }; + const write = () => { + throw new Error("write should not be called"); + }; + expect(() => { + synthesiseLine( + read, + write, + 5, + 5, + () => 0, + () => 0, + () => 0, + (v) => v, + ); + }).not.toThrow(); + expect(() => { + synthesiseLine( + read, + write, + 5, + 3, + () => 0, + () => 0, + () => 0, + (v) => v, + ); + }).not.toThrow(); + }); + + it("reads and writes exactly once, at i0, for a length-1 range -- without applying the gain at an even i0", () => { + let written: [number, number] | undefined; + synthesiseLine( + () => 42, + (index, value) => { + written = [index, value]; + }, + 4, + 5, + () => 0, + () => 0, + () => 0, + (value) => value * 1000, // would be unmistakable in the output if wrongly applied + ); + expect(written).toEqual([4, 42]); + }); + + it("applies the single-sample gain function at an odd i0", () => { + let written: [number, number] | undefined; + synthesiseLine( + () => 42, + (index, value) => { + written = [index, value]; + }, + 5, + 6, + () => 0, + () => 0, + () => 0, + (value) => value / 2, + ); + expect(written).toEqual([5, 21]); + }); + + it("fills the scratch buffer over exactly [i0 - MARGIN, i1 + MARGIN) and calls the filter once, for a length-2 range", () => { + const filled: number[] = []; + let filterCalls = 0; + synthesiseLine( + (index) => index, // echoes its own (already mirrored) index, so filled[] below records mirrored source indices + () => { + // Not under test here: the write-back loop is covered by the exact-value reconstruction tests elsewhere in this file. + }, + 10, + 12, // length 2: the smallest input that reaches the general (non-degenerate, non-single-sample) loop + (offset) => { + filled.push(offset); + }, + () => 0, + () => { + filterCalls++; + }, + (value) => value, + ); + // EXTENSION_MARGIN is 6, so a length-2 range fills 2 + 2*6 = 14 scratch offsets, 0..13. + expect(filled).toHaveLength(14); + expect(Math.min(...filled)).toBe(0); + expect(Math.max(...filled)).toBe(13); + expect(filterCalls).toBe(1); + }); + + it("writes exactly [i0, i1) back from the scratch buffer, for a length-2 range", () => { + const written: number[] = []; + synthesiseLine( + () => 0, + (index) => { + written.push(index); + }, + 10, + 12, + () => { + // Not under test here: the fill loop's own extent is covered by the test above. + }, + (offset) => offset, // echoes the scratch offset back as the "reconstructed" value, so a wrong readScratch offset would show up as a wrong written value too + () => { + // No filtering needed for this test. + }, + (value) => value, + ); + expect(written).toEqual([10, 11]); + }); +}); + +describe("mirrorIndex", () => { + it("returns the sole in-range index for a length-1 range, whatever position is asked for", () => { + expect(mirrorIndex(0, 5, 6)).toBe(5); + expect(mirrorIndex(-3, 5, 6)).toBe(5); + expect(mirrorIndex(9, 5, 6)).toBe(5); + }); + + it("returns i0 for a degenerate (empty) range", () => { + expect(mirrorIndex(0, 3, 3)).toBe(3); + }); + + it("mirrors a position before i0 about i0 itself", () => { + // [i0, i1) = [0, 4): position -1 mirrors to 1, matching F.3.4's own reflection about the first sample. + expect(mirrorIndex(-1, 0, 4)).toBe(1); + }); + + it("mirrors a position at or past i1 about the last in-range sample", () => { + expect(mirrorIndex(4, 0, 4)).toBe(2); + }); + + it("leaves a position already inside [i0, i1) unchanged", () => { + expect(mirrorIndex(2, 0, 4)).toBe(2); + }); }); From 59531e7771709e4782aa460df39c70601ef4e23b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:38:26 +0100 Subject: [PATCH 084/105] test(pdf-codec): cover remaining jpeg2000-codestream.ts header-segment cases Adds a component index wide enough to need its own two-byte field (257+ components), a signed SIZ component depth, a derived-style quantization's own step sizes, explicit per-resolution-level precincts from both COD and COC, a non-Latin COM registration that must not surface as a comment, an otherwise-unhandled marker segment (TLM) skipped without recording anything, the exact SOT length-mismatch message, a tile-part header running into EOC rather than SOD, a Psot landing exactly on an empty tile-part's own data with nothing to trim, and the three ways a tile-part's trailing bytes can fail to match the EOC signature without being trimmed. Also fixes two existing tile-part-override assertions that checked a field read back as undefined without checking it was genuinely absent as a key, which a mutant that always spread both cod and qcd together could satisfy by coincidence. --- .../src/image/jpeg2000-codestream.test.ts | 231 +++++++++++++++++- 1 file changed, 226 insertions(+), 5 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts b/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts index 1fa302c32..8fc53ce7e 100644 --- a/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts +++ b/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts @@ -41,6 +41,22 @@ describe("MarkerCursor", () => { expect(Array.from(slice)).toEqual([2, 3, 4]); expect(cursor.uint8()).toBe(5); // proves position landed on index 4, not 4 - 3 = -2 or left at 1 }); + + it("throws when reading a byte past the end of the data", () => { + expect(() => new MarkerCursor(Uint8Array.from([])).uint8()).toThrow( + /ended in the middle of a marker segment/, + ); + }); + + it("accepts a zero-length read without treating it as negative", () => { + const cursor = new MarkerCursor(Uint8Array.from([1, 2, 3])); + expect(Array.from(cursor.bytes(0))).toEqual([]); + }); + + it("accepts a read that exactly exhausts the remaining data", () => { + const cursor = new MarkerCursor(Uint8Array.from([1, 2, 3])); + expect(Array.from(cursor.bytes(3))).toEqual([1, 2, 3]); + }); }); describe("parseJpeg2000Codestream", () => { @@ -562,18 +578,223 @@ describe("parseJpeg2000Codestream, header-segment guards a real encoder never tr const data = minimalCodestream({ afterMainHeader: tilePart(0, codSegment({ layers: 3 }), []), }); - const part = parseJpeg2000Codestream(data).tileParts[0]; - expect(part?.header.cod).toMatchObject({ layers: 3 }); - expect(part?.header.qcd).toBeUndefined(); + const header = parseJpeg2000Codestream(data).tileParts[0]?.header; + expect(header?.cod).toMatchObject({ layers: 3 }); + // Not merely undefined when read: genuinely absent as a key, so a mutant that always spreads both cod and qcd together can't pass by coincidentally leaving qcd's value at undefined. + expect(header !== undefined && Object.hasOwn(header, "qcd")).toBe(false); }); it("lets a tile-part's own QCD marker override just the quantization, leaving coding style to the main header", () => { const data = minimalCodestream({ afterMainHeader: tilePart(0, qcdSegment(1, 3), []), }); + const header = parseJpeg2000Codestream(data).tileParts[0]?.header; + expect(header?.qcd).toMatchObject({ style: "derived", guardBits: 3 }); + expect(header !== undefined && Object.hasOwn(header, "cod")).toBe(false); + }); + + it("records both a tile-part's own COD and QCD overrides together", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart( + 0, + [...codSegment({ layers: 3 }), ...qcdSegment(1, 3)], + [], + ), + }); + const header = parseJpeg2000Codestream(data).tileParts[0]?.header; + expect(header?.cod).toMatchObject({ layers: 3 }); + expect(header?.qcd).toMatchObject({ style: "derived", guardBits: 3 }); + }); + + it("reads a component index as two bytes once the image declares 257 or more components", () => { + const siz = sizSegment({ componentCount: 257 }); + // component index 300 (two bytes: 0x01, 0x2c) rather than a single byte, which could not represent it at all + const coc = segment(0xff53, [1, 0x2c, 0, 0, 0, 0, 0, 1]); + const data = minimalCodestream({ siz, afterMainHeader: coc }); + expect(parseJpeg2000Codestream(data).main.coc.has(300)).toBe(true); + }); + + it("reports a signed component depth from SIZ's own sign bit", () => { + const siz = sizSegment({ componentCount: 1, componentBytes: [0x87, 1, 1] }); // Ssiz with the sign bit set + const data = minimalCodestream({ siz }); + expect(parseJpeg2000Codestream(data).siz.components).toEqual([ + { signed: true, bitDepth: 8, dx: 1, dy: 1 }, + ]); + }); + + it("reads a derived-style quantization's own step sizes, one per subband", () => { + // Style 1 (derived) transmits a 2-byte exponent/mantissa pair per subband; two entries here, spanning exactly to segmentEnd with no room for a third. + const qcd = segment(MARKER_QCD, [ + (0 << 5) | 1, + ...u16((5 << 11) | 100), + ...u16((6 << 11) | 200), + ]); + const data = minimalCodestream({ qcd }); + expect(parseJpeg2000Codestream(data).main.qcd?.stepSizes).toEqual([ + { exponent: 5, mantissa: 100 }, + { exponent: 6, mantissa: 200 }, + ]); + }); + + it("does not misread a marker segment's own explicit-precincts flag as the opposite of what it declares", () => { + const explicit = codSegment({ scod: 0x01, decompLevels: 1 }); // Scod bit 0 set: two packed precinct-size bytes follow + const packed = [0x35, 0x24]; // ppx=5,ppy=3 for level 0; ppx=4,ppy=2 for level 1 + const data = minimalCodestream({ + cod: [ + ...explicit.slice(0, 2), + ...u16(2 + explicit.slice(4).length + packed.length), + ...explicit.slice(4), + ...packed, + ], + }); + expect(parseJpeg2000Codestream(data).main.cod?.precinctSizes).toEqual([ + { ppx: 5, ppy: 3 }, + { ppx: 4, ppy: 2 }, + ]); + }); + + it("records an explicit per-component precinct override from a COC marker", () => { + const coc = segment(0xff53, [ + 0, // component 0 + 0x01, // Scoc: explicit precincts bit set + 0, // decompLevels + 0, // cbW + 0, // cbH + 0, // cbStyle + 1, // transform: reversible + 0x35, // one packed precinct byte for the single resolution level + ]); + const data = minimalCodestream({ afterMainHeader: coc }); + expect( + parseJpeg2000Codestream(data).main.coc.get(0)?.precinctSizes, + ).toEqual([{ ppx: 5, ppy: 3 }]); + }); + + it("does not record a comment from a COM marker whose registration is not 1 (Latin text)", () => { + const com = segment(0xff64, [0, 0, 0x41, 0x42]); // registration 0 (binary): "AB" must not surface as a comment + const data = minimalCodestream({ afterMainHeader: com }); + expect(parseJpeg2000Codestream(data).comments).toEqual([]); + }); + + it("skips a marker segment type this decoder has no other handling for, without recording anything", () => { + const tlm = segment(0xff55, [0, 0, 0, 0]); // TLM: positional/informational only + const data = minimalCodestream({ afterMainHeader: tlm }); + const codestream = parseJpeg2000Codestream(data); + expect(codestream.comments).toEqual([]); + expect(codestream.main.hasProgressionChanges).toBe(false); + }); + + it("starts with no comments at all when the main header carries none", () => { + expect(parseJpeg2000Codestream(minimalCodestream()).comments).toEqual([]); + }); + + it("rejects a marker segment whose own declared length would run past the end of the codestream", () => { + const data = minimalCodestream({ + afterMainHeader: [...u16(0xff64), ...u16(100)], // COM claims 98 more bytes that are not actually present + omitEoc: true, + }); + expect(() => parseJpeg2000Codestream(data)).toThrow( + /declares more data than the codestream carries/, + ); + }); + + it("rejects a codestream too short for SOC's own 4-byte check, but accepts one exactly 4 bytes long", () => { + expect(() => + parseJpeg2000Codestream(Uint8Array.from([0xff, 0x4f, 0xff])), + ).toThrow(/does not begin with an SOC/); + // Exactly 4 bytes of a genuine SOC + SIZ marker: passes the length check, then fails later (out of data for Lsiz) rather than being rejected here for being "too short". + expect(() => + parseJpeg2000Codestream(Uint8Array.from([0xff, 0x4f, 0xff, 0x51])), + ).not.toThrow(/does not begin with an SOC/); + }); + + it("rejects a bare SOC marker appearing unexpectedly inside the main header, not only a bare SOD", () => { + const data = minimalCodestream({ + afterMainHeader: [...u16(MARKER_SOC)], + }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/unexpected marker/); + }); + + it("computes the tile grid from the tile origin, not by adding it back in", () => { + const siz = sizSegment({ + xsiz: 10, + ysiz: 9, + xtsiz: 4, + ytsiz: 3, + xtosiz: 2, + ytosiz: 1, + }); + const codestream = parseJpeg2000Codestream(minimalCodestream({ siz })); + // ceil((10 - 2) / 4) = 2, and ceil((9 - 1) / 3) = 3 -- not ceil((10 + 2) / 4) = 3 or ceil((9 + 1) / 3) = 4. + expect(codestream.numTilesWide).toBe(2); + expect(codestream.numTilesHigh).toBe(3); + }); + + it("reports the exact declared length in an SOT length-mismatch error", () => { + const data = minimalCodestream({ + afterMainHeader: [ + ...u16(MARKER_SOT), + ...u16(12), // declares 12, ISO/IEC 15444-1 A.4.2 fixes it at 10 + ...u16(0), + ...u32(0), + 0, + 0, + ], + }); + expect(() => parseJpeg2000Codestream(data)).toThrow( + /declares a length of 12, but ISO\/IEC 15444-1 A\.4\.2 fixes it at 10/, + ); + }); + + it("rejects a tile-part header running into an EOC marker rather than reaching SOD", () => { + const data = minimalCodestream({ + afterMainHeader: [ + ...u16(MARKER_SOT), + ...u16(10), + ...u16(0), + ...u32(0), + 0, + 0, + ...u16(MARKER_EOC), + ], + omitEoc: true, + }); + expect(() => parseJpeg2000Codestream(data)).toThrow(/rather than at SOD/); + }); + + it("accepts a tile-part whose Psot runs exactly to the end of its own (empty) data, not merely close to it", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, [], [], 14), // 12-byte SOT + 2-byte SOD = 14, exactly consuming Psot with zero data bytes left + }); const part = parseJpeg2000Codestream(data).tileParts[0]; - expect(part?.header.qcd).toMatchObject({ style: "derived", guardBits: 3 }); - expect(part?.header.cod).toBeUndefined(); + expect(part?.dataStart).toBe(part?.dataEnd); + }); + + it("does not trim a tile-part's own data when it is shorter than the 2-byte EOC signature itself", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, [], [0xff]), + omitEoc: true, + }); + const part = parseJpeg2000Codestream(data).tileParts[0]; + expect(part?.dataEnd).toBe(data.length); + }); + + it("does not trim a tile-part's own data ending in 0xFF but not 0xD9", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, [], [0xaa, 0xff, 0x00]), + omitEoc: true, + }); + const part = parseJpeg2000Codestream(data).tileParts[0]; + expect(part?.dataEnd).toBe(data.length); + }); + + it("does not trim a tile-part's own data ending in 0xD9 that was not preceded by 0xFF", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, [], [0xaa, 0x00, 0xd9]), + omitEoc: true, + }); + const part = parseJpeg2000Codestream(data).tileParts[0]; + expect(part?.dataEnd).toBe(data.length); }); }); From f3b02f882351f5684f089fc04d5b4e00c719cb3c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:55:05 +0100 Subject: [PATCH 085/105] refactor(pdf-codec): drop trimTrailingEoc's own redundant length guard readTilePart, this function's sole caller, always passes a start sitting immediately after a real SOD marker (0xFF 0x93). Whenever the resulting range is under 2 bytes, at least one of the two positions the byte comparisons check falls on that marker's own fixed bytes instead of on real tile-part data -- and 0x93 can never be mistaken for 0xD9 -- so the comparisons already refuse a too-short range on their own, with no need to measure it first. Drops the now-unused start parameter along with it. --- packages/pdf-codec/src/image/jpeg2000-codestream.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-codestream.ts b/packages/pdf-codec/src/image/jpeg2000-codestream.ts index f7f9d90ef..8b87adeec 100644 --- a/packages/pdf-codec/src/image/jpeg2000-codestream.ts +++ b/packages/pdf-codec/src/image/jpeg2000-codestream.ts @@ -557,7 +557,7 @@ function readTilePart( ); } // A truncated final tile-part is the shape a clipped PDF stream takes; keeping whatever bytes did arrive lets the decoder report a partial image rather than nothing at all. - const trimmedEnd = trimTrailingEoc(cursor.data, dataStart, dataEnd); + const trimmedEnd = trimTrailingEoc(cursor.data, dataEnd); tileParts.push({ tileIndex, partIndex, @@ -568,13 +568,9 @@ function readTilePart( cursor.position = dataEnd; } -// A Psot of 0 runs the tile-part to the end of the codestream, which includes the EOC marker; the packet decoder must not see those two bytes as coded data. -function trimTrailingEoc( - data: Uint8Array, - start: number, - end: number, -): number { - if (end - start >= 2 && data[end - 2] === 0xff && data[end - 1] === 0xd9) { +// A Psot of 0 runs the tile-part to the end of the codestream, which includes the EOC marker; the packet decoder must not see those two bytes as coded data. Takes no separate start/length: readTilePart, this function's sole caller, always calls it with a range beginning immediately after a real SOD marker (0xFF 0x93), so whenever that range is under 2 bytes long, one of the two positions checked below falls on that marker's own fixed bytes rather than on data -- and 0x93 can never be mistaken for 0xD9 -- making the byte comparisons already refuse a too-short range on their own, with no need to measure it first. +function trimTrailingEoc(data: Uint8Array, end: number): number { + if (data[end - 2] === 0xff && data[end - 1] === 0xd9) { return end - 2; } return end; From ac6b9b5baae05f6f155e788699017aff849d03cd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:55:15 +0100 Subject: [PATCH 086/105] test(pdf-codec): cover jpeg2000-codestream.ts's remaining header-segment boundaries Adds a hasOwn check for the tile-part-header-overrides-nothing case (the same gap the sibling COD-only/QCD-only tests were already fixed for: a field read back undefined doesn't prove it's genuinely absent as a key), a quantization step-size loop that stops exactly at its own segment boundary rather than one iteration short of needing another pair, a marker segment whose declared length runs exactly to the codestream's own end, an otherwise-unhandled marker segment (TLM) whose own body is deliberately shaped like a registration-1 COM segment so a mutant that misreads it as one would surface as a spurious comment, and a tile-part whose data is exactly the 2-byte EOC signature and nothing else. --- .../src/image/jpeg2000-codestream.test.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts b/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts index 8fc53ce7e..edc557643 100644 --- a/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts +++ b/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts @@ -569,9 +569,9 @@ describe("parseJpeg2000Codestream, header-segment guards a real encoder never tr const data = minimalCodestream({ afterMainHeader: tilePart(0, [], []), }); - const part = parseJpeg2000Codestream(data).tileParts[0]; - expect(part?.header.cod).toBeUndefined(); - expect(part?.header.qcd).toBeUndefined(); + const header = parseJpeg2000Codestream(data).tileParts[0]?.header; + expect(header !== undefined && Object.hasOwn(header, "cod")).toBe(false); + expect(header !== undefined && Object.hasOwn(header, "qcd")).toBe(false); }); it("lets a tile-part's own COD marker override just the coding defaults, leaving quantization to the main header", () => { @@ -677,13 +677,40 @@ describe("parseJpeg2000Codestream, header-segment guards a real encoder never tr }); it("skips a marker segment type this decoder has no other handling for, without recording anything", () => { - const tlm = segment(0xff55, [0, 0, 0, 0]); // TLM: positional/informational only + // The body deliberately looks like a registration-1 COM segment ("registration 1, text AB") -- if TLM were ever misread as COM this would show up as a spurious comment, not merely a silent no-op that happens to look the same either way. + const tlm = segment(0xff55, [0, 1, 0x41, 0x42]); const data = minimalCodestream({ afterMainHeader: tlm }); const codestream = parseJpeg2000Codestream(data); expect(codestream.comments).toEqual([]); expect(codestream.main.hasProgressionChanges).toBe(false); }); + it("stops reading quantization step sizes exactly at its own segment boundary", () => { + // One entry, then a single trailing pad byte -- one byte short of a second entry, so a mutant that reads one iteration too many would either read past the segment into whatever follows or throw, rather than stopping here with exactly one. + const qcd = segment(MARKER_QCD, [ + (0 << 5) | 1, + ...u16((5 << 11) | 1), + 0xaa, + ]); + const data = minimalCodestream({ qcd }); + expect(parseJpeg2000Codestream(data).main.qcd?.stepSizes).toHaveLength(1); + }); + + it("accepts a marker segment whose own declared length runs exactly to the end of the codestream", () => { + const com = segment(0xff64, [0, 0, 0x41]); // registration 0 (binary), one body byte, landing exactly on the codestream's own last byte + const data = minimalCodestream({ afterMainHeader: com, omitEoc: true }); + expect(() => parseJpeg2000Codestream(data)).not.toThrow(); + }); + + it("trims a trailing EOC from a tile-part whose data is exactly the 2-byte signature and nothing else", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, [], [0xff, 0xd9]), + omitEoc: true, + }); + const part = parseJpeg2000Codestream(data).tileParts[0]; + expect(part?.dataStart).toBe(part?.dataEnd); + }); + it("starts with no comments at all when the main header carries none", () => { expect(parseJpeg2000Codestream(minimalCodestream()).comments).toEqual([]); }); From 1365788e9743a9bc00ea184a79f46ed28f8b1a76 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:55:25 +0100 Subject: [PATCH 087/105] refactor(pdf-codec): drop inverseDwt53Level/97Level's own non-positive-dimension guard interleave's own loops, sized from the same u0/u1/v0/v1, never iterate when width or height is non-positive (subbandBounds collapses each such range to an empty one), and both reconstruction loops below are bounded by width/height directly, so they no-op the same way. All a non-positive dimension could still threaten is scratch's own allocation, now floored at 0 the same way output's already is a few lines above -- removing the one remaining reason a caller needed the guard at all. Rewrites mirrorIndex's own negative-offset normalisation as the standard double modulo instead of a separate negative-offset branch: JS's % result already follows the sign of its dividend, so folding it into [0, period) this way needs no comparison of its own, and produces the identical result for every input the branching version did. --- packages/pdf-codec/src/image/jpeg2000-dwt.ts | 43 ++++++++++---------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.ts index 2600285ea..574aadb7f 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.ts @@ -48,10 +48,8 @@ export function mirrorIndex(position: number, i0: number, i1: number): number { return i0; } const period = 2 * (length - 1); - let offset = (position - i0) % period; - if (offset < 0) { - offset += period; - } + // The double modulo is the standard way to fold a JS `%` result (which follows the sign of position - i0, so it can itself be negative) into [0, period) without a separate negative-offset branch: the result is already fully normalized before the mirror step below ever runs. + const offset = (((position - i0) % period) + period) % period; return i0 + (offset >= length ? period - offset : offset); } @@ -94,8 +92,12 @@ export function interleave( // --- The reversible 5-3 filter (F.3.8.2, equations F-5 and F-6). --- -// Runs in place over an extended buffer where `buffer[index - i0 + EXTENSION_MARGIN]` holds sample `index`, the margin already filled by symmetric extension. -function inverse53Filter(buffer: Int32Array, i0: number, i1: number): void { +// Runs in place over an extended buffer where `buffer[index - i0 + EXTENSION_MARGIN]` holds sample `index`, the margin already filled by symmetric extension. Exported for direct unit testing: synthesiseLine's own scratch buffer is always sized generously enough (Math.max(width, height) + 2 * EXTENSION_MARGIN) that a wrong loop bound here would silently write into real, already-allocated cells rather than throwing -- only inspecting exactly which cells this function itself touches, directly, can tell the two apart. +export function inverse53Filter( + buffer: Int32Array, + i0: number, + i1: number, +): void { const base = EXTENSION_MARGIN - i0; const first = Math.floor(i0 / 2) - 1; const last = Math.floor(i1 / 2) + 1; @@ -117,7 +119,12 @@ function inverse53Filter(buffer: Int32Array, i0: number, i1: number): void { // --- The irreversible 9-7 filter (F.3.8.2, equations F-8 to F-13). --- -function inverse97Filter(buffer: Float32Array, i0: number, i1: number): void { +// Exported for the same reason as inverse53Filter above. +export function inverse97Filter( + buffer: Float32Array, + i0: number, + i1: number, +): void { // F.3.8.2 Table F.4: the four lifting parameters of the 9-7 analysis filter and its normalisation constant. The synthesis below applies each in reverse order with the opposite sign, which is what makes lifting invertible at all. Built inside this function rather than as module-level constants so a mutation to one of them is attributed, by Stryker's per-test coverage analysis, to the tests that actually call this function -- module-level `const`s here would run once at import time as static mutants, which Stryker tests against a single arbitrary covering test rather than the full set that genuinely exercises the 9-7 filter. const LIFT_ALPHA = -1.586134342059924; const LIFT_BETA = -0.052980118572961; @@ -235,16 +242,13 @@ export function inverseDwt53Level( const width = u1 - u0; const height = v1 - v0; const output = new Int32Array(Math.max(width * height, 0)); - if (width <= 0 || height <= 0) { - return output; - } + // No separate "is either dimension non-positive" guard is needed: interleave's own loops, sized from the same u0/u1/v0/v1, never iterate when width or height is non-positive (subbandBounds collapses each such range to an empty one), and both loops below are bounded by width/height directly, so they no-op the same way. All that's left for a non-positive dimension to threaten is scratch's own allocation, guarded the same way output's already is above. + const scratch = new Int32Array( + Math.max(Math.max(width, height) + 2 * EXTENSION_MARGIN, 0), + ); interleave(interleaveSource(bands, bounds), bounds, (u, v, value) => { output[(v - v0) * width + (u - u0)] = value; }); - - const scratch = new Int32Array( - Math.max(width, height) + 2 * EXTENSION_MARGIN, - ); // HOR_SR (F.3.5) then VER_SR (F.3.6), in that order -- with integer lifting the two are not commutative. for (let v = 0; v < height; v++) { const rowStart = v * width; @@ -295,16 +299,13 @@ export function inverseDwt97Level( const width = u1 - u0; const height = v1 - v0; const output = new Float32Array(Math.max(width * height, 0)); - if (width <= 0 || height <= 0) { - return output; - } + // See inverseDwt53Level's identical comment: no separate non-positive-dimension guard is needed once scratch's own allocation is floored at 0 the same way output's already is above. + const scratch = new Float32Array( + Math.max(Math.max(width, height) + 2 * EXTENSION_MARGIN, 0), + ); interleave(interleaveSource(bands, bounds), bounds, (u, v, value) => { output[(v - v0) * width + (u - u0)] = value; }); - - const scratch = new Float32Array( - Math.max(width, height) + 2 * EXTENSION_MARGIN, - ); for (let v = 0; v < height; v++) { const rowStart = v * width; synthesiseLine( From e7c95feeccb3807e8181264b316c14b1fcadfc8e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:55:38 +0100 Subject: [PATCH 088/105] test(pdf-codec): cover jpeg2000-dwt.ts's filter loop bounds and remaining edges Adds direct inverse53Filter/inverse97Filter suites that fill a buffer with a sentinel value distinguishable from anything either filter's own arithmetic would compute, then read back exactly which cells changed -- pinning each filter's own loop bounds directly rather than through the much larger surface of a full 2D reconstruction. Adds a scratch-allocation crash regression test for inverseDwt53Level/97Level covering the grossly-inverted-bounds case the sibling refactor commit's removed guard used to handle, and strengthens the fill-loop test to compare synthesiseLine's own fed source indices against mirrorIndex itself (already independently verified correct) rather than only their count and range. --- .../pdf-codec/src/image/jpeg2000-dwt.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts index 60b4179e5..0ef5097e1 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { interleave, type InterleaveSource, + inverse53Filter, + inverse97Filter, inverseDwt53Level, inverseDwt97Level, mirrorIndex, @@ -9,6 +11,19 @@ import { synthesiseLine, } from "./jpeg2000-dwt"; +// Every buffer cell inverse53Filter/inverse97Filter actually write to gets a value distinguishable from this sentinel: the even step's own F-5 arithmetic maps a uniform 100 to 100 - floor((100 + 100 + 2) / 4) = 50, and every later lifting step further changes whatever it touches, so a sentinel-filled buffer's own untouched/touched split can be read straight off which cells still equal 100. +const SENTINEL = 100; + +function touchedIndices(buffer: ArrayLike): number[] { + const touched: number[] = []; + for (let i = 0; i < buffer.length; i++) { + if (buffer[i] !== SENTINEL) { + touched.push(i); + } + } + return touched; +} + // The whole-image fixtures in jpeg2000.test.ts already pin this transform against real encoder output at every size and origin the fixture set covers. What follows pins the pieces those cannot isolate: the exact integers the 5-3 lifting produces for a signal short enough to compute by hand from the specification's own equations, the DC gain that makes a flat image survive, and the coordinate split a caller has to size its subband buffers by. // A resolution level one row high, so VER_SR reduces to the single-sample case and the row is a direct test of the one-dimensional 5-3 filter. @@ -147,6 +162,18 @@ describe("inverseDwt97Level", () => { ).toEqual([]); }); + it("does not crash allocating scratch space for grossly inverted (u1 < u0 and v1 < v0) bounds", () => { + const bands = { + ll: new Float32Array(0), + hl: new Float32Array(0), + lh: new Float32Array(0), + hh: new Float32Array(0), + }; + const bounds = { u0: 20, u1: 0, v0: 20, v1: 0 }; + expect(() => inverseDwt97Level(bands, bounds)).not.toThrow(); + expect(inverseDwt97Level(bands, bounds)).toHaveLength(400); + }); + it("applies the single-sample gain at the correct absolute row when the vertical origin is nonzero", () => { // u0/v0 both odd this time (band hh), and v0 = 3 rather than 0, so an (index - v0) mutant that instead adds v0 would write the vertical pass's result to output[6] (out of this length-1 buffer) rather than back to output[0], leaving the horizontal pass's own result unhalved. const bounds = { u0: 1, u1: 2, v0: 3, v1: 4 }; @@ -198,6 +225,20 @@ describe("inverseDwt53Level, zero-size and non-square cases", () => { ).toEqual([]); }); + it("does not crash allocating scratch space for grossly inverted (u1 < u0 and v1 < v0) bounds", () => { + // Both dimensions negative enough that Math.max(width, height) alone would fall below -2 * EXTENSION_MARGIN, which would make scratch's own size negative without its own floor at 0. + const bands = { + ll: new Int32Array(0), + hl: new Int32Array(0), + lh: new Int32Array(0), + hh: new Int32Array(0), + }; + const bounds = { u0: 20, u1: 0, v0: 20, v1: 0 }; + expect(() => inverseDwt53Level(bands, bounds)).not.toThrow(); + // width * height = (-20) * (-20) = 400: the same Math.max(..., 0) floor already sizes output to that, filled with its default zeros, since raster order is undefined for bounds no real caller would ever pass. + expect(inverseDwt53Level(bands, bounds)).toHaveLength(400); + }); + it("reconstructs a flat signal correctly across a non-square level with a nonzero origin", () => { const bounds = { u0: 1, u1: 5, v0: 2, v1: 5 }; // width 4, height 3 const result = inverseDwt53Level( @@ -356,6 +397,34 @@ describe("synthesiseLine", () => { expect(filterCalls).toBe(1); }); + it("reads each fill-loop sample from mirrorIndex(i0 + k, i0, i1), not mirrorIndex(i0 - k, i0, i1)", () => { + const i0 = 10; + const i1 = 12; + const fed: number[] = []; + synthesiseLine( + (index) => index, // echo: fed[] below ends up holding exactly what each fillScratch call's own source-index argument was + () => { + // Write-back is not under test here. + }, + i0, + i1, + (_offset, value) => { + fed.push(value); + }, + () => 0, + () => { + // No filtering needed for this test. + }, + (value) => value, + ); + // mirrorIndex itself is separately verified correct (see the describe block below), so it doubles here as ground truth for what synthesiseLine's fill loop ought to have fed it. + const expected = []; + for (let k = -6; k < i1 - i0 + 6; k++) { + expected.push(mirrorIndex(i0 + k, i0, i1)); + } + expect(fed).toEqual(expected); + }); + it("writes exactly [i0, i1) back from the scratch buffer, for a length-2 range", () => { const written: number[] = []; synthesiseLine( @@ -378,6 +447,38 @@ describe("synthesiseLine", () => { }); }); +describe("inverse53Filter", () => { + it("writes to exactly the buffer cells the F-5/F-6 equations need for i0 = 0, i1 = 8, and no others", () => { + const buffer = new Int32Array(30).fill(SENTINEL); + inverse53Filter(buffer, 0, 8); + // base = EXTENSION_MARGIN(6) - i0(0) = 6. Even step: n from floor(0/2) - 1 = -1 to floor(8/2) + 1 = 5 inclusive, indices base + 2n = 4, 6, 8, 10, 12, 14, 16. Odd step: n from -1 to 4 (5 excluded), indices base + 2n + 1 = 5, 7, 9, 11, 13, 15. + expect(touchedIndices(buffer)).toEqual([ + 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + ]); + }); + + it("writes to exactly the buffer cells the F-5/F-6 equations need for an odd, offset i0/i1", () => { + const buffer = new Int32Array(30).fill(SENTINEL); + inverse53Filter(buffer, 3, 9); + // base = 6 - 3 = 3. Even: n from floor(3/2) - 1 = 0 to floor(9/2) + 1 = 5, indices 3 + 2n = 3, 5, 7, 9, 11, 13. Odd: n from 0 to 4 (5 excluded), indices 3 + 2n + 1 = 4, 6, 8, 10, 12. + expect(touchedIndices(buffer)).toEqual([ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, + ]); + }); +}); + +describe("inverse97Filter", () => { + it("writes to exactly the buffer cells the F-8/F-9 normalisation pass needs for i0 = 0, i1 = 8, and no others", () => { + // F-8/F-9 is the widest of the four passes (its own n range is the other three's each extended by one or two further steps), so the overall touched set below is entirely this pass's own -- direct evidence for its own loop bound and for `last`'s own division. + const buffer = new Float32Array(30).fill(SENTINEL); + inverse97Filter(buffer, 0, 8); + // base = 6, first = floor(0/2) = 0, last = floor(8/2) = 4. F-8/F-9: n from first - 2 = -2 to last + 2 = 6, touching both even(n) = base + 2n and odd(n) = base + 2n + 1 for each -- every integer from base + 2*(-2) = 2 to base + 2*6 + 1 = 19. + expect(touchedIndices(buffer)).toEqual( + Array.from({ length: 18 }, (_, index) => index + 2), + ); + }); +}); + describe("mirrorIndex", () => { it("returns the sole in-range index for a length-1 range, whatever position is asked for", () => { expect(mirrorIndex(0, 5, 6)).toBe(5); From 0c9e6e6dbaabc2d335741986c18a6a03d48dc40c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 17:04:28 +0100 Subject: [PATCH 089/105] refactor(pdf-codec): extract inverseDwt53Level/97Level's row loop into a testable primitive HOR_SR's row loop writes each row at row * width, which for row === height lands exactly on output's own one-past-the-end index -- silently absorbed by TypedArray semantics (an out-of-bounds write is a no-op there, an out-of-bounds read is undefined) regardless of what that row's own reconstruction would have computed. A wrong loop bound is therefore unobservable through either function's own returned array, no matter what input a test supplies. Extracting the loop into times(), an exported, directly callable primitive, makes its own call count and argument sequence observable on their own terms instead. --- packages/pdf-codec/src/image/jpeg2000-dwt.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.ts index 574aadb7f..79feae1e3 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.ts @@ -7,6 +7,13 @@ // The widest read in either filter is the 9-7's own scaling step, whose loop (F-9) runs two lifting indices -- four samples -- past each end of the signal. Six samples of symmetric extension covers that with room to spare, and covers the 5-3's narrower reach as well. const EXTENSION_MARGIN = 6; +// Calls `fn` once per row index 0..count - 1. Exported for direct unit testing: HOR_SR's own row loop below writes each row at `row * width`, which for row === height lands exactly on output's own one-past-the-end index -- silently absorbed by TypedArray semantics (an out-of-bounds write is a no-op, an out-of-bounds read is undefined) regardless of what that row's own reconstruction would have computed, so a wrong loop bound there is unobservable through inverseDwt53Level/97Level's own returned array. Only counting and recording calls directly, on this extracted primitive, can catch it. +export function times(count: number, fn: (index: number) => void): void { + for (let index = 0; index < count; index++) { + fn(index); + } +} + export interface Jpeg2000ResolutionBounds { readonly u0: number; readonly u1: number; @@ -250,7 +257,7 @@ export function inverseDwt53Level( output[(v - v0) * width + (u - u0)] = value; }); // HOR_SR (F.3.5) then VER_SR (F.3.6), in that order -- with integer lifting the two are not commutative. - for (let v = 0; v < height; v++) { + times(height, (v) => { const rowStart = v * width; synthesiseLine( (index) => output[rowStart + index - u0] ?? 0, @@ -268,7 +275,7 @@ export function inverseDwt53Level( }, (value) => value >> 1, ); - } + }); for (let u = 0; u < width; u++) { synthesiseLine( (index) => output[(index - v0) * width + u] ?? 0, @@ -306,7 +313,7 @@ export function inverseDwt97Level( interleave(interleaveSource(bands, bounds), bounds, (u, v, value) => { output[(v - v0) * width + (u - u0)] = value; }); - for (let v = 0; v < height; v++) { + times(height, (v) => { const rowStart = v * width; synthesiseLine( (index) => output[rowStart + index - u0] ?? 0, @@ -324,7 +331,7 @@ export function inverseDwt97Level( }, (value) => value / 2, ); - } + }); for (let u = 0; u < width; u++) { synthesiseLine( (index) => output[(index - v0) * width + u] ?? 0, From fa06fb4f5539ee7e7971fb316c30569d34786dcf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 17:04:37 +0100 Subject: [PATCH 090/105] test(pdf-codec): cover times() directly and pin inverse97Filter's F-12/F-13 boundaries Adds a direct times() suite (call count and argument sequence, including zero and negative counts). Fixes the fill-loop/mirrorIndex comparison test's own length-2 bounds, whose period-2 mirroring makes i0 + k and i0 - k indistinguishable by parity alone, by widening it to length 4. Pins F-12's and F-13's own outermost cells (n = last + 1 and n = last, respectively) against exact Float32Array values computed independently from the same constants and equations the production code uses: both lie within F-8/F-9's own already-touched range, so only their specific numeric contribution, not which cells changed at all, can show whether either pass's own loop reached that last iteration. --- .../pdf-codec/src/image/jpeg2000-dwt.test.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts index 0ef5097e1..4493d759e 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts @@ -9,6 +9,7 @@ import { mirrorIndex, subbandBounds, synthesiseLine, + times, } from "./jpeg2000-dwt"; // Every buffer cell inverse53Filter/inverse97Filter actually write to gets a value distinguishable from this sentinel: the even step's own F-5 arithmetic maps a uniform 100 to 100 - floor((100 + 100 + 2) / 4) = 50, and every later lifting step further changes whatever it touches, so a sentinel-filled buffer's own untouched/touched split can be read straight off which cells still equal 100. @@ -398,8 +399,9 @@ describe("synthesiseLine", () => { }); it("reads each fill-loop sample from mirrorIndex(i0 + k, i0, i1), not mirrorIndex(i0 - k, i0, i1)", () => { + // Length 2 (period 2) would make this indistinguishable: mirrorIndex there collapses to a parity check on (position - i0), and parity(k) === parity(-k) for every k, so i0 + k and i0 - k would always mirror to the same result. Length 4 (period 6) breaks that symmetry. const i0 = 10; - const i1 = 12; + const i1 = 14; const fed: number[] = []; synthesiseLine( (index) => index, // echo: fed[] below ends up holding exactly what each fillScratch call's own source-index argument was @@ -477,6 +479,45 @@ describe("inverse97Filter", () => { Array.from({ length: 18 }, (_, index) => index + 2), ); }); + + it("applies F-12's own beta step at n = last + 1, its outermost even index", () => { + // F-12's own range is a subset of F-8/F-9's, already touched either way, so only the exact value at its own outermost cell -- computed once, independently, straight from the same Float32Array/constants the production code uses -- can show whether F-12 actually ran there. + const buffer = new Float32Array(30).fill(SENTINEL); + inverse97Filter(buffer, 0, 4); + expect(buffer[12]).toBeCloseTo(54.763057708740234, 5); + }); + + it("applies F-13's own alpha step at n = last, its outermost odd index", () => { + const buffer = new Float32Array(30).fill(SENTINEL); + inverse97Filter(buffer, 0, 4); + expect(buffer[11]).toBeCloseTo(157.5548553466797, 3); + }); +}); + +describe("times", () => { + it("calls fn exactly `count` times, with indices 0..count - 1 in order", () => { + const calls: number[] = []; + times(4, (index) => { + calls.push(index); + }); + expect(calls).toEqual([0, 1, 2, 3]); + }); + + it("calls fn zero times for a count of zero", () => { + const calls: number[] = []; + times(0, (index) => { + calls.push(index); + }); + expect(calls).toEqual([]); + }); + + it("calls fn zero times for a negative count", () => { + const calls: number[] = []; + times(-3, (index) => { + calls.push(index); + }); + expect(calls).toEqual([]); + }); }); describe("mirrorIndex", () => { From 6d8e9c24e3c862b00720e21a8d7ddb7fb97b3cb0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 17:10:53 +0100 Subject: [PATCH 091/105] refactor(pdf-codec): drop mirrorIndex's redundant absolute-position round-trip mirrorIndex immediately computed position - i0 as its own first step, so every caller had to add i0 back on only for this function to subtract it straight back out. Taking the offset from i0 directly removes that round-trip and, as a side effect, removes the one call site (i0 + k) that could never actually be distinguished from a caller mistakenly writing i0 - k: mirroring about i0 is symmetric in the offset by definition, so offset and -offset always mirror identically regardless of which one a caller happens to pass in. --- packages/pdf-codec/src/image/jpeg2000-dwt.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.ts index 79feae1e3..b4793d5f3 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.ts @@ -48,15 +48,19 @@ export function subbandBounds( }; } -// F.3.4's whole-sample symmetric extension: outside [i0, i1) the signal is mirrored about its own two end samples, so index i0 - k reads as i0 + k and index i1 - 1 + k as i1 - 1 - k, repeating with period 2(n - 1). Exported for direct unit testing: synthesiseLine, this function's sole production caller, only ever reaches its loop (the one place mirrorIndex is called) once it has already special-cased length 0 and length 1 itself, so no length <= 1 input ever reaches mirrorIndex through that path -- only a direct call can exercise this function's own guard against it. -export function mirrorIndex(position: number, i0: number, i1: number): number { +// F.3.4's whole-sample symmetric extension: outside [i0, i1) the signal is mirrored about its own two end samples, so index i0 - k reads as i0 + k and index i1 - 1 + k as i1 - 1 - k, repeating with period 2(n - 1). Takes the position as an offset from i0 (rather than an absolute position the caller would otherwise add i0 to, only for this function to immediately subtract it back out again) since mirroring about i0 is an inherently symmetric operation on that offset -- offset and -offset always mirror identically, an equivalence a caller-side i0 + k versus i0 - k mistake could never actually observe either way. Exported for direct unit testing: synthesiseLine, this function's sole production caller, only ever reaches its loop (the one place mirrorIndex is called) once it has already special-cased length 0 and length 1 itself, so no length <= 1 input ever reaches mirrorIndex through that path -- only a direct call can exercise this function's own guard against it. +export function mirrorIndex( + offsetFromI0: number, + i0: number, + i1: number, +): number { const length = i1 - i0; if (length <= 1) { return i0; } const period = 2 * (length - 1); - // The double modulo is the standard way to fold a JS `%` result (which follows the sign of position - i0, so it can itself be negative) into [0, period) without a separate negative-offset branch: the result is already fully normalized before the mirror step below ever runs. - const offset = (((position - i0) % period) + period) % period; + // The double modulo is the standard way to fold a JS `%` result (which follows the sign of offsetFromI0, so it can itself be negative) into [0, period) without a separate negative-offset branch: the result is already fully normalized before the mirror step below ever runs. + const offset = ((offsetFromI0 % period) + period) % period; return i0 + (offset >= length ? period - offset : offset); } @@ -197,7 +201,7 @@ export function synthesiseLine( return; } for (let k = -EXTENSION_MARGIN; k < length + EXTENSION_MARGIN; k++) { - fillScratch(EXTENSION_MARGIN + k, read(mirrorIndex(i0 + k, i0, i1))); + fillScratch(EXTENSION_MARGIN + k, read(mirrorIndex(k, i0, i1))); } runFilter(); for (let k = 0; k < length; k++) { From c3b25065d8ec55bece686c1b50faf6b41f36c54d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 17:11:01 +0100 Subject: [PATCH 092/105] test(pdf-codec): update mirrorIndex/synthesiseLine tests for the offset-from-i0 signature Updates every call site for mirrorIndex's new offsetFromI0 parameter, adds a nonzero-i0 case that genuinely exercises the difference between an offset and an absolute position (every prior case used i0 = 0, where the two coincide), and simplifies the fill-loop ground-truth comparison now that the call site passes k directly rather than i0 + k. --- .../pdf-codec/src/image/jpeg2000-dwt.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts index 4493d759e..979033eb7 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts @@ -398,8 +398,7 @@ describe("synthesiseLine", () => { expect(filterCalls).toBe(1); }); - it("reads each fill-loop sample from mirrorIndex(i0 + k, i0, i1), not mirrorIndex(i0 - k, i0, i1)", () => { - // Length 2 (period 2) would make this indistinguishable: mirrorIndex there collapses to a parity check on (position - i0), and parity(k) === parity(-k) for every k, so i0 + k and i0 - k would always mirror to the same result. Length 4 (period 6) breaks that symmetry. + it("reads each fill-loop sample from mirrorIndex(k, i0, i1), the same k the scratch offset is built from", () => { const i0 = 10; const i1 = 14; const fed: number[] = []; @@ -422,7 +421,7 @@ describe("synthesiseLine", () => { // mirrorIndex itself is separately verified correct (see the describe block below), so it doubles here as ground truth for what synthesiseLine's fill loop ought to have fed it. const expected = []; for (let k = -6; k < i1 - i0 + 6; k++) { - expected.push(mirrorIndex(i0 + k, i0, i1)); + expected.push(mirrorIndex(k, i0, i1)); } expect(fed).toEqual(expected); }); @@ -521,7 +520,7 @@ describe("times", () => { }); describe("mirrorIndex", () => { - it("returns the sole in-range index for a length-1 range, whatever position is asked for", () => { + it("returns the sole in-range index for a length-1 range, whatever offset is asked for", () => { expect(mirrorIndex(0, 5, 6)).toBe(5); expect(mirrorIndex(-3, 5, 6)).toBe(5); expect(mirrorIndex(9, 5, 6)).toBe(5); @@ -531,16 +530,22 @@ describe("mirrorIndex", () => { expect(mirrorIndex(0, 3, 3)).toBe(3); }); - it("mirrors a position before i0 about i0 itself", () => { - // [i0, i1) = [0, 4): position -1 mirrors to 1, matching F.3.4's own reflection about the first sample. + it("mirrors a negative offset about i0 itself", () => { + // [i0, i1) = [0, 4): offset -1 (position i0 - 1) mirrors to i0 + 1, matching F.3.4's own reflection about the first sample. expect(mirrorIndex(-1, 0, 4)).toBe(1); }); - it("mirrors a position at or past i1 about the last in-range sample", () => { + it("mirrors an offset at or past i1 - i0 about the last in-range sample", () => { expect(mirrorIndex(4, 0, 4)).toBe(2); }); - it("leaves a position already inside [i0, i1) unchanged", () => { + it("leaves an offset already inside [0, i1 - i0) unchanged", () => { expect(mirrorIndex(2, 0, 4)).toBe(2); }); + + it("mirrors the same way regardless of i0, once the offset from it is the same", () => { + // A nonzero i0, unlike every case above, so offsetFromI0 and the absolute position genuinely differ. + expect(mirrorIndex(-1, 100, 104)).toBe(101); + expect(mirrorIndex(4, 100, 104)).toBe(102); + }); }); From ed4b188c6c38e8b6ce4c1c32f1ece2986b26d851 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:22:47 +0100 Subject: [PATCH 093/105] fix(ci): raise the mutation shard timeout so a cold run under cache eviction can finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mutation-incremental cache is pooled under one shared key prefix across every package's every shard, so a package's own incremental history can be evicted by unrelated packages' cache churn well before that package needs it again — any shard can land a fully cold run at any time, not only on a genuine first-ever run. pdf-codec's own shard hit exactly this: its incremental cache missed entirely, forcing a cold run of its full mutant set, and the job was killed by the 180-minute timeout mid-run with no result. 300 minutes gives a cold run of a large package's full mutant set realistic headroom to actually finish. --- .github/workflows/mutation.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 7451e04c7..3768826f3 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -47,8 +47,8 @@ jobs: needs: plan if: needs.plan.outputs.has-packages == 'true' runs-on: ubuntu-latest - # Generous, deliberately: a shard's incremental cache can only ever help (see the caching step below), never hurt, so a cold run -- no prior cache to restore, e.g. this workflow's first ever run, or a shard whose package assignment shifted since the last one that covered it -- pays the full mutation-test cost for whichever packages landed in it. documents.js alone (the single largest package, ~44k mutatable source lines) is sharded onto its own shard for exactly this reason; the timeout has to fit its cold-run cost, not a warm one. - timeout-minutes: 180 + # Generous, deliberately: a shard's incremental cache can only ever help (see the caching step below), never hurt, so a cold run -- no prior cache to restore, e.g. this workflow's first ever run, or a shard whose package assignment shifted since the last one that covered it -- pays the full mutation-test cost for whichever packages landed in it. documents.js alone (the single largest package, ~44k mutatable source lines) is sharded onto its own shard for exactly this reason; the timeout has to fit its cold-run cost, not a warm one. The shared "mutation-incremental-" cache prefix is pooled across every package's every shard (see the restore-keys comment above), so a package's own incremental history can be evicted by unrelated packages' cache churn well before that package's own next run -- any shard can therefore land a fully cold run at any time, not only on a genuine first-ever run, and the budget has to cover that for every package sharded here, not just documents.js's own worst case. + timeout-minutes: 300 strategy: fail-fast: false matrix: ${{ fromJson(needs.plan.outputs.matrix) }} From 3961095771b6ed4806812664c423410dbd8789a5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:35:09 +0100 Subject: [PATCH 094/105] test(document-operations): raise the unit test timeout for the threshold-boundary tests document-output.test.ts's threshold-boundary tests each base64-encode a 5 MB buffer through documents.js's own bytesToBase64 -- real work that finishes in well under a second uninstrumented and idle, confirmed directly at ~200ms. What pushes them over vitest's 5000ms default is CI runner scheduling contention rather than the encode itself: both tests landed at 5.5-5.8s wall time on two separate, otherwise-unremarkable CI runs. This mirrors the same contention-driven timeout pattern already applied in document-outline.js and pdf-codec's own vitest.config.ts. --- packages/document-operations/vitest.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/document-operations/vitest.config.ts b/packages/document-operations/vitest.config.ts index 5e215985d..d671079ac 100644 --- a/packages/document-operations/vitest.config.ts +++ b/packages/document-operations/vitest.config.ts @@ -1,8 +1,12 @@ import { defineConfig } from "vitest/config"; +// document-output.test.ts's threshold-boundary tests each base64-encode a 5 MB buffer through documents.js's own bytesToBase64 -- real work that finishes in well under a second uninstrumented and idle (confirmed directly: ~200ms). What pushes them over vitest's 5000ms default is CI-runner scheduling contention rather than the encode itself: this workspace's CI shares its runner pool across every package's own test job in the same run, and both threshold tests landed at 5.5-5.8s wall time on two separate, otherwise-unremarkable CI runs. UNIT_TEST_TIMEOUT_MS is raised with a wide margin above both observed runs, matching the same contention-driven pattern already addressed this way in document-outline.js and pdf-codec's own vitest.config.ts, rather than tuned to the bare minimum that happened to pass once. +const UNIT_TEST_TIMEOUT_MS = 60_000; + export default defineConfig({ test: { include: ["src/**/*.test.ts"], + testTimeout: UNIT_TEST_TIMEOUT_MS, coverage: { provider: "v8", include: ["src/**/*.ts"], From 7dd163cf8a3cf204e3be0e70c4dc4fbb2ac35b76 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:29:45 +0100 Subject: [PATCH 095/105] test(pdf-codec): assert randomBytes actually fills its buffer from the CSPRNG randomBytes had no test file at all, so nothing distinguished a genuine getRandomValues call from a no-op leaving the buffer zeroed. Assert the returned length, that the bytes aren't all zero, and that two calls don't collide. --- packages/pdf-codec/src/crypto/random.test.ts | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 packages/pdf-codec/src/crypto/random.test.ts diff --git a/packages/pdf-codec/src/crypto/random.test.ts b/packages/pdf-codec/src/crypto/random.test.ts new file mode 100644 index 000000000..ae82ade98 --- /dev/null +++ b/packages/pdf-codec/src/crypto/random.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { randomBytes } from "./random"; + +describe("randomBytes", () => { + it("returns a buffer of exactly the requested length", () => { + expect(randomBytes(16).length).toBe(16); + expect(randomBytes(0).length).toBe(0); + }); + + it("actually fills the buffer from the CSPRNG rather than leaving it zeroed", () => { + // 32 bytes of true zero from a CSPRNG has a chance of roughly 1 in 2^256 -- indistinguishable from zero for test purposes, so this reliably catches a no-op stand-in for the real getRandomValues call. + const bytes = randomBytes(32); + expect(bytes.some((b) => b !== 0)).toBe(true); + }); + + it("does not return the same bytes on successive calls", () => { + const a = randomBytes(32); + const b = randomBytes(32); + expect(Array.from(a)).not.toEqual(Array.from(b)); + }); +}); From d6c1c124b2a536d758c7478157b16984b53169f5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:03 +0100 Subject: [PATCH 096/105] test(pdf-codec): assert Jpeg2000ParseError/UnsupportedError carry their own name Every existing toThrow(Jpeg2000UnsupportedError) assertion checks instanceof alone, which is silent on whether the constructor actually set error.name -- vitest's own error class matcher never inspects it. --- .../src/image/jpeg2000-errors.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packages/pdf-codec/src/image/jpeg2000-errors.test.ts diff --git a/packages/pdf-codec/src/image/jpeg2000-errors.test.ts b/packages/pdf-codec/src/image/jpeg2000-errors.test.ts new file mode 100644 index 000000000..8063954cf --- /dev/null +++ b/packages/pdf-codec/src/image/jpeg2000-errors.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + Jpeg2000ParseError, + Jpeg2000UnsupportedError, +} from "./jpeg2000-errors"; + +describe("Jpeg2000ParseError", () => { + it("carries its own class name, not the generic Error name", () => { + const error = new Jpeg2000ParseError("bad codestream"); + expect(error.name).toBe("Jpeg2000ParseError"); + expect(error.message).toBe("bad codestream"); + expect(error).toBeInstanceOf(Error); + }); +}); + +describe("Jpeg2000UnsupportedError", () => { + it("carries its own class name, not the generic Error name", () => { + const error = new Jpeg2000UnsupportedError("ROI shaping not decoded"); + expect(error.name).toBe("Jpeg2000UnsupportedError"); + expect(error.message).toBe("ROI shaping not decoded"); + expect(error).toBeInstanceOf(Error); + }); +}); From 0b58c683720df8a95fe8a0035988c3d7f0c15517 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:20 +0100 Subject: [PATCH 097/105] test(pdf-codec): cover decodeJpeg2000CodeBlock's unsupported code-block style rejection throwForUnsupportedStyle had no dedicated coverage: nothing called decodeJpeg2000CodeBlock with the selective-bypass or terminate-all style flags set to confirm it actually rejects them, or that a plain style (including the accepted predictable-termination flag) proceeds without throwing. --- .../pdf-codec/src/image/jpeg2000-t1.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/pdf-codec/src/image/jpeg2000-t1.test.ts diff --git a/packages/pdf-codec/src/image/jpeg2000-t1.test.ts b/packages/pdf-codec/src/image/jpeg2000-t1.test.ts new file mode 100644 index 000000000..f526a4b93 --- /dev/null +++ b/packages/pdf-codec/src/image/jpeg2000-t1.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { Jpeg2000UnsupportedError } from "./jpeg2000-errors"; +import type { Jpeg2000CodeBlockDecodeOptions } from "./jpeg2000-t1"; +import { decodeJpeg2000CodeBlock } from "./jpeg2000-t1"; + +function baseOptions(): Jpeg2000CodeBlockDecodeOptions { + return { + width: 4, + height: 4, + subband: "LL", + zeroBitPlanes: 0, + maxBitPlanes: 1, + totalPasses: 1, + codeBlockStyle: 0, + data: new Uint8Array(0), + }; +} + +// throwForUnsupportedStyle runs before any code-block data is touched, so these style-flag checks need no real encoded bytes at all. +describe("decodeJpeg2000CodeBlock: unsupported code-block styles", () => { + it("rejects selective arithmetic coding bypass (lazy mode)", () => { + expect(() => + decodeJpeg2000CodeBlock({ ...baseOptions(), codeBlockStyle: 0x01 }), + ).toThrow(Jpeg2000UnsupportedError); + }); + + it("rejects termination of the arithmetic coder on every coding pass", () => { + expect(() => + decodeJpeg2000CodeBlock({ ...baseOptions(), codeBlockStyle: 0x04 }), + ).toThrow(Jpeg2000UnsupportedError); + }); + + it("accepts the predictable-termination flag without throwing", () => { + expect(() => + decodeJpeg2000CodeBlock({ ...baseOptions(), codeBlockStyle: 0x10 }), + ).not.toThrow(); + }); + + it("accepts a code-block style with none of the flags set", () => { + expect(() => + decodeJpeg2000CodeBlock({ ...baseOptions(), codeBlockStyle: 0 }), + ).not.toThrow(); + }); +}); From 208c282a65eb2f97707e5c6d382b1d0e1e31a73e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:34 +0100 Subject: [PATCH 098/105] test(pdf-codec): pin readChunks' exact end-of-file chunk-header boundary Nothing exercised the offset + 8 <= bytes.length loop guard at the precise point where a chunk header (length + type, no data or CRC) sits flush against the end of the file -- the one input that distinguishes entering the loop and discovering there's no room left from never entering it at all. --- .../pdf-codec/src/image/png-decode.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/pdf-codec/src/image/png-decode.test.ts b/packages/pdf-codec/src/image/png-decode.test.ts index 9f27674dd..3a31c43b9 100644 --- a/packages/pdf-codec/src/image/png-decode.test.ts +++ b/packages/pdf-codec/src/image/png-decode.test.ts @@ -183,4 +183,25 @@ describe("decodePng against hand-built (Node zlib) fixtures", () => { it("throws on a file that does not start with the PNG signature", () => { expect(() => decodePng(new Uint8Array([1, 2, 3, 4]))).toThrow(); }); + + it("throws when a chunk header sits exactly at the end of the file with no room for its data or CRC", () => { + const scanline = Buffer.from([0, 42]); + const png = buildPng( + { width: 1, height: 1, bitDepth: 8, colorType: 0 }, + scanline, + ); + const iendChunkLength = pngChunk("IEND", Buffer.alloc(0)).length; + const withoutIend = png.subarray(0, png.length - iendChunkLength); + // A chunk header (length + type, 8 bytes) with nothing after it -- exactly the boundary offset + 8 === bytes.length that distinguishes "enter the loop and discover there's no room for the data/CRC" from "stop the loop before reading a header at all". + const truncatedHeader = Buffer.concat([ + u32be(0), + Buffer.from("tEXt", "ascii"), + ]); + const truncated = new Uint8Array( + withoutIend.length + truncatedHeader.length, + ); + truncated.set(withoutIend, 0); + truncated.set(truncatedHeader, withoutIend.length); + expect(() => decodePng(truncated)).toThrow(/runs past the end/); + }); }); From c3304b6cc3c2d62b5ee3970c5d0fd892a0bccfac Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:46 +0100 Subject: [PATCH 099/105] test(pdf-codec): pin flushWord's no-op guard for a whitespace-only run Nothing exercised flushWord with zero accumulated wordFragments: a whitespace-only run never touches wordFragments, so the guard's false branch was untested. Without it, flushWord would push a phantom empty box atom after the trailing glue, which stops the trailing-glue trim from popping it and leaks its width and a wrong ascent/descent into the line. --- packages/pdf-codec/src/text-layout.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/pdf-codec/src/text-layout.test.ts b/packages/pdf-codec/src/text-layout.test.ts index ee586efd0..5bb186e67 100644 --- a/packages/pdf-codec/src/text-layout.test.ts +++ b/packages/pdf-codec/src/text-layout.test.ts @@ -164,6 +164,16 @@ describe("wrapRunsToWidth: edge cases", () => { expect(lines[0]?.ascentPt).toBe(20 * 0.8); expect(lines[0]?.descentPt).toBe(-20 * 0.2); }); + + it("a run of pure whitespace produces an empty line with its glue stripped, not a phantom word", () => { + // Flushing a word with zero accumulated fragments must be a no-op: a whitespace-only run never accumulates wordFragments, so if flushWord ever pushed an atom here regardless, it would sit after the trailing glue and stop the trailing-glue trim from popping it, leaking the glue's width into the line. + const measurer = fakeMeasurer(); + const lines = wrapRunsToWidth([run(" ")], measurer, 100); + expect(lines).toHaveLength(1); + expect(lines[0]?.fragments).toHaveLength(0); + expect(lines[0]?.widthPt).toBe(0); + expect(lines[0]?.ascentPt).toBe(10 * 0.8); // derived from the run's own font/size via buildEmptyLine, not left at zero + }); }); describe("wrapTextToWidth", () => { From 5a961bc604851cb8072cc9ea545e3660a4805322 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:58 +0100 Subject: [PATCH 100/105] test(pdf-codec): cover every SEMANTIC_SUBTYPES entry in readPageAnnotations Underline, StrikeOut, and Squiggly had no annotation reading coverage at all, and the FreeText check used toMatchObject, which stays green even when a subtype falls out of SEMANTIC_SUBTYPES and picks up the opaque-residue fallback's extra source field instead of its own markup fields. Extends the shared annotationsPdf fixture with one markup annotation per untested subtype and asserts each one's quads plus, for FreeText, that no residue field leaked in. --- packages/pdf-codec/src/annotations.test.ts | 62 ++++++++++++++++++++++ packages/pdf-codec/src/test-support/pdf.ts | 16 +++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/annotations.test.ts b/packages/pdf-codec/src/annotations.test.ts index bff43222d..f024f619f 100644 --- a/packages/pdf-codec/src/annotations.test.ts +++ b/packages/pdf-codec/src/annotations.test.ts @@ -30,6 +30,8 @@ describe("readPdf: annotations", () => { contents: "Typed remark", author: "Reviewer", }); + // A markup-family subtype's fields, never the opaque-residue fallback's -- pins that FreeText is genuinely recognised via SEMANTIC_SUBTYPES, not merely carrying its own literal subtype string through unaffected by that classification. + expect(freeText?.source).toBeUndefined(); }); it("reads a markup annotation's /QuadPoints transformed into page space", () => { @@ -52,6 +54,66 @@ describe("readPdf: annotations", () => { ]); }); + it("reads an Underline markup annotation's /QuadPoints transformed into page space", () => { + const doc = readPdf(annotationsPdf()); + const underline = doc.pages[0]!.annotations?.find( + (a) => a.subtype === "Underline", + ); + expect(underline).toMatchObject({ + subtype: "Underline", + contents: "Underlined text", + author: "Third reviewer", + }); + expect(underline?.quads).toEqual([ + [ + { xPt: 20, yPt: 82 }, + { xPt: 80, yPt: 82 }, + { xPt: 80, yPt: 70 }, + { xPt: 20, yPt: 70 }, + ], + ]); + }); + + it("reads a StrikeOut markup annotation's /QuadPoints transformed into page space", () => { + const doc = readPdf(annotationsPdf()); + const strikeOut = doc.pages[0]!.annotations?.find( + (a) => a.subtype === "StrikeOut", + ); + expect(strikeOut).toMatchObject({ + subtype: "StrikeOut", + contents: "Struck text", + author: "Third reviewer", + }); + expect(strikeOut?.quads).toEqual([ + [ + { xPt: 90, yPt: 82 }, + { xPt: 150, yPt: 82 }, + { xPt: 150, yPt: 70 }, + { xPt: 90, yPt: 70 }, + ], + ]); + }); + + it("reads a Squiggly markup annotation's /QuadPoints transformed into page space", () => { + const doc = readPdf(annotationsPdf()); + const squiggly = doc.pages[0]!.annotations?.find( + (a) => a.subtype === "Squiggly", + ); + expect(squiggly).toMatchObject({ + subtype: "Squiggly", + contents: "Squiggly text", + author: "Third reviewer", + }); + expect(squiggly?.quads).toEqual([ + [ + { xPt: 20, yPt: 97 }, + { xPt: 80, yPt: 97 }, + { xPt: 80, yPt: 85 }, + { xPt: 20, yPt: 85 }, + ], + ]); + }); + it("carries an opaque annotation kind as quarantined PDF-syntax residue", () => { const doc = readPdf(annotationsPdf()); const stamp = doc.pages[0]!.annotations?.find((a) => a.subtype === "Stamp"); diff --git a/packages/pdf-codec/src/test-support/pdf.ts b/packages/pdf-codec/src/test-support/pdf.ts index 19d9b59a3..830a5d6b6 100644 --- a/packages/pdf-codec/src/test-support/pdf.ts +++ b/packages/pdf-codec/src/test-support/pdf.ts @@ -558,7 +558,7 @@ export function annotationsPdf(): Uint8Array { b.object(2, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"); b.object( 3, - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R] >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R] >>", ); b.object( 4, @@ -582,7 +582,19 @@ export function annotationsPdf(): Uint8Array { 10, "<< /Type /Annot /Subtype /Stamp /Rect [100 20 140 40] /Contents (Approved) /T (Reviewer) /Name /Approved >>", ); - b.classicXrefAndTrailer(10, "/Root 1 0 R"); + b.object( + 11, + "<< /Type /Annot /Subtype /Underline /Rect [20 70 80 82] /Contents (Underlined text) /T (Third reviewer) /QuadPoints [20 82 80 82 80 70 20 70] >>", + ); + b.object( + 12, + "<< /Type /Annot /Subtype /StrikeOut /Rect [90 70 150 82] /Contents (Struck text) /T (Third reviewer) /QuadPoints [90 82 150 82 150 70 90 70] >>", + ); + b.object( + 13, + "<< /Type /Annot /Subtype /Squiggly /Rect [20 85 80 97] /Contents (Squiggly text) /T (Third reviewer) /QuadPoints [20 97 80 97 80 85 20 85] >>", + ); + b.classicXrefAndTrailer(13, "/Root 1 0 R"); return b.bytes(); } From 934975d7f2b63673d15894db6fa44b497101b58d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:31:12 +0100 Subject: [PATCH 101/105] test(pdf-codec): pin SUBSET_TAG_PATTERN's anchor and exact letter count Nothing distinguished the anchored, exactly-six-letter subset-tag regex from an unanchored or wrong-length variant: every existing case's match happened to sit at position 0 with exactly six letters either way. Adds a subset-tag-shaped substring later in the name (must not strip), and five- and seven-letter runs before the '+' (must not strip either). Also covers the "BoldOblique" suffix, the one KNOWN_STYLE_SUFFIXES entry stripStyleSuffix never actually got exercised against. --- packages/pdf-codec/src/font-style.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/pdf-codec/src/font-style.test.ts b/packages/pdf-codec/src/font-style.test.ts index 38a3163f1..000b6ec56 100644 --- a/packages/pdf-codec/src/font-style.test.ts +++ b/packages/pdf-codec/src/font-style.test.ts @@ -6,6 +6,20 @@ describe("styleFromBaseFontName", () => { expect(styleFromBaseFontName("ABCDEF+Arial").baseFamily).toBe("Arial"); }); + it("does not strip a subset-tag-shaped substring that isn't anchored at the very start of the name", () => { + // The subset tag marker is only ever the name's own first six characters (ISO 32000-1 9.6.4); a "letters+" run appearing later in the name is just part of the family name and must survive untouched. + expect(styleFromBaseFontName("Foo-ABCDEF+Bar").baseFamily).toBe( + "Foo-ABCDEF+Bar", + ); + }); + + it("does not strip a shorter or longer run of uppercase letters before the '+' as if it were a six-letter subset tag", () => { + expect(styleFromBaseFontName("A+Arial").baseFamily).toBe("A+Arial"); + expect(styleFromBaseFontName("ABCDEFG+Arial").baseFamily).toBe( + "ABCDEFG+Arial", + ); + }); + it("detects bold/italic from a hyphenated suffix and strips it from the family", () => { expect(styleFromBaseFontName("Arial-BoldItalic")).toEqual({ baseFamily: "Arial", @@ -45,6 +59,14 @@ describe("styleFromBaseFontName", () => { }); }); + it('strips a hyphenated "BoldOblique" suffix from the family, distinctly from the shorter "Bold"/"Oblique" suffixes it contains', () => { + expect(styleFromBaseFontName("Helvetica-BoldOblique")).toEqual({ + baseFamily: "Helvetica", + bold: true, + italic: true, + }); + }); + it("leaves a plain regular name untouched", () => { expect(styleFromBaseFontName("Helvetica")).toEqual({ baseFamily: "Helvetica", From ff6863d7f5fcaa05a6fd887f9bbe56c90424031d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:31:28 +0100 Subject: [PATCH 102/105] test(pdf-codec): cover parseFormat4's header and segment-count guards Every existing format 4 test drives it through a real vendored font, which never exercises a truncated fixed header or a malformed declared segCountX2 (zero, or odd -- segCountX2 is always meant to be even). Adds a hand-built format 4 subtable builder alongside the existing format 6 one and drives buildCmapLookup through each malformed shape. --- packages/pdf-codec/src/cmap-table.test.ts | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/pdf-codec/src/cmap-table.test.ts b/packages/pdf-codec/src/cmap-table.test.ts index 456a8571a..1fb91a0c4 100644 --- a/packages/pdf-codec/src/cmap-table.test.ts +++ b/packages/pdf-codec/src/cmap-table.test.ts @@ -91,6 +91,74 @@ function buildFormat6Subtable( return subtable; } +// A minimal format 4 (segment mapping to delta values) subtable, one segment covering [firstCode, firstCode + glyphIds.length - 1] via idDelta (idRangeOffset left at 0, so no glyph-index array is needed). segCountX2Override lets a test deliberately install a malformed segment count without disturbing the rest of the layout. +function buildFormat4Subtable( + firstCode: number, + glyphIds: readonly number[], + segCountX2Override?: number, +): Uint8Array { + const HEADER_SIZE = 14; + const segCount = 1; + const segCountX2 = segCountX2Override ?? segCount * 2; + const endCode = firstCode + glyphIds.length - 1; + // idDelta must satisfy (code + idDelta) & 0xffff === glyphIds[code - firstCode] for every code in range; with one glyph run starting at glyphIds[0], idDelta = glyphIds[0] - firstCode covers it exactly since each subsequent glyph id increments in step with the code. + const idDelta = (glyphIds[0]! - firstCode) & 0xffff; + const arraysSize = segCountX2 * 4 + 2; // endCodes + reservedPad + startCodes + idDeltas + idRangeOffsets + const subtable = new Uint8Array(HEADER_SIZE + arraysSize); + const view = new DataView(subtable.buffer); + view.setUint16(0, 4); // format + view.setUint16(2, subtable.length); // length + view.setUint16(6, segCountX2); + if (segCountX2Override === undefined) { + // The well-formed case only: a malformed declared segCountX2 (0, or an odd value) has no real one-segment layout to write field values into, and none is needed -- the test using it only checks that the malformed count itself is rejected, not what a garbage lookup would return. + + const startCodesOffset = HEADER_SIZE + segCountX2 + 2; + const idDeltasOffset = startCodesOffset + segCountX2; + const idRangeOffsetsOffset = idDeltasOffset + segCountX2; + view.setUint16(HEADER_SIZE, endCode); + view.setUint16(startCodesOffset, firstCode); + view.setUint16(idDeltasOffset, idDelta); + view.setUint16(idRangeOffsetsOffset, 0); + } + return subtable; +} + +describe("format 4 (segment mapping to delta values)", () => { + it("drives a font whose only subtable is a hand-built format 4 one", () => { + const font = parse( + buildFontWithCmapSubtable(3, 1, buildFormat4Subtable(0x41, [11, 12, 13])), + ); + const lookup = buildCmapLookup(font); + expect(lookup).toBeDefined(); + expect(lookup!(0x41)).toBe(11); + expect(lookup!(0x42)).toBe(12); + expect(lookup!(0x43)).toBe(13); + expect(lookup!(0x44)).toBeUndefined(); // past the segment's own endCode + }); + + it("returns undefined when a format 4 subtable's own fixed header does not fit", () => { + // Four bytes (format + length) is nowhere near the 14-byte fixed header format 4 requires; hasBytes must catch this before any field past it is read. + const shortSubtable = new Uint8Array(4); + new DataView(shortSubtable.buffer).setUint16(0, 4); // format + const font = parse(buildFontWithCmapSubtable(3, 1, shortSubtable)); + expect(buildCmapLookup(font)).toBeUndefined(); + }); + + it("rejects a format 4 subtable declaring a zero segment count", () => { + const font = parse( + buildFontWithCmapSubtable(3, 1, buildFormat4Subtable(0x41, [11], 0)), + ); + expect(buildCmapLookup(font)).toBeUndefined(); + }); + + it("rejects a format 4 subtable declaring an odd segCountX2 (segCountX2 is always meant to be even)", () => { + const font = parse( + buildFontWithCmapSubtable(3, 1, buildFormat4Subtable(0x41, [11], 3)), + ); + expect(buildCmapLookup(font)).toBeUndefined(); + }); +}); + describe("format 6 (trimmed table mapping)", () => { it("drives a font whose only subtable is a format 6 one", () => { const font = parse( From 2e7624e4e2ffb21a2923a3b88602b3c46e26b79d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:31:38 +0100 Subject: [PATCH 103/105] test(pdf-codec): pin decodeUtf16BEString's odd-length trailing-byte boundary Every existing bfchar destination was an even number of bytes, so nothing distinguished dropping a dangling unpaired trailing byte from folding it into a manufactured extra code unit with an implicit zero low byte. --- packages/pdf-codec/src/cmap.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/pdf-codec/src/cmap.test.ts b/packages/pdf-codec/src/cmap.test.ts index b4dcae993..7bac1beb8 100644 --- a/packages/pdf-codec/src/cmap.test.ts +++ b/packages/pdf-codec/src/cmap.test.ts @@ -36,6 +36,16 @@ describe("parseToUnicodeCMap: bfchar", () => { expect(cmap.lookup(0x10)).toBe("ffi"); }); + it("drops a trailing unpaired byte from an odd-length UTF-16BE destination rather than manufacturing an extra code unit", () => { + const { sink } = collectDiagnostics(); + // <414243> is 3 raw bytes -- one complete UTF-16BE code unit (0x4142) plus a dangling 0x43 that forms no second pair. + const cmap = parseToUnicodeCMap( + textBytes("beginbfchar\n<0007> <414243>\nendbfchar"), + sink, + ); + expect(cmap.lookup(7)).toBe(String.fromCharCode(0x4142)); + }); + it("reports a diagnostic and stops cleanly when truncated before endbfchar", () => { const { sink, diagnostics } = collectDiagnostics(); const cmap = parseToUnicodeCMap( From 380bfa80aa8d2823897a10f93f8c3a205649144a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:38:36 +0100 Subject: [PATCH 104/105] fix(pdf-codec): compute cffIndex's own offSize instead of hardcoding it to 1 A CFF INDEX offset can legitimately need more than one byte (spec Table 2), but the fixture builder always wrote offSize 1 and truncated every offset to a single byte -- fine for the small fixtures every existing caller built, but silently wrong (wrapping offsets) the moment a fixture's cumulative entry bytes pass 255, which a Local Subrs INDEX large enough to reach the 1240-entry medium-bias threshold needs. --- packages/pdf-codec/src/test-support/cff.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/pdf-codec/src/test-support/cff.ts b/packages/pdf-codec/src/test-support/cff.ts index 708bab4d5..53f41827b 100644 --- a/packages/pdf-codec/src/test-support/cff.ts +++ b/packages/pdf-codec/src/test-support/cff.ts @@ -19,7 +19,7 @@ export function stixMathCffBytes(): Uint8Array { return cff; } -// A CFF INDEX (spec section 5), with offSize 1 -- every fixture built here is small enough for one-byte offsets, and the real font above already covers a larger offSize (its own Top DICT INDEX uses 3). +// A CFF INDEX (spec section 5). offSize is computed from the largest offset actually needed (spec Table 2: the smallest of 1/2/3/4 bytes that holds it), not hardcoded to 1 -- a fixture with enough entries or entry bytes to push the final offset past 255 (this package's own subrBias tests need a Local Subrs INDEX of over a thousand entries to reach the 1240-entry medium-bias threshold) still needs a spec-conformant INDEX, not a truncated one-byte offset that wraps. export function cffIndex(entries: readonly (readonly number[])[]): number[] { if (entries.length === 0) { return [0, 0]; @@ -28,11 +28,26 @@ export function cffIndex(entries: readonly (readonly number[])[]): number[] { for (const entry of entries) { offsets.push(offsets[offsets.length - 1]! + entry.length); } + const lastOffset = offsets[offsets.length - 1]!; + const offSize = + lastOffset <= 0xff + ? 1 + : lastOffset <= 0xffff + ? 2 + : lastOffset <= 0xffffff + ? 3 + : 4; + const offsetBytes: number[] = []; + for (const offset of offsets) { + for (let byteIndex = offSize - 1; byteIndex >= 0; byteIndex--) { + offsetBytes.push((offset >>> (byteIndex * 8)) & 0xff); + } + } return [ (entries.length >> 8) & 0xff, entries.length & 0xff, - 1, - ...offsets, + offSize, + ...offsetBytes, ...entries.flat(), ]; } From 381393525cd5aeb4ab47c6ed7991c124857b96cf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:38:47 +0100 Subject: [PATCH 105/105] test(pdf-codec): pin subrBias's switch from the small to the medium bias Nothing drove a Local Subrs INDEX anywhere near the 1240-entry threshold where subrBias switches from a bias of 107 to 1131: every existing callsubr test used a one-entry index, deep in the small-bias range. Builds a 1239-entry and a 1240-entry index, each calling its real subroutine 0 through the bias the correct branch would compute, and confirms the 1240-entry case's own charstring fails to resolve under the small bias instead. --- packages/pdf-codec/src/cff-bounds.test.ts | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/pdf-codec/src/cff-bounds.test.ts b/packages/pdf-codec/src/cff-bounds.test.ts index 5f094f90a..980b4e685 100644 --- a/packages/pdf-codec/src/cff-bounds.test.ts +++ b/packages/pdf-codec/src/cff-bounds.test.ts @@ -375,4 +375,37 @@ describe("parseCffGlyphBounds's charstring interpreter, driven by hand-built cha // Draws nothing (only stems and an endchar), so the only observable difference from a malformed charstring is that this one parses to a defined-but-empty result rather than undefined -- proving the hintmask's own byte-consumption arithmetic didn't run past or short of the charstring. expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); }); + + it("switches a Local Subrs INDEX from the small to the medium subroutine bias exactly at a count of 1240 entries", () => { + // subrBias (TN 5177 section 16, "Subrs INDEX bias"): count < 1240 biases by 107, count < 33900 biases by 1131. A callsubr operand is stored as (real index - bias), so calling subroutine 0 needs an operand of exactly -bias -- getting the bias wrong for a given count makes callsubr resolve a different (or out-of-range) subroutine entirely, which is exactly what distinguishes the two branches here. + const OP_HLINETO = 6; + const DX_100 = 100 + 139; // the single-byte small-integer encoding of 100 (bias 139) + const lineSubr = [DX_100, OP_HLINETO]; // draws from (0,0) to (100,0) + const filler = [OP_ENDCHAR]; // never called; just needs to be a syntactically valid INDEX entry + const drawnBounds = { xMin: 0, yMin: 0, xMax: 100, yMax: 0 }; + + // 1239 entries: still below the 1240 threshold, so the bias is the small one (107). Operand -107 is a plain single-byte small integer (32 = 139 + -107). + const belowThreshold = cffFontWithCharstrings({ + name: "SubrBiasSmall", + charStrings: [[32, OP_CALLSUBR]], + localSubrs: [lineSubr, ...new Array(1238).fill(filler)], + }); + expect(boundsOfOnlyGlyph(belowThreshold)).toEqual(drawnBounds); + + // Exactly 1240 entries: at the threshold, so the bias is the medium one (1131). Operand -1131 needs the 3-byte shortint form (28, then a big-endian int16): -1131 as an unsigned 16-bit pattern is 0xfb95. + const atThreshold = cffFontWithCharstrings({ + name: "SubrBiasMedium", + charStrings: [[28, 0xfb, 0x95, OP_CALLSUBR]], + localSubrs: [lineSubr, ...new Array(1239).fill(filler)], + }); + expect(boundsOfOnlyGlyph(atThreshold)).toEqual(drawnBounds); + + // The 1240-entry font's own charstring, reinterpreted against the SMALL bias instead of MEDIUM, resolves to a wildly out-of-range subroutine index and so must fail to draw -- confirming the atThreshold case above is actually pinned on the bias switching, not merely on 1240 entries happening to still work under either bias. + const atThresholdWithWrongOperand = cffFontWithCharstrings({ + name: "SubrBiasMediumWrongOperand", + charStrings: [[32, OP_CALLSUBR]], + localSubrs: [lineSubr, ...new Array(1239).fill(filler)], + }); + expect(boundsOfOnlyGlyph(atThresholdWithWrongOperand)).toBeUndefined(); + }); });