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) }} 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"], diff --git a/packages/pdf-codec/src/afm-widths.test.ts b/packages/pdf-codec/src/afm-widths.test.ts index 1dbfaa2ba..7d507d8aa 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' }); @@ -76,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!); + } + }); }); diff --git a/packages/pdf-codec/src/annotations.test.ts b/packages/pdf-codec/src/annotations.test.ts index bff43222d..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. @@ -30,6 +58,36 @@ 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(); + // 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", () => { @@ -52,6 +110,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"); @@ -72,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/attachments.test.ts b/packages/pdf-codec/src/attachments.test.ts index 00054d62a..1f3534208 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"; @@ -21,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"); @@ -29,5 +33,28 @@ 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", () => { + 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/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, + ); + }); }); 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", () => { diff --git a/packages/pdf-codec/src/cff-bounds.test.ts b/packages/pdf-codec/src/cff-bounds.test.ts index ef4dc2379..f7ee2d9a3 100644 --- a/packages/pdf-codec/src/cff-bounds.test.ts +++ b/packages/pdf-codec/src/cff-bounds.test.ts @@ -7,11 +7,27 @@ import { CFF_HEADER, ROS_OPERANDS_AND_OPERATOR, 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. @@ -217,3 +233,918 @@ 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; + + 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("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; + 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(); + }); + + 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(); + }); + + 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. 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, ...enc(5), ...enc(0), OP_HLINETO, OP_ENDCHAR], + ], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 5, + yMax: 0, + }); + }); + + it("draws through exactly MAX_OPERATIONS_PER_GLYPH operators, one short of the ceiling this module already refuses", () => { + // 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, ...enc(5), ...enc(0), OP_HLINETO, OP_ENDCHAR]], + }); + expect(boundsOfOnlyGlyph(bytes)).toEqual({ + xMin: 0, + yMin: 0, + xMax: 5, + yMax: 0, + }); + }); + + 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("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 + 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(); + + // 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", () => { + 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/cff-probe.test.ts b/packages/pdf-codec/src/cff-probe.test.ts index 7957d8d28..2a9fb0503 100644 --- a/packages/pdf-codec/src/cff-probe.test.ts +++ b/packages/pdf-codec/src/cff-probe.test.ts @@ -83,12 +83,32 @@ 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])), ).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 (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, + 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]]), + ]); + 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; diff --git a/packages/pdf-codec/src/cmap-table.test.ts b/packages/pdf-codec/src/cmap-table.test.ts index 456a8571a..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[], @@ -91,6 +111,329 @@ 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; +} + +// 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( + 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(); + }); + + 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)", () => { it("drives a font whose only subtable is a format 6 one", () => { const font = parse( @@ -204,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 }); + }); }); diff --git a/packages/pdf-codec/src/cmap.test.ts b/packages/pdf-codec/src/cmap.test.ts index b4dcae993..8e6f3a496 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( @@ -43,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, @@ -58,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", () => { @@ -72,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", @@ -83,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", () => { @@ -92,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", + }), + ]); }); }); 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)); + }); +}); diff --git a/packages/pdf-codec/src/crypto/rc4.ts b/packages/pdf-codec/src/crypto/rc4.ts index 6e6ff489d..385e0c41a 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); @@ -23,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; } diff --git a/packages/pdf-codec/src/crypto/sha2.ts b/packages/pdf-codec/src/crypto/sha2.ts index 61e102dfb..e99958ece 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); } @@ -153,22 +154,30 @@ 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 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); 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]!; @@ -194,18 +203,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; } @@ -221,22 +228,28 @@ 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 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); 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]!; diff --git a/packages/pdf-codec/src/document-residue.test.ts b/packages/pdf-codec/src/document-residue.test.ts index a9a95873e..49de7cbe3 100644 --- a/packages/pdf-codec/src/document-residue.test.ts +++ b/packages/pdf-codec/src/document-residue.test.ts @@ -2,9 +2,30 @@ 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", () => { + 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"); @@ -25,8 +46,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); }); }); 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/embedded-font-write.test.ts b/packages/pdf-codec/src/embedded-font-write.test.ts index c2cfb286b..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 { @@ -13,7 +14,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, @@ -32,7 +33,13 @@ 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, + caladeaRegularBytes, + 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 +54,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 +258,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( @@ -408,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", () => { @@ -478,4 +471,80 @@ 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", () => { + 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); + }); }); 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); +} 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/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); } 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/, ); }); 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([ diff --git a/packages/pdf-codec/src/font-style.test.ts b/packages/pdf-codec/src/font-style.test.ts index 38a3163f1..c6a13a787 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", @@ -79,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); 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/glyf-contours.test.ts b/packages/pdf-codec/src/glyf-contours.test.ts index 43f2ac670..fcd420a9e 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,351 @@ 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("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 = { + 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(); + }); +}); 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"); + }); +}); 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. 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/); + }); +}); 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); }); }); diff --git a/packages/pdf-codec/src/image/jp2-boxes.ts b/packages/pdf-codec/src/image/jp2-boxes.ts index 38b7068b3..7055d0428 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; @@ -63,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 ); } @@ -168,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++) { @@ -204,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) { @@ -240,13 +225,21 @@ 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) { - 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), ); } @@ -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) { diff --git a/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts b/packages/pdf-codec/src/image/jpeg2000-codestream.test.ts index 983206d92..edc557643 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,50 @@ 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 + }); + + 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", () => { it("reads the geometry, coding style and quantization of a real main header", () => { const codestream = parseJpeg2000Codestream(fixture("ramp-basic")); @@ -146,6 +190,641 @@ 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 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", () => { + const data = minimalCodestream({ + afterMainHeader: tilePart(0, codSegment({ layers: 3 }), []), + }); + 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", () => { + // 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([]); + }); + + 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?.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); + }); +}); + // 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); diff --git a/packages/pdf-codec/src/image/jpeg2000-codestream.ts b/packages/pdf-codec/src/image/jpeg2000-codestream.ts index 486421e8d..8b87adeec 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"; @@ -136,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( @@ -242,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`, ); @@ -277,8 +266,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", @@ -560,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, @@ -571,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; diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts index 16073da63..979033eb7 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.test.ts @@ -1,10 +1,30 @@ import { describe, expect, it } from "vitest"; import { + interleave, + type InterleaveSource, + inverse53Filter, + inverse97Filter, inverseDwt53Level, inverseDwt97Level, + 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. +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. @@ -127,4 +147,405 @@ 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("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 }; + 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("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( + { + 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("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[] = []; + 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(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( + () => 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("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), + ); + }); + + 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", () => { + 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); + }); + + it("returns i0 for a degenerate (empty) range", () => { + expect(mirrorIndex(0, 3, 3)).toBe(3); + }); + + 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 an offset at or past i1 - i0 about the last in-range sample", () => { + expect(mirrorIndex(4, 0, 4)).toBe(2); + }); + + 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); + }); }); diff --git a/packages/pdf-codec/src/image/jpeg2000-dwt.ts b/packages/pdf-codec/src/image/jpeg2000-dwt.ts index 83cb0ec00..b4793d5f3 100644 --- a/packages/pdf-codec/src/image/jpeg2000-dwt.ts +++ b/packages/pdf-codec/src/image/jpeg2000-dwt.ts @@ -7,12 +7,12 @@ // 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; +// 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; @@ -48,29 +48,32 @@ 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). 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); - 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 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); } // 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, @@ -100,8 +103,12 @@ 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; @@ -123,7 +130,18 @@ 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; + 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); @@ -161,8 +179,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, @@ -183,7 +201,7 @@ 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++) { @@ -235,18 +253,15 @@ 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++) { + times(height, (v) => { const rowStart = v * width; synthesiseLine( (index) => output[rowStart + index - u0] ?? 0, @@ -264,7 +279,7 @@ export function inverseDwt53Level( }, (value) => value >> 1, ); - } + }); for (let u = 0; u < width; u++) { synthesiseLine( (index) => output[(index - v0) * width + u] ?? 0, @@ -295,17 +310,14 @@ 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++) { + times(height, (v) => { const rowStart = v * width; synthesiseLine( (index) => output[rowStart + index - u0] ?? 0, @@ -323,7 +335,7 @@ export function inverseDwt97Level( }, (value) => value / 2, ); - } + }); for (let u = 0; u < width; u++) { synthesiseLine( (index) => output[(index - v0) * width + u] ?? 0, 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); + }); +}); 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(); + }); +}); 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/); + }); }); 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: [], + }, + ], + }, + ], }; } diff --git a/packages/pdf-codec/src/math-content-write.test.ts b/packages/pdf-codec/src/math-content-write.test.ts index 525a849df..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,4 +249,234 @@ 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 }; +// 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)!; + // 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( + [ + { + kind: "glyphs", + xPt: 5, + yPt: 20, + text: "A∫", + 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 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( + 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(""); + }); }); 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. 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]); + }); +}); diff --git a/packages/pdf-codec/src/math-font.test.ts b/packages/pdf-codec/src/math-font.test.ts index f650ef8e7..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", () => { @@ -58,6 +114,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); diff --git a/packages/pdf-codec/src/math-stretch.test.ts b/packages/pdf-codec/src/math-stretch.test.ts index 3d78f31fa..39847f29b 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); @@ -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; 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); + }); +}); diff --git a/packages/pdf-codec/src/navigation.test.ts b/packages/pdf-codec/src/navigation.test.ts index c33a3b273..390908b36 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,524 @@ 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"); + 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", () => { + const { sink, diagnostics } = collectDiagnostics(); + expect( + parseDestination( + pdfArray([pdfRef(3, 0)]), + 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("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, + ), + ).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", () => { + 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, + ), + ).toStrictEqual({ 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, + ), + ).toStrictEqual({ + 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, + ), + ).toStrictEqual({ 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, + ), + ).toStrictEqual({ 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, + ), + ).toStrictEqual({ 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, + ), + ).toStrictEqual({ + 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, + ), + ).toStrictEqual({ 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, + ), + ).toStrictEqual({ 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, + ), + ).toStrictEqual({ 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]?.code).toBe("pdf/destination-invalid"); + 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("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" }, + }); + 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", () => { + 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); + // 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", () => { + 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) }) }); + 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", () => { + 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: [] }] }, + ]); + 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); 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"]); + }); +}); 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(); 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", + ); + }); +}); diff --git a/packages/pdf-codec/src/raster.test.ts b/packages/pdf-codec/src/raster.test.ts index 35dff5aa1..4a8dc6506 100644 --- a/packages/pdf-codec/src/raster.test.ts +++ b/packages/pdf-codec/src/raster.test.ts @@ -6,8 +6,15 @@ import { PdfParseError } from "./diagnostics"; import { parseHead, parseMaxp } from "./font-tables"; import { parseGlyf } from "./glyf"; import { parseHmtx } from "./hmtx-table"; -import { applyMatrix } from "./matrix"; -import { renderPdfPage } from "./raster"; +import type { GlyphContourPoint, GlyphOutline } from "./glyf-contours"; +import type { Matrix } from "./matrix"; +import { applyMatrix, BEZIER_KAPPA, IDENTITY_MATRIX } from "./matrix"; +import { + drawGlyphOutline, + flattenCubic, + glyphOutlineSubpaths, + renderPdfPage, +} from "./raster"; import type { PageRasteriser, RasterDrawOp, @@ -16,6 +23,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, @@ -124,6 +133,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, @@ -280,6 +307,42 @@ 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("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()), @@ -287,9 +350,282 @@ 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("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(); + expect(() => + renderPdfPage( + onePagePdf(content), + 0, + { signal: controller.signal }, + new RecordingRasteriser(), + ), + ).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(); + 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", () => { + 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", + 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 >>"); + 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("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("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", () => { @@ -383,6 +719,271 @@ 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 }]); + }); + + 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 }]); + }); +}); + +// 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("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([ + 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(); @@ -407,23 +1008,161 @@ describe("renderPdfPage: vector draw ops", () => { ]); }); - it("fills a general path with the paint operator's own fill rule and carries strokes on the same op", () => { + 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 0 0 rg 100 10 m 130 10 l 115 40 l h f*"), - 0, - {}, - rasteriser, - ); - const fill = rasteriser.ops.find(isPath); - expect(fill?.fill?.fillRule).toBe("evenodd"); - expect(fill?.subpaths[0]).toEqual({ - startXPx: 100, - startYPx: 90, - segments: [ - { kind: "line", xPx: 130, yPx: 90 }, - { kind: "line", xPx: 115, yPx: 60 }, - ], + 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 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"), + 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 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("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(); + 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); + // 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", () => { + const rasteriser = new RecordingRasteriser(); + drive( + onePagePdf("0 0 0 rg 100 10 m 130 10 l 115 40 l h f*"), + 0, + {}, + rasteriser, + ); + const fill = rasteriser.ops.find(isPath); + expect(fill?.fill?.fillRule).toBe("evenodd"); + expect(fill?.subpaths[0]).toEqual({ + startXPx: 100, + startYPx: 90, + segments: [ + { kind: "line", xPx: 130, yPx: 90 }, + { kind: "line", xPx: 115, yPx: 60 }, + ], closed: true, }); }); @@ -444,6 +1183,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( @@ -453,7 +1228,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", @@ -463,10 +1239,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). @@ -507,6 +1313,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. --- @@ -628,6 +1508,36 @@ 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; + readonly fontBytes?: Uint8Array; + } = {}, +): Uint8Array { + 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 >>"); + 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"); @@ -677,6 +1587,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"); @@ -696,6 +1629,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", () => { @@ -747,6 +1705,486 @@ describe("renderPdfPage: text refusals are named, never approximated", () => { // The page carries text only, so nothing else paints. 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; + readonly descendantFontsEntry?: string; + readonly descendantExtra?: string; + readonly cidToGidMap?: string; + readonly fontDescriptorBody?: string; + readonly fontFileKey?: string; + readonly fontFileBytes?: Uint8Array; + }): Uint8Array { + 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 >>"); + 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 /${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")); + 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 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" }), + ); + 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("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({ + 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("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" }), + ); + 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("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", { + 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([]); + }); + + 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")?.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", { + 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("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 >>"); + 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. --- @@ -787,4 +2225,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 }, + }, + ]); + }); }); diff --git a/packages/pdf-codec/src/raster.ts b/packages/pdf-codec/src/raster.ts index a11b7a83c..81815c486 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)); @@ -224,11 +221,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; @@ -723,7 +717,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 }, @@ -768,8 +763,6 @@ 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( @@ -778,12 +771,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" }; @@ -809,12 +802,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); @@ -839,12 +828,7 @@ function openEmbeddedProgram( return { kind: "glyf", sfnt, - face: { - composite: false, - glyf, - unitsPerEm: head.unitsPerEm, - glyphIdOf: () => undefined, - }, + face: { glyf, unitsPerEm: head.unitsPerEm }, }; } return { kind: "absent" }; @@ -972,18 +956,17 @@ function buildTextOutlineFace( entries.push((decodedBytes[i]! << 8) | decodedBytes[i + 1]!); } return { - composite: true, 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]; }, }; } // /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]!, @@ -1009,7 +992,6 @@ function buildTextOutlineFace( return; } return { - composite: false, glyf: program.face.glyf, unitsPerEm: program.face.unitsPerEm, glyphIdOf: (codes, offset) => { @@ -1059,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; @@ -1068,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, @@ -1114,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, @@ -1125,20 +1108,30 @@ 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. -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[] { @@ -1147,30 +1140,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; @@ -1220,23 +1211,21 @@ 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; } // --- 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 { @@ -1244,15 +1233,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 { diff --git a/packages/pdf-codec/src/read.test.ts b/packages/pdf-codec/src/read.test.ts index 69fd27dee..11e862d28 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( @@ -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(); @@ -314,6 +318,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/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; diff --git a/packages/pdf-codec/src/roundtrip.test.ts b/packages/pdf-codec/src/roundtrip.test.ts index 45e35e9fd..e047a7089 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). @@ -642,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( + /target.*beyond the document/, + ); + }); + it("throws rather than guessing when an internal link names a destination the document does not carry", () => { const doc = docWithItems([ { @@ -653,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/serialize.test.ts b/packages/pdf-codec/src/serialize.test.ts index 46de9d900..b3d665dfe 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,26 @@ 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 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"); + }); + + 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 bd403034d..e64445716 100644 --- a/packages/pdf-codec/src/serialize.ts +++ b/packages/pdf-codec/src/serialize.ts @@ -7,23 +7,18 @@ 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"; } - let formatted = n.toFixed(NUMBER_DECIMAL_PLACES); - if (formatted.includes(".")) { - formatted = formatted.replace(/0+$/, "").replace(/\.$/, ""); - } - return formatted === "-0" ? "0" : 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 = /[^!-~]|[#()<>[\]{}/%]/; - // 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)!; diff --git a/packages/pdf-codec/src/structure.test.ts b/packages/pdf-codec/src/structure.test.ts index 973410956..16d20dbd2 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,11 @@ 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", + ); + // 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: [] }]); }); }); 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: 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 1252a1809..4baff0474 100644 --- a/packages/pdf-codec/src/test-support/cff.ts +++ b/packages/pdf-codec/src/test-support/cff.ts @@ -4,22 +4,33 @@ 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; } -// 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). +// 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) { return [0, 0]; @@ -28,15 +39,36 @@ 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(), ]; } +// 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. @@ -68,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 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 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) 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( @@ -83,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) { @@ -98,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 @@ -136,3 +238,64 @@ 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]; + // 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 + ? 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, + ]); +} 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); } 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[] = [ diff --git a/packages/pdf-codec/src/test-support/pdf.test.ts b/packages/pdf-codec/src/test-support/pdf.test.ts index 2513cbdc5..0e16ce180 100644 --- a/packages/pdf-codec/src/test-support/pdf.test.ts +++ b/packages/pdf-codec/src/test-support/pdf.test.ts @@ -2,13 +2,16 @@ import { unzlibSync } from "fflate"; import { describe, expect, it } from "vitest"; import { brokenStartxrefPdf, + FixtureBuilder, formXObjectPdf, incrementalUpdatePdf, inheritedPageAttributesPdf, inlineImagePdf, minimalClassicXrefPdf, nonZeroOriginMediaBoxPdf, + pagelessPdf, rotatedPagePdf, + symbolFontProgramPdf, unsupportedSecurityHandlerPdf, withInfoDictPdf, xrefStreamWithObjectStreamPdf, @@ -66,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]); @@ -173,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", () => { @@ -274,5 +294,76 @@ 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"); + }); +}); + +// 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")) + .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 4a14800e0..830a5d6b6 100644 --- a/packages/pdf-codec/src/test-support/pdf.ts +++ b/packages/pdf-codec/src/test-support/pdf.ts @@ -9,8 +9,15 @@ function enc(text: string): Uint8Array { return new TextEncoder().encode(text); } -// 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 { +// 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. 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(); @@ -28,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`); @@ -99,14 +101,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 +117,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 +135,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,11 +180,10 @@ 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. + // /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 @@ -192,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`); @@ -219,18 +221,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 +259,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 +272,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 +284,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 +299,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 +311,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 +342,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 +351,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 +366,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 +387,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 +403,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 +417,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,18 +450,18 @@ 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] >>", + "<< /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( 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] >>"); @@ -497,13 +499,15 @@ 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(); } // 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] >> >> >>", @@ -513,20 +517,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"), ), @@ -549,19 +553,19 @@ 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( 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, "<< /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) >>", @@ -578,13 +582,25 @@ 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(); } // 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] >> >>", @@ -594,8 +610,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 >>", @@ -643,7 +659,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] >>", @@ -653,8 +669,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, @@ -672,7 +688,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, @@ -681,7 +697,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", ), @@ -696,7 +712,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, @@ -705,7 +721,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", ), @@ -716,7 +732,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, @@ -726,10 +742,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", ), @@ -740,30 +756,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( @@ -774,41 +790,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"), ), @@ -853,7 +869,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( @@ -864,26 +880,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"), ), ); @@ -906,25 +922,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"), ), ); @@ -948,7 +964,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"), ), ); @@ -963,22 +979,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"), ), ); 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..4030d5b41 --- /dev/null +++ b/packages/pdf-codec/src/test-support/sfnt.test.ts @@ -0,0 +1,612 @@ +import { describe, expect, it } from "vitest"; +import { + buildChainContextFormat1, + buildChainContextFormat2, + buildClassDefFormat1, + buildCmapTable, + buildContextFormat1, + buildContextFormat2, + buildCoverageFormat1, + buildCoverageFormat2, + 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("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([ + [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); + // 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); + }); + + 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); + // 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("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( + [], + [{ 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); + 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", () => { + 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 558479fd0..1bae05eca 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); @@ -43,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; @@ -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,26 @@ 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. +// 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( - 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); + 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; +} + +// 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 +372,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 +460,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 +487,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 +505,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 +538,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,17 +649,15 @@ 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]!); 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) => { @@ -698,10 +704,8 @@ export function buildGdefTable(classes: { readonly markAttachClassDef?: Uint8Array; 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,9 +721,9 @@ export function buildGdefTable(classes: { at += coverage.length; }); return def.bytes; - })() + })(classes.markGlyphSets) : undefined; - const headerSize = withSets ? 14 : 12; + const headerSize = markGlyphSetsDef === undefined ? 12 : 14; const blobs = [ classes.glyphClassDef, classes.markAttachClassDef, @@ -728,7 +732,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; @@ -744,11 +748,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; } 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); + }); +}); 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(); +} 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", () => { 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"); + } }); }); 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); 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 = { diff --git a/packages/pdf-codec/src/write.test.ts b/packages/pdf-codec/src/write.test.ts index 4b2aa2a25..8359bad8a 100644 --- a/packages/pdf-codec/src/write.test.ts +++ b/packages/pdf-codec/src/write.test.ts @@ -1,9 +1,11 @@ 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"; import type { LayoutDocument, + LayoutFormField, LayoutImageAsset, LayoutItem, LayoutPage, @@ -72,6 +74,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 +166,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", () => { @@ -196,9 +288,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", () => { @@ -221,6 +320,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( @@ -358,6 +559,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( @@ -380,10 +642,96 @@ 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", () => { + 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("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 () => { @@ -427,9 +775,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); @@ -661,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([ @@ -674,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, @@ -742,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. @@ -800,8 +1187,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 }, @@ -819,6 +1210,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, @@ -922,6 +1343,147 @@ 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("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.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, @@ -933,6 +1495,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 = { @@ -1047,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([ @@ -1135,6 +1720,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 = { @@ -1156,3 +1774,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"); + }); +}); 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"), 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({}); + }); +}); 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 } : {}; } diff --git a/packages/pdf-codec/stryker.config.ts b/packages/pdf-codec/stryker.config.ts index 06152deea..31ccdf238 100644 --- a/packages/pdf-codec/stryker.config.ts +++ b/packages/pdf-codec/stryker.config.ts @@ -2,6 +2,8 @@ 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, + // 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, }); 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, }, });