From b0651913bf3e82c84c798b37001eddd9397f249f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:11:00 +0100 Subject: [PATCH 001/135] 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 60ef6f29364b27f353649822b925299ec3d2589b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 23:20:36 +0100 Subject: [PATCH 002/135] 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 2a2981070a654a7f2ccb0a13b78b2aff856772ab Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:42:46 +0100 Subject: [PATCH 003/135] 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 d368282c2c972f9328f8d50c66f7e76626539452 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:51:09 +0100 Subject: [PATCH 004/135] 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 7c1f85fef97ee75fb93f435e087dc15237c9767a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:58:44 +0100 Subject: [PATCH 005/135] 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 c2ab7b6c314dc81c3c7b0f19bb20b351d12ca5b8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:11 +0100 Subject: [PATCH 006/135] 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 a7e577f845afa2d324f1b32c25f78c7704cd24b5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:19 +0100 Subject: [PATCH 007/135] 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 2e15014c7094d52e2a0bba2032552f3d09dcf3a7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:28 +0100 Subject: [PATCH 008/135] 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 3c626b08244b05692ff5f80f12ea6c53d19dfef2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:36 +0100 Subject: [PATCH 009/135] 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 12185066bc16e9a2a381fb80857ff1826af738d0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:44 +0100 Subject: [PATCH 010/135] 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 404282db95475335df816417a14783d09c3cf261 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:01:53 +0100 Subject: [PATCH 011/135] 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 78c1bc8d56b36b337085f10033ca8ea9a17851a8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:01 +0100 Subject: [PATCH 012/135] 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 40311867b449d0e2bdc16151acc1e2d12a79a36b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:11 +0100 Subject: [PATCH 013/135] 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 2e12bb1e47dc2282b8da5f88d1425f6311cc08b1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:20 +0100 Subject: [PATCH 014/135] 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 e8e923d379594a4b99c6f54c459beb593382c9b6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:29 +0100 Subject: [PATCH 015/135] 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 0f2e77fcf11d447ff53c5c000fba807271c4316d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:37 +0100 Subject: [PATCH 016/135] 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 350f10484f6df2aaa5e4006d306333c92189c2f6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:46 +0100 Subject: [PATCH 017/135] 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 efbdaed9c7d0cc1aba675f9ee53291456e975209 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:02:55 +0100 Subject: [PATCH 018/135] 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 5828eaaa64a7a5bce9468c72de377b36fc6dfc0f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:09:30 +0100 Subject: [PATCH 019/135] 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 294bd83f4d4e51ac91c294a785624c6bde4c9574 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:24:42 +0100 Subject: [PATCH 020/135] 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 188f4fc68daf16183d8276f7c7b2d2f8cef259e0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:24:54 +0100 Subject: [PATCH 021/135] 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 ac4f06973bb46fb09c4652ac9fab5afb4a814639 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:03 +0100 Subject: [PATCH 022/135] 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 811ddef84e1027d947a39381c0c0b0e66df3353e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:14 +0100 Subject: [PATCH 023/135] 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 f2ba3566cefd5e48bfba015629ab76d08e706904 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:25 +0100 Subject: [PATCH 024/135] 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 477be5888acc43de7870f067dcfe6f0c3b5a1a44 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:34 +0100 Subject: [PATCH 025/135] 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 258fdcc52608d11023a98011f7b41483d5f3c689 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:25:52 +0100 Subject: [PATCH 026/135] 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 f5f09742c3560b1360f7fe24f89f4b9fa130f471 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:34:55 +0100 Subject: [PATCH 027/135] 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 f887c934a525cb9e1dca23da7da00c93511cc70d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:35:05 +0100 Subject: [PATCH 028/135] 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 0a1aa62dd510ceeb232d7b13123d1e5db78f06ff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:35:15 +0100 Subject: [PATCH 029/135] 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 947f73839a9ff2f7b76caf463ab5bd6c59d732bd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:35:27 +0100 Subject: [PATCH 030/135] 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 9a27caab4daf416c79931802ee03a10f536968b2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:35:44 +0100 Subject: [PATCH 031/135] 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 6cb620fb02eb8274088a8901696c78f1d7e4fc35 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:36:07 +0100 Subject: [PATCH 032/135] 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 d42b1f2cb7bbb13826a7d287fb8b835e0a4391e9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 03:15:09 +0100 Subject: [PATCH 033/135] 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 864150261f6a321b7bc8fb6e74079427d29b8beb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 03:54:25 +0100 Subject: [PATCH 034/135] 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 55979841e22409b08357373169e2606c8f1030b1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:34:33 +0100 Subject: [PATCH 035/135] 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 bb81617b8fac83626688800becbed65d16f4f5ad Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:00:02 +0100 Subject: [PATCH 036/135] 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 26f09f8471305b8840fbea570c309203de7db0ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:08:47 +0100 Subject: [PATCH 037/135] 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 27687e5add1c9ef4e08bcd1f9ee4ec032a2cc25f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:10:46 +0100 Subject: [PATCH 038/135] 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 7d2c791b2fcb210653c5af3984f1c137bd4fbe15 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:14:34 +0100 Subject: [PATCH 039/135] 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 28986c8a7579bdabe8935ef4f56c2f62162d6273 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:59:07 +0100 Subject: [PATCH 040/135] 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 55a465f4f2879b9cb15ba1bd5e194c4efd3673dd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 06:03:17 +0100 Subject: [PATCH 041/135] 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 c4fc2f45b25b5bb6779d5b37e2f9a283ff1ace08 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 06:09:23 +0100 Subject: [PATCH 042/135] 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 30f1e88b383231caad2d5d4355b24686e29a0b32 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 06:26:58 +0100 Subject: [PATCH 043/135] 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 0718b4bb838feb1d5d9cad074079b291161b5094 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 06:31:16 +0100 Subject: [PATCH 044/135] 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 353a947f48148b0bcde22cd6a224eda337d2139f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:07:54 +0100 Subject: [PATCH 045/135] 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 928b8004f10c0e98f2cf6ce4ac0e16b73e1b4c11 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:03 +0100 Subject: [PATCH 046/135] 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 73f6387f858b0d6e601a3ff38eccd34ad1e2024f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:11 +0100 Subject: [PATCH 047/135] 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 23539b8c384977e17b7ad34569d359e1110e45ad Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:24 +0100 Subject: [PATCH 048/135] 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 e2dd41704f21393e14350f2f718a4869c44fd105 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:32 +0100 Subject: [PATCH 049/135] 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 3bc3e37279fcbba5c06f535cec17580ce7292e1a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:08:40 +0100 Subject: [PATCH 050/135] 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 983b27ba740c3d9860c5b52485254773a188d4ff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:18:22 +0100 Subject: [PATCH 051/135] 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 35564f2c65c55b7e23e9ae75b230e663d01554c8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:18:32 +0100 Subject: [PATCH 052/135] 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 9b8155b20cc993ec2c7c6b93dce345fa1764848e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:22:22 +0100 Subject: [PATCH 053/135] 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 95ecc9e2a5b08969a73100f1ac36cc040303f4b3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:22:26 +0100 Subject: [PATCH 054/135] 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 7354a8422fe7ed8d7f006e47d4629edceab6908d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:29:52 +0100 Subject: [PATCH 055/135] 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 e2c77ddbc1a75ad92ee89bbf303b9c24af0c5c57 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:33:47 +0100 Subject: [PATCH 056/135] 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 b90a902072a66edb08d2b268f1fc30ecfdce7489 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 07:41:57 +0100 Subject: [PATCH 057/135] 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 1f76ebd4c2d06c7b927a61304ad371ef6881959a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:05:41 +0100 Subject: [PATCH 058/135] 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 071eff1532d84865c812417fec3783b62f2b690e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:23:20 +0100 Subject: [PATCH 059/135] 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 1210b03ef9c4c365d372d3c6224900a997b65350 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:33:40 +0100 Subject: [PATCH 060/135] 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 17a1a5eb6e513000a9988bc58cb8f7e41f486a10 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:38:24 +0100 Subject: [PATCH 061/135] 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 70b2d16955f06d5d06cc53ff01f9a5581eb116e7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:41:36 +0100 Subject: [PATCH 062/135] 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 47e98357168dfd7a1f76dfbcd7ee7663e82b7db3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:58:15 +0100 Subject: [PATCH 063/135] 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 83a4aa0eb576a39efaea5ac5393f9ec806b1ef5e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:24:59 +0100 Subject: [PATCH 064/135] 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 a16999b896ca14e2b6424ee694a971592b161e5f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:37:11 +0100 Subject: [PATCH 065/135] 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 9a877cd750a9b73d433c2b75667c433a712fb763 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:53:53 +0100 Subject: [PATCH 066/135] 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 a38839a66e6f2969bd104cc4ebbee1ecde7457f2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:54:09 +0100 Subject: [PATCH 067/135] 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 b3d42d86baee4bcf8b38251eedcb62181d23877f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:54:24 +0100 Subject: [PATCH 068/135] 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 b42a8fae6c5e71e559f2ebfef2282a1fd386fc5f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:54:39 +0100 Subject: [PATCH 069/135] 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 81d16bbcd03bde40528199e8be78c1c31734c9d5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 12:28:30 +0100 Subject: [PATCH 070/135] 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 7c59d77c206dedfb0cfe4b416fc16f089a6472a3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 12:28:46 +0100 Subject: [PATCH 071/135] 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 3bf963d9fde5113e8441ac4c03235ccd19d12e0b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 12:29:00 +0100 Subject: [PATCH 072/135] 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 8a0dc63b1cabc25323b0a9a357995225ed240c83 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 12:39:09 +0100 Subject: [PATCH 073/135] 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 8c963dce48e880eafe265d8e8d1b0e95d9c0abbe Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 14:16:53 +0100 Subject: [PATCH 074/135] 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 c2ff33530662eae94d7e355e9ba3088489dd20fb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 15:35:20 +0100 Subject: [PATCH 075/135] 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 33bbd9df5d923a020a1033b1048e76fa67a35383 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 15:35:27 +0100 Subject: [PATCH 076/135] 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 497ecc240f007a7ab54f9802bd04a248af92438d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 15:35:35 +0100 Subject: [PATCH 077/135] 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 8aa6786a35ac6ad408a95de5a5975b3423dc3da2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:18:30 +0100 Subject: [PATCH 078/135] 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 57012b3d7a3e33e698112b9f9a5ba11cb3ec1829 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:18:38 +0100 Subject: [PATCH 079/135] 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 c9cdaba07aba8dfb2ba6ab2eadaea6030b9e5349 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:26:27 +0100 Subject: [PATCH 080/135] 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 e62e75be3bc1fc6d1e124d6ca2d06ed5a9baee00 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:26:37 +0100 Subject: [PATCH 081/135] 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 5af4f42657df08845599a89f84868713fae49a2c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:38:03 +0100 Subject: [PATCH 082/135] 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 3c1c25587f27c8dc0316f05abe6d427696b8a08d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:38:13 +0100 Subject: [PATCH 083/135] 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 beecdf2cb91afa69fae8c0baded528fe3cdcafd3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:38:26 +0100 Subject: [PATCH 084/135] 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 7efd6cfb60400122ce20a41ca41adb143ddc9041 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:55:05 +0100 Subject: [PATCH 085/135] 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 f73ab704b4062ff77fabfab7479f56dd709cdd73 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:55:15 +0100 Subject: [PATCH 086/135] 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 4a478bc1de55047e1517aaba1496a0f63d20abdf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:55:25 +0100 Subject: [PATCH 087/135] 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 59f3eade845a13f75c2131be44b53c90b0a41f97 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:55:38 +0100 Subject: [PATCH 088/135] 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 1952697db3c93c30952007dc118bcac93f903bff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 17:04:28 +0100 Subject: [PATCH 089/135] 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 729a4a8fac5cd6f6bc9d067b1d00cefd5b1effd6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 17:04:37 +0100 Subject: [PATCH 090/135] 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 cc05c7019d56995765e24ce932ca33d05423decc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 17:10:53 +0100 Subject: [PATCH 091/135] 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 41ff7ca88305bc7c9595023480963f9d665f9ed2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 17:11:01 +0100 Subject: [PATCH 092/135] 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 e3f7b8ea9d5618dbec6ee549f3821587c3e69f79 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:22:47 +0100 Subject: [PATCH 093/135] 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 7cde17404938e4294a1f03db47dbdb033b3e9995 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 20:35:09 +0100 Subject: [PATCH 094/135] 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 d9c218247d17ac2356478124ad9dffda9bf75e81 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:29:45 +0100 Subject: [PATCH 095/135] 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 f6ae3d0ad9f74ac0cb7448fb8e29e40ee043d6b7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:03 +0100 Subject: [PATCH 096/135] 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 84143afc8d338b8d2fda82ffcd404df5e2830dc8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:20 +0100 Subject: [PATCH 097/135] 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 9c73e387609e335b16c8b72a377716489cfeaed3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:34 +0100 Subject: [PATCH 098/135] 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 a9f3d1b99fc266c8ffadd446c8a47bb472b6b187 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:46 +0100 Subject: [PATCH 099/135] 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 b57b44818fe11654eda9577ede16179b0fc8f411 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:30:58 +0100 Subject: [PATCH 100/135] 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 87dbb8fcad7a87a86b84bdff846ce0a56de61ea0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:31:12 +0100 Subject: [PATCH 101/135] 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 f0ccba70e5646cf115df53deb559680c93e3e713 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:31:28 +0100 Subject: [PATCH 102/135] 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 b30a5a26f4279c77d1204d237c4f10d6440bc5fb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:31:38 +0100 Subject: [PATCH 103/135] 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 6aede90bb421b6598eb69f32355445342b707571 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:38:36 +0100 Subject: [PATCH 104/135] 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 cc6986bd10f28c4f000960d55ae51866b825790a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:38:47 +0100 Subject: [PATCH 105/135] 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(); + }); }); From 027b864eb1482299f9dc7c91de8c7628b1d23ef0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:23:12 +0100 Subject: [PATCH 106/135] test(pdf-codec): pin widthOfCode's short-circuit for a monospace face Assert the per-glyph AFM Map is never consulted when a face's fixed width already answers the lookup, so a mutant deleting the monospace short-circuit and always falling through to the table lookup cannot survive with the same observable output. --- packages/pdf-codec/src/afm-widths.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/afm-widths.test.ts b/packages/pdf-codec/src/afm-widths.test.ts index 1dbfaa2ba..b9aa5e525 100644 --- a/packages/pdf-codec/src/afm-widths.test.ts +++ b/packages/pdf-codec/src/afm-widths.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { STANDARD_METRICS, widthOfCode } from "./afm-widths"; import { WINANSI_GLYPH_NAMES } from "./encoding"; @@ -69,6 +69,13 @@ describe("widthOfCode", () => { expect(widthOfCode("Courier", 105)).toBe(600); }); + it("returns the fixed width without ever consulting the per-glyph AFM table for a monospace face", () => { + const getSpy = vi.spyOn(STANDARD_METRICS.Courier.widths, "get"); + expect(widthOfCode("Courier", 65)).toBe(600); + expect(getSpy).not.toHaveBeenCalled(); + getSpy.mockRestore(); + }); + it("returns the AFM width for a proportional face", () => { expect(widthOfCode("Helvetica", 65)).toBe(667); // 'A' }); From 1778e7960df6e21c880c0b44c9ca65e94da3b71e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:32:37 +0100 Subject: [PATCH 107/135] test(pdf-codec): key the vendored-face cache by its own base64 constant loadFace took a separate name string purely to key its cache Map, with no other use -- every StringLiteral mutant on those five names was unobservable through any of the exported getters, since nothing else ever looked up that key. Each face's deflated-base64 constant is already a unique identifier, so keying the cache by that directly removes the redundant parameter along with every mutant on it, and a new test-support/fonts.test.ts covers the caching behaviour itself (identical instance on a repeat call, real bytes on a first call, distinct bytes across two different faces). --- .../pdf-codec/src/test-support/fonts.test.ts | 26 +++++++++++++++++++ packages/pdf-codec/src/test-support/fonts.ts | 21 +++++++-------- 2 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 packages/pdf-codec/src/test-support/fonts.test.ts diff --git a/packages/pdf-codec/src/test-support/fonts.test.ts b/packages/pdf-codec/src/test-support/fonts.test.ts new file mode 100644 index 000000000..d37831534 --- /dev/null +++ b/packages/pdf-codec/src/test-support/fonts.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + caladeaItalicBytes, + carlitoBoldBytes, + carlitoItalicBytes, +} from "./fonts"; + +describe("loadFace's own per-face cache", () => { + it("returns the identical array instance on a second call for the same face, proving the cached branch actually ran", () => { + const first = carlitoBoldBytes(); + const second = carlitoBoldBytes(); + expect(second).toBe(first); + }); + + it("inflates real bytes on the very first call for a face, not a stale undefined placeholder", () => { + // caladeaItalicBytes is not called anywhere else in this file, so this is genuinely that face's first lookup against a fresh, empty cache in this test module instance. + const bytes = caladeaItalicBytes(); + expect(bytes.length).toBeGreaterThan(0); + }); + + it("keeps two different faces' inflated bytes distinct rather than sharing one cache slot", () => { + const bold = carlitoBoldBytes(); + const italic = carlitoItalicBytes(); + expect(bold).not.toEqual(italic); + }); +}); diff --git a/packages/pdf-codec/src/test-support/fonts.ts b/packages/pdf-codec/src/test-support/fonts.ts index 9a2b4af70..99f750cb7 100644 --- a/packages/pdf-codec/src/test-support/fonts.ts +++ b/packages/pdf-codec/src/test-support/fonts.ts @@ -8,14 +8,11 @@ import { base64ToBytes } from "../util/base64"; // The real, vendored text fonts as raw sfnt bytes, for tests that parse genuine font tables rather than a synthetic fixture. These are the exact bytes of assets/fonts/{carlito,caladea}/*.ttf: scripts/generate-text-font-assets.mjs DEFLATE-compresses and base64-encodes each vendored file into src/assets/, and inflating one here reverses that transform byte for byte (proved independently by src/assets/text-font-assets.test.ts). Going through the embedded asset rather than reading assets/ from disk keeps the suite filesystem-free, matching the convention test-support/ccitt-fax.ts states for its own fixtures. // -// Each face is inflated at most once per process: a Carlito face is ~600 KB inflated, and several test files parse the same one. +// Each face is inflated at most once per process: a Carlito face is ~600 KB inflated, and several test files parse the same one. Keyed by the face's own deflated-base64 constant rather than a separate name string -- that constant is already a unique per-face identifier, so a second string whose only job was cache-key uniqueness would just be one more thing to keep in sync with no observable behaviour of its own. const cache = new Map>(); -function loadFace( - name: string, - deflatedBase64: string, -): Uint8Array { - const cached = cache.get(name); +function loadFace(deflatedBase64: string): Uint8Array { + const cached = cache.get(deflatedBase64); if (cached !== undefined) { return cached; } @@ -23,26 +20,26 @@ function loadFace( const inflated = inflateSync(base64ToBytes(deflatedBase64)); const bytes = new Uint8Array(inflated.length); bytes.set(inflated); - cache.set(name, bytes); + cache.set(deflatedBase64, bytes); return bytes; } export function carlitoRegularBytes(): Uint8Array { - return loadFace("Carlito-Regular", CARLITO_REGULAR_FONT_DEFLATED_BASE64); + return loadFace(CARLITO_REGULAR_FONT_DEFLATED_BASE64); } export function carlitoBoldBytes(): Uint8Array { - return loadFace("Carlito-Bold", CARLITO_BOLD_FONT_DEFLATED_BASE64); + return loadFace(CARLITO_BOLD_FONT_DEFLATED_BASE64); } export function carlitoItalicBytes(): Uint8Array { - return loadFace("Carlito-Italic", CARLITO_ITALIC_FONT_DEFLATED_BASE64); + return loadFace(CARLITO_ITALIC_FONT_DEFLATED_BASE64); } export function caladeaRegularBytes(): Uint8Array { - return loadFace("Caladea-Regular", CALADEA_REGULAR_FONT_DEFLATED_BASE64); + return loadFace(CALADEA_REGULAR_FONT_DEFLATED_BASE64); } export function caladeaItalicBytes(): Uint8Array { - return loadFace("Caladea-Italic", CALADEA_ITALIC_FONT_DEFLATED_BASE64); + return loadFace(CALADEA_ITALIC_FONT_DEFLATED_BASE64); } From 068c5b14d9a0ec41303137531c2075323a799b74 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:45:13 +0100 Subject: [PATCH 108/135] fix(pdf-codec): scope font-style's subset-tag pattern and suffix list to their one caller Both were module-level constants read by exactly one function each. A module-level initializer only runs once per process, so any mutation to it is only ever active during that single, already-passed evaluation -- no later test run can observe a difference no matter what it asserts, since the correctly-evaluated value is what every subsequent call sees regardless of which mutant is nominally active. Moving each into its one caller's body makes it re-evaluate per call, where it is reachable again, and a new case-insensitivity test for stripStyleSuffix's regex flag covers the one gap that reachability alone didn't already close. --- packages/pdf-codec/src/font-style.test.ts | 5 ++++ packages/pdf-codec/src/font-style.ts | 28 +++++++++++------------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/pdf-codec/src/font-style.test.ts b/packages/pdf-codec/src/font-style.test.ts index 000b6ec56..c6a13a787 100644 --- a/packages/pdf-codec/src/font-style.test.ts +++ b/packages/pdf-codec/src/font-style.test.ts @@ -101,6 +101,11 @@ describe("styleFromBaseFontName", () => { ).toMatchObject({ italic: false }); }); + it("strips a hyphenated or comma-separated style suffix regardless of its letter case", () => { + expect(styleFromBaseFontName("Arial-bold").baseFamily).toBe("Arial"); + expect(styleFromBaseFontName("Arial,BOLD").baseFamily).toBe("Arial"); + }); + it("combines a name-based signal with flags rather than letting one override the other", () => { // The name alone says bold; flags alone say italic -- both should be honoured. expect(styleFromBaseFontName("Arial-Bold", { italicFlag: true })).toEqual({ diff --git a/packages/pdf-codec/src/font-style.ts b/packages/pdf-codec/src/font-style.ts index 84f385005..26f253845 100644 --- a/packages/pdf-codec/src/font-style.ts +++ b/packages/pdf-codec/src/font-style.ts @@ -13,21 +13,17 @@ export interface FontNameStyle { readonly italic: boolean; } -// Exactly six uppercase letters followed by '+' (ISO 32000-1 9.6.4) marks a subsetted font's unique tag -- meaningless to a reader and never part of the real family name. -const SUBSET_TAG_PATTERN = /^[A-Z]{6}\+/; - -// Longer, more specific suffixes are listed before the shorter suffixes they contain (BoldItalic before Bold), though the end-anchored regexes below make the order not strictly load-bearing -- "-Bold$" cannot match a string ending in "-BoldItalic". -const KNOWN_STYLE_SUFFIXES = [ - "BoldItalic", - "BoldOblique", - "Bold", - "Italic", - "Oblique", - "Regular", -]; - function stripStyleSuffix(name: string): string { - for (const suffix of KNOWN_STYLE_SUFFIXES) { + // Longer, more specific suffixes are listed before the shorter suffixes they contain (BoldItalic before Bold), though the end-anchored regexes below make the order not strictly load-bearing -- "-Bold$" cannot match a string ending in "-BoldItalic". Declared inside this function, rather than as a module-level constant, so each element is evaluated fresh on every call: a top-level array literal is built exactly once at import time, which puts every one of its entries beyond the reach of Stryker's per-test mutation switch for the rest of the process's life (see the config comment on `ignoreStatic` in ../../stryker.shared.ts for the general shape of this limitation). + const knownStyleSuffixes = [ + "BoldItalic", + "BoldOblique", + "Bold", + "Italic", + "Oblique", + "Regular", + ]; + for (const suffix of knownStyleSuffixes) { const commaPattern = new RegExp(`,${suffix}$`, "i"); const hyphenPattern = new RegExp(`-${suffix}$`, "i"); if (commaPattern.test(name)) { @@ -44,7 +40,9 @@ export function styleFromBaseFontName( baseFont: string, flags?: FontStyleFlags, ): FontNameStyle { - const withoutSubset = baseFont.replace(SUBSET_TAG_PATTERN, ""); + // Exactly six uppercase letters followed by '+' (ISO 32000-1 9.6.4) marks a subsetted font's unique tag -- meaningless to a reader and never part of the real family name. Declared here rather than as a module-level constant for the same reachability reason as knownStyleSuffixes in stripStyleSuffix above. + const subsetTagPattern = /^[A-Z]{6}\+/; + const withoutSubset = baseFont.replace(subsetTagPattern, ""); const lower = withoutSubset.toLowerCase(); const nameBold = lower.includes("bold"); const nameItalic = /italic|oblique/.test(lower); From 91f435979a1c013b22d8838e844a1f1e79e0b948 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:55:50 +0100 Subject: [PATCH 109/135] test(pdf-codec): cover every ToUnicode CMap error and boundary path Adds diagnostic-message assertions to the existing bfchar/bfrange truncation tests, plus new cases for: a stray non-end keyword mid bfchar/bfrange section (proving the end check is genuinely comparing against the exact keyword, not any keyword), a bfchar/bfrange entry whose destination or high-end token isn't a hex string, a bfrange single destination too short to carry one UTF-16BE code unit, a base unit whose high byte is non-zero (the previous fixture's 0x0041 base unit couldn't distinguish a wrong byte offset from the right one since its high byte was already zero), a bfrange destination that is neither a hex string nor an array, and an array destination truncated before its closing bracket. --- packages/pdf-codec/src/cmap.test.ts | 136 +++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 4 deletions(-) diff --git a/packages/pdf-codec/src/cmap.test.ts b/packages/pdf-codec/src/cmap.test.ts index 7bac1beb8..8e6f3a496 100644 --- a/packages/pdf-codec/src/cmap.test.ts +++ b/packages/pdf-codec/src/cmap.test.ts @@ -53,13 +53,47 @@ describe("parseToUnicodeCMap: bfchar", () => { sink, ); expect(cmap.lookup(3)).toBe("A"); - expect(diagnostics.some((d) => d.code === "pdf/cmap-truncated")).toBe(true); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: "pdf/cmap-truncated", + message: "bfchar section was truncated before endbfchar", + }), + ]); + }); + + it("reports a diagnostic and skips an entry whose destination isn't a hex string", () => { + const { sink, diagnostics } = collectDiagnostics(); + const cmap = parseToUnicodeCMap( + textBytes("beginbfchar\n<0003> 42\n<0004> <0042>\nendbfchar"), + sink, + ); + expect(cmap.lookup(3)).toBeUndefined(); + expect(cmap.lookup(4)).toBe("B"); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: "pdf/cmap-entry-invalid", + message: "bfchar entry had no valid destination hex string", + }), + ]); + }); + + it("does not stop at a stray keyword that isn't endbfchar, and reports no diagnostic for a well-formed section", () => { + const { sink, diagnostics } = collectDiagnostics(); + const cmap = parseToUnicodeCMap( + textBytes( + "beginbfchar\n<0003> <0041>\nsomestrayword\n<0004> <0042>\nendbfchar", + ), + sink, + ); + expect(cmap.lookup(3)).toBe("A"); + expect(cmap.lookup(4)).toBe("B"); + expect(diagnostics).toEqual([]); }); }); describe("parseToUnicodeCMap: bfrange", () => { it("maps a contiguous range via a single incrementing destination", () => { - const { sink } = collectDiagnostics(); + const { sink, diagnostics } = collectDiagnostics(); const cmap = parseToUnicodeCMap( textBytes("beginbfrange\n<0005> <0007> <0043>\nendbfrange"), sink, @@ -68,6 +102,7 @@ describe("parseToUnicodeCMap: bfrange", () => { expect(cmap.lookup(6)).toBe("D"); expect(cmap.lookup(7)).toBe("E"); expect(cmap.lookup(8)).toBeUndefined(); + expect(diagnostics).toEqual([]); }); it("keeps a shared prefix fixed while only the final code unit increments", () => { @@ -82,8 +117,31 @@ describe("parseToUnicodeCMap: bfrange", () => { expect(cmap.lookup(2)).toBe("XC"); }); - it("maps each code independently when the destination is an array", () => { + it("reads the base unit's high byte from the byte immediately before the low byte, not some other offset", () => { + // Base unit 0x3041 has a non-zero high byte (0x30), unlike the 0x0041 fixture above, so a wrong offset into dstBytes for the high byte produces a visibly different character rather than coincidentally the same one. const { sink } = collectDiagnostics(); + const cmap = parseToUnicodeCMap( + textBytes("beginbfrange\n<0000> <0001> <00513041>\nendbfrange"), + sink, + ); + expect(cmap.lookup(0)).toBe("Q" + String.fromCharCode(0x3041)); + expect(cmap.lookup(1)).toBe("Q" + String.fromCharCode(0x3042)); + }); + + it("maps nothing for a range whose single destination is too short to carry even one UTF-16BE code unit", () => { + const { sink, diagnostics } = collectDiagnostics(); + const cmap = parseToUnicodeCMap( + textBytes("beginbfrange\n<0005> <0007> <00>\nendbfrange"), + sink, + ); + expect(cmap.lookup(5)).toBeUndefined(); + expect(cmap.lookup(6)).toBeUndefined(); + expect(cmap.lookup(7)).toBeUndefined(); + expect(diagnostics).toEqual([]); + }); + + it("maps each code independently when the destination is an array", () => { + const { sink, diagnostics } = collectDiagnostics(); const cmap = parseToUnicodeCMap( textBytes( "beginbfrange\n<0000> <0002> [<0041> <0058> <0059>]\nendbfrange", @@ -93,6 +151,71 @@ describe("parseToUnicodeCMap: bfrange", () => { expect(cmap.lookup(0)).toBe("A"); expect(cmap.lookup(1)).toBe("X"); expect(cmap.lookup(2)).toBe("Y"); + expect(diagnostics).toEqual([]); + }); + + it("reports a diagnostic and skips an entry whose high end isn't a hex string", () => { + const { sink, diagnostics } = collectDiagnostics(); + const cmap = parseToUnicodeCMap( + textBytes("beginbfrange\n<0005> 42\n<0008> <0009> <0044>\nendbfrange"), + sink, + ); + expect(cmap.lookup(5)).toBeUndefined(); + expect(cmap.lookup(8)).toBe("D"); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: "pdf/cmap-entry-invalid", + message: "bfrange entry had no valid high-end hex string", + }), + ]); + }); + + it("reports a diagnostic and stops cleanly when an array destination is truncated before its closing bracket", () => { + const { sink, diagnostics } = collectDiagnostics(); + const cmap = parseToUnicodeCMap( + textBytes("beginbfrange\n<0000> <0002> [<0041>"), + sink, + ); + expect(cmap.lookup(0)).toBe("A"); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: "pdf/cmap-truncated", + message: "bfrange array destination was truncated", + }), + expect.objectContaining({ + code: "pdf/cmap-truncated", + message: "bfrange section was truncated before endbfrange", + }), + ]); + }); + + it("reports a diagnostic and maps nothing for a destination that is neither a hex string nor an array", () => { + const { sink, diagnostics } = collectDiagnostics(); + const cmap = parseToUnicodeCMap( + textBytes("beginbfrange\n<0005> <0007> 42\nendbfrange"), + sink, + ); + expect(cmap.lookup(5)).toBeUndefined(); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: "pdf/cmap-entry-invalid", + message: "bfrange entry had no valid destination", + }), + ]); + }); + + it("does not stop at a stray keyword that isn't endbfrange, and reports no diagnostic for a well-formed section", () => { + const { sink, diagnostics } = collectDiagnostics(); + const cmap = parseToUnicodeCMap( + textBytes( + "beginbfrange\n<0005> <0007> <0043>\nsomestrayword\n<0008> <0009> <0044>\nendbfrange", + ), + sink, + ); + expect(cmap.lookup(5)).toBe("C"); + expect(cmap.lookup(8)).toBe("D"); + expect(cmap.lookup(9)).toBe("E"); + expect(diagnostics).toEqual([]); }); it("reports a diagnostic and stops cleanly when truncated before endbfrange", () => { @@ -102,7 +225,12 @@ describe("parseToUnicodeCMap: bfrange", () => { sink, ); expect(cmap.lookup(5)).toBe("C"); - expect(diagnostics.some((d) => d.code === "pdf/cmap-truncated")).toBe(true); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: "pdf/cmap-truncated", + message: "bfrange section was truncated before endbfrange", + }), + ]); }); }); From f0808d63747ff8a5f79f5139dfbc7de5b2504241 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:08:37 +0100 Subject: [PATCH 110/135] fix(pdf-codec): scope annotation subtype sets to readPageAnnotations and cover every branch SEMANTIC_SUBTYPES and OWNED_ELSEWHERE_SUBTYPES were module-level Sets each read by only that one function, which put every one of their entries beyond Stryker's reach for the same reason recorded for font-style.ts. Moves both into the function body. Also adds the coverage the file was missing entirely: a bare Link/FileAttachment/Widget/Popup annotation is genuinely skipped, a non-Text annotation is never mistaken for the presenter-notes marker just because its /T matches, an annotation missing /Contents, /T, and /M omits those keys outright rather than carrying them as undefined, markupFields rejects both a too-short and an empty (below-8-but- already-a-multiple-of-8) /QuadPoints, and a missing /Rect reports its diagnostic with the right code and message before the entry is dropped. roundtrip.test.ts's existing hidden-notes test now also asserts the annotation itself never leaks into the annotations list, not only that its kind is excluded from visible LayoutItems. --- packages/pdf-codec/src/annotations.test.ts | 110 ++++++++++++++++++++- packages/pdf-codec/src/annotations.ts | 36 +++---- packages/pdf-codec/src/roundtrip.test.ts | 2 + 3 files changed, 129 insertions(+), 19 deletions(-) diff --git a/packages/pdf-codec/src/annotations.test.ts b/packages/pdf-codec/src/annotations.test.ts index f024f619f..6cdb2a900 100644 --- a/packages/pdf-codec/src/annotations.test.ts +++ b/packages/pdf-codec/src/annotations.test.ts @@ -1,6 +1,34 @@ import { describe, expect, it } from "vitest"; +import { NOTES_ANNOTATION_AUTHOR } from "./notes-annotation-author"; import { readPdf } from "./read"; -import { annotationsPdf } from "./test-support/pdf"; +import { annotationsPdf, FixtureBuilder } from "./test-support/pdf"; + +const HELVETICA_FONT_DICT_FOR_ANNOT_FIXTURES = + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; + +// A minimal one-page PDF whose single /Annots entry is exactly the given raw PDF dict literal (minus its own outer << >>, e.g. "/Type /Annot /Subtype /Highlight /Rect [10 10 50 20] /QuadPoints [1 2 3 4]") -- isolates one annotation-dict shape at a time from annotationsPdf()'s own fixture, whose entries are all otherwise well-formed. +function pdfWithOneAnnotation(annotDictBody: string): Uint8Array { + const b = new FixtureBuilder().header(); + 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 /Annots [6 0 R] >>", + ); + b.object(4, HELVETICA_FONT_DICT_FOR_ANNOT_FIXTURES); + b.stream(5, "<< /Length 0 >>", new Uint8Array(0)); + b.object(6, `<< ${annotDictBody} >>`); + b.classicXrefAndTrailer(6, "/Root 1 0 R"); + return b.bytes(); +} + +function markupPdfWithQuadPoints( + quadPointsLiteral: string, +): Uint8Array { + return pdfWithOneAnnotation( + `/Type /Annot /Subtype /Highlight /Rect [10 10 50 20] /QuadPoints ${quadPointsLiteral}`, + ); +} // Annotations (#721 phase 4): genuine third-party sticky notes (/Subtype /Text without this package's own presenter-notes marker), FreeText and the /QuadPoints markup family, and the opaque kinds (Stamp, Ink, ...) carried as quarantined residue -- the annotation row's marker-plus-body and residue verdicts. Link, FileAttachment, and Widget annotations are skipped here: they are owned by the link items, the attachments table, and the AcroForm field tree respectively. @@ -32,6 +60,34 @@ describe("readPdf: annotations", () => { }); // 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(); + // FreeText here carries no /QuadPoints at all -- markupFields must tolerate that rather than assuming every semantic subtype has one. + expect(freeText?.quads).toBeUndefined(); + }); + + it("omits quads for a markup annotation whose /QuadPoints has too few numbers for even one quad", () => { + const doc = readPdf(markupPdfWithQuadPoints("[1 2 3 4]")); + const highlight = doc.pages[0]!.annotations?.find( + (a) => a.subtype === "Highlight", + ); + expect(highlight?.quads).toBeUndefined(); + }); + + it("omits quads for a markup annotation whose /QuadPoints is empty -- a length that is both below 8 and already a multiple of 8, so only the length check (not the multiple-of-8 check) can be what rejects it", () => { + const doc = readPdf(markupPdfWithQuadPoints("[]")); + const highlight = doc.pages[0]!.annotations?.find( + (a) => a.subtype === "Highlight", + ); + expect(highlight?.quads).toBeUndefined(); + }); + + it("omits quads for a markup annotation whose /QuadPoints length isn't a multiple of 8", () => { + const doc = readPdf( + markupPdfWithQuadPoints("[1 2 3 4 5 6 7 8 9 10 11 12]"), + ); + const highlight = doc.pages[0]!.annotations?.find( + (a) => a.subtype === "Highlight", + ); + expect(highlight?.quads).toBeUndefined(); }); it("reads a markup annotation's /QuadPoints transformed into page space", () => { @@ -134,4 +190,56 @@ describe("readPdf: annotations", () => { const doc = readPdf(annotationsPdf()); expect(doc.pages[1]!.annotations).toBeUndefined(); }); + + it("reports a diagnostic and skips an annotation that carries no /Rect", () => { + const diagnostics: unknown[] = []; + const doc = readPdf( + pdfWithOneAnnotation("/Type /Annot /Subtype /Highlight"), + { sink: (d) => diagnostics.push(d) }, + ); + expect(doc.pages[0]!.annotations).toBeUndefined(); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: "pdf/annotation-missing-rect", + message: "a /Highlight annotation carries no /Rect; skipping it", + }), + ]); + }); + + it.each(["Link", "FileAttachment", "Widget", "Popup"])( + "skips a bare %s annotation entirely, since another reader owns that kind", + (subtype) => { + const doc = readPdf( + pdfWithOneAnnotation( + `/Type /Annot /Subtype /${subtype} /Rect [10 10 50 20]`, + ), + ); + expect(doc.pages[0]!.annotations).toBeUndefined(); + }, + ); + + it("does not skip a non-Text annotation even when its /T happens to equal the presenter-notes marker author", () => { + const doc = readPdf( + pdfWithOneAnnotation( + `/Type /Annot /Subtype /FreeText /Rect [10 10 50 20] /T (${NOTES_ANNOTATION_AUTHOR})`, + ), + ); + // The presenter-notes skip check is specifically subtype === "Text"; a FreeText annotation must never be excluded by it, no matter what its /T reads. + expect(doc.pages[0]!.annotations).toHaveLength(1); + }); + + it("omits contents, author, and modification date entirely -- not as present keys holding undefined -- when a semantic annotation carries none of /Contents, /T, or /M", () => { + const doc = readPdf( + pdfWithOneAnnotation( + "/Type /Annot /Subtype /Highlight /Rect [10 10 50 20] /QuadPoints [10 20 50 20 50 10 10 10]", + ), + ); + const highlight = doc.pages[0]!.annotations?.find( + (a) => a.subtype === "Highlight", + ); + expect(highlight).toBeDefined(); + expect(Object.hasOwn(highlight!, "contents")).toBe(false); + expect(Object.hasOwn(highlight!, "author")).toBe(false); + expect(Object.hasOwn(highlight!, "modifiedIso")).toBe(false); + }); }); diff --git a/packages/pdf-codec/src/annotations.ts b/packages/pdf-codec/src/annotations.ts index 9668d54d8..b0f370029 100644 --- a/packages/pdf-codec/src/annotations.ts +++ b/packages/pdf-codec/src/annotations.ts @@ -11,28 +11,28 @@ import type { Matrix } from "./matrix"; // Annotation reading (#721 phase 4): the /Annots walk for everything that is neither a link item (read.ts's own walk), a /FileAttachment (the attachments table owns its filespec), nor a /Widget (the AcroForm field tree owns it). The semantic set is the sticky note, FreeText, and the /QuadPoints markup family; every other kind degrades to its rect plus the raw annotation dictionary in the quarantined residue channel -- the verdict row's own split. Popup annotations are dropped outright as derivable (a popup's rect is the parent plus a fixed offset, and its contents ARE the parent's). -const SEMANTIC_SUBTYPES = new Set([ - "Text", - "FreeText", - "Highlight", - "Underline", - "StrikeOut", - "Squiggly", -]); -// Annotations another reader here already owns; listing them keeps this walk's skip set explicit rather than an else-shaped accident. -const OWNED_ELSEWHERE_SUBTYPES = new Set([ - "Link", - "FileAttachment", - "Widget", - "Popup", -]); - export function readPageAnnotations( page: PdfDict, pageMatrix: Matrix, resolver: PdfObjectResolver, sink: PdfDiagnosticSink, ): LayoutAnnotation[] { + // Both sets are scoped to this function, its only reader, rather than declared at module level: a module-level initializer runs exactly once per process, which puts every one of its literal entries permanently beyond the reach of Stryker's per-test mutation switch (see the memory note on this in the project's own notes) -- scoping them here re-evaluates them fresh on every call, where each entry is reachable again. + const semanticSubtypes = new Set([ + "Text", + "FreeText", + "Highlight", + "Underline", + "StrikeOut", + "Squiggly", + ]); + // Annotations another reader here already owns; listing them keeps this walk's skip set explicit rather than an else-shaped accident. + const ownedElsewhereSubtypes = new Set([ + "Link", + "FileAttachment", + "Widget", + "Popup", + ]); const annotsArr = asArray(dictGet(page, "Annots")); if (annotsArr === undefined) { return []; @@ -44,7 +44,7 @@ export function readPageAnnotations( continue; } const subtype = asName(dictGet(annot, "Subtype")); - if (subtype === undefined || OWNED_ELSEWHERE_SUBTYPES.has(subtype)) { + if (subtype === undefined || ownedElsewhereSubtypes.has(subtype)) { continue; } // This package's own hidden presenter-notes annotation is a round-trip mechanism, not document content -- readPageNotes consumes it, and it must not also surface as a sticky note. @@ -87,7 +87,7 @@ export function readPageAnnotations( ...(contents !== undefined ? { contents } : {}), ...(author !== undefined ? { author } : {}), ...(modifiedIso !== undefined ? { modifiedIso } : {}), - ...(SEMANTIC_SUBTYPES.has(subtype) + ...(semanticSubtypes.has(subtype) ? markupFields(annot, pageMatrix) : { source: { diff --git a/packages/pdf-codec/src/roundtrip.test.ts b/packages/pdf-codec/src/roundtrip.test.ts index 45e35e9fd..1dfc74ee4 100644 --- a/packages/pdf-codec/src/roundtrip.test.ts +++ b/packages/pdf-codec/src/roundtrip.test.ts @@ -568,6 +568,8 @@ describe("writePdf -> readPdf: structural round trip", () => { kind: "text", text: "Visible", }); + // Proves the hidden notes annotation is excluded from the annotations list itself, on its /T marker -- not merely that its kind never becomes a visible LayoutItem, which the assertion above already covers by a different mechanism. + expect(result.pages[0]!.annotations).toBeUndefined(); }); // Internal links and the destinations table they resolve against (#721): the writer emits each internalLink as a /Dest direct destination array naming the target page object, so the link and its table entry both survive -- the reader re-mints a fresh destN name for the array on the way back, which is the documented round-trip shape (names are the reader's minting, positions are the file's facts). From 5effe600cc6b32db09775be21cd890b078ef1789 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:16:21 +0100 Subject: [PATCH 111/135] test(pdf-codec): verify assemblePdf's own byte structure directly Every existing caller round-trips assemblePdf's output through this package's own readPdf, which tolerates a mangled xref table, missing trailer/startxref/%%EOF markers, and wrong offsets via its recovery scan -- so none of them could ever observe assemblePdf itself writing the wrong marker, sort order, xref count, offset width, or trailer field. Adds a dedicated suite that decodes the raw output bytes and asserts on them directly: ascending object order regardless of input order, the exact header/marker literals, the xref subsection count, each entry's fixed-width zero-padded offset pointing at that object's real byte position, and the trailer's /Size and /Root values. --- .../test-support/write-pdf-fixture.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/pdf-codec/src/test-support/write-pdf-fixture.test.ts diff --git a/packages/pdf-codec/src/test-support/write-pdf-fixture.test.ts b/packages/pdf-codec/src/test-support/write-pdf-fixture.test.ts new file mode 100644 index 000000000..630e302c7 --- /dev/null +++ b/packages/pdf-codec/src/test-support/write-pdf-fixture.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { pdfDict, pdfNum } from "../objects"; +import type { AllocatedObject } from "./write-pdf-fixture"; +import { assemblePdf } from "./write-pdf-fixture"; + +// assemblePdf's own byte-level shape is otherwise invisible to every consuming test: this package's real readPdf tolerates a mangled xref table, a missing "trailer"/"startxref"/"%%EOF" marker, or a wrong offset by falling back to a linear object scan, so every existing caller of assemblePdf still round-trips correctly even when this file's own literals or arithmetic are wrong. These tests read the raw produced bytes directly instead, independent of readPdf's own leniency. +function decode(bytes: Uint8Array): string { + return new TextDecoder("latin1").decode(bytes); +} + +describe("assemblePdf", () => { + it("writes each object at ascending object number regardless of the order given, not the order inserted", () => { + const objects: AllocatedObject[] = [ + { num: 3, value: pdfNum(30) }, + { num: 1, value: pdfNum(10) }, + { num: 2, value: pdfNum(20) }, + ]; + const text = decode(assemblePdf(objects, 1)); + const at = (marker: string): number => { + const index = text.indexOf(marker); + expect(index).toBeGreaterThanOrEqual(0); + return index; + }; + const pos1 = at("1 0 obj\n10\nendobj\n"); + const pos2 = at("2 0 obj\n20\nendobj\n"); + const pos3 = at("3 0 obj\n30\nendobj\n"); + expect(pos1).toBeLessThan(pos2); + expect(pos2).toBeLessThan(pos3); + }); + + it("starts with the PDF header and writes an endobj/xref/trailer/startxref/%%EOF tail with the exact markers a reader looks for", () => { + const objects: AllocatedObject[] = [{ num: 1, value: pdfNum(42) }]; + const text = decode(assemblePdf(objects, 1)); + expect(text.startsWith("%PDF-1.7\n")).toBe(true); + expect(text).toContain("1 0 obj\n42\nendobj\n"); + expect(text).toContain("xref\n"); + expect(text).toContain("trailer\n"); + expect(text).toContain("\nstartxref\n"); + expect(text.endsWith("%%EOF")).toBe(true); + }); + + it("writes an xref subsection header naming exactly one more entry than the highest object number", () => { + const objects: AllocatedObject[] = [ + { num: 1, value: pdfNum(1) }, + { num: 2, value: pdfNum(2) }, + { num: 3, value: pdfNum(3) }, + ]; + const text = decode(assemblePdf(objects, 1)); + expect(text).toContain("xref\n0 4\n"); + expect(text).toContain("0000000000 65535 f \n"); + }); + + it("records each object's xref entry as its own real byte offset into the file, not any other object's", () => { + const objects: AllocatedObject[] = [ + { num: 1, value: pdfNum(1) }, + { num: 2, value: pdfNum(2) }, + ]; + const bytes = assemblePdf(objects, 1); + const text = decode(bytes); + const xrefStart = text.indexOf("xref\n"); + const subsectionHeaderEnd = + text.indexOf("\n", xrefStart + "xref\n".length) + 1; + // Skip the free-entry line to reach the first real object's own xref line. + const firstEntryStart = + text.indexOf("\n", subsectionHeaderEnd + "0000000000 65535 f ".length) + + 1; + const firstEntryLine = text.slice( + firstEntryStart, + firstEntryStart + "0000000000 00000 n ".length, + ); + // Each xref entry is fixed-width (ISO 32000-1 7.5.4): a zero-padded 10-digit offset, exactly, not merely a number that happens to parse correctly regardless of its own width. + expect(firstEntryLine).toMatch(/^\d{10} 00000 n $/); + const recordedOffset = Number.parseInt(firstEntryLine.split(" ")[0]!, 10); + expect(text.slice(recordedOffset, recordedOffset + "1 0 obj".length)).toBe( + "1 0 obj", + ); + }); + + it("writes the trailer dict with /Size one more than the highest object number and /Root pointing at the given root", () => { + const objects: AllocatedObject[] = [ + { num: 1, value: pdfDict({}) }, + { num: 2, value: pdfNum(0) }, + { num: 3, value: pdfNum(0) }, + ]; + const text = decode(assemblePdf(objects, 1)); + const trailerStart = text.indexOf("trailer\n") + "trailer\n".length; + const trailerText = text.slice(trailerStart, text.indexOf("startxref") - 1); + expect(trailerText).toContain("/Size 4"); + expect(trailerText).toContain("/Root 1 0 R"); + }); + + it("points startxref at the real byte offset of its own xref keyword", () => { + const objects: AllocatedObject[] = [{ num: 1, value: pdfNum(1) }]; + const text = decode(assemblePdf(objects, 1)); + const xrefOffset = text.indexOf("xref\n"); + const startxrefMatch = /startxref\n(\d+)\n/.exec(text); + expect(startxrefMatch).not.toBeNull(); + expect(Number.parseInt(startxrefMatch![1]!, 10)).toBe(xrefOffset); + }); +}); From 26d26004948ea90ce1b2b45f43d74fc4e69a11f1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:24:37 +0100 Subject: [PATCH 112/135] fix(pdf-codec): stop buildGsubTable racing a markFilteringSet write against its own subtable write The eager markFilteringSet write and the subtable-placement loop target the same byte offset whenever no markFilteringSet slot is reserved, and the loop always runs after the write -- so a wrong guard there is invisible for every lookup with at least one subtable, since the real subtable bytes simply overwrite whatever the guard wrote first. A lookup with zero subtables has nothing to overwrite it with, so the same wrong guard instead writes two bytes straight past the end of a table sized for no such slot. Simplifies the flag check itself: bitwise AND already coerces an absent flag to 0, so the explicit undefined check was redundant with no observable behaviour of its own, and removing it removes an unkillable mutant along with it. A new empty-subtables test covers the guard directly. --- packages/pdf-codec/src/test-support/sfnt.test.ts | 9 +++++++++ packages/pdf-codec/src/test-support/sfnt.ts | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/test-support/sfnt.test.ts b/packages/pdf-codec/src/test-support/sfnt.test.ts index ec6c5e804..4030d5b41 100644 --- a/packages/pdf-codec/src/test-support/sfnt.test.ts +++ b/packages/pdf-codec/src/test-support/sfnt.test.ts @@ -522,6 +522,15 @@ describe("buildGsubTable", () => { ]).toEqual([9, 9]); }); + it("never writes a markFilteringSet slot for a lookup with no subtables at all", () => { + // A lookup with zero subtables leaves no trailing byte range for an eagerly-written markFilteringSet slot to be silently overwritten by afterwards (unlike every non-empty case, where the following subtable write clobbers it back) -- so a guard that fires unconditionally writes straight past the end of this lookup's own 6-byte header, which the reserved-width computation left with no extra room for. + const table = buildGsubTable([], [{ type: 1, subtables: [] }]); + const lookupListAt = u16(table, 8); + const lookupAt = lookupListAt + u16(table, lookupListAt + 2); + expect(u16(table, lookupAt + 4)).toBe(0); // subtableCount + expect(table.length - lookupAt).toBe(6); + }); + it("omits the markFilteringSet slot when the flag is set but does not select useMarkFilteringSet", () => { const table = buildGsubTable( [], diff --git a/packages/pdf-codec/src/test-support/sfnt.ts b/packages/pdf-codec/src/test-support/sfnt.ts index e2f3aab9b..1bae05eca 100644 --- a/packages/pdf-codec/src/test-support/sfnt.ts +++ b/packages/pdf-codec/src/test-support/sfnt.ts @@ -656,8 +656,8 @@ export function buildGsubTable( featureTableAt += featureTables[index]!.length; }); const lookupTables = lookups.map((lookup) => { - const markFilteringSetWidth = - lookup.flag !== undefined && (lookup.flag & 0x0010) !== 0 ? 2 : 0; + // No separate "is flag even defined" check is needed: JS's bitwise `&` coerces `undefined` to 0 before operating, so `undefined & 0x0010` is already 0 -- exactly the same as explicitly treating an absent flag as clearing every bit. + const markFilteringSetWidth = ((lookup.flag ?? 0) & 0x0010) !== 0 ? 2 : 0; // The Lookup table's own layout: a 6-byte header, the subtable offset array, then — only when the flag selects one — the trailing markFilteringSet index the flag's set number refers to. let at = 6 + lookup.subtables.length * 2 + markFilteringSetWidth; const offsets = lookup.subtables.map((subtable) => { From 7b9f4555ae0dac7225e78f8903d0880119a38647 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:32:23 +0100 Subject: [PATCH 113/135] test(pdf-codec): exercise widthOfCode's missing-AFM-width guard directly Every real standard-14 AFM defines a width for every WinAnsi-mapped glyph, so this guard's throw is unreachable through the public API with real data -- it exists purely as a caller-invariant check against a future data gap. STANDARD_METRICS is already exported for testing (the monospace short-circuit spy above it does the same), so this deletes one real widths entry, exercises the guard, and restores it. --- packages/pdf-codec/src/afm-widths.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/pdf-codec/src/afm-widths.test.ts b/packages/pdf-codec/src/afm-widths.test.ts index b9aa5e525..7d507d8aa 100644 --- a/packages/pdf-codec/src/afm-widths.test.ts +++ b/packages/pdf-codec/src/afm-widths.test.ts @@ -83,4 +83,18 @@ describe("widthOfCode", () => { it("throws for a code with no WinAnsi glyph mapping", () => { expect(() => widthOfCode("Helvetica", 1)).toThrow(/WinAnsi/); }); + + it("throws naming the face, glyph, and code when a face's own AFM table is genuinely missing a glyph its widths map should carry", () => { + // Every real standard-14 AFM defines a width for every WinAnsi-mapped glyph (proved by the spot-check above), so this path is unreachable through the public API with real data -- it exists as a caller-invariant guard against a future data gap, per the function's own doc comment. STANDARD_METRICS is exported specifically so a test can reach behind that invariant and exercise the guard directly, deleting one real entry and restoring it immediately after. The cast undoes only this module's own `ReadonlyMap` return type, which exists to stop ordinary callers mutating shared metrics -- the backing object is a genuine mutable Map, and this test's whole point is temporarily mutating it. + const widths = STANDARD_METRICS.Helvetica.widths as Map; + const original = widths.get("A"); + widths.delete("A"); + try { + expect(() => widthOfCode("Helvetica", 65)).toThrow( + "Helvetica has no AFM width for glyph 'A' (code 65)", + ); + } finally { + widths.set("A", original!); + } + }); }); From 46dbdc562a614f8be25ebb6fb29154431fca4c27 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:39:57 +0100 Subject: [PATCH 114/135] test(pdf-codec): cover cffIndex's offSize boundaries and the two sfnt/CFF-table guards Adds a dedicated test-support/cff.test.ts (none existed before) that pins cffIndex's offSize selection at each exact boundary (0xff, 0xffff, 0xffffff) rather than relying on incidental coverage from unrelated charstring tests. Extracts stixMathCffBytes's two guard clauses into a new cffTableFromSfnt so they can be driven directly against a small synthetic sfnt built with buildSfnt -- neither guard is reachable through the one real 691 KB vendored asset, which always parses successfully. Also stops cffFontWithCharstrings's local-subrs default from going through an always-unreachable ?? fallback: hasPrivate is already exactly the same check as the default's own condition, so narrowing directly on options.localSubrs lets TypeScript rule the fallback value out rather than leaving dead code behind it. --- .../pdf-codec/src/test-support/cff.test.ts | 65 +++++++++++++++++++ packages/pdf-codec/src/test-support/cff.ts | 25 +++++-- 2 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 packages/pdf-codec/src/test-support/cff.test.ts diff --git a/packages/pdf-codec/src/test-support/cff.test.ts b/packages/pdf-codec/src/test-support/cff.test.ts new file mode 100644 index 000000000..94d29edfe --- /dev/null +++ b/packages/pdf-codec/src/test-support/cff.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { buildSfnt } from "./sfnt"; +import { cffIndex, cffTableFromSfnt } from "./cff"; + +// cffIndex's own offSize selection (CFF spec Table 2): the smallest of 1/2/3/4 bytes that holds the INDEX's final cumulative offset. A single entry of length N gives a final offset of exactly 1 + N (the INDEX's offsets are 1-based), so choosing N pins the exact boundary between two offSize widths without needing a real font-sized fixture. +function indexWithOneEntryOfLength(length: number): readonly number[] { + return cffIndex([new Array(length).fill(0)]); +} + +describe("cffIndex offSize selection", () => { + it("stays at offSize 1 for a final offset of exactly 0xff", () => { + const result = indexWithOneEntryOfLength(0xff - 1); + expect(result[2]).toBe(1); + }); + + it("steps up to offSize 2 the moment the final offset exceeds 0xff", () => { + const result = indexWithOneEntryOfLength(0xff); + expect(result[2]).toBe(2); + }); + + it("stays at offSize 2 for a final offset of exactly 0xffff", () => { + const result = indexWithOneEntryOfLength(0xffff - 1); + expect(result[2]).toBe(2); + }); + + it("steps up to offSize 3 the moment the final offset exceeds 0xffff", () => { + const result = indexWithOneEntryOfLength(0xffff); + expect(result[2]).toBe(3); + }); + + it("stays at offSize 3 for a final offset of exactly 0xffffff", () => { + const result = indexWithOneEntryOfLength(0xffffff - 1); + expect(result[2]).toBe(3); + }); + + it("steps up to offSize 4 the moment the final offset exceeds 0xffffff", () => { + const result = indexWithOneEntryOfLength(0xffffff); + expect(result[2]).toBe(4); + }); + + it("returns the fixed 2-byte {count: 0} form for an empty INDEX", () => { + expect(cffIndex([])).toEqual([0, 0]); + }); +}); + +describe("cffTableFromSfnt", () => { + it("throws naming the source when the bytes given don't parse as an sfnt container at all", () => { + expect(() => + cffTableFromSfnt(new Uint8Array([1, 2, 3, 4]), "a made-up test font"), + ).toThrow("a made-up test font failed to parse as an sfnt container"); + }); + + it("throws naming the source when the sfnt parses but carries no 'CFF ' table", () => { + const sfnt = buildSfnt(new Map([["head", new Uint8Array(4)]])); + expect(() => cffTableFromSfnt(sfnt, "a made-up test font")).toThrow( + "a made-up test font has no CFF table", + ); + }); + + it("returns the real table bytes when the sfnt does carry a 'CFF ' table", () => { + const cffBytes = new Uint8Array([9, 9, 9]); + const sfnt = buildSfnt(new Map([["CFF ", cffBytes]])); + expect(cffTableFromSfnt(sfnt, "a made-up test font")).toEqual(cffBytes); + }); +}); diff --git a/packages/pdf-codec/src/test-support/cff.ts b/packages/pdf-codec/src/test-support/cff.ts index 53f41827b..fdf5b7544 100644 --- a/packages/pdf-codec/src/test-support/cff.ts +++ b/packages/pdf-codec/src/test-support/cff.ts @@ -4,21 +4,32 @@ import { base64ToBytes } from "../util/base64"; // Fixtures for the two CFF readers (cff-probe.ts and cff-bounds.ts): the real vendored font's own 'CFF ' table, plus a builder for the small hand-made programs that font does not happen to contain (a CID-keyed Top DICT, and the malformed shapes). -// The real, vendored STIX Two Math font's own 'CFF ' table -- 691 KB of genuine CFF data produced by a real font toolchain, not a fixture written to satisfy these parsers. -export function stixMathCffBytes(): Uint8Array { - const font = parseSfnt(base64ToBytes(STIX_TWO_MATH_FONT_BASE64)); +// Extracted from stixMathCffBytes so a test can drive its two guards directly against a small synthetic sfnt, rather than only against the one real 691 KB vendored asset that never actually triggers either of them. +export function cffTableFromSfnt( + sfntBytes: Uint8Array, + sourceDescription: string, +): Uint8Array { + const font = parseSfnt(sfntBytes); if (font === undefined) { throw new Error( - "the vendored STIX Two Math font failed to parse as an sfnt container", + `${sourceDescription} failed to parse as an sfnt container`, ); } const cff = sfntTableBytes(font, "CFF "); if (cff === undefined) { - throw new Error("the vendored STIX Two Math font has no CFF table"); + throw new Error(`${sourceDescription} has no CFF table`); } return cff; } +// The real, vendored STIX Two Math font's own 'CFF ' table -- 691 KB of genuine CFF data produced by a real font toolchain, not a fixture written to satisfy these parsers. +export function stixMathCffBytes(): Uint8Array { + return cffTableFromSfnt( + base64ToBytes(STIX_TWO_MATH_FONT_BASE64), + "the vendored STIX Two Math font", + ); +} + // 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) { @@ -181,7 +192,9 @@ export function cffFontWithCharstrings(options: { // 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 ?? []) : []; + // Narrowed directly on options.localSubrs itself, not on the separately-computed hasPrivate boolean above -- hasPrivate is already defined as this exact check, so a `?? []` fallback here could never actually fire; checking the real value lets TypeScript rule that branch out entirely instead of leaving an always-unreachable default in the code. + const localSubrIndex = + options.localSubrs === undefined ? [] : cffIndex(options.localSubrs); const privateSize = privateDictBytes.length; const charStringsOffset = hasPrivate From 57654474afe306d4929b226b1638961825d75395 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:21:58 +0100 Subject: [PATCH 115/135] test(pdf-codec): cover format 12 cmap subtables and the subtable-preference ranking buildCmapLookup's preferenceRank ranked format 12 subtables and a (3, 10)/(0, *) platform preference among competing subtables, but no test ever built a format 12 subtable or a font with more than one candidate subtable, leaving that ranking and the whole format 12 reader (parseFormat12, its own header/group-array truncation guards, and its forEachMapping clamp to the last valid Unicode code point) unexercised. Also covers format 4's own idRangeOffset !== 0 glyph-index-array path (only the idDelta-only path had a fixture), a subtable in an unsupported format being dropped without disturbing its siblings, and a cmap whose own subtable-record array or an individual record's offset doesn't fit the table. --- packages/pdf-codec/src/cmap-table.test.ts | 366 +++++++++++++++++++++- 1 file changed, 354 insertions(+), 12 deletions(-) diff --git a/packages/pdf-codec/src/cmap-table.test.ts b/packages/pdf-codec/src/cmap-table.test.ts index 1fb91a0c4..5251ce1f0 100644 --- a/packages/pdf-codec/src/cmap-table.test.ts +++ b/packages/pdf-codec/src/cmap-table.test.ts @@ -45,22 +45,33 @@ describe("buildCmapLookup against the real vendored fonts", () => { }); }); -// A minimal sfnt carrying exactly one 'cmap' subtable, built to the spec's own layout (ISO/IEC 14496-22 clause 5.1). No vendored font here ships a format 6 subtable as its only mapping, so the fallback that reads one has no real font to exercise it. -function buildFontWithCmapSubtable( - platformId: number, - encodingId: number, - subtable: Uint8Array, +// A minimal sfnt carrying one or more 'cmap' subtables, built to the spec's own layout (ISO/IEC 14496-22 clause 5.1). Subtables are laid out back-to-back in the order given, right after the fixed-size array of subtable records that names each one's platform/encoding pair and byte offset. +function buildFontWithCmapSubtables( + entries: readonly { + readonly platformId: number; + readonly encodingId: number; + readonly subtable: Uint8Array; + }[], ): Uint8Array { const CMAP_HEADER_SIZE = 4; const SUBTABLE_RECORD_SIZE = 8; - const subtableOffset = CMAP_HEADER_SIZE + SUBTABLE_RECORD_SIZE; - const cmap = new Uint8Array(subtableOffset + subtable.length); + const recordsSize = entries.length * SUBTABLE_RECORD_SIZE; + const subtablesSize = entries.reduce( + (total, entry) => total + entry.subtable.length, + 0, + ); + const cmap = new Uint8Array(CMAP_HEADER_SIZE + recordsSize + subtablesSize); const cmapView = new DataView(cmap.buffer); - cmapView.setUint16(2, 1); // numTables - cmapView.setUint16(CMAP_HEADER_SIZE, platformId); - cmapView.setUint16(CMAP_HEADER_SIZE + 2, encodingId); - cmapView.setUint32(CMAP_HEADER_SIZE + 4, subtableOffset); - cmap.set(subtable, subtableOffset); + cmapView.setUint16(2, entries.length); // numTables + let subtableOffset = CMAP_HEADER_SIZE + recordsSize; + entries.forEach((entry, index) => { + const recordOffset = CMAP_HEADER_SIZE + index * SUBTABLE_RECORD_SIZE; + cmapView.setUint16(recordOffset, entry.platformId); + cmapView.setUint16(recordOffset + 2, entry.encodingId); + cmapView.setUint32(recordOffset + 4, subtableOffset); + cmap.set(entry.subtable, subtableOffset); + subtableOffset += entry.subtable.length; + }); const DIRECTORY_SIZE = 12 + 16; const font = new Uint8Array(DIRECTORY_SIZE + cmap.length); @@ -74,6 +85,15 @@ function buildFontWithCmapSubtable( return font; } +// The single-subtable case, which every existing test below was written against. +function buildFontWithCmapSubtable( + platformId: number, + encodingId: number, + subtable: Uint8Array, +): Uint8Array { + return buildFontWithCmapSubtables([{ platformId, encodingId, subtable }]); +} + function buildFormat6Subtable( firstCode: number, glyphIds: readonly number[], @@ -123,6 +143,58 @@ function buildFormat4Subtable( return subtable; } +// A minimal format 12 (segmented coverage) subtable, one group per {startCharCode, endCharCode, startGlyphId} triple, exactly as the spec lays it out (ISO/IEC 14496-22 clause 5.1.7). +function buildFormat12Subtable( + groups: readonly { + readonly startCharCode: number; + readonly endCharCode: number; + readonly startGlyphId: number; + }[], +): Uint8Array { + const HEADER_SIZE = 16; + const GROUP_SIZE = 12; + const subtable = new Uint8Array(HEADER_SIZE + groups.length * GROUP_SIZE); + const view = new DataView(subtable.buffer); + view.setUint16(0, 12); // format + view.setUint32(4, subtable.length); // length + view.setUint32(12, groups.length); // numGroups + groups.forEach((group, index) => { + const recordOffset = HEADER_SIZE + index * GROUP_SIZE; + view.setUint32(recordOffset, group.startCharCode); + view.setUint32(recordOffset + 4, group.endCharCode); + view.setUint32(recordOffset + 8, group.startGlyphId); + }); + return subtable; +} + +// A format 4 subtable with an explicit glyph-index array, for exercising the idRangeOffset !== 0 branch buildFormat4Subtable's own idDelta-only layout never reaches. One segment [firstCode, firstCode + glyphIds.length - 1], each code's glyph ID read from the array itself rather than derived by adding idDelta. +function buildFormat4SubtableWithGlyphArray( + firstCode: number, + glyphIds: readonly number[], +): Uint8Array { + const HEADER_SIZE = 14; + const segCountX2 = 2; + const endCode = firstCode + glyphIds.length - 1; + const startCodesOffset = HEADER_SIZE + segCountX2 + 2; + const idDeltasOffset = startCodesOffset + segCountX2; + const idRangeOffsetsOffset = idDeltasOffset + segCountX2; + const glyphArrayOffset = idRangeOffsetsOffset + segCountX2; + const subtable = new Uint8Array(glyphArrayOffset + glyphIds.length * 2); + const view = new DataView(subtable.buffer); + view.setUint16(0, 4); // format + view.setUint16(2, subtable.length); // length + view.setUint16(6, segCountX2); + view.setUint16(HEADER_SIZE, endCode); + view.setUint16(startCodesOffset, firstCode); + view.setUint16(idDeltasOffset, 0); // idDelta is added to the array's own glyph ID, so 0 leaves it unchanged + // idRangeOffset is relative to its OWN field's byte position, per spec Table 5b. + view.setUint16(idRangeOffsetsOffset, glyphArrayOffset - idRangeOffsetsOffset); + glyphIds.forEach((glyphId, index) => { + view.setUint16(glyphArrayOffset + index * 2, glyphId); + }); + 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( @@ -157,6 +229,209 @@ describe("format 4 (segment mapping to delta values)", () => { ); expect(buildCmapLookup(font)).toBeUndefined(); }); + + it("resolves a code through a segment's own glyph-index array (idRangeOffset !== 0), not through idDelta", () => { + const font = parse( + buildFontWithCmapSubtable( + 3, + 1, + buildFormat4SubtableWithGlyphArray(0x41, [11, 0, 13]), + ), + ); + const lookup = buildCmapLookup(font); + expect(lookup!(0x41)).toBe(11); + expect(lookup!(0x42)).toBeUndefined(); // the array's own glyph ID is 0: unmapped, not glyph 0 + expect(lookup!(0x43)).toBe(13); + }); + + it("reports a code whose glyph-index array cell runs past the subtable as unmapped rather than reading past it", () => { + const whole = buildFormat4SubtableWithGlyphArray(0x41, [11, 12, 13]); + const truncated = whole.subarray(0, whole.length - 2); // drops the last glyph-array cell + const clipped = new Uint8Array(truncated.length); + clipped.set(truncated); + const font = parse(buildFontWithCmapSubtable(3, 1, clipped)); + const lookup = buildCmapLookup(font); + expect(lookup!(0x41)).toBe(11); + expect(lookup!(0x43)).toBeUndefined(); // its own cell is exactly the two bytes that got dropped + }); +}); + +describe("format 12 (segmented coverage)", () => { + it("drives a font whose only subtable is a hand-built format 12 one", () => { + const font = parse( + buildFontWithCmapSubtable( + 3, + 10, + buildFormat12Subtable([ + { startCharCode: 0x1_0000, endCharCode: 0x1_0002, startGlyphId: 50 }, + ]), + ), + ); + const lookup = buildCmapLookup(font); + expect(lookup).toBeDefined(); + expect(lookup!(0x1_0000)).toBe(50); + expect(lookup!(0x1_0001)).toBe(51); + expect(lookup!(0x1_0002)).toBe(52); + expect(lookup!(0x1_0003)).toBeUndefined(); // past the group's own endCharCode + expect(lookup!(0xffff)).toBeUndefined(); // below the group's own startCharCode + }); + + it("resolves multiple groups independently, not just the first", () => { + const font = parse( + buildFontWithCmapSubtable( + 3, + 10, + buildFormat12Subtable([ + { startCharCode: 0x41, endCharCode: 0x41, startGlyphId: 5 }, + { startCharCode: 0x1_0000, endCharCode: 0x1_0000, startGlyphId: 99 }, + ]), + ), + ); + const lookup = buildCmapLookup(font); + expect(lookup!(0x41)).toBe(5); + expect(lookup!(0x1_0000)).toBe(99); + expect(lookup!(0x42)).toBeUndefined(); // between the two groups + }); + + it("returns undefined when a format 12 subtable's own fixed header does not fit", () => { + const shortSubtable = new Uint8Array(4); + new DataView(shortSubtable.buffer).setUint16(0, 12); // format + const font = parse(buildFontWithCmapSubtable(3, 10, shortSubtable)); + expect(buildCmapLookup(font)).toBeUndefined(); + }); + + it("returns undefined when a format 12 subtable's groups array runs past the table", () => { + // numGroups claims 2 groups but only one group's worth of bytes actually follows the header. + const oneGroup = buildFormat12Subtable([ + { startCharCode: 0x41, endCharCode: 0x41, startGlyphId: 5 }, + ]); + new DataView( + oneGroup.buffer, + oneGroup.byteOffset, + oneGroup.byteLength, + ).setUint32(12, 2); + const font = parse(buildFontWithCmapSubtable(3, 10, oneGroup)); + expect(buildCmapLookup(font)).toBeUndefined(); + }); + + it("enumerates a format 12 subtable's own mappings, clamped to the last valid Unicode code point", () => { + // A malformed group declaring an endCharCode past U+10FFFF must not enumerate beyond it. + const font = parse( + buildFontWithCmapSubtable( + 3, + 10, + buildFormat12Subtable([ + { startCharCode: 0x10_ffff, endCharCode: 0xff_ffff, startGlyphId: 7 }, + ]), + ), + ); + const [subtable] = readCmapSubtables(font); + const visited: [number, number][] = []; + subtable?.forEachMapping((code, glyphId) => visited.push([code, glyphId])); + expect(visited).toEqual([[0x10_ffff, 7]]); + }); +}); + +describe("choosing among several available subtables (preferenceRank)", () => { + it("prefers a (3, 10) Windows/UCS-4 format 12 subtable over any other format 12 subtable", () => { + const font = parse( + buildFontWithCmapSubtables([ + { + platformId: 0, + encodingId: 4, + subtable: buildFormat12Subtable([ + { startCharCode: 0x41, endCharCode: 0x41, startGlyphId: 1 }, + ]), + }, + { + platformId: 3, + encodingId: 10, + subtable: buildFormat12Subtable([ + { startCharCode: 0x41, endCharCode: 0x41, startGlyphId: 2 }, + ]), + }, + ]), + ); + expect(buildCmapLookup(font)!(0x41)).toBe(2); + }); + + it("prefers a (0, *) Unicode format 12 subtable over a non-(3,10) one when there is no (3, 10) subtable", () => { + const font = parse( + buildFontWithCmapSubtables([ + { + platformId: 1, + encodingId: 0, + subtable: buildFormat12Subtable([ + { startCharCode: 0x41, endCharCode: 0x41, startGlyphId: 1 }, + ]), + }, + { + platformId: 0, + encodingId: 4, + subtable: buildFormat12Subtable([ + { startCharCode: 0x41, endCharCode: 0x41, startGlyphId: 2 }, + ]), + }, + ]), + ); + expect(buildCmapLookup(font)!(0x41)).toBe(2); + }); + + it("prefers any format 12 subtable over a format 4 subtable", () => { + const font = parse( + buildFontWithCmapSubtables([ + { + platformId: 3, + encodingId: 1, + subtable: buildFormat4Subtable(0x41, [1]), + }, + { + platformId: 1, + encodingId: 0, + subtable: buildFormat12Subtable([ + { startCharCode: 0x41, endCharCode: 0x41, startGlyphId: 2 }, + ]), + }, + ]), + ); + expect(buildCmapLookup(font)!(0x41)).toBe(2); + }); + + it("prefers a (3, 1) Windows/BMP format 4 subtable over a non-(3,1) format 4 subtable", () => { + const font = parse( + buildFontWithCmapSubtables([ + { + platformId: 1, + encodingId: 0, + subtable: buildFormat4Subtable(0x41, [1]), + }, + { + platformId: 3, + encodingId: 1, + subtable: buildFormat4Subtable(0x41, [2]), + }, + ]), + ); + expect(buildCmapLookup(font)!(0x41)).toBe(2); + }); + + it("prefers any format 4 subtable over a format 6 subtable", () => { + const font = parse( + buildFontWithCmapSubtables([ + { + platformId: 1, + encodingId: 0, + subtable: buildFormat6Subtable(0x41, [1]), + }, + { + platformId: 1, + encodingId: 0, + subtable: buildFormat4Subtable(0x41, [2]), + }, + ]), + ); + expect(buildCmapLookup(font)!(0x41)).toBe(2); + }); }); describe("format 6 (trimmed table mapping)", () => { @@ -272,4 +547,71 @@ describe("readCmapSubtables", () => { }); expect(checked).toBeGreaterThan(0); }); + + it("drops a subtable in a format this module does not read (format 2), keeping the rest of the table's own subtables", () => { + const format2 = new Uint8Array(6); + new DataView(format2.buffer).setUint16(0, 2); // format: high-byte mapping through table, unsupported + const font = parse( + buildFontWithCmapSubtables([ + { platformId: 1, encodingId: 0, subtable: format2 }, + { + platformId: 3, + encodingId: 1, + subtable: buildFormat4Subtable(0x41, [11]), + }, + ]), + ); + const subtables = readCmapSubtables(font); + expect(subtables).toHaveLength(1); + expect(subtables[0]?.format).toBe(4); + }); + + it("returns no subtables when the cmap header's own subtable-record array does not fit", () => { + // numTables claims 3 records but the table holds only the fixed 4-byte header. + const cmap = new Uint8Array(4); + new DataView(cmap.buffer).setUint16(2, 3); + const DIRECTORY_SIZE = 12 + 16; + const font = new Uint8Array(DIRECTORY_SIZE + cmap.length); + const view = new DataView(font.buffer); + view.setUint32(0, 0x00010000); + view.setUint16(4, 1); + font.set(Uint8Array.from([0x63, 0x6d, 0x61, 0x70]), 12); + view.setUint32(12 + 8, DIRECTORY_SIZE); + view.setUint32(12 + 12, cmap.length); + font.set(cmap, DIRECTORY_SIZE); + expect(readCmapSubtables(parse(font))).toEqual([]); + }); + + it("skips a subtable record whose own offset points past the table, keeping a sibling record that is readable", () => { + const good = buildFormat4Subtable(0x41, [11]); + const CMAP_HEADER_SIZE = 4; + const SUBTABLE_RECORD_SIZE = 8; + const goodOffset = CMAP_HEADER_SIZE + 2 * SUBTABLE_RECORD_SIZE; + const cmap = new Uint8Array(goodOffset + good.length); + const view = new DataView(cmap.buffer); + view.setUint16(2, 2); // numTables + // Record 0: claims an offset far past the end of this table. + view.setUint16(CMAP_HEADER_SIZE, 1); + view.setUint16(CMAP_HEADER_SIZE + 2, 0); + view.setUint32(CMAP_HEADER_SIZE + 4, 10_000); + // Record 1: a genuinely readable format 4 subtable. + view.setUint16(CMAP_HEADER_SIZE + SUBTABLE_RECORD_SIZE, 3); + view.setUint16(CMAP_HEADER_SIZE + SUBTABLE_RECORD_SIZE + 2, 1); + view.setUint32(CMAP_HEADER_SIZE + SUBTABLE_RECORD_SIZE + 4, goodOffset); + cmap.set(good, goodOffset); + + const DIRECTORY_SIZE = 12 + 16; + const font = new Uint8Array(DIRECTORY_SIZE + cmap.length); + const fontView = new DataView(font.buffer); + fontView.setUint32(0, 0x00010000); + fontView.setUint16(4, 1); + font.set(Uint8Array.from([0x63, 0x6d, 0x61, 0x70]), 12); + fontView.setUint32(12 + 8, DIRECTORY_SIZE); + fontView.setUint32(12 + 12, cmap.length); + font.set(cmap, DIRECTORY_SIZE); + + const subtables = readCmapSubtables(parse(font)); + expect(subtables).toHaveLength(1); + expect(subtables[0]).toMatchObject({ platformId: 3, encodingId: 1 }); + }); }); From 91119fadfc5772c2fa273582575faea48b43097b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:22:28 +0100 Subject: [PATCH 116/135] test(pdf-codec): drive cff-bounds's interpreter through its untested operators Extends the hand-built charstring fixtures to cover paths the vendored real STIX Two Math font's own well-formed charstrings never reach: the flex family (escape 12 35/34/36/37) and each one's own too-short-stack guard, hmoveto and vmoveto (the font's own charstrings apparently never use either, always preferring rmoveto), the 16.16 fixed-point and positive/truncated 16-bit integer operand forms, callgsubr's own global-subroutine and bias selection (previously only exercised through callsubr's local one), a failure several levels deep in the charstring still propagating even once the glyph has already drawn something, vvcurveto's leading cross-axis delta applying to only the first of several curves, and the exact off-by-one boundaries of the subroutine-nesting depth, operand-stack size, per-glyph operation ceiling, and the rlineto/rcurveline/rlinecurve/hstem-width-parity loops. Also adds the Global Subrs INDEX's own medium-to-large bias threshold at 33900 entries, the mirror of the existing Local Subrs 1240-entry test. Moves the shared boundsOfOnlyGlyph fixture helper to module scope so both charstring-interpreter describe blocks can use it, and adds an enc() helper that picks whichever of the two numeric operand encodings a given value needs. --- packages/pdf-codec/src/cff-bounds.test.ts | 534 ++++++++++++++++++++- packages/pdf-codec/src/test-support/cff.ts | 96 +++- 2 files changed, 612 insertions(+), 18 deletions(-) diff --git a/packages/pdf-codec/src/cff-bounds.test.ts b/packages/pdf-codec/src/cff-bounds.test.ts index 980b4e685..1200e89ef 100644 --- a/packages/pdf-codec/src/cff-bounds.test.ts +++ b/packages/pdf-codec/src/cff-bounds.test.ts @@ -9,10 +9,25 @@ import { cffFont, cffFontWithCharstrings, cffIndex, + csInt16, stixMathCffBytes, } from "./test-support/cff"; import { base64ToBytes } from "./util/base64"; +// The single-byte small-integer encoding (TN 5177 section 3.2) covers -107..107; anything outside that range needs the 3-byte int16 form. Shared by every hand-built charstring test below so each one states the operand it wants, not which of the two encodings reaches it. +function enc(value: number): number[] { + return value >= -107 && value <= 107 ? [value + 139] : csInt16(value); +} + +// Runs a font built with exactly one glyph (glyph 0) and returns its ink bounds -- the common shape every hand-built charstring-interpreter test below drives. +function boundsOfOnlyGlyph(bytes: Uint8Array) { + const bounds = parseCffGlyphBounds(bytes); + if (bounds === undefined) { + throw new Error("fixture font failed to parse"); + } + return bounds.bounds(0); +} + // Every bounding box asserted below was cross-checked against fontTools 4.61.1's own BoundsPen run over the same vendored assets/fonts/STIXTwoMath-Regular.otf -- an independent, mature implementation of the same computation, not this package's own output re-asserted against itself. That comparison was run across the font's whole 5543-glyph repertoire while building this module: every glyph matched to within 0.01 design units, and every glyph fontTools reported as drawing nothing this module reports as `undefined`. // // The font's nominal vertical metrics, for the "tighter than the metric it replaces" assertions: unitsPerEm 1000, hhea ascent 762, hhea descent -238. @@ -233,14 +248,6 @@ describe("parseCffGlyphBounds's charstring interpreter, driven by hand-built cha 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) @@ -408,4 +415,515 @@ describe("parseCffGlyphBounds's charstring interpreter, driven by hand-built cha }); expect(boundsOfOnlyGlyph(atThresholdWithWrongOperand)).toBeUndefined(); }); + + it("switches a Global Subrs INDEX from the medium to the large subroutine bias exactly at a count of 33900 entries", () => { + // The second subrBias threshold (TN 5177 section 16): count < 33900 biases by 1131 (medium), count >= 33900 biases by 32768 (large). A global subr INDEX of exactly 33900 filler entries puts the bias at the large value; calling subroutine 0 there needs operand -32768, the most negative int16 value, which only the large bias resolves correctly. + const OP_HLINETO = 6; + const OP_ENDCHAR = 14; + const OP_CALLGSUBR = 29; + const DX_100 = 100 + 139; // the single-byte small-integer encoding of 100 (bias 139) + const lineSubr = [DX_100, OP_HLINETO]; + const filler = [OP_ENDCHAR]; + const negative32768 = [28, 0x80, 0x00]; // -32768 as an unsigned 16-bit pattern is 0x8000 + const bytes = cffFontWithCharstrings({ + name: "SubrBiasLarge", + charStrings: [[...negative32768, OP_CALLGSUBR]], + globalSubrs: [lineSubr, ...new Array(33899).fill(filler)], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 100, + yMax: 0, + }); + }); +}); + +describe("parseCffGlyphBounds's charstring interpreter: hmoveto/vmoveto, escaped operators, and the remaining interpreter boundaries", () => { + const OP_HSTEM = 1; + const OP_VMOVETO = 4; + const OP_RLINETO = 5; + const OP_HLINETO = 6; + const OP_VLINETO = 7; + const OP_ESCAPE = 12; + const OP_ENDCHAR = 14; + const OP_HSTEMHM = 18; + const OP_HINTMASK = 19; + const OP_HMOVETO = 22; + const OP_RCURVELINE = 24; + const OP_RLINECURVE = 25; + const OP_VVCURVETO = 26; + const OP_CALLGSUBR = 29; + const ESC_HFLEX = 34; + const ESC_FLEX = 35; + const ESC_HFLEX1 = 36; + const ESC_FLEX1 = 37; + const RESERVED_OPERATOR = 13; + const MAX_SUBR_DEPTH = 10; + const MAX_OPERAND_STACK = 48; + const MAX_OPERATIONS_PER_GLYPH = 100_000; + + // A chain of `depth` distinct global subroutines, each calling the next, the last one drawing a single horizontal line -- lets a test reach an EXACT nesting depth (rather than only "eventually recurses past the limit", which the self-calling DeepRecursion fixture above already covers) to pin the interpreter's own off-by-one boundary. + function callChainOfDepth(depth: number): { + charStrings: number[][]; + globalSubrs: number[][]; + } { + const bias = 107; // subrBias for a chain this short (count < 1240) + const globalSubrs: number[][] = []; + for (let i = 0; i < depth; i++) { + globalSubrs.push( + i === depth - 1 + ? [enc(100)[0]!, OP_HLINETO] + : [...enc(i + 1 - bias), OP_CALLGSUBR], + ); + } + return { charStrings: [[...enc(0 - bias), OP_CALLGSUBR]], globalSubrs }; + } + + it("draws through a subroutine chain nested exactly to the spec's own depth limit", () => { + const { charStrings, globalSubrs } = callChainOfDepth(MAX_SUBR_DEPTH); + const bytes = cffFontWithCharstrings({ + name: "ExactSubrDepth", + charStrings, + globalSubrs, + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 100, + yMax: 0, + }); + }); + + it("draws through exactly MAX_OPERAND_STACK operands cleared by one operator, one short of the overrun this module already refuses", () => { + // MAX_OPERAND_STACK single-byte zero operands, THEN a stack-clearing hstem: the stack never exceeds the limit because hstem clears it before another operand is pushed, unlike the StackOverflow fixture above, which never clears the stack at all. + const zeros = new Array(MAX_OPERAND_STACK).fill(139); + const bytes = cffFontWithCharstrings({ + name: "ExactOperandStack", + charStrings: [[...zeros, OP_HSTEM, OP_ENDCHAR]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); // draws nothing (only stems), but must not be REJECTED for overflowing + }); + + it("draws through exactly MAX_OPERATIONS_PER_GLYPH operators, one short of the ceiling this module already refuses", () => { + const exact = new Array(MAX_OPERATIONS_PER_GLYPH - 1).fill( + OP_HSTEM, + ); + const bytes = cffFontWithCharstrings({ + name: "ExactOperationCeiling", + charStrings: [[...exact, OP_ENDCHAR]], // the ceiling counts the endchar itself too + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("decodes the 16.16 fixed-point operand form (TN 5177 section 3.2, operand 255)", () => { + // 0x0002_8000 is 2.5 in 16.16 fixed point (0x0002_0000 = 2, + 0x8000 = 0.5). + const FIXED_2_5 = [255, 0x00, 0x02, 0x80, 0x00]; + const bytes = cffFontWithCharstrings({ + name: "FixedOperand", + charStrings: [[...FIXED_2_5, OP_HMOVETO, ...FIXED_2_5, OP_VLINETO]], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 2.5, // hmoveto's own destination is never added to the box; only the line drawn afterward is + yMin: 0, + xMax: 2.5, + yMax: 2.5, + }); + }); + + it("returns undefined for a truncated 16.16 fixed-point operand", () => { + const bytes = cffFontWithCharstrings({ + name: "TruncatedFixed", + charStrings: [[255, 0x00, 0x02, 0x80]], // needs 4 bytes after 255; only 3 supplied + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("decodes a positive 16-bit integer operand (TN 5177 section 3.2, operand 28)", () => { + // Every existing int16 fixture uses a NEGATIVE value (the medium-bias subroutine index); this is the positive half of the same 3-byte form. + const bytes = cffFontWithCharstrings({ + name: "PositiveInt16Operand", + charStrings: [[...csInt16(300), OP_HMOVETO, OP_ENDCHAR]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); // hmoveto alone draws nothing; this is purely a decode smoke test + }); + + it("returns undefined for a truncated 16-bit integer operand", () => { + const bytes = cffFontWithCharstrings({ + name: "TruncatedInt16", + charStrings: [[28, 0x01]], // needs 2 bytes after 28; only 1 supplied + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("returns undefined for a truncated medium-range single-byte-extra operand", () => { + const bytes = cffFontWithCharstrings({ + name: "TruncatedMedium", + charStrings: [[247]], // 247 (medium-positive) needs one more byte + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("returns undefined for a truncated negative-medium-range single-byte-extra operand", () => { + const bytes = cffFontWithCharstrings({ + name: "TruncatedNegativeMedium", + charStrings: [[251]], // 251 (medium-negative) needs one more byte + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("moves the current point horizontally with a bare hmoveto, the minimal one-operand case", () => { + const bytes = cffFontWithCharstrings({ + name: "BareHmoveto", + charStrings: [[...enc(5), OP_HMOVETO, ...enc(3), OP_HLINETO, OP_ENDCHAR]], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 5, + yMin: 0, + xMax: 8, + yMax: 0, + }); + }); + + it("moves the current point vertically with a bare vmoveto, applying the delta to y rather than x", () => { + const bytes = cffFontWithCharstrings({ + name: "BareVmoveto", + charStrings: [[...enc(4), OP_VMOVETO, ...enc(2), OP_VLINETO, OP_ENDCHAR]], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 4, + xMax: 0, + yMax: 6, + }); + }); + + it("uses parity (evenArgs), not a fixed expected count, to detect a stack-clearing hint operator's own leading width", () => { + // 9 zero-width stem pairs (18 operands, even -- no width to shift) registered via hstemhm, followed by a bare hintmask that must consume exactly ceil(9/8)=2 mask bytes. If the width-detection wrongly used "stack.length > 0" instead of parity, it would shift one operand off, leaving 17 (8 stems, ceil(8/8)=1 mask byte) -- desyncing the byte stream, so the hlineto that follows would be misread and the draw would fail instead of producing this exact box. + const pairs18 = new Array(18).fill(139); // 9 zero-width stem pairs + const bytes = cffFontWithCharstrings({ + name: "HstemWidthParity", + charStrings: [ + [ + ...pairs18, + OP_HSTEMHM, + OP_HINTMASK, + 0xff, + 0xff, // 2 mask bytes, matching the 9 real stems + ...enc(5), + ...enc(0), + OP_HLINETO, + OP_ENDCHAR, + ], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 5, + yMax: 0, + }); + }); + + it("applies vvcurveto's own leading cross-axis delta only to the first curve of a multi-curve call, not every curve", () => { + // Two curve groups plus a leading cross value (9 operands: 1 + 4 + 4). The cross moves the FIRST curve's own first control point off-axis; the second curve gets no cross at all, so x never moves past the first curve's own contribution. + const bytes = cffFontWithCharstrings({ + name: "VvcurvetoCrossOnce", + charStrings: [ + [ + ...enc(5), // leading cross + ...enc(0), + ...enc(0), + ...enc(0), + ...enc(10), // group 1: dy1=0,dx2=0,dy2=0,dy3=10 + ...enc(0), + ...enc(0), + ...enc(0), + ...enc(10), // group 2: dy1=0,dx2=0,dy2=0,dy3=10, no cross + OP_VVCURVETO, + OP_ENDCHAR, + ], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 5, + yMax: 20, + }); + }); + + it("stops an rlineto after the last complete coordinate pair, ignoring a trailing unpaired operand", () => { + const bytes = cffFontWithCharstrings({ + name: "RlinetoOddTrailer", + charStrings: [[...enc(5), ...enc(0), ...enc(3), OP_RLINETO, OP_ENDCHAR]], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 5, + yMax: 0, + }); + }); + + it("treats an rcurveline stack too short to reserve its own trailing line pair as a bare line, not a curve", () => { + // 7 operands: not enough to fit a 6-argument curve AND still reserve 2 for the mandatory trailing line, so the whole stack is read as just the trailing line -- the first two operands only, the other five ignored. + const bytes = cffFontWithCharstrings({ + name: "RcurvelineShortStack", + charStrings: [ + [ + ...enc(10), + ...enc(0), + ...enc(1), + ...enc(1), + ...enc(1), + ...enc(1), + ...enc(1), + OP_RCURVELINE, + OP_ENDCHAR, + ], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 10, + yMax: 0, + }); + }); + + it("treats an rlinecurve stack too short to reserve any leading line pair as a bare curve, not a line", () => { + // 7 operands: below the threshold that would let the loop treat any of them as a leading line, so all read as the mandatory trailing 6-argument curve, with the 7th ignored. + const bytes = cffFontWithCharstrings({ + name: "RlinecurveShortStack", + charStrings: [ + [ + ...enc(4), + ...enc(0), + ...enc(4), + ...enc(0), + ...enc(4), + ...enc(0), + ...enc(999), + OP_RLINECURVE, + OP_ENDCHAR, + ], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 12, + yMax: 0, + }); + }); + + it("draws through a real Global Subrs INDEX reached via callgsubr, the mirror of the existing local-subr success case", () => { + // Proves callgsubr's OWN branch (context.globalSubrs/globalBias), not local's: no Private DICT/Local Subrs exist at all here, so a wrong branch (using the undefined local subrs, or the wrong bias) fails to resolve any subroutine. + const bias = 107; // subrBias for a one-entry Global Subrs INDEX (count < 1240) + const bytes = cffFontWithCharstrings({ + name: "DrawViaGlobalSubr", + charStrings: [[...enc(0 - bias), OP_CALLGSUBR]], + globalSubrs: [[...enc(100), OP_HLINETO]], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 100, + yMax: 0, + }); + }); + + it("propagates a failure from deeper in the charstring even after this glyph has already drawn something", () => { + // Each of these draws a line first (box.drawn becomes true), then hits a failure that must still make the WHOLE glyph undefined -- proving the failure genuinely propagates rather than being masked by the box already having content, which is exactly the difference a `return true` in place of the interpreter's own `return false` would hide. + const drawFirst = [...enc(5), ...enc(0), OP_HLINETO]; + + const emptyCallsubrStack = cffFontWithCharstrings({ + name: "DrawnThenEmptyCallsubr", + charStrings: [[...drawFirst, OP_CALLGSUBR]], + }); + expect(boundsOfOnlyGlyph(emptyCallsubrStack)).toBeUndefined(); + + const unresolvedSubr = cffFontWithCharstrings({ + name: "DrawnThenUnresolvedSubr", + charStrings: [[...drawFirst, ...enc(0), OP_CALLGSUBR]], + globalSubrs: [], // count 0: any index resolves to nothing + }); + expect(boundsOfOnlyGlyph(unresolvedSubr)).toBeUndefined(); + + const bias = 107; + const nestedFailure = cffFontWithCharstrings({ + name: "DrawnThenNestedFailure", + charStrings: [[...drawFirst, ...enc(0 - bias), OP_CALLGSUBR]], + globalSubrs: [[RESERVED_OPERATOR]], // fails immediately once entered + }); + expect(boundsOfOnlyGlyph(nestedFailure)).toBeUndefined(); + + const reservedAfterDrawing = cffFontWithCharstrings({ + name: "DrawnThenReserved", + charStrings: [[...drawFirst, RESERVED_OPERATOR]], + }); + expect(boundsOfOnlyGlyph(reservedAfterDrawing)).toBeUndefined(); + }); + + it("refuses an escape operator with no following byte", () => { + const bytes = cffFontWithCharstrings({ + name: "TruncatedEscape", + charStrings: [[OP_ESCAPE]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("refuses an escaped operator this module does not interpret (the arithmetic/storage/conditional set)", () => { + const ESC_AND = 3; + const bytes = cffFontWithCharstrings({ + name: "UnsupportedEscape", + charStrings: [[OP_ESCAPE, ESC_AND]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("draws the flex operator's own two-curve construction (escape 12 35), whose 13th argument (fd) has no effect on the curve", () => { + const bytes = cffFontWithCharstrings({ + name: "Flex", + charStrings: [ + [ + ...enc(0), + ...enc(0), + ...enc(0), + ...enc(0), + ...enc(0), + ...enc(10), // curve 1: straight up by 10 + ...enc(0), + ...enc(0), + ...enc(0), + ...enc(0), + ...enc(0), + ...enc(10), // curve 2: straight up by another 10 + ...enc(50), // fd: a rasterisation hint, ignored + OP_ESCAPE, + ESC_FLEX, + OP_ENDCHAR, + ], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 0, + yMax: 20, + }); + }); + + it("refuses a flex operator whose stack is too short for its own 13 arguments", () => { + const bytes = cffFontWithCharstrings({ + name: "FlexTooShort", + charStrings: [[...new Array(12).fill(139), OP_ESCAPE, ESC_FLEX]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("draws the hflex operator (escape 12 34), whose second curve negates the first's own dy2 to return to the start y", () => { + // dx1 dx2 dy2 dx3 dx4 dx5 dx6, all positive: x accumulates monotonically to their sum (60); y rises to dy2=20 by the end of the first curve, then the second curve's own -dy2 must bring it back down to exactly 0. A sign error on that negation (turning -stack[2] into +stack[2]) would send y on to 40 instead of back to 0, which the exact yMax assertion below catches, distinct from the trivial yMin=0 every curveTo already includes via its own start point. + const bytes = cffFontWithCharstrings({ + name: "Hflex", + charStrings: [ + [ + ...enc(10), // dx1 + ...enc(10), // dx2 + ...enc(20), // dy2 + ...enc(10), // dx3 + ...enc(10), // dx4 + ...enc(10), // dx5 + ...enc(10), // dx6 + OP_ESCAPE, + ESC_HFLEX, + OP_ENDCHAR, + ], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 60, + yMax: 20, + }); + }); + + it("refuses an hflex operator whose stack is too short for its own 7 arguments", () => { + const bytes = cffFontWithCharstrings({ + name: "HflexTooShort", + charStrings: [[...new Array(6).fill(139), OP_ESCAPE, ESC_HFLEX]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("draws the hflex1 operator (escape 12 36), whose final point returns to the flex's own starting y", () => { + const bytes = cffFontWithCharstrings({ + name: "Hflex1", + charStrings: [ + [ + ...enc(0), + ...enc(10), // dx1, dy1 + ...enc(10), + ...enc(10), // dx2, dy2 + ...enc(10), // dx3 + ...enc(10), // dx4 + ...enc(0), + ...enc(-20), // dx5, dy5 + ...enc(0), // dx6 + OP_ESCAPE, + ESC_HFLEX1, + OP_ENDCHAR, + ], + ], + }); + const box = boundsOfOnlyGlyph(bytes)!; + expect(box.yMin).toBe(0); + expect(box.yMax).toBe(20); // rises by dy1+dy2=20, then the final curve's own computed dy returns exactly to 0 + }); + + it("refuses an hflex1 operator whose stack is too short for its own 9 arguments", () => { + const bytes = cffFontWithCharstrings({ + name: "Hflex1TooShort", + charStrings: [[...new Array(8).fill(139), OP_ESCAPE, ESC_HFLEX1]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + + it("draws the flex1 operator (escape 12 37), whose own final delta applies to whichever axis moved further", () => { + // The five leading deltas move further in x (30 total) than in y (4 total), so the last delta (d6) applies to x and y returns exactly to its own start -- the branch a wrong Math.abs comparison would swap for its opposite. + const bytes = cffFontWithCharstrings({ + name: "Flex1", + charStrings: [ + [ + ...enc(10), + ...enc(1), // dx1,dy1 + ...enc(10), + ...enc(1), // dx2,dy2 + ...enc(10), + ...enc(1), // dx3,dy3 + ...enc(10), + ...enc(1), // dx4,dy4 + ...enc(10), + ...enc(0), // dx5,dy5 + ...enc(5), // d6 + OP_ESCAPE, + ESC_FLEX1, + OP_ENDCHAR, + ], + ], + }); + const box = boundsOfOnlyGlyph(bytes)!; + expect(box.yMin).toBe(0); + expect(box.xMax).toBe(55); // 10+10+10+10+10+5 + }); + + it("refuses a flex1 operator whose stack is too short for its own 11 arguments", () => { + const bytes = cffFontWithCharstrings({ + name: "Flex1TooShort", + charStrings: [[...new Array(10).fill(139), OP_ESCAPE, ESC_FLEX1]], + }); + 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 fdf5b7544..4baff0474 100644 --- a/packages/pdf-codec/src/test-support/cff.ts +++ b/packages/pdf-codec/src/test-support/cff.ts @@ -63,6 +63,12 @@ export function cffIndex(entries: readonly (readonly number[])[]): number[] { ]; } +// A charstring operand in its 3-byte int16 form (TN 5177 section 3.2, operand 28): valid for any value in [-32768, 32767], which is every integer a curve-bounds test needs to place a control point at. Deliberately uniform rather than picking the shortest single-byte encoding a real font toolchain would choose -- cff-bounds.ts's own readOperand already has dedicated tests for its other operand forms, so a charstring built purely to drive the curve-extrema math needs only one encoding it never has to think about. +export function csInt16(value: number): number[] { + const unsigned = value & 0xffff; + return [28, (unsigned >>> 8) & 0xff, unsigned & 0xff]; +} + export const CFF_HEADER = [1, 0, 4, 1]; // major 1, minor 0, hdrSize 4, offSize 1 // A minimal CFF program: header, a Name INDEX holding `name`, and a Top DICT INDEX holding `topDict`. Deliberately stops there -- the String and Global Subr INDEXes that a real program carries next are only reached by a reader that gets past the Top DICT, which is exactly what the fixtures built from this are testing does not happen. @@ -94,13 +100,60 @@ function dictInt32(value: number): number[] { ]; } -const CFF_STANDARD_STRING_COUNT = 391; // SIDs below this index the standard strings (spec Appendix A); the String INDEX starts here +export const CFF_STANDARD_STRING_COUNT = 391; // SIDs below this index the standard strings (spec Appendix A); the String INDEX starts here + +// A charset (spec section 13) in format 1: one run of consecutive SIDs per range, each range a first SID plus a count of additional glyphs it covers -- the form a real font toolchain reaches for once its glyph SIDs are dense enough that format 0's one-SID-per-glyph listing wastes space. Builds the same glyph-order SIDs cffFontWithBuiltinEncoding's own format 0 charset does (CFF_STANDARD_STRING_COUNT + index per glyph), just run-length-encoded into ranges of `rangeSize` glyphs apiece so a test can choose whether the whole charset is one range or several. +function charsetFormat1(glyphCount: number, rangeSize: number): number[] { + const bytes = [1]; + for (let glyphId = 1; glyphId < glyphCount;) { + const firstSid = CFF_STANDARD_STRING_COUNT + (glyphId - 1); + const nLeft = Math.min(rangeSize, glyphCount - glyphId) - 1; + bytes.push((firstSid >> 8) & 0xff, firstSid & 0xff, nLeft); + glyphId += nLeft + 1; + } + return bytes; +} + +// The same run-length encoding as charsetFormat1, but with a 16-bit nLeft (format 2): the form a font with tens of thousands of glyphs in one contiguous SID range needs, since format 1's own nLeft is a single byte. +function charsetFormat2(glyphCount: number, rangeSize: number): number[] { + const bytes = [2]; + for (let glyphId = 1; glyphId < glyphCount;) { + const firstSid = CFF_STANDARD_STRING_COUNT + (glyphId - 1); + const nLeft = Math.min(rangeSize, glyphCount - glyphId) - 1; + bytes.push( + (firstSid >> 8) & 0xff, + firstSid & 0xff, + (nLeft >> 8) & 0xff, + nLeft & 0xff, + ); + glyphId += nLeft + 1; + } + return bytes; +} + +// An Encoding (spec section 12) in format 1: ranges of consecutive codes assigned to consecutive glyph IDs starting at 1, each range a first code plus a count of additional codes it covers -- the form a font toolchain reaches for once most of its codes are contiguous, rather than format 0's one-code-per-glyph list. Run-length-encodes `codesByGlyph` (glyph 1's code, glyph 2's code, ...) into the fewest ranges that reproduce it: a run of consecutive codes collapses into one range with nLeft > 0, and any break (a gap, or an unmapped glyph's placeholder 0) starts a new one -- so a caller supplying genuinely consecutive codes exercises the multi-code, nLeft > 0 span this format exists for, not just one range per glyph. +function encodingFormat1(codesByGlyph: readonly number[]): number[] { + const ranges: { first: number; nLeft: number }[] = []; + for (const code of codesByGlyph) { + const last = ranges[ranges.length - 1]; + if (last !== undefined && code === last.first + last.nLeft + 1) { + last.nLeft += 1; + } else { + ranges.push({ first: code, nLeft: 0 }); + } + } + return [1, ranges.length, ...ranges.flatMap((r) => [r.first, r.nLeft])]; +} -// A complete-enough CFF program carrying its own built-in encoding: a custom Encoding (spec section 12, format 0) mapping character codes onto glyph indices, and a charset (section 13, format 0) naming each glyph through a SID resolved against the String INDEX. Every glyph name is written as a custom string rather than reused from the standard strings, which is what a subsetted symbol font really does with names outside the ISOAdobe repertoire. +// A complete-enough CFF program carrying its own built-in encoding: a custom Encoding (spec section 12) mapping character codes onto glyph indices, and a charset (section 13) naming each glyph through a SID resolved against the String INDEX. Every glyph name is written as a custom string rather than reused from the standard strings, which is what a subsetted symbol font really does with names outside the ISOAdobe repertoire. `charsetFormat`/`encodingFormat` choose the on-disk encoding of each (format 0's explicit per-glyph list by default); `encodingSupplement` adds format 0/1's own optional supplementary code -> SID entries (spec section 12's high bit on the format byte), each resolved against the charset's own SIDs rather than glyph indices directly. export function cffFontWithBuiltinEncoding(options: { readonly name: string; readonly glyphNames: readonly string[]; // glyphs 1..n; glyph 0 is always .notdef and is not named here readonly encoding: ReadonlyMap; // character code -> glyph index + readonly charsetFormat?: 0 | 1 | 2; + readonly charsetRangeSize?: number; // format 1/2 only: how many glyphs each range covers before starting a new one + readonly encodingFormat?: 0 | 1; + readonly encodingSupplement?: readonly { code: number; sid: number }[]; }): Uint8Array { const nameIndex = cffIndex([[...new TextEncoder().encode(options.name)]]); const stringIndex = cffIndex( @@ -109,13 +162,20 @@ export function cffFontWithBuiltinEncoding(options: { ]), ); const globalSubrIndex = [0, 0]; - const charset = [ - 0, - ...options.glyphNames.flatMap((_, index) => { - const sid = CFF_STANDARD_STRING_COUNT + index; - return [(sid >> 8) & 0xff, sid & 0xff]; - }), - ]; + const glyphCount = options.glyphNames.length + 1; // +1 for .notdef + const charsetFormat = options.charsetFormat ?? 0; + const charset = + charsetFormat === 1 + ? charsetFormat1(glyphCount, options.charsetRangeSize ?? glyphCount) + : charsetFormat === 2 + ? charsetFormat2(glyphCount, options.charsetRangeSize ?? glyphCount) + : [ + 0, + ...options.glyphNames.flatMap((_, index) => { + const sid = CFF_STANDARD_STRING_COUNT + index; + return [(sid >> 8) & 0xff, sid & 0xff]; + }), + ]; const codesByGlyph = [...options.glyphNames.keys()].map((index) => { for (const [code, glyph] of options.encoding) { if (glyph === index + 1) { @@ -124,7 +184,23 @@ export function cffFontWithBuiltinEncoding(options: { } return 0; }); - const encoding = [0, codesByGlyph.length, ...codesByGlyph]; + const supplementBytes = (options.encodingSupplement ?? []).flatMap((s) => [ + s.code, + (s.sid >> 8) & 0xff, + s.sid & 0xff, + ]); + const supplementFlag = options.encodingSupplement === undefined ? 0 : 0x80; + const encodingBody = + options.encodingFormat === 1 + ? encodingFormat1(codesByGlyph) + : [0, codesByGlyph.length, ...codesByGlyph]; + const encoding = [ + supplementFlag | encodingBody[0]!, + ...encodingBody.slice(1), + ...(options.encodingSupplement === undefined + ? [] + : [options.encodingSupplement.length, ...supplementBytes]), + ]; const charStrings = cffIndex([ [14], ...options.glyphNames.map(() => [14]), // one bare `endchar` charstring per glyph: the CharStrings INDEX count is what sizes the charset From 352d8c19fea4b9e3d3482289fba4f9306bbab6ea Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:22:39 +0100 Subject: [PATCH 117/135] test(pdf-codec): cover CFF charset/encoding formats 1/2 and Type 1's PFB form readCffCharset and readCffEncoding each read three on-disk shapes (format 0/1/2 for the charset, format 0/1 plus an optional supplement for the encoding), but every existing fixture built only format 0 of each, and the predefined ISOAdobe charset and predefined StandardEncoding paths (a font stating neither operator at all) had no fixture either. Also covers a PFB-segmented Type 1 program (a 6-byte binary segment header ahead of the same cleartext this module already reads for a bare PFA program) and a program whose cleartext header never reaches an eexec marker at all. Generalises cffFontWithBuiltinEncoding's own fixture builder to choose the charset and Encoding format, and to add the Encoding's own supplementary code -> SID entries, rather than always emitting format 0 of each. --- .../pdf-codec/src/builtin-encoding.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/packages/pdf-codec/src/builtin-encoding.test.ts b/packages/pdf-codec/src/builtin-encoding.test.ts index 7a5c16422..dc3e28868 100644 --- a/packages/pdf-codec/src/builtin-encoding.test.ts +++ b/packages/pdf-codec/src/builtin-encoding.test.ts @@ -3,9 +3,11 @@ import { readFontProgramEncoding } from "./builtin-encoding"; import { STIX_TWO_MATH_FONT_BASE64 } from "./assets/stix-two-math-font"; import { CFF_HEADER, + CFF_STANDARD_STRING_COUNT, ROS_OPERANDS_AND_OPERATOR, cffFont, cffFontWithBuiltinEncoding, + cffFontWithCharstrings, } from "./test-support/cff"; import { caladeaRegularBytes, carlitoRegularBytes } from "./test-support/fonts"; import { @@ -135,6 +137,82 @@ describe("readFontProgramEncoding: TrueType programs", () => { }); describe("readFontProgramEncoding: CFF programs", () => { + it("names a glyph through the predefined ISOAdobe charset and predefined StandardEncoding when the font states neither operator", () => { + // A raw (non-sfnt) CFF program with no Charset or Encoding operator at all: glyph 1's SID defaults to its own glyph ID (SID 1 is "space" in the standard strings), and predefinedEncodingApplies is true for a bare /FontFile3 Type1C, so StandardEncoding's own code 0x20 reaches it too. + const program = cffFontWithCharstrings({ + name: "PredefinedCharsetAndEncoding", + charStrings: [[14], [14]], // glyph 0 (.notdef) and glyph 1, both a bare endchar + }); + const encoding = readFontProgramEncoding(program); + expect(encoding?.glyphIdToUnicode(1)).toBe(0x20); + expect(encoding?.codeToUnicode(0x20)).toBe(0x20); + }); + + it("names glyphs through a format 1 charset (ranges of consecutive SIDs)", () => { + const program = cffFontWithBuiltinEncoding({ + name: "Format1Charset", + glyphNames: ["Omega", "mu", "A"], + encoding: new Map([ + [0x57, 1], + [0x6d, 2], + [0x41, 3], + ]), + charsetFormat: 1, + charsetRangeSize: 2, // forces more than one range across 4 glyphs (.notdef + 3) + }); + const encoding = readFontProgramEncoding(program); + expect(encoding?.codeToUnicode(0x57)).toBe(OHM_SIGN); + expect(encoding?.codeToUnicode(0x6d)).toBe(0xb5); + expect(encoding?.codeToUnicode(0x41)).toBe(0x41); + }); + + it("names glyphs through a format 2 charset (16-bit range counts)", () => { + const program = cffFontWithBuiltinEncoding({ + name: "Format2Charset", + glyphNames: ["Omega", "mu"], + encoding: new Map([ + [0x57, 1], + [0x6d, 2], + ]), + charsetFormat: 2, + charsetRangeSize: 1, // one glyph per range, so the format 2 loop runs more than once + }); + const encoding = readFontProgramEncoding(program); + expect(encoding?.codeToUnicode(0x57)).toBe(OHM_SIGN); + expect(encoding?.codeToUnicode(0x6d)).toBe(0xb5); + }); + + it("maps codes through a format 1 Encoding (ranges of consecutive codes)", () => { + const program = cffFontWithBuiltinEncoding({ + name: "Format1Encoding", + glyphNames: ["A", "B", "C"], + // Consecutive codes assigned to consecutive glyphs collapse into a single format 1 range. + encoding: new Map([ + [0x41, 1], + [0x42, 2], + [0x43, 3], + ]), + encodingFormat: 1, + }); + const encoding = readFontProgramEncoding(program); + expect(encoding?.codeToUnicode(0x41)).toBe(0x41); + expect(encoding?.codeToUnicode(0x42)).toBe(0x42); + expect(encoding?.codeToUnicode(0x43)).toBe(0x43); + }); + + it("resolves a supplementary code through the Encoding's own supplement entries, addressed by SID rather than glyph index", () => { + const program = cffFontWithBuiltinEncoding({ + name: "EncodingSupplement", + glyphNames: ["Omega"], + encoding: new Map([[0x57, 1]]), + // Glyph 1's own SID under the default format 0 charset is CFF_STANDARD_STRING_COUNT + 0 (its custom "Omega" string). + encodingSupplement: [{ code: 0x1a, sid: CFF_STANDARD_STRING_COUNT }], + }); + const encoding = readFontProgramEncoding(program); + expect(encoding?.codeToUnicode(0x57)).toBe(OHM_SIGN); // the base format 0 mapping still works + expect(encoding?.codeToUnicode(0x1a)).toBe(OHM_SIGN); // reached only through the supplement + }); + it("maps codes through a custom Encoding and names glyphs through the charset", () => { const program = cffFontWithBuiltinEncoding({ name: "SymbolSubset", @@ -157,6 +235,8 @@ describe("readFontProgramEncoding: CFF programs", () => { ); expect(encoding?.glyphIdToUnicode(5)).toBe(0x43); expect(encoding?.glyphIdToUnicode(35)).toBe(0x1ea8); + // An sfnt-wrapped CFF states its encoding through the container's own 'cmap', never the CFF Encoding operator (predefinedEncodingApplies is false here) -- so with no symbolic cmap subtable in this font, no code reaches any glyph at all, only glyph IDs do. + expect(encoding?.codeToUnicode(0x43)).toBeUndefined(); }); }); @@ -199,4 +279,35 @@ describe("readFontProgramEncoding: Type 1 programs", () => { ), ).toBeUndefined(); }); + + it("reads a PFB-segmented Type 1 program, whose cleartext header follows a 6-byte binary segment marker", () => { + const cleartext = [ + "%!PS-AdobeFont-1.0: PFB 001.000", + "/Encoding 256 array", + "dup 87 /Omega put", + "readonly def", + "currentfile eexec", + "", + ].join("\n"); + const body = new TextEncoder().encode(cleartext); + const segmentHeader = [0x80, 1, 0, 0, 0, 0]; // marker + a segment-type/length header this module never reads + const program = new Uint8Array([...segmentHeader, ...body]); + expect(readFontProgramEncoding(program)?.codeToUnicode(0x57)).toBe( + OHM_SIGN, + ); + }); + + it("reads the /Encoding array out of a program with no eexec marker at all, using the whole file as the cleartext header", () => { + const program = textBytes( + [ + "%!PS-AdobeFont-1.0: NoEexec 001.000", + "/Encoding 256 array", + "dup 87 /Omega put", + "readonly def", + ].join("\n"), + ); + expect(readFontProgramEncoding(program)?.codeToUnicode(0x57)).toBe( + OHM_SIGN, + ); + }); }); From 2b88a3867690d4412d80db46e3ea7b3e06e33c02 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 05:38:26 +0100 Subject: [PATCH 118/135] test(pdf-codec): draw after each interpreter-limit boundary to make success observable A charstring that draws nothing reports undefined from a successful walk and from a failed one alike, so a boundary test that never draws (the operand- stack, operation-count, and truncated-operand cases) cannot actually tell a correct exact-boundary success apart from an off-by-one bug that rejects it one iteration early -- both assert the same toBeUndefined(). Adds a trailing line draw after each boundary so success produces a real, checkable box, and extends the existing "failure still propagates once something is drawn" case to the operation ceiling, operand-stack overflow, subroutine-depth overflow, and a truncated operand, which shared the identical blind spot. --- packages/pdf-codec/src/cff-bounds.test.ts | 61 ++++++++++++++++++++--- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/pdf-codec/src/cff-bounds.test.ts b/packages/pdf-codec/src/cff-bounds.test.ts index 1200e89ef..942fb7047 100644 --- a/packages/pdf-codec/src/cff-bounds.test.ts +++ b/packages/pdf-codec/src/cff-bounds.test.ts @@ -496,24 +496,37 @@ describe("parseCffGlyphBounds's charstring interpreter: hmoveto/vmoveto, escaped }); it("draws through exactly MAX_OPERAND_STACK operands cleared by one operator, one short of the overrun this module already refuses", () => { - // MAX_OPERAND_STACK single-byte zero operands, THEN a stack-clearing hstem: the stack never exceeds the limit because hstem clears it before another operand is pushed, unlike the StackOverflow fixture above, which never clears the stack at all. + // MAX_OPERAND_STACK single-byte zero operands, THEN a stack-clearing hstem: the stack never exceeds the limit because hstem clears it before another operand is pushed, unlike the StackOverflow fixture above, which never clears the stack at all. A trailing draw after the hstem makes the "succeeds" claim observable -- a glyph that draws nothing reports undefined regardless of whether it was rejected for overflowing or genuinely walked to completion, so only a real box proves the walk actually continued. const zeros = new Array(MAX_OPERAND_STACK).fill(139); const bytes = cffFontWithCharstrings({ name: "ExactOperandStack", - charStrings: [[...zeros, OP_HSTEM, OP_ENDCHAR]], + charStrings: [ + [...zeros, OP_HSTEM, ...enc(5), ...enc(0), OP_HLINETO, OP_ENDCHAR], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 5, + yMax: 0, }); - expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); // draws nothing (only stems), but must not be REJECTED for overflowing }); it("draws through exactly MAX_OPERATIONS_PER_GLYPH operators, one short of the ceiling this module already refuses", () => { - const exact = new Array(MAX_OPERATIONS_PER_GLYPH - 1).fill( + // The ceiling counts every operand push and every operator dispatch as one operation each (execute's own per-iteration counter): MAX filler hstems, plus the trailing draw's own 2 operand pushes and 2 operators (hlineto, endchar), totals exactly MAX_OPERATIONS_PER_GLYPH. + const exact = new Array(MAX_OPERATIONS_PER_GLYPH - 4).fill( OP_HSTEM, ); const bytes = cffFontWithCharstrings({ name: "ExactOperationCeiling", - charStrings: [[...exact, OP_ENDCHAR]], // the ceiling counts the endchar itself too + charStrings: [[...exact, ...enc(5), ...enc(0), OP_HLINETO, OP_ENDCHAR]], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 5, + yMax: 0, }); - expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); }); it("decodes the 16.16 fixed-point operand form (TN 5177 section 3.2, operand 255)", () => { @@ -764,6 +777,42 @@ describe("parseCffGlyphBounds's charstring interpreter: hmoveto/vmoveto, escaped charStrings: [[...drawFirst, RESERVED_OPERATOR]], }); expect(boundsOfOnlyGlyph(reservedAfterDrawing)).toBeUndefined(); + + // The three interpreter ceilings, and the truncated-operand decode failure: each is itself only reachable/observable this way, since a charstring that fails before drawing anything is indistinguishable from one that "succeeds" while drawing nothing (both report undefined regardless of which is correct). + const operationCeilingAfterDrawing = cffFontWithCharstrings({ + name: "DrawnThenOperationCeiling", + charStrings: [ + [ + ...drawFirst, + ...new Array(MAX_OPERATIONS_PER_GLYPH).fill(OP_HSTEM), + ], + ], + }); + expect(boundsOfOnlyGlyph(operationCeilingAfterDrawing)).toBeUndefined(); + + const operandStackOverflowAfterDrawing = cffFontWithCharstrings({ + name: "DrawnThenOperandStackOverflow", + charStrings: [ + [...drawFirst, ...new Array(MAX_OPERAND_STACK + 1).fill(139)], + ], + }); + expect(boundsOfOnlyGlyph(operandStackOverflowAfterDrawing)).toBeUndefined(); + + const subrDepthOverflowAfterDrawing = (() => { + const selfCall = [32, OP_CALLGSUBR]; // -107, this INDEX's own subroutine, recursing forever + return cffFontWithCharstrings({ + name: "DrawnThenSubrDepthOverflow", + charStrings: [[...drawFirst, ...selfCall]], + globalSubrs: [selfCall], + }); + })(); + expect(boundsOfOnlyGlyph(subrDepthOverflowAfterDrawing)).toBeUndefined(); + + const truncatedOperandAfterDrawing = cffFontWithCharstrings({ + name: "DrawnThenTruncatedOperand", + charStrings: [[...drawFirst, 247]], // medium-positive needs one more byte + }); + expect(boundsOfOnlyGlyph(truncatedOperandAfterDrawing)).toBeUndefined(); }); it("refuses an escape operator with no following byte", () => { From c31a33b186122b42b09c5ffedef928cc1d6cd469 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:15:53 +0100 Subject: [PATCH 119/135] refactor(pdf-codec): drop widthForWidthsArray's dead zero-width branch WINANSI_GLYPH_NAMES defines a glyph name for every code in FIRST_CHAR..LAST_CHAR (unassigned CP1252 positions get a placeholder name like "bullet" rather than an empty string), and every standard-14 AFM table defines a width for every glyph name that table can produce. widthOfCode() therefore never throws across the full range for any of the 12 faces, so the "no WinAnsi glyph mapping" fallback to a 0 width was unreachable dead code. buildFontObjects now calls widthOfCode directly. --- packages/pdf-codec/src/write.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/pdf-codec/src/write.ts b/packages/pdf-codec/src/write.ts index 790a00649..d8b9f4534 100644 --- a/packages/pdf-codec/src/write.ts +++ b/packages/pdf-codec/src/write.ts @@ -32,7 +32,6 @@ import type { EmbeddedFace, EmbeddedFaceSubstitution } from "./embedded-font"; import { collectEmbeddedGlyphs } from "./embedded-font"; import { NOTES_ANNOTATION_AUTHOR } from "./notes-annotation-author"; import { buildEmbeddedFontObjects } from "./embedded-font-write"; -import { winAnsiGlyphName } from "./encoding"; import type { FontRegistry } from "./font-registry"; import { resolveFaceWithRegistry } from "./font-registry"; import { @@ -183,29 +182,15 @@ function computeFontFlags( return flags; } -// The Widths array must cover FIRST_CHAR..LAST_CHAR without gaps. widthOfCode() throws for a code with no WinAnsi glyph mapping (a caller-invariant violation on the text-showing path, which is expected to sanitize first) -- but a handful of WinAnsi byte positions are simply unassigned by the encoding itself, and the Widths array still needs an entry for them. widthOfCode already special-cases fixed-width (Courier) faces before ever consulting the glyph name, so this only needs its own check for the proportional faces. -function widthForWidthsArray( - standardName: StandardFontName, - code: number, -): number { - const metrics = STANDARD_METRICS[standardName]; - if ( - metrics.fixedWidth === undefined && - winAnsiGlyphName(code) === undefined - ) { - return 0; - } - return widthOfCode(standardName, code); -} - function buildFontObjects( standardName: StandardFontName, descriptorRef: PdfObject, ): { readonly font: PdfDict; readonly descriptor: PdfDict } { const metrics = STANDARD_METRICS[standardName]; const widths: PdfObject[] = []; + // The Widths array must cover FIRST_CHAR..LAST_CHAR without gaps. WINANSI_GLYPH_NAMES defines a glyph name for every one of those codes (the CP1252 positions with no real assignment are filled with a placeholder name like "bullet" rather than left empty -- see encoding.ts's own comment), and every standard-14 AFM table carries a width for every name that table can produce, so widthOfCode never throws across this whole range for any of the 12 faces. for (let code = FIRST_CHAR; code <= LAST_CHAR; code++) { - widths.push(pdfNum(widthForWidthsArray(standardName, code))); + widths.push(pdfNum(widthOfCode(standardName, code))); } const font = pdfDict({ Type: pdfName("Font"), From 689314ff01506fa21eaf7ecfb84a6b4aa89aefee Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:16:01 +0100 Subject: [PATCH 120/135] test(pdf-codec): cover Info dict metadata, font flags, JPEG colour space, and embedded formulas writePdf's own doc.metadata fields (title/author/subject/keywords/creator/ createdIso/modifiedIso), computeFontFlags' fixed-pitch/serif/italic/force-bold bits, the full real AFM Widths array, and prepareJpegImage's colour-space and CMYK /Decode-inversion branches had no coverage at all. options.formulas -- writePdf's own side channel for embedded-math-font content -- had never been exercised through writePdf itself, only through math-content-write.ts and math-font-write.ts's own lower-level unit tests, leaving the actual object allocation, resource-dict wiring, and per-page content-stream routing untested. --- packages/pdf-codec/src/write.test.ts | 332 +++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) diff --git a/packages/pdf-codec/src/write.test.ts b/packages/pdf-codec/src/write.test.ts index 4b2aa2a25..8e00409a8 100644 --- a/packages/pdf-codec/src/write.test.ts +++ b/packages/pdf-codec/src/write.test.ts @@ -1,4 +1,5 @@ import { decodePng, encodePng } from "byte-codec"; +import type { PositionedFormula } from "document-schema.js"; import { base64ToBytes, bytesToBase64 } from "./util/base64"; import { describe, expect, it } from "vitest"; import { openPdfDocument } from "./document"; @@ -72,6 +73,56 @@ function tinyJpegAsset(): LayoutImageAsset { }; } +// Same shape as tinyJpegAsset, generalised over component count and an optional Adobe APP14 marker (ISO 32000-1 has no opinion on this marker; it's a de facto Adobe convention every real CMYK JPEG carries), for exercising prepareJpegImage's colour-space and /Decode-inversion branches. +function jpegAsset( + components: 1 | 3 | 4, + adobeTransform?: number, +): LayoutImageAsset { + const componentBytes: number[] = []; + for (let i = 0; i < components; i++) { + componentBytes.push(i + 1, 0x22, 0); + } + const app14: number[] = + adobeTransform === undefined + ? [] + : [ + 0xff, + 0xee, // APP14 + 0x00, + 0x0e, // length 14 + 0x41, + 0x64, + 0x6f, + 0x62, + 0x65, // "Adobe" + 0x00, + 0x64, // version + 0x00, + 0x00, // flags0 + 0x00, + 0x00, // flags1 + adobeTransform, + ]; + // prettier-ignore + const bytes = new Uint8Array([ + 0xff, 0xd8, // SOI + ...app14, + 0xff, 0xc0, 0x00, 8 + 3 * components, // SOF0 + 0x08, // precision + 0x00, 0x02, // height = 2 + 0x00, 0x03, // width = 3 + components, + ...componentBytes, + 0xff, 0xd9, // EOI + ]); + return { + format: "jpeg", + base64: bytesToBase64(bytes), + widthPx: 3, + heightPx: 2, + }; +} + describe("writePdf: document structure", () => { it("starts with the PDF header and ends with %%EOF", () => { const bytes = writePdf(docWithPages([])); @@ -114,6 +165,46 @@ describe("writePdf: document structure", () => { ); expect(text).toContain("/MediaBox [0 0 612 792]"); }); + + it("round-trips every optional Info dict field, and omits the ones the source document does not carry", async () => { + const { readPdf } = await import("./read"); + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: { + title: "A Title", + author: "An Author", + subject: "A Subject", + keywords: ["one", "two"], + creator: "A Creator", + createdIso: "2024-03-05T06:07:08Z", + modifiedIso: "2024-03-06T07:08:09Z", + }, + pages: [], + images: {}, + }; + const out = writePdf(doc, { compress: false }); + const reread = readPdf(out); + expect(reread.metadata.title).toBe("A Title"); + expect(reread.metadata.author).toBe("An Author"); + expect(reread.metadata.subject).toBe("A Subject"); + expect(reread.metadata.keywords).toEqual(["one", "two"]); + expect(reread.metadata.creator).toBe("A Creator"); + expect(reread.metadata.createdIso).toBe("2024-03-05T06:07:08Z"); + expect(reread.metadata.modifiedIso).toBe("2024-03-06T07:08:09Z"); + + const bareText = decode(writePdf(docWithPages([]), { compress: false })); + for (const key of [ + "/Title", + "/Author", + "/Subject", + "/Keywords", + "/Creator", + "/CreationDate", + "/ModDate", + ]) { + expect(bareText).not.toContain(key); + } + }); }); describe("writePdf: text and fonts", () => { @@ -221,6 +312,108 @@ describe("writePdf: text and fonts", () => { expect(text).not.toContain("BT\n"); }); + it("sets FontDescriptor /Flags bits per standard face: fixed-pitch, serif, italic, and force-bold each add their own bit to the always-set nonsymbolic bit", () => { + const courierNew = { + family: "Courier New", + weight: "normal", + style: "normal", + } as const; + const timesRoman = { + family: "Times New Roman", + weight: "normal", + style: "normal", + } as const; + const timesItalic = { + family: "Times New Roman", + weight: "normal", + style: "italic", + } as const; + const helveticaBold = { + family: "Helvetica", + weight: "bold", + style: "normal", + } as const; + const text = decode( + writePdf( + docWithItems([ + { + kind: "text", + text: "A", + xPt: 0, + yPt: 0, + font: courierNew, + sizePt: 10, + color: BLACK, + }, + { + kind: "text", + text: "B", + xPt: 0, + yPt: 0, + font: timesRoman, + sizePt: 10, + color: BLACK, + }, + { + kind: "text", + text: "C", + xPt: 0, + yPt: 0, + font: timesItalic, + sizePt: 10, + color: BLACK, + }, + { + kind: "text", + text: "D", + xPt: 0, + yPt: 0, + font: helveticaBold, + sizePt: 10, + color: BLACK, + }, + ]), + { compress: false }, + ), + ); + // NONSYMBOLIC(32) is always set; each face then adds FIXED_PITCH(1), SERIF(2), ITALIC(64), or FORCE_BOLD(262144) of its own on top of it. + expect(text).toContain("/BaseFont /Courier "); + expect(text).toContain("/Flags 33"); // Courier: nonsymbolic + fixed-pitch + expect(text).toContain("/BaseFont /Times-Roman"); + expect(text).toContain("/Flags 34"); // Times-Roman: nonsymbolic + serif + expect(text).toContain("/BaseFont /Times-Italic"); + expect(text).toContain("/Flags 98"); // Times-Italic: nonsymbolic + serif + italic + expect(text).toContain("/BaseFont /Helvetica-Bold"); + expect(text).toContain("/Flags 262176"); // Helvetica-Bold: nonsymbolic + force-bold + }); + + it("gives every Widths-array entry the font's own real AFM advance width, across the full FirstChar..LastChar range", () => { + const text = decode( + writePdf( + docWithItems([ + { + kind: "text", + text: "A", + xPt: 0, + yPt: 0, + font: HELVETICA, + sizePt: 10, + color: BLACK, + }, + ]), + { compress: false }, + ), + ); + const widthsMatch = /\/Widths \[([^\]]*)\]/.exec(text); + expect(widthsMatch).not.toBeNull(); + const widths = widthsMatch![1]!.trim().split(/\s+/).map(Number); + expect(widths).toHaveLength(255 - 32 + 1); + // Every code in range resolves to a real, positive advance width -- WINANSI_GLYPH_NAMES has no gap in this range and every standard-14 AFM table defines every glyph name it can produce, so a 0 anywhere here would mean a genuine regression, not a legitimate "unassigned code" placeholder. + expect(widths.every((w) => w > 0)).toBe(true); + // Space (code 32, the first entry) is a known, specific value worth pinning exactly. + expect(widths[0]).toBe(278); + }); + it("reports WinAnsi substitutions via the onSubstitution callback, with the page index", () => { const substitutions: { from: string; to: string; pageIndex: number }[] = []; writePdf( @@ -386,6 +579,65 @@ describe("writePdf: images", () => { expect(text).toContain("/ColorSpace /DeviceRGB"); }); + it("resolves a JPEG's colour space from its own component count: 1 -> DeviceGray, 3 -> DeviceRGB, 4 -> DeviceCMYK", () => { + for (const [components, colorSpace] of [ + [1, "DeviceGray"], + [3, "DeviceRGB"], + [4, "DeviceCMYK"], + ] as const) { + const doc = docWithPages( + [ + { + widthPt: 100, + heightPt: 100, + items: [ + { + kind: "image", + imageId: "photo", + xPt: 0, + yPt: 0, + widthPt: 50, + heightPt: 50, + }, + ], + }, + ], + { photo: jpegAsset(components) }, + ); + const text = decode(writePdf(doc, { compress: false })); + expect(text).toContain(`/ColorSpace /${colorSpace}`); + } + }); + + it("inverts a CMYK JPEG's colour with /Decode when its Adobe transform is YCCK (2) or absent, but not when it is explicitly untransformed (0)", () => { + const decodeFor = (adobeTransform: number | undefined): boolean => { + const doc = docWithPages( + [ + { + widthPt: 100, + heightPt: 100, + items: [ + { + kind: "image", + imageId: "photo", + xPt: 0, + yPt: 0, + widthPt: 50, + heightPt: 50, + }, + ], + }, + ], + { photo: jpegAsset(4, adobeTransform) }, + ); + const text = decode(writePdf(doc, { compress: false })); + return text.includes("/Decode [1 0 1 0 1 0 1 0]"); + }; + expect(decodeFor(2)).toBe(true); + expect(decodeFor(undefined)).toBe(true); + expect(decodeFor(0)).toBe(false); + }); + it("writes a bilevel image as CCITT Group 4 when that is smaller than Flate, and reads it back (#975)", async () => { // A diagonal edge: every row shifts the black/white boundary one pixel right, so each row codes as two vertical-mode offsets against the previous one -- the vertically coherent shape CCITT Group 4 exists for (a real scan's edges and text baselines behave exactly this way). Decorrelated noise would instead be deflate's own best case, which is what the pick-the-smaller rule protects onto Flate. const width = 96; @@ -1156,3 +1408,83 @@ describe("writePdf: package-level residue (#967)", () => { expect(readPdf(bytes).source?.["open-action"]).toBeUndefined(); }); }); + +// options.formulas is writePdf's own side channel for embedded-math-font content (see this module's own top comment for why a formula cannot travel as an ordinary LayoutItem) -- exercised here through writePdf itself, not just through math-content-write.ts/math-font-write.ts's own unit tests, since only this integration proves the allocation, the resource dict, and the emitted content stream actually agree on object numbers. +describe("writePdf: embedded formulas", () => { + function formula(pageIndex: number): PositionedFormula { + return { + pageIndex, + xPt: 50, + yPt: 100, + box: { + widthPt: 20, + heightPt: 12, + ascentPt: 12, + descentPt: 0, + items: [ + { + kind: "glyphs", + xPt: 0, + yPt: 0, + text: "x", + sizePt: 12, + color: BLACK, + }, + ], + }, + }; + } + + it("allocates a Type0/CIDFontType0 composite font group, references it from the page Resources, and draws the formula's own glyph run", () => { + const text = decode( + writePdf(docWithItems([]), { + compress: false, + formulas: [formula(0)], + }), + ); + expect(text).toContain("/Subtype /Type0"); + expect(text).toContain("/Subtype /CIDFontType0"); + expect(text).toContain("/FontFile3"); + expect(text).toContain("/Font < { + const text = decode( + writePdf(docWithItems([]), { compress: false, formulas: [] }), + ); + expect(text).not.toContain("/CIDFontType0"); + expect(text).not.toContain("/MF"); + }); + + it("routes each formula to its own page's Contents stream by pageIndex, never the other page's", () => { + const marker = (label: string): LayoutItem => ({ + kind: "text", + text: label, + xPt: 0, + yPt: 0, + font: HELVETICA, + sizePt: 10, + color: BLACK, + }); + const text = decode( + writePdf( + docWithPages([ + { widthPt: 100, heightPt: 100, items: [marker("A")] }, + { widthPt: 100, heightPt: 100, items: [marker("B")] }, + ]), + { compress: false, formulas: [formula(1)] }, + ), + ); + const streams = [...text.matchAll(/stream\n([\s\S]*?)\nendstream/g)].map( + (m) => m[1]!, + ); + const pageAStream = streams.find((s) => s.includes("<41>")); // 'A' + const pageBStream = streams.find((s) => s.includes("<42>")); // 'B' + expect(pageAStream).toBeDefined(); + expect(pageBStream).toBeDefined(); + expect(pageAStream).not.toContain("/MF"); + expect(pageBStream).toContain("/MF 12 Tf"); + }); +}); From 05b717695be2c34c4c0582f73eac5fd580d912ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:16:12 +0100 Subject: [PATCH 121/135] test(pdf-codec): cover every internal-link destination view type destinationViewArray's fitH/fitV/fitR/fitB/fitBH/fitBV branches (and the plain 'fit' case), each with and without their own optional coordinates, had no coverage -- only the default 'xyz' view was ever round-tripped through an internal link. Also covers resolveDestinationArray's own "page index beyond the document" guard, which had no test at all. --- packages/pdf-codec/src/roundtrip.test.ts | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/pdf-codec/src/roundtrip.test.ts b/packages/pdf-codec/src/roundtrip.test.ts index 1dfc74ee4..e86c69472 100644 --- a/packages/pdf-codec/src/roundtrip.test.ts +++ b/packages/pdf-codec/src/roundtrip.test.ts @@ -644,6 +644,69 @@ describe("writePdf -> readPdf: structural round trip", () => { }); }); + // Every display-destination view type ISO 32000-1 Table 151 defines (destinationViewArray's own full branch set), each round-tripped through an internal link so the written direct array and the read side's parseDestination agree exactly on both the view type and its own particular coordinates. + it.each([ + { target: { kind: "fit" as const } }, + { target: { kind: "fitH" as const, topPt: 55 } }, + { target: { kind: "fitH" as const } }, + { target: { kind: "fitV" as const, leftPt: 33 } }, + { target: { kind: "fitV" as const } }, + { + target: { + kind: "fitR" as const, + leftPt: 1, + bottomPt: 2, + rightPt: 3, + topPt: 4, + }, + }, + { target: { kind: "fitB" as const } }, + { target: { kind: "fitBH" as const, topPt: 66 } }, + { target: { kind: "fitBH" as const } }, + { target: { kind: "fitBV" as const, leftPt: 77 } }, + { target: { kind: "fitBV" as const } }, + ])( + "round-trips an internal link's $target.kind destination view", + ({ target }) => { + const doc = docWithPages([ + { widthPt: 300, heightPt: 200, items: [] }, + { widthPt: 300, heightPt: 200, items: [] }, + ]); + doc.destinations = [{ name: "target", pageIndex: 1, target }]; + doc.pages[0]!.items.push({ + kind: "internalLink", + destination: "target", + xPt: 0, + yPt: 0, + widthPt: 10, + heightPt: 10, + }); + const result = readPdf(writePdf(doc, { compress: false })); + expect(result.destinations).toEqual([ + { name: "dest1", pageIndex: 1, target }, + ]); + }, + ); + + it("throws rather than guessing when a destination names a page index beyond the document's own pages", () => { + const doc = docWithItems([ + { + kind: "internalLink", + destination: "target", + xPt: 0, + yPt: 0, + widthPt: 10, + heightPt: 10, + }, + ]); + doc.destinations = [ + { name: "target", pageIndex: 5, target: { kind: "fit" } }, + ]; + expect(() => writePdf(doc, { compress: false })).toThrow( + /beyond the document/, + ); + }); + it("throws rather than guessing when an internal link names a destination the document does not carry", () => { const doc = docWithItems([ { From 3911e298f443994d14c038dfd04278157d806c9c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:27:02 +0100 Subject: [PATCH 122/135] test(pdf-codec): cover rmoveto/hmoveto/vmoveto width-shift and cubic-axis extrema rmoveto had no test at all, and neither hmoveto nor vmoveto was tested with its own optional leading width operand present -- takeWidth's evenArgs=false branch (moveto's own arity-based width detection) was entirely unexercised. includeCubicAxis's own root-finding had two branches with no direct hand-built coverage: the genuinely non-degenerate quadratic case with two distinct real roots, and the degenerate (a === 0) linear fallback a curve with collinear control points on one axis produces. The real STIX Two Math font's own glyphs exercise curve extrema in general, but not these two specific coefficient shapes. --- packages/pdf-codec/src/cff-bounds.test.ts | 135 ++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/packages/pdf-codec/src/cff-bounds.test.ts b/packages/pdf-codec/src/cff-bounds.test.ts index 942fb7047..fe960b735 100644 --- a/packages/pdf-codec/src/cff-bounds.test.ts +++ b/packages/pdf-codec/src/cff-bounds.test.ts @@ -611,6 +611,141 @@ describe("parseCffGlyphBounds's charstring interpreter: hmoveto/vmoveto, escaped }); }); + it("moves the current point diagonally with a bare rmoveto, the minimal two-operand case", () => { + const OP_RMOVETO = 21; + const bytes = cffFontWithCharstrings({ + name: "BareRmoveto", + charStrings: [ + [...enc(5), ...enc(4), OP_RMOVETO, ...enc(1), ...enc(1), OP_RLINETO], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 5, + yMin: 4, + xMax: 6, + yMax: 5, + }); + }); + + it("discards rmoveto's own leading width operand, using only the last two operands as dx/dy", () => { + // 3 operands: a leading width the interpreter must shift off, then dx=5, dy=4. Using the wrong two (say, the first two, treating the width as dx) would move to a completely different point. + const OP_RMOVETO = 21; + const bytes = cffFontWithCharstrings({ + name: "RmovetoWithWidth", + charStrings: [ + [ + ...enc(999), + ...enc(5), + ...enc(4), + OP_RMOVETO, + ...enc(1), + ...enc(1), + OP_RLINETO, + ], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 5, + yMin: 4, + xMax: 6, + yMax: 5, + }); + }); + + it("discards hmoveto's own leading width operand, using only the last operand as dx", () => { + const bytes = cffFontWithCharstrings({ + name: "HmovetoWithWidth", + charStrings: [ + [...enc(999), ...enc(5), OP_HMOVETO, ...enc(3), OP_HLINETO, OP_ENDCHAR], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 5, + yMin: 0, + xMax: 8, + yMax: 0, + }); + }); + + it("discards vmoveto's own leading width operand, using only the last operand as dy", () => { + const bytes = cffFontWithCharstrings({ + name: "VmovetoWithWidth", + charStrings: [ + [...enc(999), ...enc(4), OP_VMOVETO, ...enc(2), OP_VLINETO, OP_ENDCHAR], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 4, + xMax: 0, + yMax: 6, + }); + }); + + it("finds both of a cubic curve's own interior extrema when the derivative's quadratic has two distinct real roots", () => { + // Both axes share control points 0, 30, -30, 0 (an S-shaped overshoot: the curve swings past its own two endpoints in both directions before returning to 0). The derivative's quadratic (a=180, b=-180, c=30) has two real, distinct roots strictly inside (0,1) -- t=0.2113 and t=0.7887 -- giving an exact extremum of +-5*sqrt(3) on each axis, verified independently by dense sampling. + const OP_RRCURVETO = 8; + const bytes = cffFontWithCharstrings({ + name: "CurveTwoRealRoots", + charStrings: [ + [ + ...enc(30), + ...enc(30), + ...enc(-60), + ...enc(-60), + ...enc(30), + ...enc(30), + OP_RRCURVETO, + OP_ENDCHAR, + ], + ], + }); + const bounds = boundsOfOnlyGlyph(bytes); + if (bounds === undefined) { + throw new Error("fixture glyph unexpectedly drew nothing"); + } + const extremum = 5 * Math.sqrt(3); + expect(bounds.xMin).toBeCloseTo(-extremum, 9); + expect(bounds.xMax).toBeCloseTo(extremum, 9); + expect(bounds.yMin).toBeCloseTo(-extremum, 9); + expect(bounds.yMax).toBeCloseTo(extremum, 9); + }); + + it("finds a cubic axis's own extremum through the degenerate (a === 0) linear derivative case, not just the quadratic formula", () => { + // x control points 0, 10, 5, -15: a = -0+30-15-15 = 0 exactly (the derivative's own leading term vanishes), so this axis's extremum can only come from includeCubicAxis's linear (b !== 0) fallback, never the quadratic formula the test above exercises. The true extremum (verified by dense sampling) is xMax=5 at t=1/3, past both endpoints 0 and -15; y stays flat at 0 throughout, so this isolates the x-axis's own degenerate branch from the y-axis's ordinary one. + const OP_RRCURVETO = 8; + const bytes = cffFontWithCharstrings({ + name: "CurveDegenerateAxis", + charStrings: [ + [ + ...enc(10), + ...enc(0), + ...enc(-5), + ...enc(0), + ...enc(-20), + ...enc(0), + OP_RRCURVETO, + OP_ENDCHAR, + ], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: -15, + yMin: 0, + xMax: 5, + yMax: 0, + }); + }); + + it("returns undefined for an rmoveto with fewer than 2 operands on the stack", () => { + const OP_RMOVETO = 21; + const bytes = cffFontWithCharstrings({ + name: "RmovetoTooFewArgs", + charStrings: [[...enc(5), OP_RMOVETO]], + }); + expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); + }); + it("uses parity (evenArgs), not a fixed expected count, to detect a stack-clearing hint operator's own leading width", () => { // 9 zero-width stem pairs (18 operands, even -- no width to shift) registered via hstemhm, followed by a bare hintmask that must consume exactly ceil(9/8)=2 mask bytes. If the width-detection wrongly used "stack.length > 0" instead of parity, it would shift one operand off, leaving 17 (8 stems, ceil(8/8)=1 mask byte) -- desyncing the byte stream, so the hlineto that follows would be misread and the draw would fail instead of producing this exact box. const pairs18 = new Array(18).fill(139); // 9 zero-width stem pairs From 1844db44e8de5afdbfbeeda6fb33e062e3042137 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:36:50 +0100 Subject: [PATCH 123/135] test(pdf-codec): cover endchar's own bare-width and width-plus-seac arities endchar's own arity rule (its leading width shows up as exactly 1 or 5 operands, distinct from every other stack-clearing operator's takeWidth-based detection) only had a test for the 4-operand bare-seac case. A bare width (1 operand) and a width-plus-seac (5 operands) were both untested, so neither of those two boundary values on stack.length was actually exercised. --- packages/pdf-codec/src/cff-bounds.test.ts | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/pdf-codec/src/cff-bounds.test.ts b/packages/pdf-codec/src/cff-bounds.test.ts index fe960b735..f7ee2d9a3 100644 --- a/packages/pdf-codec/src/cff-bounds.test.ts +++ b/packages/pdf-codec/src/cff-bounds.test.ts @@ -343,6 +343,43 @@ describe("parseCffGlyphBounds's charstring interpreter, driven by hand-built cha expect(boundsOfOnlyGlyph(bytes)).toBeUndefined(); }); + it("shifts off endchar's own bare leading width (exactly 1 operand), succeeding rather than treating it as malformed", () => { + const OP_HLINETO = 6; + const bytes = cffFontWithCharstrings({ + name: "EndcharBareWidth", + charStrings: [ + [...enc(5), OP_HLINETO, ...enc(999), OP_ENDCHAR], // draw, then a single width-only operand ahead of endchar + ], + }); + // If width-shifting were broken, endchar's own arity check would see 1 leftover operand and treat it identically to the seac case's own boundary -- this must draw successfully instead. + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 5, + yMax: 0, + }); + }); + + it("treats endchar's own width-plus-seac (exactly 5 operands) as the seac-like form too, discarding any already-drawn ink", () => { + const OP_HLINETO = 6; + const bytes = cffFontWithCharstrings({ + name: "EndcharWidthPlusSeac", + charStrings: [ + [ + ...enc(5), + OP_HLINETO, // draws something, so a wrongly-permissive check would report real bounds instead of undefined + ...enc(999), // width + ...enc(0), + ...enc(0), + ...enc(0), + ...enc(0), // 4 seac-like args + OP_ENDCHAR, + ], + ], + }); + 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; From cc83f8b1c92909db6e30e2ce472f0a7d11cee576 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:55:07 +0100 Subject: [PATCH 124/135] test(pdf-codec): pin dict-key names, sort order, and empty-collection boundaries A scoped mutation run against write.ts (after the earlier coverage additions) surfaced a large batch of Survived mutants in code that was now reached but not precisely asserted: - Image XObject dict entries (Type/Subtype/BitsPerComponent/Columns/Rows/ BlackIs1) were exercised but never checked, so a wrong or blanked-out key name went unnoticed. - The CMYK JPEG /Decode inversion only ever ran against 4-component assets, so the "info.components === 4" guard itself was never independently proven -- a 3-component asset with a matching Adobe transform now confirms the guard, not just the transform value, gates the inversion. - Font and image resource naming ("/F1", "/Im1") was only checked for existence, never for which underlying object each name actually pointed at, so removing the sort-by-name/sort-by-id step left the tests green. - Several "only when non-empty" branches (optional-content ON/OFF arrays, AcroForm's own field-count guard) were exercised exclusively with non-empty input, so the boundary itself was never distinguished from an unconditional branch. - A group AcroForm field carrying more than one widget must stay a single object (multi-widget splitting is a terminal-field concept); nothing had ever exercised a group with more than one widget to prove that guard is real, as opposed to redundant. - objectContainsReference's dict and stream branches were exercised only through an array-shaped residue row. --- packages/pdf-codec/src/write.test.ts | 218 ++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/write.test.ts b/packages/pdf-codec/src/write.test.ts index 8e00409a8..5b470a1e5 100644 --- a/packages/pdf-codec/src/write.test.ts +++ b/packages/pdf-codec/src/write.test.ts @@ -287,9 +287,16 @@ describe("writePdf: text and fonts", () => { ); expect(text).toContain("/BaseFont /Times-Roman"); expect(text).toContain("/BaseFont /Helvetica"); - // Sorted alphabetically, "Helvetica" < "Times-Roman", so Helvetica gets F1 and is used by the second item's Tf. expect(text).toContain("/F1 10 Tf"); expect(text).toContain("/F2 10 Tf"); + // Sorted alphabetically, "Helvetica" < "Times-Roman", so F1 must resolve to the Helvetica object specifically -- not merely "some Font resource named F1 exists", which the two toContain checks above don't distinguish from insertion order (Times New Roman was the first item's own font). + const f1Ref = /\/F1 (\d+) 0 R/.exec(text); + expect(f1Ref).not.toBeNull(); + const f1Obj = new RegExp( + `\\n${f1Ref![1]} 0 obj\\n([\\s\\S]*?)\\nendobj`, + ).exec(text); + expect(f1Obj).not.toBeNull(); + expect(f1Obj![1]).toContain("/BaseFont /Helvetica"); }); it("by default (compress: true) hides the content stream as FlateDecode-compressed bytes", () => { @@ -551,6 +558,67 @@ describe("writePdf: images", () => { expect(text).toContain("/XObject < { + // Two distinctly-sized assets so each one's own object dict is independently identifiable. "zebra" is the FIRST page item (first-encountered), but "apple" sorts first alphabetically -- if imageIds were resource-named by encounter order instead of sorted order, Im1 would resolve to zebra's own 4x4 dict instead of apple's 2x2 one. + const small = tinyPngAsset(); // 2x2 + const width = 4; + const height = 4; + const large = { + format: "png" as const, + base64: bytesToBase64( + encodePng({ + width, + height, + channels: 3, + data: new Uint8Array(width * height * 3), + }), + ), + widthPx: width, + heightPx: height, + }; + const text = decode( + writePdf( + docWithPages( + [ + { + widthPt: 100, + heightPt: 100, + items: [ + { + kind: "image", + imageId: "zebra", // encountered FIRST, but sorts LAST; the 4x4 asset + xPt: 0, + yPt: 0, + widthPt: 50, + heightPt: 50, + }, + { + kind: "image", + imageId: "apple", // encountered SECOND, but sorts FIRST; the 2x2 asset + xPt: 0, + yPt: 0, + widthPt: 50, + heightPt: 50, + }, + ], + }, + ], + { zebra: large, apple: small }, + ), + { compress: false }, + ), + ); + const im1Ref = /\/Im1 (\d+) 0 R/.exec(text); + expect(im1Ref).not.toBeNull(); + const im1Obj = new RegExp( + `\\n${im1Ref![1]} 0 obj\\n([\\s\\S]*?)\\nendobj`, + ).exec(text); + expect(im1Obj).not.toBeNull(); + // "apple" sorts before "zebra", so Im1 must be apple's own 2x2 object, never zebra's 4x4 one. + expect(im1Obj![1]).toContain("/Width 2"); + expect(im1Obj![1]).toContain("/Height 2"); + }); + it("embeds a JPEG-sourced image verbatim via DCTDecode, never re-encoding it", () => { const asset = tinyJpegAsset(); const doc = docWithPages( @@ -573,10 +641,13 @@ describe("writePdf: images", () => { { photo: asset }, ); const text = decode(writePdf(doc, { compress: false })); + expect(text).toContain("/Type /XObject"); + expect(text).toContain("/Subtype /Image"); expect(text).toContain("/Filter /DCTDecode"); expect(text).toContain("/Width 3"); expect(text).toContain("/Height 2"); expect(text).toContain("/ColorSpace /DeviceRGB"); + expect(text).toContain("/BitsPerComponent 8"); }); it("resolves a JPEG's colour space from its own component count: 1 -> DeviceGray, 3 -> DeviceRGB, 4 -> DeviceCMYK", () => { @@ -638,6 +709,30 @@ describe("writePdf: images", () => { expect(decodeFor(0)).toBe(false); }); + it("never adds the CMYK /Decode inversion to a non-CMYK (3-component) JPEG, even with an Adobe transform of 2", () => { + const doc = docWithPages( + [ + { + widthPt: 100, + heightPt: 100, + items: [ + { + kind: "image", + imageId: "photo", + xPt: 0, + yPt: 0, + widthPt: 50, + heightPt: 50, + }, + ], + }, + ], + { photo: jpegAsset(3, 2) }, + ); + const text = decode(writePdf(doc, { compress: false })); + expect(text).not.toContain("/Decode"); + }); + it("writes a bilevel image as CCITT Group 4 when that is smaller than Flate, and reads it back (#975)", async () => { // A diagonal edge: every row shifts the black/white boundary one pixel right, so each row codes as two vertical-mode offsets against the previous one -- the vertically coherent shape CCITT Group 4 exists for (a real scan's edges and text baselines behave exactly this way). Decorrelated noise would instead be deflate's own best case, which is what the pick-the-smaller rule protects onto Flate. const width = 96; @@ -679,9 +774,15 @@ describe("writePdf: images", () => { // compress defaults to true -- G4 is compression, so it sits behind the same option as Flate; the dictionary entries stay plain ASCII either way, only streams are flated. const out = writePdf(doc); const text = new TextDecoder("latin1").decode(out); + expect(text).toContain("/Type /XObject"); + expect(text).toContain("/Subtype /Image"); + expect(text).toContain("/ColorSpace /DeviceGray"); expect(text).toContain("/CCITTFaxDecode"); expect(text).toContain("/K -1"); expect(text).toContain("/BitsPerComponent 1"); + expect(text).toContain(`/Columns ${width}`); + expect(text).toContain(`/Rows ${height}`); + expect(text).toContain("/BlackIs1 false"); // ...and the package's own reader decodes the G4 stream back to pixels: the recovered asset is a PNG whose samples equal the original checkerboard exactly. const { readPdf } = await import("./read"); const reread = readPdf(out); @@ -1052,8 +1153,12 @@ describe("writePdf: optional-content layers (#967)", () => { { name: "Annotations", visible: false }, ], }; + const bytes = writePdf(doc, { compress: false }); + const rawText = new TextDecoder("latin1").decode(bytes); + expect(rawText).toContain("/ON ["); + expect(rawText).toContain("/OFF ["); const { readPdf } = await import("./read"); - const reread = readPdf(writePdf(doc)); + const reread = readPdf(bytes); expect(reread.layers).toEqual([ { name: "Background", visible: true }, { name: "Annotations", visible: false }, @@ -1071,6 +1176,36 @@ describe("writePdf: optional-content layers (#967)", () => { expect(rect?.layer).toBe("Annotations"); }); + it("omits /OFF entirely when every layer is visible, and /ON entirely when every layer is hidden", () => { + const allVisible = writePdf( + { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [], + images: {}, + layers: [{ name: "Background", visible: true }], + }, + { compress: false }, + ); + const allVisibleText = new TextDecoder("latin1").decode(allVisible); + expect(allVisibleText).toContain("/ON ["); + expect(allVisibleText).not.toContain("/OFF ["); + + const allHidden = writePdf( + { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [], + images: {}, + layers: [{ name: "Background", visible: false }], + }, + { compress: false }, + ); + const allHiddenText = new TextDecoder("latin1").decode(allHidden); + expect(allHiddenText).not.toContain("/ON ["); + expect(allHiddenText).toContain("/OFF ["); + }); + it("writes no /OCProperties at all for a document with no layers", () => { const bytes = writePdf({ formatVersion: LAYOUT_FORMAT_VERSION, @@ -1174,6 +1309,40 @@ describe("writePdf: AcroForm fields (#967)", () => { ]); }); + it("never splits a group field into per-widget kid objects, even one that carries more than one widget", () => { + // Multi-widget object-splitting is a TERMINAL-field concept (12.7.4's own /Kids-as-widgets spelling); a group field's own /Kids are its child FIELDS, never widget annotations, so this must stay a single object regardless of how many widgets it happens to carry. + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [{ widthPt: 200, heightPt: 100, items: [] }], + images: {}, + form: [ + { + name: "oddGroup", + fieldType: "group", + widgets: [ + { pageIndex: 0, xPt: 10, yPt: 60, widthPt: 10, heightPt: 10 }, + { pageIndex: 0, xPt: 10, yPt: 40, widthPt: 10, heightPt: 10 }, + ], + children: [ + { + name: "oddGroup.child", + fieldType: "text", + value: "x", + widgets: [ + { pageIndex: 0, xPt: 10, yPt: 20, widthPt: 60, heightPt: 12 }, + ], + children: [], + }, + ], + }, + ], + }; + const text = decode(writePdf(doc, { compress: false })); + // Exactly 2 field objects (the group itself, plus its one terminal child) -- if the group's own 2 widgets were wrongly split into their own kid objects, a third and fourth "/Subtype /Widget" object would exist beyond the child's own. + expect(text.match(/\/Subtype \/Widget/g)).toHaveLength(1); + }); + it("writes no /AcroForm for a document with no fields", () => { const bytes = writePdf({ formatVersion: LAYOUT_FORMAT_VERSION, @@ -1185,6 +1354,18 @@ describe("writePdf: AcroForm fields (#967)", () => { expect(text).not.toContain("/AcroForm"); }); + it("writes no /AcroForm for a document whose form array is present but empty", () => { + const bytes = writePdf({ + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [], + images: {}, + form: [], + }); + const text = new TextDecoder("latin1").decode(bytes); + expect(text).not.toContain("/AcroForm"); + }); + it("lists every widget annotation in its page's /Annots as the field tree's own objects", () => { // A single-widget field merges into its field dict, so its page /Annots entry is that very object; a multi-widget field's widgets are separate kid objects, each listed in /Annots AND in the field's /Kids — the same annotation object in both places, the spelling real Acrobat files carry (ISO 32000-1 12.5.1: a page's /Annots holds indirect references to its annotations, and 12.7.4 hangs the widgets under the field). A viewer rendering only page-level /Annots sees every field widget without walking the AcroForm tree. const doc: LayoutDocument = { @@ -1387,6 +1568,39 @@ describe("writePdf: package-level residue (#967)", () => { expect(reread.source?.["output-intents"]).toBeUndefined(); }); + it("does not restore a row whose serialisation is a dict that CONTAINS a reference, not just a bare array of one", async () => { + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [], + images: {}, + source: { + "piece-info": { format: "pdf", xml: "<< /Private 3 0 R >>" }, + }, + }; + const { readPdf } = await import("./read"); + const reread = readPdf(writePdf(doc)); + expect(reread.source?.["piece-info"]).toBeUndefined(); + }); + + it("does not restore a row whose serialisation is a stream whose OWN dict contains a reference", async () => { + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [], + images: {}, + source: { + "piece-info": { + format: "pdf", + xml: "<< /Length 5 /Extra 3 0 R >>\nstream\nhello\nendstream", + }, + }, + }; + const { readPdf } = await import("./read"); + const reread = readPdf(writePdf(doc)); + expect(reread.source?.["piece-info"]).toBeUndefined(); + }); + it("never restores the open-action row, including an inline action", async () => { // /OpenAction is active content: a viewer executes an inline JavaScript/Launch/URI action on open, so restoring it verbatim from a source file would re-arm attacker-supplied behaviour in the rewritten output. The row restores as nothing whether its serialisation is reference-free or not. const doc: LayoutDocument = { From acd80f3776d32731a6d2219fdd8d7655a4e27d70 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 06:55:19 +0100 Subject: [PATCH 125/135] test(pdf-codec): pin passthrough image headers and destination-lookup-by-name preparePassthroughImage's own /Type, /Subtype, /ColorSpace, and /BitsPerComponent entries were never checked directly, and the JBIG2-vs-JPX branch (objectContainsReference's own filter === "jbig2" check) had no test proving a JPX asset gets neither key -- only that a JBIG2 one gets both. resolveDestinationArray's own destination lookup was only ever exercised with a single-entry destinations table, so a predicate that ignored the name entirely and returned the first entry would have passed unnoticed; the internal-link Annot dict's own /Type, /Border, and error-message text were similarly unchecked. --- packages/pdf-codec/src/roundtrip.test.ts | 54 ++++++++++++++++++- .../pdf-codec/src/write-passthrough.test.ts | 9 ++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/roundtrip.test.ts b/packages/pdf-codec/src/roundtrip.test.ts index e86c69472..e047a7089 100644 --- a/packages/pdf-codec/src/roundtrip.test.ts +++ b/packages/pdf-codec/src/roundtrip.test.ts @@ -703,7 +703,7 @@ describe("writePdf -> readPdf: structural round trip", () => { { name: "target", pageIndex: 5, target: { kind: "fit" } }, ]; expect(() => writePdf(doc, { compress: false })).toThrow( - /beyond the document/, + /target.*beyond the document/, ); }); @@ -718,6 +718,56 @@ describe("writePdf -> readPdf: structural round trip", () => { heightPt: 14, }, ]); - expect(() => writePdf(doc, { compress: false })).toThrow(/nowhere/); + expect(() => writePdf(doc, { compress: false })).toThrow( + /internal link.*nowhere/, + ); + }); + + it("resolves a destination by its own name, not merely the first entry in the destinations table", () => { + const doc = docWithPages([ + { widthPt: 300, heightPt: 200, items: [] }, + { widthPt: 300, heightPt: 200, items: [] }, + { widthPt: 300, heightPt: 200, items: [] }, + ]); + doc.destinations = [ + { name: "decoy", pageIndex: 0, target: { kind: "fit" } }, + { name: "real-target", pageIndex: 2, target: { kind: "fit" } }, + ]; + doc.pages[0]!.items.push({ + kind: "internalLink", + destination: "real-target", + xPt: 0, + yPt: 0, + widthPt: 10, + heightPt: 10, + }); + const result = readPdf(writePdf(doc, { compress: false })); + const link = result.pages[0]!.items.find((i) => i.kind === "internalLink"); + if (link?.kind !== "internalLink") { + throw new Error("expected an internalLink item"); + } + // A wrongly permissive lookup (matching the first destination regardless of name) would resolve to page index 0 (decoy) instead of 2 (real-target). + const resolved = result.destinations?.find( + (d) => d.name === link.destination, + ); + expect(resolved?.pageIndex).toBe(2); + }); + + it("writes an internal link's own /Type /Annot and zero-width /Border, matching an ordinary link's", () => { + const doc = docWithPages([{ widthPt: 300, heightPt: 200, items: [] }]); + doc.destinations = [ + { name: "target", pageIndex: 0, target: { kind: "fit" } }, + ]; + doc.pages[0]!.items.push({ + kind: "internalLink", + destination: "target", + xPt: 0, + yPt: 0, + widthPt: 10, + heightPt: 10, + }); + const text = new TextDecoder().decode(writePdf(doc, { compress: false })); + expect(text).toContain("/Type /Annot"); + expect(text).toContain("/Border [0 0 0]"); }); }); diff --git a/packages/pdf-codec/src/write-passthrough.test.ts b/packages/pdf-codec/src/write-passthrough.test.ts index bb9165096..951e47b43 100644 --- a/packages/pdf-codec/src/write-passthrough.test.ts +++ b/packages/pdf-codec/src/write-passthrough.test.ts @@ -203,7 +203,11 @@ describe("writePdf: verbatim passthrough of no-encoder image filters", () => { const bytes = writePdf(docWithImage(built!.asset), { compress: false }); const text = new TextDecoder("latin1").decode(bytes); + expect(text).toContain("/Type /XObject"); + expect(text).toContain("/Subtype /Image"); expect(text).toContain("/JBIG2Decode"); + expect(text).toContain("/ColorSpace /DeviceGray"); + expect(text).toContain("/BitsPerComponent 1"); expect(containsSubsequence(bytes, jbig2FixtureBytes(generic!.stream))).toBe( true, ); @@ -251,7 +255,12 @@ describe("writePdf: verbatim passthrough of no-encoder image filters", () => { const bytes = writePdf(docWithImage(built!.asset), { compress: false }); const text = new TextDecoder("latin1").decode(bytes); + expect(text).toContain("/Type /XObject"); + expect(text).toContain("/Subtype /Image"); expect(text).toContain("/JPXDecode"); + // Unlike JBIG2 (always 1-bit /DeviceGray), a JPX codestream states its own component count and sample depth -- neither /ColorSpace nor /BitsPerComponent is written for it. + expect(text).not.toContain("/ColorSpace"); + expect(text).not.toContain("/BitsPerComponent"); expect( containsSubsequence(bytes, jpeg2000FixtureBytes(fixture!.codestream)), ).toBe(true); From 828908dbb96b1f36a6f82cd56adec8f6265db0e0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:00:29 +0100 Subject: [PATCH 126/135] test(pdf-codec): cover outline dict keys, attachment Desc, and AcroForm /FT and /Ff The outline tree's own /Type, /Parent, /Prev, /Next, /First, /Last, and /Count entries were exercised by the existing round-trip test but never checked directly -- read.ts's own outline walk doesn't depend on most of them, so a wrong or blanked-out key name went unnoticed. An attachment's optional /Desc entry was only ever exercised with a description present, so the "carries no description" branch was never distinguished from an unconditional one; radio, button, and signature field types had no test naming their own /FT value; and the /Ff flag bits (read-only, pushbutton, radio, combo) had no test at all, independently or combined. --- packages/pdf-codec/src/write.test.ts | 111 ++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/write.test.ts b/packages/pdf-codec/src/write.test.ts index 5b470a1e5..9a16f6760 100644 --- a/packages/pdf-codec/src/write.test.ts +++ b/packages/pdf-codec/src/write.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { openPdfDocument } from "./document"; import type { LayoutDocument, + LayoutFormField, LayoutImageAsset, LayoutItem, LayoutPage, @@ -1014,7 +1015,10 @@ describe("writePdf: embedded-file attachments (#967)", () => { }, ], }; - const bytes = writePdf(doc); + const bytes = writePdf(doc, { compress: false }); + const rawText = new TextDecoder("latin1").decode(bytes); + expect(rawText).toContain("/Type /EmbeddedFile"); + expect(rawText).toContain("/Type /Filespec"); const { readPdf } = await import("./read"); const reread = readPdf(bytes); expect(reread.attachments).toEqual([ @@ -1027,6 +1031,27 @@ describe("writePdf: embedded-file attachments (#967)", () => { ]); }); + it("writes no /Desc entry for an attachment carrying no description", async () => { + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [], + images: {}, + attachments: [ + { + name: "plain.txt", + mimeType: "text/plain", + base64: bytesToBase64(new TextEncoder().encode("no description")), + }, + ], + }; + const bytes = writePdf(doc, { compress: false }); + const rawText = new TextDecoder("latin1").decode(bytes); + expect(rawText).not.toContain("/Desc"); + const { readPdf } = await import("./read"); + expect(readPdf(bytes).attachments?.[0]?.description).toBeUndefined(); + }); + it("writes no /Names tree at all for a document with no attachments", () => { const bytes = writePdf({ formatVersion: LAYOUT_FORMAT_VERSION, @@ -1095,6 +1120,15 @@ describe("writePdf: the outline (#967)", () => { ], }; const bytes = writePdf(doc); + const rawText = new TextDecoder("latin1").decode(bytes); + expect(rawText).toContain("/Type /Outlines"); + // Chapter 1 is the sole top-level item and has two children: /Parent on each item, /Prev+/Next linking the two siblings, and /First+/Last+/Count on the parent that owns them. + expect(rawText).toContain("/Parent"); + expect(rawText).toContain("/Prev"); + expect(rawText).toContain("/Next"); + expect(rawText).toContain("/First"); + expect(rawText).toContain("/Last"); + expect(rawText).toContain("/Count 2"); const { readPdf } = await import("./read"); const reread = readPdf(bytes); // Destinations are spelled as direct arrays (the identical convention the internal-link writer established: no /Dests tree is emitted), so the reader re-mints table names in read order -- "dest1", "dest2" -- while titles, nesting, and the TARGETS themselves round-trip exactly. @@ -1343,6 +1377,81 @@ describe("writePdf: AcroForm fields (#967)", () => { expect(text.match(/\/Subtype \/Widget/g)).toHaveLength(1); }); + it("maps radio, button, and signature field types to their own /FT value", () => { + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [{ widthPt: 200, heightPt: 100, items: [] }], + images: {}, + form: [ + { + name: "choice", + fieldType: "radio", + widgets: [ + { pageIndex: 0, xPt: 10, yPt: 60, widthPt: 10, heightPt: 10 }, + ], + children: [], + }, + { + name: "submit", + fieldType: "button", + widgets: [ + { pageIndex: 0, xPt: 10, yPt: 40, widthPt: 40, heightPt: 12 }, + ], + children: [], + }, + { + name: "sig", + fieldType: "signature", + widgets: [ + { pageIndex: 0, xPt: 10, yPt: 20, widthPt: 40, heightPt: 12 }, + ], + children: [], + }, + ], + }; + const text = decode(writePdf(doc, { compress: false })); + expect(text).toContain("/FT /Btn"); + expect(text).toContain("/FT /Sig"); + // radio and button both map to Btn, so distinguishing them isn't possible from /FT alone -- but exactly two Btn fields and one Sig field must exist. + expect(text.match(/\/FT \/Btn/g)).toHaveLength(2); + }); + + it("sets /Ff bits for read-only, pushbutton, radio, and combo, each independently and combined", () => { + const fieldFor = ( + overrides: Partial & { name: string }, + ): LayoutFormField => ({ + fieldType: "text", + widgets: [{ pageIndex: 0, xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }], + children: [], + ...overrides, + }); + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [{ widthPt: 200, heightPt: 100, items: [] }], + images: {}, + form: [ + fieldFor({ name: "plain" }), // no flags at all -- no /Ff entry + fieldFor({ name: "locked", readOnly: true }), // 1 + fieldFor({ name: "push", fieldType: "button" }), // 4 + fieldFor({ name: "choice", fieldType: "radio" }), // 32768 + fieldFor({ name: "combo", fieldType: "combobox" }), // 131072 + fieldFor({ name: "lockedPush", fieldType: "button", readOnly: true }), // 1 | 4 = 5 + ], + }; + const text = decode(writePdf(doc, { compress: false })); + for (const ff of ["1", "4", "32768", "131072", "5"]) { + expect(text).toContain(`/Ff ${ff}`); + } + // "plain" carries no flag bits at all -- no /Ff entry for it, distinct from the others which each have their own combination. Field names are written as hex strings, so "plain" (0x706c61696e) identifies its own object's line. + const plainLine = text + .split("\n") + .find((line) => line.includes("<706c61696e>")); + expect(plainLine).toBeDefined(); + expect(plainLine).not.toContain("/Ff"); + }); + it("writes no /AcroForm for a document with no fields", () => { const bytes = writePdf({ formatVersion: LAYOUT_FORMAT_VERSION, From d2db69a9187cdf55337dfa40aaa9f49c5010d1d3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:03:35 +0100 Subject: [PATCH 127/135] test(pdf-codec): cover a checkbox's own /V export-value derivation checked/value only had one combination exercised (checked: true with no value); the field's own three other meaningfully distinct outcomes -- an unchecked box, a box with neither flag set, and an explicit export value overriding checked in either direction -- had no test naming the /V PDF name each one actually produces. --- packages/pdf-codec/src/write.test.ts | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/pdf-codec/src/write.test.ts b/packages/pdf-codec/src/write.test.ts index 9a16f6760..82d7bf7bf 100644 --- a/packages/pdf-codec/src/write.test.ts +++ b/packages/pdf-codec/src/write.test.ts @@ -1452,6 +1452,38 @@ describe("writePdf: AcroForm fields (#967)", () => { expect(plainLine).not.toContain("/Ff"); }); + it.each([ + { checked: true, value: undefined, expected: "Yes" }, + { checked: false, value: undefined, expected: "Off" }, + { checked: undefined, value: undefined, expected: "Off" }, + { checked: false, value: "onValue", expected: "onValue" }, // an explicit export value wins regardless of checked + { checked: true, value: "onValue", expected: "onValue" }, + ] as const)( + "gives a checkbox its own /V export value for checked=$checked, value=$value", + ({ checked, value, expected }) => { + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [{ widthPt: 200, heightPt: 100, items: [] }], + images: {}, + form: [ + { + name: "box", + fieldType: "checkbox", + ...(checked === undefined ? {} : { checked }), + ...(value === undefined ? {} : { value }), + widgets: [ + { pageIndex: 0, xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + ], + children: [], + }, + ], + }; + const text = decode(writePdf(doc, { compress: false })); + expect(text).toContain(`/V /${expected}`); + }, + ); + it("writes no /AcroForm for a document with no fields", () => { const bytes = writePdf({ formatVersion: LAYOUT_FORMAT_VERSION, From bd1357b696cf5b4c108d6d303b65602e82068481 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:07:54 +0100 Subject: [PATCH 128/135] test(pdf-codec): cover structure element dict keys and the /Lang attribute /Type /StructElem, /P (parent reference), and the per-element /Lang override were exercised by the existing round-trip test but never checked directly -- read.ts's own structure walk doesn't depend on /Type or /P at all, so a wrong or blanked-out key name went unnoticed, and no test carried a language attribute at all. --- packages/pdf-codec/src/write.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/pdf-codec/src/write.test.ts b/packages/pdf-codec/src/write.test.ts index 82d7bf7bf..8359bad8a 100644 --- a/packages/pdf-codec/src/write.test.ts +++ b/packages/pdf-codec/src/write.test.ts @@ -1621,14 +1621,25 @@ describe("writePdf: the tagged structure tree (#967)", () => { id: "e-root", type: "Document", children: [ - { id: "e-h1", type: "H1", title: "The heading", children: [] }, + { + id: "e-h1", + type: "H1", + title: "The heading", + language: "en-GB", + children: [], + }, { id: "e-p", type: "P", alt: "a paragraph", children: [] }, ], }, ], }; + const bytes = writePdf(doc, { compress: false }); + const rawText = new TextDecoder("latin1").decode(bytes); + expect(rawText).toContain("/Type /StructElem"); + expect(rawText).toContain("/P "); + expect(rawText).toContain("/Lang"); const { readPdf } = await import("./read"); - const reread = readPdf(writePdf(doc)); + const reread = readPdf(bytes); const tree = reread.structure!; // Element ids are reader-minted in document order, so identity is positional: the first H1 under the root owns the heading item. expect(tree).toEqual([ From 8e17881b98ab2453a9e18c0f0c3df2d597a67650 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:44:49 +0100 Subject: [PATCH 129/135] fix(pdf-codec): drop requiredRepeatCount's redundant empty-extenders guard Summing zero extender parts is exactly 0, and extenders.length * minConnectorOverlap is exactly 0 too when extenders is empty, so growthPerRepeat is always 0 in that case and the growthPerRepeat <= 0 guard already returns the same minimum on its own. Add a test proving the no-parts case still returns undefined rather than a hollow zero-size construction. --- packages/pdf-codec/src/math-stretch.test.ts | 14 ++++++++++++++ packages/pdf-codec/src/math-stretch.ts | 5 +---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/pdf-codec/src/math-stretch.test.ts b/packages/pdf-codec/src/math-stretch.test.ts index d25a74ef6..39847f29b 100644 --- a/packages/pdf-codec/src/math-stretch.test.ts +++ b/packages/pdf-codec/src/math-stretch.test.ts @@ -420,6 +420,20 @@ describe("assembleStretchyGlyph on constructions the real font does not contain" ).toBeUndefined(); }); + it("returns undefined for an assembly with no parts at all, rather than a hollow zero-size construction", () => { + const construction: MathGlyphConstruction = { + variants: [], + assembly: { italicsCorrection: 0, parts: [] }, + }; + expect( + assembleStretchyGlyph(construction, { + axis: "vertical", + targetSize: 1000, + minConnectorOverlap: 100, + }), + ).toBeUndefined(); + }); + it("falls back to the largest variant when the target is unreachable and there is no assembly", () => { const construction: MathGlyphConstruction = { variants: [ diff --git a/packages/pdf-codec/src/math-stretch.ts b/packages/pdf-codec/src/math-stretch.ts index 1520d306c..a4fe74981 100644 --- a/packages/pdf-codec/src/math-stretch.ts +++ b/packages/pdf-codec/src/math-stretch.ts @@ -39,7 +39,7 @@ function sumBy(items: readonly T[], value: (item: T) => number): number { return items.reduce((total, item) => total + value(item), 0); } -// How many times each extender part must repeat for the assembly to reach `targetSize`, at the tightest packing the font permits (i.e. overlapping by exactly minConnectorOverlap, which is what makes the assembly as LARGE as it can be for a given repeat count). Solved directly rather than by growing a loop: with A/E the summed full advances of the fixed and extender parts, n/x their counts and m the minimum overlap, the assembly's own size at repeat count r is A + rE - (n + rx - 1)m, so the smallest r meeting the target is ceil((target - A + (n - 1)m) / (E - xm)). A non-positive denominator means every extra repetition costs at least as much overlap as it adds advance, so no repeat count reaches the target at all and the minimum is used. +// How many times each extender part must repeat for the assembly to reach `targetSize`, at the tightest packing the font permits (i.e. overlapping by exactly minConnectorOverlap, which is what makes the assembly as LARGE as it can be for a given repeat count). Solved directly rather than by growing a loop: with A/E the summed full advances of the fixed and extender parts, n/x their counts and m the minimum overlap, the assembly's own size at repeat count r is A + rE - (n + rx - 1)m, so the smallest r meeting the target is ceil((target - A + (n - 1)m) / (E - xm)). A non-positive denominator means every extra repetition costs at least as much overlap as it adds advance, so no repeat count reaches the target at all and the minimum is used. An empty `extenders` is not special-cased separately: summing zero parts is exactly 0 and `extenders.length * minConnectorOverlap` is exactly 0 too, so `growthPerRepeat` is always exactly 0 in that case and the `growthPerRepeat <= 0` guard below already returns the same minimum on its own. function requiredRepeatCount( fixed: readonly MathGlyphPart[], extenders: readonly MathGlyphPart[], @@ -48,9 +48,6 @@ function requiredRepeatCount( ): number { // A recipe made entirely of extenders has no fixed part to stand alone, so it needs at least one repetition to place anything at all; one that has fixed parts can legitimately use zero repetitions as its smallest form. const minimumRepeat = fixed.length === 0 ? 1 : 0; - if (extenders.length === 0) { - return minimumRepeat; - } const growthPerRepeat = sumBy(extenders, (part) => part.fullAdvance) - extenders.length * minConnectorOverlap; From 99e7edda506b249442203d8d3382825d48981ee4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:45:45 +0100 Subject: [PATCH 130/135] fix(pdf-codec): drop xmp's unreachable absent-capturing-group fallback The regex's capturing group is not itself optional, so a successful match always populates match[1] (with the empty string in the degenerate zero-width case) -- there is no absent-group case for the ?? "" fallback to actually handle. --- packages/pdf-codec/src/xmp.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/pdf-codec/src/xmp.ts b/packages/pdf-codec/src/xmp.ts index 56a7ce0d3..f13378367 100644 --- a/packages/pdf-codec/src/xmp.ts +++ b/packages/pdf-codec/src/xmp.ts @@ -35,7 +35,8 @@ function xmpValue( if (match === null) { return undefined; } - const inner = match[1] ?? ""; + // The capturing group is not itself optional, so a successful match always populates it (with the empty string in the degenerate zero-width case) -- there is no absent-group case to fall back for. + const inner = match[1]!; const items = listItems(inner); if (items.length > 0) { return items; @@ -49,7 +50,8 @@ function listItems(inner: string): string[] { const pattern = /]*)?>([\s\S]*?)<\/rdf:li>/g; let match: RegExpExecArray | null; while ((match = pattern.exec(inner)) !== null) { - const text = (match[1] ?? "").trim(); + // Same guaranteed-present capturing group as xmpValue above. + const text = match[1]!.trim(); if (text.length > 0) { items.push(text); } @@ -77,6 +79,7 @@ function keywordsOf(packet: string): { keywords?: string[] } { if (match === null) { return {}; } - const items = listItems(match[1] ?? ""); + // Same guaranteed-present capturing group as xmpValue above. + const items = listItems(match[1]!); return items.length > 0 ? { keywords: items } : {}; } From 992a4d29e1aacd5a820762f1f20e26b6cf98c07e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:48:04 +0100 Subject: [PATCH 131/135] test(pdf-codec): cover SCALED_COMPONENT_OFFSET applied to a component's own placement offset No real vendored composite in this suite's own fonts ever sets SCALED_COMPONENT_OFFSET (bit 11) without also setting UNSCALED_COMPONENT_OFFSET (bit 12) -- Microsoft's own OpenType toolchain never emits that combination, only Apple's does -- so this placement path was only reachable through a hand-built fixture. --- packages/pdf-codec/src/glyf-contours.test.ts | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/pdf-codec/src/glyf-contours.test.ts b/packages/pdf-codec/src/glyf-contours.test.ts index c52a30fb5..fcd420a9e 100644 --- a/packages/pdf-codec/src/glyf-contours.test.ts +++ b/packages/pdf-codec/src/glyf-contours.test.ts @@ -402,6 +402,30 @@ describe("decodeGlyphOutline's simple-glyph and composite decoding, driven by a expect(decodeGlyphOutline(glyf, 0)).toBeUndefined(); }); + it("applies the SCALED_COMPONENT_OFFSET transform to a component's own placement offset, not just its outline points", () => { + // No real vendored composite in this suite's own fonts ever sets SCALED_COMPONENT_OFFSET (bit 11, 0x0800) without also setting UNSCALED_COMPONENT_OFFSET (bit 12, 0x1000) -- Microsoft's own OpenType toolchain never emits that combination, only Apple's does -- so this is the one placement path only a hand-built fixture can reach at all. Component 0's own base point (10, 20), the transform [a,b,c,d] = [2,3,5,7], and offset arguments (6, 8) are all pairwise distinct so that swapping any single +/-/*// in the placement arithmetic below changes the result: dx = a*6 + c*8 = 52, dy = b*6 + d*8 = 74, and the final point is the transformed base point plus that SCALED offset, not the raw (6, 8) UNSCALED_COMPONENT_OFFSET would have placed it at. + const base = simpleGlyphBytes([0], [{ dx: 10, dy: 20, onCurve: true }]); + const scaledOffsetComponent: CompositeComponent = { + flags: 0x0800, + glyphIndex: 0, + argument1: 6, + argument2: 8, + argsAreXyValues: true, + transform: [2, 3, 5, 7], + }; + const glyf = fakeGlyfTable({ + entries: new Map([[0, base]]), + composites: new Map([[1, [scaledOffsetComponent]]]), + }); + const outline = decodeGlyphOutline(glyf, 1); + expect(outline?.contours).toHaveLength(1); + expect(outline?.contours[0]?.[0]).toEqual({ + x: 172, // 2*10 + 5*20 + (2*6 + 5*8) + y: 244, // 3*10 + 7*20 + (3*6 + 7*8) + onCurve: true, + }); + }); + 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 = { From b1db7c06032b0b1192612e01cf2b69a2a4f8a240 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:48:31 +0100 Subject: [PATCH 132/135] test(pdf-codec): cover loadMathFont's broken-parse guards Exercises the unreadable-sfnt, missing-required-table, and no-readable-cmap-subtable error paths -- invariant checks on this package's own build output, never reachable through the real vendored font. loadMathFont() caches its result in a module-scoped variable, so each case uses vi.resetModules() plus a dynamic re-import to get a clean, uncached instance, with vi.doMock failing exactly one real dependency while every other real parser still runs underneath it. --- packages/pdf-codec/src/math-font.test.ts | 58 +++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/pdf-codec/src/math-font.test.ts b/packages/pdf-codec/src/math-font.test.ts index f070fd139..305ac7320 100644 --- a/packages/pdf-codec/src/math-font.test.ts +++ b/packages/pdf-codec/src/math-font.test.ts @@ -1,5 +1,61 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { loadMathFont } from "./math-font"; +import type * as SfntModule from "./sfnt"; +import type * as CmapTableModule from "./cmap-table"; + +// loadMathFont() caches its result in a module-scoped variable, so exercising its "the embedded font is unreadable" guards -- invariant checks on this package's own build output, never reachable through the real vendored font -- needs a fresh, unmocked module per test: vi.resetModules() plus a dynamic re-import gets a clean, uncached loadMathFont, and vi.doMock lets exactly one of its own real dependencies fail while every other real parser still runs underneath it. +describe("loadMathFont against a deliberately broken parse (module-level cache reset per test)", () => { + afterEach(() => { + vi.doUnmock("./sfnt"); + vi.doUnmock("./cmap-table"); + vi.resetModules(); + }); + + it("throws its own exact message when the embedded bytes are not a readable sfnt container at all", async () => { + vi.resetModules(); + vi.doMock("./sfnt", async (importOriginal) => ({ + ...(await importOriginal()), + parseSfnt: () => undefined, + })); + const { loadMathFont: freshLoadMathFont } = await import("./math-font"); + expect(() => freshLoadMathFont()).toThrow( + "embedded math font is not a readable sfnt container", + ); + }); + + it("throws its own exact message when a required sfnt table (head/hhea/CFF ) is missing", async () => { + vi.resetModules(); + vi.doMock("./sfnt", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sfntTableBytes: (font: unknown, tag: string) => + tag === "CFF " + ? undefined + : actual.sfntTableBytes( + font as Parameters[0], + tag, + ), + }; + }); + const { loadMathFont: freshLoadMathFont } = await import("./math-font"); + expect(() => freshLoadMathFont()).toThrow( + "embedded math font is missing a required sfnt table (head/hhea/CFF )", + ); + }); + + it("throws its own exact message when the embedded font has no readable cmap subtable", async () => { + vi.resetModules(); + vi.doMock("./cmap-table", async (importOriginal) => ({ + ...(await importOriginal()), + buildCmapLookup: () => undefined, + })); + const { loadMathFont: freshLoadMathFont } = await import("./math-font"); + expect(() => freshLoadMathFont()).toThrow( + "embedded math font has no readable cmap subtable", + ); + }); +}); // Every expected value below was independently verified against the real vendored assets/fonts/STIXTwoMath-Regular.otf's own raw bytes while building math-table.ts/cmap-table.ts/hmtx-table.ts (a standalone Node script reading the sfnt table directory directly, not this package's own parser) -- these are real, external cross-checks, not values derived from and re-asserted against this module's own output. describe("loadMathFont", () => { From b6f23c7800ace8bd1816fbff1000b4bc6f340598 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:48:47 +0100 Subject: [PATCH 133/135] test(pdf-codec): cover readOptionalContent's unresolved-OCG and layer-naming gaps Adds direct coverage, against a synthetic catalog, for an /OCGs entry that fails to resolve to a dictionary (reported and skipped), and for mintLayerName skipping an already-claimed layerN name so two distinct groups can never collide onto the same layer. --- .../pdf-codec/src/optional-content.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/pdf-codec/src/optional-content.test.ts b/packages/pdf-codec/src/optional-content.test.ts index f634186d2..93cc8385f 100644 --- a/packages/pdf-codec/src/optional-content.test.ts +++ b/packages/pdf-codec/src/optional-content.test.ts @@ -1,8 +1,36 @@ import { describe, expect, it } from "vitest"; +import type { PdfDiagnostic } from "./diagnostics"; +import type { PdfObjectResolver } from "./interpret"; import type { LayoutText } from "./layout"; +import { readOptionalContent } from "./optional-content"; +import type { PdfDict, PdfObject } from "./objects"; +import { asDict, pdfArray, pdfDict, pdfLiteralString, pdfRef } from "./objects"; import { readPdf } from "./read"; import { ocgPdf } from "./test-support/pdf"; +// A resolver over a plain ref-number -> object table, matching interpret.test.ts's/navigation.test.ts's own makeResolver. +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 }; +} + +function collectDiagnostics(): { + readonly sink: (d: PdfDiagnostic) => void; + readonly diagnostics: PdfDiagnostic[]; +} { + const diagnostics: PdfDiagnostic[] = []; + return { sink: (d) => diagnostics.push(d), diagnostics }; +} + +function str(text: string): PdfObject { + return pdfLiteralString(new TextEncoder().encode(text)); +} + // Optional content (#721 phase 3): /OCProperties groups with the default configuration's visibility state, /OC membership from BDC spans (both the named-property-list and inline-dict forms) stamped onto extracted items as a layer name, and /ActualText from a marked-content property dict. The visibility state is what fixes the active bug the issue names: content an author placed in an OFF layer no longer extracts as if unconditionally visible -- the membership is now on the item for a consumer to act on. describe("readPdf: optional content groups", () => { @@ -55,3 +83,42 @@ describe("readPdf: optional content groups", () => { expect(ownedFormText).toMatchObject({ layer: "Notes" }); }); }); + +describe("readOptionalContent, driven directly against a synthetic catalog", () => { + it("reports and skips an /OCGs entry that does not resolve to a dictionary", () => { + const { sink, diagnostics } = collectDiagnostics(); + const catalog = pdfDict({ + OCProperties: pdfDict({ + // Object 99 is never in the resolver's own table, so this ref resolves to nothing. + OCGs: pdfArray([pdfRef(99, 0)]), + }), + }); + const context = readOptionalContent(catalog, makeResolver(), sink); + expect(context.layers).toEqual([]); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toEqual({ + code: "pdf/ocg-unresolved", + severity: "warning", + message: + "an entry in /OCProperties /OCGs did not resolve to a dictionary; skipping it", + }); + }); + + it("mints the next free layerN name, skipping one a real /Name already claimed", () => { + // Group 1 carries a real /Name of "layer1"; group 2 carries no /Name at all, so mintLayerName must skip the already-taken "layer1" and mint "layer2" -- landing on "layer1" a second time (an unmutated loop that never advances n, or a broken template literal) would collide two distinct OCGs onto the same layer name. + const namedGroup = pdfDict({ Name: str("layer1") }); + const unnamedGroup = pdfDict({}); + const objects = new Map([ + [1, namedGroup], + [2, unnamedGroup], + ]); + const catalog = pdfDict({ + OCProperties: pdfDict({ + OCGs: pdfArray([pdfRef(1, 0), pdfRef(2, 0)]), + }), + }); + const { sink } = collectDiagnostics(); + const context = readOptionalContent(catalog, makeResolver(objects), sink); + expect(context.layers.map((l) => l.name)).toEqual(["layer1", "layer2"]); + }); +}); From 38eeaaea3a97e670d0d66f8bc2821f5593e2aa49 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:48:55 +0100 Subject: [PATCH 134/135] test(pdf-codec): add direct byte-level coverage for the MATH table parser Builds a minimal but structurally real 'MATH' table field-by-field -- the 10-byte header, a zero-filled MathConstants subtable, a zero-filled MathGlyphInfo subtable, and an optional MathVariants subtable built from a per-axis coverage/construction description -- the same not-mocked, real-byte-parsing approach cmap-table.test.ts's own buildFontWithCmapSubtable already uses. --- packages/pdf-codec/src/math-table.test.ts | 182 ++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/pdf-codec/src/math-table.test.ts diff --git a/packages/pdf-codec/src/math-table.test.ts b/packages/pdf-codec/src/math-table.test.ts new file mode 100644 index 000000000..85eb9271b --- /dev/null +++ b/packages/pdf-codec/src/math-table.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { parseMathTable } from "./math-table"; +import type { SfntFont } from "./sfnt"; +import { parseSfnt } from "./sfnt"; + +// A minimal but structurally real 'MATH' table (Microsoft OpenType MATH spec), built field-by-field the same way cmap-table.test.ts's own buildFontWithCmapSubtable builds a synthetic 'cmap' -- not mocked, so every one of these tests genuinely exercises math-table.ts's real byte-level parsing rather than a stand-in. Layout, in order: the 10-byte MATH header; a zero-filled MathConstants subtable (every MathValueRecord at 0 is a legitimate, if degenerate, font); a zero-filled MathGlyphInfo subtable (both its own coverage offsets 0, meaning neither italics-correction nor top-accent data); then, only when `variants` is given, a MathVariants subtable built from the caller's own per-axis coverage/construction description. +const HEADER_SIZE = 10; +const CONSTANTS_SIZE = 8 + 51 * 4 + 2; // MATH_VALUE_RECORDS_START + 51 MathValueRecords + the trailing percent field +const GLYPH_INFO_SIZE = 4; // two Offset16 fields (italics, top-accent), both left 0 +const VARIANTS_HEADER_SIZE = 10; // minConnectorOverlap + two coverage Offset16s + two counts + +interface AxisVariantsFixture { + // Raw bytes for this axis's own Coverage table (already in final on-disk form), or undefined for an axis with no coverage at all (coverageOffset 0). + readonly coverage?: Uint8Array; + // One MathGlyphConstruction per coverage-array slot, in slot order -- each just a variant list, no assembly, which is all these tests need to prove an entry was (or wasn't) resolved. + readonly constructions: readonly { glyphId: number; advance: number }[]; + // The real construction-ARRAY length recorded in the header (MathVariantCount) -- deliberately allowed to differ from `constructions.length` so a test can under-declare it and prove the out-of-range slots this axis's own coverage table still names are skipped rather than read. + readonly declaredCount: number; +} + +function buildMathBytes( + minConnectorOverlap: number, + vertical: AxisVariantsFixture | undefined, + horizontal: AxisVariantsFixture | undefined, +): Uint8Array { + const variantsOffset = HEADER_SIZE + CONSTANTS_SIZE + GLYPH_INFO_SIZE; + // The real parser computes each axis's own construction-array position structurally (immediately after the header, vertical then horizontal -- see parseMathVariants's own verticalArrayOffset/horizontalArrayOffset), never from a stored field, so this builder must lay them out the identical way rather than wherever it happens to place other content. + const verticalCount = vertical?.declaredCount ?? 0; + const horizontalCount = horizontal?.declaredCount ?? 0; + const verticalArrayOffset = variantsOffset + VARIANTS_HEADER_SIZE; + const horizontalArrayOffset = verticalArrayOffset + verticalCount * 2; + const chunks: { offset: number; bytes: Uint8Array }[] = []; + let cursor = horizontalArrayOffset + horizontalCount * 2; + + function place(bytes: Uint8Array): number { + const offset = cursor; + chunks.push({ offset, bytes }); + cursor += bytes.length; + return offset - variantsOffset; // every stored offset in a MathVariants subtable is relative to its own start + } + + function placeAxis( + axis: AxisVariantsFixture | undefined, + arrayOffset: number, + ): { coverageOffset: number } { + if (axis === undefined) { + return { coverageOffset: 0 }; + } + const constructionOffsets = axis.constructions.map((construction) => { + const table = new Uint8Array(4 + 4); + const view = new DataView(table.buffer); + view.setUint16(0, 0); // assemblyOffset: none + view.setUint16(2, 1); // variantCount + view.setUint16(4, construction.glyphId); + view.setUint16(6, construction.advance); + return place(table); + }); + const array = new Uint8Array(axis.declaredCount * 2); + const arrayView = new DataView(array.buffer); + constructionOffsets.forEach((relativeOffset, index) => { + if (index < axis.declaredCount) { + arrayView.setUint16(index * 2, relativeOffset); + } + }); + chunks.push({ offset: arrayOffset, bytes: array }); + const coverageOffset = + axis.coverage === undefined ? 0 : place(axis.coverage); + return { coverageOffset }; + } + + const verticalPlacement = placeAxis(vertical, verticalArrayOffset); + const horizontalPlacement = placeAxis(horizontal, horizontalArrayOffset); + + const total = new Uint8Array(cursor); + const view = new DataView(total.buffer); + view.setUint16(0, 1); // majorVersion + view.setUint16(2, 0); // minorVersion + view.setUint16(4, HEADER_SIZE); // mathConstantsOffset + view.setUint16(6, HEADER_SIZE + CONSTANTS_SIZE); // mathGlyphInfoOffset + view.setUint16(8, variantsOffset); // mathVariantsOffset + view.setUint16(variantsOffset + 0, minConnectorOverlap); + view.setUint16(variantsOffset + 2, verticalPlacement.coverageOffset); + view.setUint16(variantsOffset + 4, horizontalPlacement.coverageOffset); + view.setUint16(variantsOffset + 6, verticalCount); + view.setUint16(variantsOffset + 8, horizontalCount); + for (const chunk of chunks) { + total.set(chunk.bytes, chunk.offset); + } + return total; +} + +function buildMathBytesWithNoVariantsTable(): Uint8Array { + const total = new Uint8Array(HEADER_SIZE + CONSTANTS_SIZE + GLYPH_INFO_SIZE); + const view = new DataView(total.buffer); + view.setUint16(0, 1); + view.setUint16(2, 0); + view.setUint16(4, HEADER_SIZE); + view.setUint16(6, HEADER_SIZE + CONSTANTS_SIZE); + view.setUint16(8, 0); // mathVariantsOffset: this font declares no MathVariants subtable at all + return total; +} + +// Format 1 Coverage (ot-layout-common.ts): a plain ascending glyph-ID list, each glyph's own position in it being its coverage index. +function format1Coverage(glyphIds: readonly number[]): Uint8Array { + const table = new Uint8Array(4 + glyphIds.length * 2); + const view = new DataView(table.buffer); + view.setUint16(0, 1); // format + view.setUint16(2, glyphIds.length); // glyphCount + glyphIds.forEach((glyphId, index) => { + view.setUint16(4 + index * 2, glyphId); + }); + return table; +} + +function buildFontWithMathTable(mathBytes: Uint8Array): SfntFont { + const DIRECTORY_SIZE = 12 + 16; + const font = new Uint8Array(DIRECTORY_SIZE + mathBytes.length); + const view = new DataView(font.buffer); + view.setUint32(0, 0x00010000); + view.setUint16(4, 1); // numTables + font.set(Uint8Array.from([0x4d, 0x41, 0x54, 0x48]), 12); // 'MATH' + view.setUint32(12 + 8, DIRECTORY_SIZE); + view.setUint32(12 + 12, mathBytes.length); + font.set(mathBytes, DIRECTORY_SIZE); + const parsed = parseSfnt(font); + if (parsed === undefined) { + throw new Error("synthetic font failed to parse as an sfnt container"); + } + return parsed; +} + +describe("parseMathTable against synthetic MATH tables", () => { + it("throws its own exact message when the font has no MATH table at all", () => { + const font: SfntFont = { bytes: new Uint8Array(0), tables: new Map() }; + expect(() => parseMathTable(font)).toThrow("math font has no MATH table"); + }); + + it("reports empty vertical and horizontal maps for a font that declares no MathVariants subtable", () => { + const font = buildFontWithMathTable(buildMathBytesWithNoVariantsTable()); + const math = parseMathTable(font); + expect(math.variants).toEqual({ + minConnectorOverlap: 0, + vertical: new Map(), + horizontal: new Map(), + }); + }); + + it("leaves an axis with no coverage table empty while its sibling axis still resolves normally", () => { + // The vertical axis carries real coverage; the horizontal axis's own coverageOffset is 0. minConnectorOverlap is deliberately 1 (a Coverage table's own format 1) -- reading from the MathVariants subtable's own start (what a mutant that dropped this guard would do for a 0 coverageOffset) means byte 2 of that misread header is the VERTICAL axis's own (nonzero) coverageOffset field, read instead as a bogus glyph count. This is what proves the guard is load-bearing rather than a dead branch: without it, the horizontal axis would resolve extra, wrong entries from that misread instead of staying empty. + const font = buildFontWithMathTable( + buildMathBytes( + 1, + { + coverage: format1Coverage([40]), + constructions: [{ glyphId: 41, advance: 111 }], + declaredCount: 1, + }, + undefined, + ), + ); + const math = parseMathTable(font); + expect(math.variants.horizontal.size).toBe(0); + expect(math.variants.vertical.get(40)).toEqual({ + variants: [{ glyphId: 41, advanceMeasurement: 111 }], + }); + }); + + it("skips a coverage entry whose index falls beyond the construction array's own declared length", () => { + // Two glyphs (50, 51) covered at indices 0 and 1, but the construction array declares a length of only 1 -- index 1 names a slot the array was never given, and must be skipped rather than read past the array's own end. + const font = buildFontWithMathTable( + buildMathBytes(0, undefined, { + coverage: format1Coverage([50, 51]), + constructions: [{ glyphId: 60, advance: 222 }], + declaredCount: 1, + }), + ); + const math = parseMathTable(font); + expect(math.variants.horizontal.has(50)).toBe(true); + expect(math.variants.horizontal.has(51)).toBe(false); + expect(math.variants.horizontal.size).toBe(1); + }); +}); From 7858055cdafa8698163b46085d1e9a4f0e27d3c3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:49:06 +0100 Subject: [PATCH 135/135] test(pdf-codec): add direct coverage for decodePdfString and parsePdfDate Covers decodePdfString's UTF-16BE-with-BOM, plain-ASCII, and zero-byte paths, and parsePdfDate's undefined/non-date/full/partial inputs -- including ISO 32000-1 7.9.4's every-field-after-the-year default and the per-field partial-default cases (a year-only date, a year+month+day date, and a date with a sign and hour but no offset minute). --- packages/pdf-codec/src/pdf-text.test.ts | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 packages/pdf-codec/src/pdf-text.test.ts diff --git a/packages/pdf-codec/src/pdf-text.test.ts b/packages/pdf-codec/src/pdf-text.test.ts new file mode 100644 index 000000000..5953589d1 --- /dev/null +++ b/packages/pdf-codec/src/pdf-text.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { decodePdfString, parsePdfDate } from "./pdf-text"; + +describe("decodePdfString", () => { + it("decodes a UTF-16BE-with-BOM string", () => { + const bytes = Uint8Array.from([0xfe, 0xff, 0x00, 0x41, 0x00, 0x42]); // BOM + 'A' + 'B' + expect(decodePdfString(bytes)).toBe("AB"); + }); + + it("decodes a plain-ASCII PDFDocEncoding string byte-per-character", () => { + const bytes = Uint8Array.from([0x48, 0x69]); // 'H', 'i' -- no BOM + expect(decodePdfString(bytes)).toBe("Hi"); + }); + + it("returns the empty string for zero bytes", () => { + expect(decodePdfString(new Uint8Array(0))).toBe(""); + }); +}); + +describe("parsePdfDate", () => { + it("returns undefined for undefined input", () => { + expect(parsePdfDate(undefined)).toBeUndefined(); + }); + + it("returns undefined for a string that isn't a PDF date at all", () => { + expect(parsePdfDate("not a date")).toBeUndefined(); + }); + + it("parses a fully-specified date with an explicit UTC offset", () => { + expect(parsePdfDate("D:20240115093045+05'30'")).toBe( + "2024-01-15T09:30:45+05:30", + ); + }); + + it("parses a fully-specified date with a bare Z offset", () => { + expect(parsePdfDate("D:20240115093045Z")).toBe("2024-01-15T09:30:45Z"); + }); + + it("defaults every field after the year -- month, day, hour, minute, second, and the offset -- when the source date carries only the year", () => { + // ISO 32000-1 7.9.4 makes every field after the year optional; a producer that writes only "D:2024" still names a valid date, and the spec's own reading is "the first moment of that year, UTC" -- exactly what every default below encodes. + expect(parsePdfDate("D:2024")).toBe("2024-01-01T00:00:00Z"); + }); + + it("defaults only the fields the source date omits, keeping every field it does supply", () => { + // Month and day are supplied; hour/minute/second and the offset are not, so only those default while 03/17 stay exactly as given. + expect(parsePdfDate("D:20240317")).toBe("2024-03-17T00:00:00Z"); + }); + + it("defaults the timezone minute to 00 when a sign and hour are given but no minute", () => { + expect(parsePdfDate("D:20240115093045-05")).toBe( + "2024-01-15T09:30:45-05:00", + ); + }); +});