Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
135 commits
Select commit Hold shift + click to select a range
b065191
test(pdf-codec): raise the unit suite's test timeout to absorb shared…
Mearman Sep 13, 2026
60ef6f2
test(pdf-codec): serialize Stryker's own worker processes to one at a…
Mearman Sep 13, 2026
2a29810
refactor(pdf-codec): remove sfnt fixture builder's equivalent-mutant …
Mearman Sep 14, 2026
d368282
test(pdf-codec): pin sfnt.ts fixture builders' own byte layout directly
Mearman Sep 14, 2026
7c1f85f
test(pdf-codec): close sfnt.ts's remaining coverage and equivalent-mu…
Mearman Sep 14, 2026
c2ab7b6
refactor(pdf-codec): build rc4's initial state array by index-mapping
Mearman Sep 14, 2026
a7e577f
refactor(pdf-codec): drop padBigEndian's unreachable early-exit guard
Mearman Sep 14, 2026
2e15014
refactor(pdf-codec): remove formatNumber's unreachable -0 normalisation
Mearman Sep 14, 2026
3c626b0
refactor(pdf-codec): narrow applyEncryptMethod off the unused identit…
Mearman Sep 14, 2026
1218506
refactor(pdf-codec): compare parities directly in the checker8 fixture
Mearman Sep 14, 2026
404282d
refactor(pdf-codec): build jpeg2000FixtureSamples' planes by length-m…
Mearman Sep 14, 2026
78c1bc8
test(pdf-codec): assert throwIfAborted's DOMException name and message
Mearman Sep 14, 2026
4031186
test(pdf-codec): reject a non-1 major version whose body parses cleanly
Mearman Sep 14, 2026
2e12bb1
test(pdf-codec): refuse a font whose hhea declares zero horizontal me…
Mearman Sep 14, 2026
e8e923d
test(pdf-codec): cover encodeCcittFax's degenerate-geometry guard
Mearman Sep 14, 2026
0f2e77f
test(pdf-codec): round-trip a form array through LayoutDocumentSchema
Mearman Sep 14, 2026
350f104
test(pdf-codec): cover deflate's level option and inflate's size guard
Mearman Sep 14, 2026
efbdaed
test(pdf-codec): warn on a filespec whose /EF has no /F or /UF stream
Mearman Sep 14, 2026
5828eaa
refactor(pdf-codec): dedupe pdf.ts fixture builder's boilerplate lite…
Mearman Sep 14, 2026
294bd83
test(pdf-codec): distinguish isTrueTypeCollection's own two guards
Mearman Sep 14, 2026
188f4fc
test(pdf-codec): add a dedicated suite for parseHmtx
Mearman Sep 14, 2026
ac4f069
test(pdf-codec): cover buildSimpleFont/buildCompositeFont's BaseFont …
Mearman Sep 14, 2026
811ddef
test(pdf-codec): cover writeDoublePath's fill rule and zero-bisector …
Mearman Sep 14, 2026
f2ba356
test(pdf-codec): add a direct test file for jbig2-generic.ts
Mearman Sep 14, 2026
477be58
test(pdf-codec): cover computeFlags' FLAG_ITALIC bit
Mearman Sep 14, 2026
258fdcc
test(pdf-codec): pin FixtureBuilder's own byte-level mechanics directly
Mearman Sep 14, 2026
f5f0974
test(pdf-codec): close pdf.ts fixture-consumer gaps around vacuous ne…
Mearman Sep 14, 2026
f887c93
refactor(pdf-codec): drive rc4's keystream loop from data.forEach
Mearman Sep 14, 2026
0a1aa62
refactor(pdf-codec): remove sha2's fixed-size-array equivalent mutants
Mearman Sep 14, 2026
947f738
fix(pdf-codec): remove probeCff's redundant empty-Name-INDEX check
Mearman Sep 14, 2026
9a27caa
fix(pdf-codec): cover formatNumber's epsilon guard and escapeName's b…
Mearman Sep 14, 2026
6cb620f
test(pdf-codec): add a dedicated suite for readXmpMetadata
Mearman Sep 14, 2026
d42b1f2
test(pdf-codec): pin flattenCubic's own subdivision arithmetic directly
Mearman Sep 14, 2026
8641502
test(pdf-codec): close renderPdfPage's own boundary and geometry gaps
Mearman Sep 14, 2026
5597984
test(pdf-codec): cover renderPdfPage's Type0/CIDFontType2 font-refusa…
Mearman Sep 14, 2026
bb81617
test(pdf-codec): pin renderPdfPage's abort checks, clip boundaries, a…
Mearman Sep 14, 2026
26f09f8
test(pdf-codec): cover flattenCubic's max-distance and exact-toleranc…
Mearman Sep 14, 2026
27687e5
test(pdf-codec): cover the stroke branch of drawRect, drawEllipse, an…
Mearman Sep 14, 2026
7d2c791
test(pdf-codec): cover the empty-glyph skip, unstated descendant subt…
Mearman Sep 14, 2026
28986c8
refactor(pdf-codec): drop dead outline-face fields and a redundant le…
Mearman Sep 14, 2026
55a465f
test(pdf-codec): cover CIDToGIDMap's own trailing-unpaired-byte bound
Mearman Sep 14, 2026
c4fc2f4
test(pdf-codec): pin glyphOutlineSubpaths' contour walk directly
Mearman Sep 14, 2026
30f1e88
refactor(pdf-codec): eliminate two more redundant bounds guards
Mearman Sep 14, 2026
0718b4b
test(pdf-codec): pin the outline-refusal diagnostic's own face-name f…
Mearman Sep 14, 2026
353a947
refactor(pdf-codec): eliminate sha2's round-expansion length equivale…
Mearman Sep 14, 2026
928b800
refactor(pdf-codec): remove escapeName's dead whole-name safety check
Mearman Sep 14, 2026
73f6387
test(pdf-codec): align the too-small-headerSize fixture's own byte of…
Mearman Sep 14, 2026
23539b8
refactor(pdf-codec): stop writing object 0's xref-stream row as a lit…
Mearman Sep 14, 2026
e2dd417
test(pdf-codec): pin header()'s default version and the first xref re…
Mearman Sep 14, 2026
3bc3e37
test(pdf-codec): read a pageless document on the unaborted path
Mearman Sep 14, 2026
983b27b
test(pdf-codec): pin the dedup annotation's own parse and the manifes…
Mearman Sep 14, 2026
35564f2
test(pdf-codec): assert the raw XMP residue matches byte-for-byte
Mearman Sep 14, 2026
9b8155b
test(pdf-codec): read the metadata fixture's own page alongside its m…
Mearman Sep 14, 2026
95ecc9e
test(pdf-codec): pin equalCropBoxPdf's own declared CropBox bytes
Mearman Sep 14, 2026
7354a84
test(pdf-codec): pin taggedFormPdf's struct elements and both fixture…
Mearman Sep 14, 2026
e2c77dd
test(pdf-codec): pin parentTreeMissingEntryPdf's own struct element
Mearman Sep 14, 2026
b90a902
refactor(pdf-codec): stop computing an unread MediaBox width/height f…
Mearman Sep 14, 2026
1f76ebd
refactor(pdf-codec): extract drawGlyphOutline for direct coverage of …
Mearman Sep 14, 2026
071eff1
refactor(pdf-codec): remove drawTextRun's dead glyphAdvance fallback
Mearman Sep 14, 2026
1210b03
test(pdf-codec): pin drawPath's own dotted-stroke width scaling
Mearman Sep 14, 2026
17a1a5e
refactor(pdf-codec): remove glyphOutlineSubpaths' redundant segment-c…
Mearman Sep 14, 2026
70b2d16
test(pdf-codec): pin the /Contents array's own inter-chunk separator …
Mearman Sep 14, 2026
47e9835
refactor(pdf-codec): remove drawTextRun's redundant empty-contours check
Mearman Sep 14, 2026
83a4aa0
test(pdf-codec): drive cff-bounds.ts's charstring interpreter with ha…
Mearman Sep 14, 2026
a16999b
test(pdf-codec): drive glyf-contours.ts's simple-glyph decoding with …
Mearman Sep 14, 2026
9a877cd
test(pdf-codec): share the write-side PDF fixture and assert CIDSyste…
Mearman Sep 14, 2026
a38839a
test(pdf-codec): cover math-font-write's descriptor scaling, W array,…
Mearman Sep 14, 2026
b3d42d8
test(pdf-codec): cover writeFormulaContentStream's glyph-run, rule, a…
Mearman Sep 14, 2026
b42a8fa
test(pdf-codec): cover parseDestination's view types and the outline …
Mearman Sep 14, 2026
81d16bb
refactor(pdf-codec): remove the unreachable duplicate-name check in t…
Mearman Sep 14, 2026
7c59d77
test(pdf-codec): pick characters that actually distinguish math-conte…
Mearman Sep 14, 2026
3bf963d
test(pdf-codec): cover embedded-font-write's serif flag, subset tag a…
Mearman Sep 14, 2026
8a0dc63
refactor(pdf-codec): build utf16BeWithBom's bytes by appending, not b…
Mearman Sep 14, 2026
8c963dc
test(pdf-codec): assert every MATH constant field metricsAt exposes
Mearman Sep 14, 2026
c2ff335
refactor(pdf-codec): build jp2-boxes' colour-space lookup inside the …
Mearman Sep 14, 2026
33bbd9d
refactor(pdf-codec): build the progression-order table inside readCod…
Mearman Sep 14, 2026
497ecc2
refactor(pdf-codec): build the 9-7 lifting constants inside inverse97…
Mearman Sep 14, 2026
8aa6786
refactor(pdf-codec): drop jp2-boxes guards that duplicate a later bou…
Mearman Sep 14, 2026
57012b3
test(pdf-codec): kill jp2-boxes.ts mutants left over from the JPEG 20…
Mearman Sep 14, 2026
c9cdaba
refactor(pdf-codec): expose MarkerCursor and drop a redundant code-bl…
Mearman Sep 14, 2026
e62e75b
test(pdf-codec): cover jpeg2000-codestream.ts's header-segment and cu…
Mearman Sep 14, 2026
5af4f42
refactor(pdf-codec): expose interleave, mirrorIndex and synthesiseLin…
Mearman Sep 14, 2026
3c1c255
test(pdf-codec): cover jpeg2000-dwt.ts's zero-size, boundary and inde…
Mearman Sep 14, 2026
beecdf2
test(pdf-codec): cover remaining jpeg2000-codestream.ts header-segmen…
Mearman Sep 14, 2026
7efd6cf
refactor(pdf-codec): drop trimTrailingEoc's own redundant length guard
Mearman Sep 14, 2026
f73ab70
test(pdf-codec): cover jpeg2000-codestream.ts's remaining header-segm…
Mearman Sep 14, 2026
4a478bc
refactor(pdf-codec): drop inverseDwt53Level/97Level's own non-positiv…
Mearman Sep 14, 2026
59f3ead
test(pdf-codec): cover jpeg2000-dwt.ts's filter loop bounds and remai…
Mearman Sep 14, 2026
1952697
refactor(pdf-codec): extract inverseDwt53Level/97Level's row loop int…
Mearman Sep 14, 2026
729a4a8
test(pdf-codec): cover times() directly and pin inverse97Filter's F-1…
Mearman Sep 14, 2026
cc05c70
refactor(pdf-codec): drop mirrorIndex's redundant absolute-position r…
Mearman Sep 14, 2026
41ff7ca
test(pdf-codec): update mirrorIndex/synthesiseLine tests for the offs…
Mearman Sep 14, 2026
e3f7b8e
fix(ci): raise the mutation shard timeout so a cold run under cache e…
Mearman Sep 14, 2026
7cde174
test(document-operations): raise the unit test timeout for the thresh…
Mearman Sep 14, 2026
d9c2182
test(pdf-codec): assert randomBytes actually fills its buffer from th…
Mearman Sep 15, 2026
f6ae3d0
test(pdf-codec): assert Jpeg2000ParseError/UnsupportedError carry the…
Mearman Sep 15, 2026
84143af
test(pdf-codec): cover decodeJpeg2000CodeBlock's unsupported code-blo…
Mearman Sep 15, 2026
9c73e38
test(pdf-codec): pin readChunks' exact end-of-file chunk-header boundary
Mearman Sep 15, 2026
a9f3d1b
test(pdf-codec): pin flushWord's no-op guard for a whitespace-only run
Mearman Sep 15, 2026
b57b448
test(pdf-codec): cover every SEMANTIC_SUBTYPES entry in readPageAnnot…
Mearman Sep 15, 2026
87dbb8f
test(pdf-codec): pin SUBSET_TAG_PATTERN's anchor and exact letter count
Mearman Sep 15, 2026
f0ccba7
test(pdf-codec): cover parseFormat4's header and segment-count guards
Mearman Sep 15, 2026
b30a5a2
test(pdf-codec): pin decodeUtf16BEString's odd-length trailing-byte b…
Mearman Sep 15, 2026
6aede90
fix(pdf-codec): compute cffIndex's own offSize instead of hardcoding …
Mearman Sep 15, 2026
cc6986b
test(pdf-codec): pin subrBias's switch from the small to the medium bias
Mearman Sep 15, 2026
027b864
test(pdf-codec): pin widthOfCode's short-circuit for a monospace face
Mearman Sep 15, 2026
1778e79
test(pdf-codec): key the vendored-face cache by its own base64 constant
Mearman Sep 15, 2026
068c5b1
fix(pdf-codec): scope font-style's subset-tag pattern and suffix list…
Mearman Sep 15, 2026
91f4359
test(pdf-codec): cover every ToUnicode CMap error and boundary path
Mearman Sep 15, 2026
f0808d6
fix(pdf-codec): scope annotation subtype sets to readPageAnnotations …
Mearman Sep 15, 2026
5effe60
test(pdf-codec): verify assemblePdf's own byte structure directly
Mearman Sep 15, 2026
26d2600
fix(pdf-codec): stop buildGsubTable racing a markFilteringSet write a…
Mearman Sep 15, 2026
7b9f455
test(pdf-codec): exercise widthOfCode's missing-AFM-width guard directly
Mearman Sep 15, 2026
46dbdc5
test(pdf-codec): cover cffIndex's offSize boundaries and the two sfnt…
Mearman Sep 15, 2026
5765447
test(pdf-codec): cover format 12 cmap subtables and the subtable-pref…
Mearman Sep 15, 2026
91119fa
test(pdf-codec): drive cff-bounds's interpreter through its untested …
Mearman Sep 15, 2026
352d8c1
test(pdf-codec): cover CFF charset/encoding formats 1/2 and Type 1's …
Mearman Sep 15, 2026
2b88a38
test(pdf-codec): draw after each interpreter-limit boundary to make s…
Mearman Sep 15, 2026
c31a33b
refactor(pdf-codec): drop widthForWidthsArray's dead zero-width branch
Mearman Sep 15, 2026
689314f
test(pdf-codec): cover Info dict metadata, font flags, JPEG colour sp…
Mearman Sep 15, 2026
05b7176
test(pdf-codec): cover every internal-link destination view type
Mearman Sep 15, 2026
3911e29
test(pdf-codec): cover rmoveto/hmoveto/vmoveto width-shift and cubic-…
Mearman Sep 15, 2026
1844db4
test(pdf-codec): cover endchar's own bare-width and width-plus-seac a…
Mearman Sep 15, 2026
cc83f8b
test(pdf-codec): pin dict-key names, sort order, and empty-collection…
Mearman Sep 15, 2026
acd80f3
test(pdf-codec): pin passthrough image headers and destination-lookup…
Mearman Sep 15, 2026
828908d
test(pdf-codec): cover outline dict keys, attachment Desc, and AcroFo…
Mearman Sep 15, 2026
d2db69a
test(pdf-codec): cover a checkbox's own /V export-value derivation
Mearman Sep 15, 2026
bd1357b
test(pdf-codec): cover structure element dict keys and the /Lang attr…
Mearman Sep 15, 2026
8e17881
fix(pdf-codec): drop requiredRepeatCount's redundant empty-extenders …
Mearman Sep 15, 2026
99e7edd
fix(pdf-codec): drop xmp's unreachable absent-capturing-group fallback
Mearman Sep 15, 2026
992a4d2
test(pdf-codec): cover SCALED_COMPONENT_OFFSET applied to a component…
Mearman Sep 15, 2026
b1db7c0
test(pdf-codec): cover loadMathFont's broken-parse guards
Mearman Sep 15, 2026
b6f23c7
test(pdf-codec): cover readOptionalContent's unresolved-OCG and layer…
Mearman Sep 15, 2026
38eeaae
test(pdf-codec): add direct byte-level coverage for the MATH table pa…
Mearman Sep 15, 2026
7858055
test(pdf-codec): add direct coverage for decodePdfString and parsePdf…
Mearman Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) }}
Expand Down
4 changes: 4 additions & 0 deletions packages/document-operations/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
23 changes: 22 additions & 1 deletion packages/pdf-codec/src/afm-widths.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -69,11 +69,32 @@ 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'
});

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<string, number>;
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!);
}
});
});
172 changes: 171 additions & 1 deletion packages/pdf-codec/src/annotations.test.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> {
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<ArrayBuffer> {
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.

Expand Down Expand Up @@ -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", () => {
Expand All @@ -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");
Expand All @@ -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);
});
});
36 changes: 18 additions & 18 deletions packages/pdf-codec/src/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand All @@ -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.
Expand Down Expand Up @@ -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: {
Expand Down
29 changes: 28 additions & 1 deletion packages/pdf-codec/src/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,13 +22,39 @@ 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");
expect(logo?.base64).toBe(bytesToBase64(new Uint8Array([0, 1, 2])));
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",
}),
);
});
});
Loading
Loading