Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
136 commits
Select commit Hold shift + click to select a range
faa30f8
test(pdf-codec): raise the unit suite's test timeout to absorb shared…
Mearman Sep 13, 2026
93df4b9
test(pdf-codec): serialize Stryker's own worker processes to one at a…
Mearman Sep 13, 2026
c8e7cfb
refactor(pdf-codec): remove sfnt fixture builder's equivalent-mutant …
Mearman Sep 14, 2026
c53c930
test(pdf-codec): pin sfnt.ts fixture builders' own byte layout directly
Mearman Sep 14, 2026
371bada
test(pdf-codec): close sfnt.ts's remaining coverage and equivalent-mu…
Mearman Sep 14, 2026
bf67bbf
refactor(pdf-codec): build rc4's initial state array by index-mapping
Mearman Sep 14, 2026
89f85e9
refactor(pdf-codec): drop padBigEndian's unreachable early-exit guard
Mearman Sep 14, 2026
37c2423
refactor(pdf-codec): remove formatNumber's unreachable -0 normalisation
Mearman Sep 14, 2026
9e49af2
refactor(pdf-codec): narrow applyEncryptMethod off the unused identit…
Mearman Sep 14, 2026
9bfe126
refactor(pdf-codec): compare parities directly in the checker8 fixture
Mearman Sep 14, 2026
b8056fc
refactor(pdf-codec): build jpeg2000FixtureSamples' planes by length-m…
Mearman Sep 14, 2026
5627d01
test(pdf-codec): assert throwIfAborted's DOMException name and message
Mearman Sep 14, 2026
d29ec67
test(pdf-codec): reject a non-1 major version whose body parses cleanly
Mearman Sep 14, 2026
bafdfca
test(pdf-codec): refuse a font whose hhea declares zero horizontal me…
Mearman Sep 14, 2026
15a4740
test(pdf-codec): cover encodeCcittFax's degenerate-geometry guard
Mearman Sep 14, 2026
e623591
test(pdf-codec): round-trip a form array through LayoutDocumentSchema
Mearman Sep 14, 2026
0c67fcc
test(pdf-codec): cover deflate's level option and inflate's size guard
Mearman Sep 14, 2026
76ad9a4
test(pdf-codec): warn on a filespec whose /EF has no /F or /UF stream
Mearman Sep 14, 2026
d2d7a7c
refactor(pdf-codec): dedupe pdf.ts fixture builder's boilerplate lite…
Mearman Sep 14, 2026
78d0242
test(pdf-codec): distinguish isTrueTypeCollection's own two guards
Mearman Sep 14, 2026
69f2b1f
test(pdf-codec): add a dedicated suite for parseHmtx
Mearman Sep 14, 2026
478a65c
test(pdf-codec): cover buildSimpleFont/buildCompositeFont's BaseFont …
Mearman Sep 14, 2026
4ab48db
test(pdf-codec): cover writeDoublePath's fill rule and zero-bisector …
Mearman Sep 14, 2026
54f1802
test(pdf-codec): add a direct test file for jbig2-generic.ts
Mearman Sep 14, 2026
0f35581
test(pdf-codec): cover computeFlags' FLAG_ITALIC bit
Mearman Sep 14, 2026
a139ae4
test(pdf-codec): pin FixtureBuilder's own byte-level mechanics directly
Mearman Sep 14, 2026
5018c51
test(pdf-codec): close pdf.ts fixture-consumer gaps around vacuous ne…
Mearman Sep 14, 2026
5fc3cee
refactor(pdf-codec): drive rc4's keystream loop from data.forEach
Mearman Sep 14, 2026
055c82f
refactor(pdf-codec): remove sha2's fixed-size-array equivalent mutants
Mearman Sep 14, 2026
8899889
fix(pdf-codec): remove probeCff's redundant empty-Name-INDEX check
Mearman Sep 14, 2026
808fd32
fix(pdf-codec): cover formatNumber's epsilon guard and escapeName's b…
Mearman Sep 14, 2026
8e5f704
test(pdf-codec): add a dedicated suite for readXmpMetadata
Mearman Sep 14, 2026
6f445a3
test(pdf-codec): pin flattenCubic's own subdivision arithmetic directly
Mearman Sep 14, 2026
58cb944
test(pdf-codec): close renderPdfPage's own boundary and geometry gaps
Mearman Sep 14, 2026
2311be7
test(pdf-codec): cover renderPdfPage's Type0/CIDFontType2 font-refusa…
Mearman Sep 14, 2026
db4a296
test(pdf-codec): pin renderPdfPage's abort checks, clip boundaries, a…
Mearman Sep 14, 2026
945af72
test(pdf-codec): cover flattenCubic's max-distance and exact-toleranc…
Mearman Sep 14, 2026
b41d92f
test(pdf-codec): cover the stroke branch of drawRect, drawEllipse, an…
Mearman Sep 14, 2026
23a7a82
test(pdf-codec): cover the empty-glyph skip, unstated descendant subt…
Mearman Sep 14, 2026
1cdc32c
refactor(pdf-codec): drop dead outline-face fields and a redundant le…
Mearman Sep 14, 2026
361ed83
test(pdf-codec): cover CIDToGIDMap's own trailing-unpaired-byte bound
Mearman Sep 14, 2026
d28afd0
test(pdf-codec): pin glyphOutlineSubpaths' contour walk directly
Mearman Sep 14, 2026
a19a6a3
refactor(pdf-codec): eliminate two more redundant bounds guards
Mearman Sep 14, 2026
13e511f
test(pdf-codec): pin the outline-refusal diagnostic's own face-name f…
Mearman Sep 14, 2026
749e9c7
refactor(pdf-codec): eliminate sha2's round-expansion length equivale…
Mearman Sep 14, 2026
39c0156
refactor(pdf-codec): remove escapeName's dead whole-name safety check
Mearman Sep 14, 2026
d03c28c
test(pdf-codec): align the too-small-headerSize fixture's own byte of…
Mearman Sep 14, 2026
472077a
refactor(pdf-codec): stop writing object 0's xref-stream row as a lit…
Mearman Sep 14, 2026
f269ff8
test(pdf-codec): pin header()'s default version and the first xref re…
Mearman Sep 14, 2026
701bdb3
test(pdf-codec): read a pageless document on the unaborted path
Mearman Sep 14, 2026
3f09271
test(pdf-codec): pin the dedup annotation's own parse and the manifes…
Mearman Sep 14, 2026
646f6e2
test(pdf-codec): assert the raw XMP residue matches byte-for-byte
Mearman Sep 14, 2026
e11761f
test(pdf-codec): read the metadata fixture's own page alongside its m…
Mearman Sep 14, 2026
5befed3
test(pdf-codec): pin equalCropBoxPdf's own declared CropBox bytes
Mearman Sep 14, 2026
8d6e9f7
test(pdf-codec): pin taggedFormPdf's struct elements and both fixture…
Mearman Sep 14, 2026
0b31ba5
test(pdf-codec): pin parentTreeMissingEntryPdf's own struct element
Mearman Sep 14, 2026
a5579e9
refactor(pdf-codec): stop computing an unread MediaBox width/height f…
Mearman Sep 14, 2026
3b40a8c
refactor(pdf-codec): extract drawGlyphOutline for direct coverage of …
Mearman Sep 14, 2026
6200be7
refactor(pdf-codec): remove drawTextRun's dead glyphAdvance fallback
Mearman Sep 14, 2026
fc18d12
test(pdf-codec): pin drawPath's own dotted-stroke width scaling
Mearman Sep 14, 2026
ddd8278
refactor(pdf-codec): remove glyphOutlineSubpaths' redundant segment-c…
Mearman Sep 14, 2026
ad9e16f
test(pdf-codec): pin the /Contents array's own inter-chunk separator …
Mearman Sep 14, 2026
5c4b0e6
refactor(pdf-codec): remove drawTextRun's redundant empty-contours check
Mearman Sep 14, 2026
be70376
test(pdf-codec): drive cff-bounds.ts's charstring interpreter with ha…
Mearman Sep 14, 2026
a7195ab
test(pdf-codec): drive glyf-contours.ts's simple-glyph decoding with …
Mearman Sep 14, 2026
6efa048
test(pdf-codec): share the write-side PDF fixture and assert CIDSyste…
Mearman Sep 14, 2026
6c00f69
test(pdf-codec): cover math-font-write's descriptor scaling, W array,…
Mearman Sep 14, 2026
5d9808b
test(pdf-codec): cover writeFormulaContentStream's glyph-run, rule, a…
Mearman Sep 14, 2026
f907951
test(pdf-codec): cover parseDestination's view types and the outline …
Mearman Sep 14, 2026
f11976d
refactor(pdf-codec): remove the unreachable duplicate-name check in t…
Mearman Sep 14, 2026
cfd3057
test(pdf-codec): pick characters that actually distinguish math-conte…
Mearman Sep 14, 2026
775cc4d
test(pdf-codec): cover embedded-font-write's serif flag, subset tag a…
Mearman Sep 14, 2026
c64d001
refactor(pdf-codec): build utf16BeWithBom's bytes by appending, not b…
Mearman Sep 14, 2026
1411e8d
test(pdf-codec): assert every MATH constant field metricsAt exposes
Mearman Sep 14, 2026
c9f21a1
refactor(pdf-codec): build jp2-boxes' colour-space lookup inside the …
Mearman Sep 14, 2026
c7f8d9d
refactor(pdf-codec): build the progression-order table inside readCod…
Mearman Sep 14, 2026
9f8fafa
refactor(pdf-codec): build the 9-7 lifting constants inside inverse97…
Mearman Sep 14, 2026
20b0129
refactor(pdf-codec): drop jp2-boxes guards that duplicate a later bou…
Mearman Sep 14, 2026
0cac4f2
test(pdf-codec): kill jp2-boxes.ts mutants left over from the JPEG 20…
Mearman Sep 14, 2026
64e508d
refactor(pdf-codec): expose MarkerCursor and drop a redundant code-bl…
Mearman Sep 14, 2026
0accdc9
test(pdf-codec): cover jpeg2000-codestream.ts's header-segment and cu…
Mearman Sep 14, 2026
1c7ea4c
refactor(pdf-codec): expose interleave, mirrorIndex and synthesiseLin…
Mearman Sep 14, 2026
5431b60
test(pdf-codec): cover jpeg2000-dwt.ts's zero-size, boundary and inde…
Mearman Sep 14, 2026
133bfb4
test(pdf-codec): cover remaining jpeg2000-codestream.ts header-segmen…
Mearman Sep 14, 2026
107f8d2
refactor(pdf-codec): drop trimTrailingEoc's own redundant length guard
Mearman Sep 14, 2026
635f3a0
test(pdf-codec): cover jpeg2000-codestream.ts's remaining header-segm…
Mearman Sep 14, 2026
aae9e64
refactor(pdf-codec): drop inverseDwt53Level/97Level's own non-positiv…
Mearman Sep 14, 2026
3df587f
test(pdf-codec): cover jpeg2000-dwt.ts's filter loop bounds and remai…
Mearman Sep 14, 2026
6394db0
refactor(pdf-codec): extract inverseDwt53Level/97Level's row loop int…
Mearman Sep 14, 2026
df7e6a0
test(pdf-codec): cover times() directly and pin inverse97Filter's F-1…
Mearman Sep 14, 2026
47d3c8b
refactor(pdf-codec): drop mirrorIndex's redundant absolute-position r…
Mearman Sep 14, 2026
dbacc23
test(pdf-codec): update mirrorIndex/synthesiseLine tests for the offs…
Mearman Sep 14, 2026
3884d72
fix(ci): raise the mutation shard timeout so a cold run under cache e…
Mearman Sep 14, 2026
a6871e8
test(document-operations): raise the unit test timeout for the thresh…
Mearman Sep 14, 2026
f1f137b
test(pdf-codec): assert randomBytes actually fills its buffer from th…
Mearman Sep 15, 2026
ac1546f
test(pdf-codec): assert Jpeg2000ParseError/UnsupportedError carry the…
Mearman Sep 15, 2026
4b0a0a3
test(pdf-codec): cover decodeJpeg2000CodeBlock's unsupported code-blo…
Mearman Sep 15, 2026
3403200
test(pdf-codec): pin readChunks' exact end-of-file chunk-header boundary
Mearman Sep 15, 2026
a284495
test(pdf-codec): pin flushWord's no-op guard for a whitespace-only run
Mearman Sep 15, 2026
3dc8ef7
test(pdf-codec): cover every SEMANTIC_SUBTYPES entry in readPageAnnot…
Mearman Sep 15, 2026
788358a
test(pdf-codec): pin SUBSET_TAG_PATTERN's anchor and exact letter count
Mearman Sep 15, 2026
d7a5c8c
test(pdf-codec): cover parseFormat4's header and segment-count guards
Mearman Sep 15, 2026
aaa00a5
test(pdf-codec): pin decodeUtf16BEString's odd-length trailing-byte b…
Mearman Sep 15, 2026
a172ba1
fix(pdf-codec): compute cffIndex's own offSize instead of hardcoding …
Mearman Sep 15, 2026
6b718e9
test(pdf-codec): pin subrBias's switch from the small to the medium bias
Mearman Sep 15, 2026
cced11f
test(pdf-codec): pin widthOfCode's short-circuit for a monospace face
Mearman Sep 15, 2026
ecfe53b
test(pdf-codec): key the vendored-face cache by its own base64 constant
Mearman Sep 15, 2026
a8f5082
fix(pdf-codec): scope font-style's subset-tag pattern and suffix list…
Mearman Sep 15, 2026
06052b4
test(pdf-codec): cover every ToUnicode CMap error and boundary path
Mearman Sep 15, 2026
002b28d
fix(pdf-codec): scope annotation subtype sets to readPageAnnotations …
Mearman Sep 15, 2026
1b0677a
test(pdf-codec): verify assemblePdf's own byte structure directly
Mearman Sep 15, 2026
2a05673
fix(pdf-codec): stop buildGsubTable racing a markFilteringSet write a…
Mearman Sep 15, 2026
166fb5b
test(pdf-codec): exercise widthOfCode's missing-AFM-width guard directly
Mearman Sep 15, 2026
a7d10eb
test(pdf-codec): cover cffIndex's offSize boundaries and the two sfnt…
Mearman Sep 15, 2026
979b70f
test(pdf-codec): cover format 12 cmap subtables and the subtable-pref…
Mearman Sep 15, 2026
5992ad3
test(pdf-codec): drive cff-bounds's interpreter through its untested …
Mearman Sep 15, 2026
218f49c
test(pdf-codec): cover CFF charset/encoding formats 1/2 and Type 1's …
Mearman Sep 15, 2026
84eb6ed
test(pdf-codec): draw after each interpreter-limit boundary to make s…
Mearman Sep 15, 2026
96456d7
refactor(pdf-codec): drop widthForWidthsArray's dead zero-width branch
Mearman Sep 15, 2026
ce527f2
test(pdf-codec): cover Info dict metadata, font flags, JPEG colour sp…
Mearman Sep 15, 2026
4ccf43f
test(pdf-codec): cover every internal-link destination view type
Mearman Sep 15, 2026
be059d7
test(pdf-codec): cover rmoveto/hmoveto/vmoveto width-shift and cubic-…
Mearman Sep 15, 2026
6fb0187
test(pdf-codec): cover endchar's own bare-width and width-plus-seac a…
Mearman Sep 15, 2026
9ea90a9
test(pdf-codec): pin dict-key names, sort order, and empty-collection…
Mearman Sep 15, 2026
a1dad92
test(pdf-codec): pin passthrough image headers and destination-lookup…
Mearman Sep 15, 2026
3de8fcc
test(pdf-codec): cover outline dict keys, attachment Desc, and AcroFo…
Mearman Sep 15, 2026
0d9126d
test(pdf-codec): cover a checkbox's own /V export-value derivation
Mearman Sep 15, 2026
34eb19e
test(pdf-codec): cover structure element dict keys and the /Lang attr…
Mearman Sep 15, 2026
89dbeac
fix(pdf-codec): drop requiredRepeatCount's redundant empty-extenders …
Mearman Sep 15, 2026
a5cb629
fix(pdf-codec): drop xmp's unreachable absent-capturing-group fallback
Mearman Sep 15, 2026
be3d41b
test(pdf-codec): cover SCALED_COMPONENT_OFFSET applied to a component…
Mearman Sep 15, 2026
5eba492
test(pdf-codec): cover loadMathFont's broken-parse guards
Mearman Sep 15, 2026
6fead9a
test(pdf-codec): cover readOptionalContent's unresolved-OCG and layer…
Mearman Sep 15, 2026
0cc10b8
test(pdf-codec): add direct byte-level coverage for the MATH table pa…
Mearman Sep 15, 2026
32f1b4b
test(pdf-codec): add direct coverage for decodePdfString and parsePdf…
Mearman Sep 15, 2026
a104fad
test(pdf-raster-cpu): raise the unit suite's test timeout past vitest…
Mearman Sep 16, 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