Skip to content

test(pdf-codec): work toward a genuine 100% mutation score - #1270

Draft
Mearman wants to merge 135 commits into
mainfrom
feat/100-percent-mutation-pdf-codec
Draft

Mearman wants to merge 135 commits into
mainfrom
feat/100-percent-mutation-pdf-codec

Conversation

@Mearman

@Mearman Mearman commented Sep 13, 2026

Copy link
Copy Markdown
Member

Working through pdf-codec's Stryker mutation baseline toward a genuine 100% score, no suppression comments.

First landed change: the unit suite's crypto and font tests were timing out under Stryker's instrumented dry run on this shared, heavily-loaded dev machine, not because of a defect in any one test but because instrumentation overhead scales with contention this machine has a lot of. Replaced scattered per-test timeout overrides with a single package-wide UNIT_TEST_TIMEOUT_MS.

Baseline mutation score and breakThreshold derivation still to come once a full run completes.

Progress notes (round 3)

Fixed cff-bounds.ts's charstring-interpreter gaps from 230 down to 122 (scoped --mutate src/cff-bounds.ts --force runs, verified before/after). Also added cmap-table.ts format-12/preference-ranking coverage and builtin-encoding.ts charset/encoding-format coverage (unit-tested, not yet re-verified with a scoped Stryker run this round).

Two things worth knowing before continuing on cff-bounds.ts:

  1. A "must draw first" blind spot recurs across the file. A charstring that draws nothing reports undefined from a successful walk and a failed one alike, so a test for a failure path (a truncated operand, an interpreter ceiling, a reserved operator) that never draws anything first cannot actually distinguish correct behaviour from a return true/return false swap — both assert the same toBeUndefined(). Every remaining "refuses X" test that doesn't already draw something before hitting the failure should get a drawFirst prefix and an assertion that the earlier box is lost, following the pattern in the "propagates a failure...even after this glyph has already drawn something" test.

  2. A few remaining survivors look like genuine equivalent mutants, confirmed by literally applying the mutation and running the whole suite (not just reasoning about it):

    • takeWidth's own leading-width shift (lines ~247-256): every caller either reads only the tail of the stack (moveto family — removing a front element never changes the last k values) or only the stack's length via Math.floor(n/2) (the hint operators — floor(n/2) == floor((n-1)/2) whenever a width was actually present, i.e. n is odd), and endchar's own separate width check doesn't cross its own <4 seac boundary differently either way. Emptying the whole function body still passes all 1867 package tests.
    • Line 359's if (b0 > CHARSTRING_OPERATOR_LIMIT) return false is redundant with the switch statement's own default: return false a few lines down, since every valid operator is documented and enforced to be ≤31 — any byte that reaches this check and fails to decode as an operand necessarily also fails to match any switch case.
    • flex1's own dx/dy accumulators (lines 674-675) are only ever consumed through Math.abs(dx) > Math.abs(dy); flipping += to -= negates a sum that starts at 0, and Math.abs of a negation equals the original, so the comparison's outcome is unconditionally unchanged.

    None of these have an obvious simplification that wouldn't itself reduce robustness for a case this module doesn't currently exercise (e.g. takeWidth remains correct per spec even though no current caller happens to observe its effect) — flagging for a fresh look rather than deleting working, spec-motivated logic to chase the metric.

Remaining in cff-bounds.ts: mostly the curve-extrema math (includeCubicAxis's degenerate/discriminant branches), a handful of loop-boundary arithmetic mutants in the multi-curve operators, and the readPrivateSubrs/header-validation edge cases. The rest of the package (write.ts, gsub-table.ts, the image codecs, etc.) hasn't been touched yet.

@Mearman
Mearman force-pushed the feat/100-percent-mutation-pdf-codec branch 7 times, most recently from 21c66d4 to 3961095 Compare September 14, 2026 19:39
…-machine contention

Individual crypto and font tests carried per-test timeout overrides (60s,
then briefly 300s) sized against an isolated, lightly-loaded run. Under
Stryker's mutation-instrumented dry run, contended against this shared
machine's other concurrent work, both AES-256 key-derivation tests and an
unrelated CFF font-parsing test missed timeouts far larger than their own
uninstrumented cost, proving the slowdown is general contention rather
than any one test's own logic.

Replace the scattered per-test overrides with a single UNIT_TEST_TIMEOUT_MS
applied to the whole unit project in vitest.config.ts, carried through
explicitly into vitest.mutation.config.ts since that file replaces the
base config's test block rather than merging into it. Raise pdf-codec's
dryRunTimeoutMinutes so the whole dry run has room for several worst-case
tests landing in the same run.
… time

The instrumented unit suite's heaviest tests (AES-256 key derivation, the
whole-Unicode-range glyphId enumeration) are already measured to exceed
their own generous timeout under this shared machine's contention;
running several Stryker workers concurrently multiplies that same
contention rather than avoiding it. Scoped to this package's own
stryker.config.ts, per PackageStrykerOptions.concurrency, rather than
lowering the workspace-wide default every other package's mutation run
would then pay for.
…surface

buildSfnt's and buildGsubTable's tag-writing loops iterated a hardcoded
4-character bound and wrote each byte manually; since a Uint8Array
coerces an out-of-range charCodeAt(4) to 0 and the byte was already 0,
an off-by-one bound mutation was byte-for-byte indistinguishable from
the original. Replace both loops with TextEncoder().encode(tag) plus a
single Uint8Array.set, which has no bound to mutate at all.

buildContextFormat1/buildContextFormat2 always passed empty backtrack
and lookahead arrays into the shared chained/non-chained SequenceRule
builder, but the non-chained branch never reads them -- the arrays
were live but their contents unobservable. Split the builder into
buildSequenceRuleBytes (plain, input only) and
buildChainSequenceRuleBytes (backtrack/input/lookahead), so the
non-chained callers no longer construct fields nothing consumes.

buildCmapTable indexed a parallel `encoded` array by position and
guarded the lookup with a throw for undefined, even though the array
is built by mapping over `subtables` one-to-one and can never actually
be short. Zip spec and encoded bytes into one array up front and
iterate that instead, removing the unreachable guard entirely.

buildGdefTable computed `sets` from `markGlyphSets ?? []` unconditionally,
but the fallback only matters when markGlyphSets is undefined, which is
exactly when the value is never read (the IIFE that reads it only runs
when markGlyphSets is defined). Pass markGlyphSets directly into the
IIFE instead of materialising the fallback.
gsub-table.test.ts and gdef-table.test.ts only exercise these builders
indirectly through a real reader, which tolerates a wrong offset or
operator as long as the resulting bytes still parse into something
plausible. Add direct tests against every builder's raw output --
buildSfnt's directory records, all three cmap subtable formats
(including sorting mappings given out of insertion order and a
non-power-of-two segCount for format 4's searchRange/entrySelector/
rangeShift), post v2/v3, coverage/single-subst/ligature sorting and
byte placement, the plain vs chained SequenceRule bodies, format 3's
chained and plain layouts, GSUB's per-feature table placement and
lookup markFilteringSet width across all three flag cases, and GDEF's
v1.0/v1.2 header selection -- closing the coverage/precision gap the
indirect tests left around each builder's own arithmetic.

Also removes buildGdefTable's now-redundant `withSets` variable,
missed when the previous commit collapsed its two `=== undefined`
checks into one.
…tant gaps

buildCoverageFormat2 had no direct test at all, leaving every field
write and the running coverageIndex accumulation across ranges
unverified. Add a test with two ranges asserting the second range's
coverage index carries the first range's real glyph count forward.

buildFormat0's explicit view.setUint16(0, 0) wrote a value the buffer
already held from Uint8Array's own zero-initialization -- removing the
call changes nothing observable, so delete it rather than leave a
mutation target with no real behaviour to test.

putSequenceRuleTail's return value was unused by its one caller,
leaving the arithmetic that computed it untestable by construction.
Inline it into buildSequenceRuleBytes (its only caller) and drop the
dead return entirely, since a "shared" tail with exactly one caller
was never actually shared.

Strengthen the markFilteringSet lookup tests to check the lookup's own
byte length and the untouched subtable content, not just the recorded
offsets -- a wrongly-forced markFilteringSetWidth can leave the
recorded offsets self-consistent while still corrupting or
mis-sizing the bytes that follow. Also assert feature 0's own
lookupIndices values in the multi-feature layout test, previously only
checked for feature 1.
The KSA's state[i] = i loop bounded i < STATE_SIZE, but a typed array
silently drops an out-of-range integer-index write -- state[256] = 256
on a 256-entry Uint8Array is a no-op -- so a loop bound mutated to
i <= STATE_SIZE produced byte-for-byte the same state array, an
equivalent mutant no test could ever distinguish. Uint8Array.from's own
length argument builds the identical array with no comparison operator
for a mutation to target.
The big-endian bit-length loop stopped early once bitLength reached 0,
via `i < lengthBytes && bitLength > 0`. padded is already zero-filled,
so writing 0 % 256 into the remaining length-field bytes is a no-op,
and a JS number's own 2^53 precision ceiling never needs more than 7 of
SHA-256's 8 (or SHA-512's 16) length bytes to represent -- no reachable
message ever runs the loop far enough for the bitLength > 0 half of the
guard to be what stops it. Drop it and let the loop run its full
lengthBytes iterations unconditionally.
Every magnitude that could round to the literal string "-0" at
NUMBER_DECIMAL_PLACES -- including -0 itself -- already satisfies
abs(n) < NUMBER_EPSILON and returns "0" from the guard above, since
NUMBER_EPSILON is exactly one unit in the last of those decimal places.
toFixed can only produce "-0.0000" for a magnitude below half that
unit, which is caught by the same guard. The ternary comparing the
stripped string against "-0" was therefore dead code no input could
ever reach.
…y method

buildEncryptor's own method parameter is already narrowed to
Extract<CipherMethod, "rc4" | "aes"> (every SCHEME_SPECS entry only
ever carries one of those two), so the "identity" branch inside
applyEncryptMethod could never be reached through any real call path
in this module. Narrow its parameter to match and delete the dead
branch, rather than leave a comparison no test could ever exercise.
isBlack computed (a + b) % 2 === 0 from a = x/2|0 and b = y/2|0. Sum and
difference of two integers always share the same parity, so an
ArithmeticOperator mutation to a - b produces byte-for-byte the same
checkerboard no decoded bitmap could ever distinguish. Compare (a & 1)
against (b & 1) directly instead, leaving no arithmetic operator for
that mutation to target.
…apping

The outer per-component loop bounded c < fixture.componentCount, but an
off-by-one bound there would silently append one extra all-zero plane
(every index inside it reads past the end of `bytes`, and `?? 0`
swallows the resulting undefined) -- a difference visible only in the
returned array's own length, which nothing calling this helper actually
re-checks. Build the planes with Array.from's own length argument
instead of a counted for-loop, removing the vulnerable comparison
outright.
The existing test only checked the thrown value's constructor, leaving
both string literals passed to the DOMException constructor
unverified.
The existing CFF2 case (header size 5, no valid Name INDEX past it)
also fails for reasons unrelated to the majorVersion check, so removing
that check entirely left the test still passing. Add a case with
majorVersion 2 but an otherwise valid CFF 1.0 layout -- headerSize 4, a
readable Name INDEX, a plain Top DICT -- where the version check is the
only thing standing between it and a wrongly-defined probe result.
…trics

parseHhea's numberOfHMetrics === 0 guard had no test forcing it: every
existing case used a real font whose hhea always declares at least one
metric. Patch Carlito's own hhea table directly (a new patchU16InTable
helper alongside the existing dropTable/truncateTable) to zero that
field and confirm loadEmbeddedFace refuses the font.
Nothing exercised the columns <= 0 || rows <= 0 early return, including
the negative-rows case, which the ||-composed guard treats identically
to zero.
The round-trip fixture never included a form field, leaving
LayoutFormFieldSchema's own z.enum(fieldType) array (and every other
field on the schema) unparsed by any test in this file. Add a text
field and a group with one nested checkbox child to the fixture.
Neither had a test: no call ever passed an explicit level to deflate
(so the { level } object literal it builds had no coverage), and
MAX_INFLATE_OUTPUT_BYTES's throw was unreached by any real input. Mock
unzlibSync's return value for the size-guard case rather than actually
decompressing half a gigabyte on every one of this suite's mutation
runs -- the guard only ever reads the result's .length.
readFilespec's own missing-stream warning had no test: every existing
filespec fixture either resolved a real embedded stream or never
declared /EF at all. Add a catalog /AF entry whose /EF resolves to an
empty dict, and assert both the dropped attachment and the emitted
diagnostic.
…rals

Four literals -- the empty stream dict "<< >>", marked-content "EMC",
PDF version "1.4", and the Helvetica font dict -- were each retyped
verbatim at every one of dozens of call sites across independent
fixture functions. Since every copy is its own separate string literal
to the type checker (and to Stryker's mutation testing, its own
separate mutation target), a single fixture author typo in any one
copy would silently diverge from the rest with nothing to catch it.

Name each one once (EMPTY_DICT, EMC, PDF_1_4, HELVETICA_FONT_DICT) and
reference it everywhere it recurred, matching the existing
HELLO_CONTENT constant's own pattern. Also drop the redundant explicit
.header("1.7") argument at call sites that were only ever restating
FixtureBuilder.header's own default value.
isTrueTypeCollection's hasBytes(bytes, 0, 4) && u32(...) check had two
survivable mutants: forcing either side to a bare `true` made every
parse failure misreport as a TrueType Collection, and the existing
"generic parse failure" test could not catch it because the source
label it asserted on ("not-a-font.bin") also appears verbatim inside
the TTC message. Assert the actual generic wording instead, and add a
buffer too short to hold even the 4-byte tag -- hasBytes' own job is
keeping that case from ever reaching u32, which would throw past this
file's bounds rather than yield a clean FontFaceParseError.

Also assert FontFaceParseError's own .name, which nothing checked.
parseHmtx had no test file at all -- every existing exercise of it went
through embedded-font.ts's own guard, which already refuses a font
before hmtx's zero-metrics and missing-table throws could ever run.
Cover advance-width lookup, the last-entry fallback for glyph IDs past
numberOfHMetrics, both missing-table throws, and the zero-metrics
throw directly against hand-built hhea/hmtx bytes.
…fallback

Every existing font fixture named an explicit /BaseFont, leaving the ??
"Helvetica" default unreachable for both the simple and composite font
builders. Also cover readCidWidths' malformed-leading-operand recovery
(i++; continue), which had no test where a /W array actually contained
a non-numeric c/cFirst entry.
…case

The one existing filled-double-stroke test never set fillRule, so the
evenodd -> "f*" branch had no coverage. Also cover averageNormal's
zero-length case directly: an open path that goes out and immediately
reverses along the same line gives its shared vertex two exactly
opposite chord normals, which sum to the zero vector rather than a
divide-by-zero -- that vertex stays at its original coordinates on
both offset copies while the two open ends still move along their own
single chord's normal.
loadFace took a separate name string purely to key its cache Map, with
no other use -- every StringLiteral mutant on those five names was
unobservable through any of the exported getters, since nothing else
ever looked up that key. Each face's deflated-base64 constant is
already a unique identifier, so keying the cache by that directly
removes the redundant parameter along with every mutant on it, and a
new test-support/fonts.test.ts covers the caching behaviour itself
(identical instance on a repeat call, real bytes on a first call,
distinct bytes across two different faces).
… to their one caller

Both were module-level constants read by exactly one function each. A
module-level initializer only runs once per process, so any mutation
to it is only ever active during that single, already-passed
evaluation -- no later test run can observe a difference no matter
what it asserts, since the correctly-evaluated value is what every
subsequent call sees regardless of which mutant is nominally active.
Moving each into its one caller's body makes it re-evaluate per call,
where it is reachable again, and a new case-insensitivity test for
stripStyleSuffix's regex flag covers the one gap that reachability
alone didn't already close.
Adds diagnostic-message assertions to the existing bfchar/bfrange
truncation tests, plus new cases for: a stray non-end keyword mid
bfchar/bfrange section (proving the end check is genuinely comparing
against the exact keyword, not any keyword), a bfchar/bfrange entry
whose destination or high-end token isn't a hex string, a bfrange
single destination too short to carry one UTF-16BE code unit, a base
unit whose high byte is non-zero (the previous fixture's 0x0041 base
unit couldn't distinguish a wrong byte offset from the right one since
its high byte was already zero), a bfrange destination that is neither
a hex string nor an array, and an array destination truncated before
its closing bracket.
…and cover every branch

SEMANTIC_SUBTYPES and OWNED_ELSEWHERE_SUBTYPES were module-level Sets
each read by only that one function, which put every one of their
entries beyond Stryker's reach for the same reason recorded for
font-style.ts. Moves both into the function body.

Also adds the coverage the file was missing entirely: a bare
Link/FileAttachment/Widget/Popup annotation is genuinely skipped, a
non-Text annotation is never mistaken for the presenter-notes marker
just because its /T matches, an annotation missing /Contents, /T, and
/M omits those keys outright rather than carrying them as undefined,
markupFields rejects both a too-short and an empty (below-8-but-
already-a-multiple-of-8) /QuadPoints, and a missing /Rect reports its
diagnostic with the right code and message before the entry is
dropped. roundtrip.test.ts's existing hidden-notes test now also
asserts the annotation itself never leaks into the annotations list,
not only that its kind is excluded from visible LayoutItems.
Every existing caller round-trips assemblePdf's output through this
package's own readPdf, which tolerates a mangled xref table, missing
trailer/startxref/%%EOF markers, and wrong offsets via its recovery
scan -- so none of them could ever observe assemblePdf itself writing
the wrong marker, sort order, xref count, offset width, or trailer
field. Adds a dedicated suite that decodes the raw output bytes and
asserts on them directly: ascending object order regardless of input
order, the exact header/marker literals, the xref subsection count,
each entry's fixed-width zero-padded offset pointing at that object's
real byte position, and the trailer's /Size and /Root values.
…gainst its own subtable write

The eager markFilteringSet write and the subtable-placement loop
target the same byte offset whenever no markFilteringSet slot is
reserved, and the loop always runs after the write -- so a wrong
guard there is invisible for every lookup with at least one subtable,
since the real subtable bytes simply overwrite whatever the guard
wrote first. A lookup with zero subtables has nothing to overwrite it
with, so the same wrong guard instead writes two bytes straight past
the end of a table sized for no such slot. Simplifies the flag check
itself: bitwise AND already coerces an absent flag to 0, so the
explicit undefined check was redundant with no observable behaviour
of its own, and removing it removes an unkillable mutant along with
it. A new empty-subtables test covers the guard directly.
Every real standard-14 AFM defines a width for every WinAnsi-mapped
glyph, so this guard's throw is unreachable through the public API
with real data -- it exists purely as a caller-invariant check against
a future data gap. STANDARD_METRICS is already exported for testing
(the monospace short-circuit spy above it does the same), so this
deletes one real widths entry, exercises the guard, and restores it.
…/CFF-table guards

Adds a dedicated test-support/cff.test.ts (none existed before) that
pins cffIndex's offSize selection at each exact boundary (0xff, 0xffff,
0xffffff) rather than relying on incidental coverage from unrelated
charstring tests. Extracts stixMathCffBytes's two guard clauses into a
new cffTableFromSfnt so they can be driven directly against a small
synthetic sfnt built with buildSfnt -- neither guard is reachable
through the one real 691 KB vendored asset, which always parses
successfully. Also stops cffFontWithCharstrings's local-subrs default
from going through an always-unreachable ?? fallback: hasPrivate is
already exactly the same check as the default's own condition, so
narrowing directly on options.localSubrs lets TypeScript rule the
fallback value out rather than leaving dead code behind it.
…erence ranking

buildCmapLookup's preferenceRank ranked format 12 subtables and a (3, 10)/(0, *)
platform preference among competing subtables, but no test ever built a format 12
subtable or a font with more than one candidate subtable, leaving that ranking and
the whole format 12 reader (parseFormat12, its own header/group-array truncation
guards, and its forEachMapping clamp to the last valid Unicode code point)
unexercised. Also covers format 4's own idRangeOffset !== 0 glyph-index-array path
(only the idDelta-only path had a fixture), a subtable in an unsupported format
being dropped without disturbing its siblings, and a cmap whose own subtable-record
array or an individual record's offset doesn't fit the table.
…operators

Extends the hand-built charstring fixtures to cover paths the vendored real
STIX Two Math font's own well-formed charstrings never reach: the flex family
(escape 12 35/34/36/37) and each one's own too-short-stack guard, hmoveto and
vmoveto (the font's own charstrings apparently never use either, always
preferring rmoveto), the 16.16 fixed-point and positive/truncated 16-bit
integer operand forms, callgsubr's own global-subroutine and bias selection
(previously only exercised through callsubr's local one), a failure several
levels deep in the charstring still propagating even once the glyph has
already drawn something, vvcurveto's leading cross-axis delta applying to
only the first of several curves, and the exact off-by-one boundaries of the
subroutine-nesting depth, operand-stack size, per-glyph operation ceiling, and
the rlineto/rcurveline/rlinecurve/hstem-width-parity loops. Also adds the
Global Subrs INDEX's own medium-to-large bias threshold at 33900 entries,
the mirror of the existing Local Subrs 1240-entry test.

Moves the shared boundsOfOnlyGlyph fixture helper to module scope so both
charstring-interpreter describe blocks can use it, and adds an enc() helper
that picks whichever of the two numeric operand encodings a given value needs.
…PFB form

readCffCharset and readCffEncoding each read three on-disk shapes (format
0/1/2 for the charset, format 0/1 plus an optional supplement for the
encoding), but every existing fixture built only format 0 of each, and the
predefined ISOAdobe charset and predefined StandardEncoding paths (a font
stating neither operator at all) had no fixture either. Also covers a
PFB-segmented Type 1 program (a 6-byte binary segment header ahead of the
same cleartext this module already reads for a bare PFA program) and a
program whose cleartext header never reaches an eexec marker at all.

Generalises cffFontWithBuiltinEncoding's own fixture builder to choose the
charset and Encoding format, and to add the Encoding's own supplementary
code -> SID entries, rather than always emitting format 0 of each.
…uccess observable

A charstring that draws nothing reports undefined from a successful walk and
from a failed one alike, so a boundary test that never draws (the operand-
stack, operation-count, and truncated-operand cases) cannot actually tell a
correct exact-boundary success apart from an off-by-one bug that rejects it
one iteration early -- both assert the same toBeUndefined(). Adds a trailing
line draw after each boundary so success produces a real, checkable box, and
extends the existing "failure still propagates once something is drawn" case
to the operation ceiling, operand-stack overflow, subroutine-depth overflow,
and a truncated operand, which shared the identical blind spot.
WINANSI_GLYPH_NAMES defines a glyph name for every code in FIRST_CHAR..LAST_CHAR
(unassigned CP1252 positions get a placeholder name like "bullet" rather than an
empty string), and every standard-14 AFM table defines a width for every glyph
name that table can produce. widthOfCode() therefore never throws across the
full range for any of the 12 faces, so the "no WinAnsi glyph mapping" fallback
to a 0 width was unreachable dead code. buildFontObjects now calls widthOfCode
directly.
…ace, and embedded formulas

writePdf's own doc.metadata fields (title/author/subject/keywords/creator/
createdIso/modifiedIso), computeFontFlags' fixed-pitch/serif/italic/force-bold
bits, the full real AFM Widths array, and prepareJpegImage's colour-space and
CMYK /Decode-inversion branches had no coverage at all. options.formulas --
writePdf's own side channel for embedded-math-font content -- had never been
exercised through writePdf itself, only through math-content-write.ts and
math-font-write.ts's own lower-level unit tests, leaving the actual object
allocation, resource-dict wiring, and per-page content-stream routing
untested.
destinationViewArray's fitH/fitV/fitR/fitB/fitBH/fitBV branches (and the
plain 'fit' case), each with and without their own optional coordinates, had
no coverage -- only the default 'xyz' view was ever round-tripped through an
internal link. Also covers resolveDestinationArray's own "page index beyond
the document" guard, which had no test at all.
…axis extrema

rmoveto had no test at all, and neither hmoveto nor vmoveto was tested with
its own optional leading width operand present -- takeWidth's evenArgs=false
branch (moveto's own arity-based width detection) was entirely unexercised.

includeCubicAxis's own root-finding had two branches with no direct hand-built
coverage: the genuinely non-degenerate quadratic case with two distinct real
roots, and the degenerate (a === 0) linear fallback a curve with collinear
control points on one axis produces. The real STIX Two Math font's own glyphs
exercise curve extrema in general, but not these two specific coefficient
shapes.
…rities

endchar's own arity rule (its leading width shows up as exactly 1 or 5
operands, distinct from every other stack-clearing operator's takeWidth-based
detection) only had a test for the 4-operand bare-seac case. A bare width
(1 operand) and a width-plus-seac (5 operands) were both untested, so neither
of those two boundary values on stack.length was actually exercised.
… boundaries

A scoped mutation run against write.ts (after the earlier coverage additions)
surfaced a large batch of Survived mutants in code that was now reached but
not precisely asserted:

- Image XObject dict entries (Type/Subtype/BitsPerComponent/Columns/Rows/
  BlackIs1) were exercised but never checked, so a wrong or blanked-out key
  name went unnoticed.
- The CMYK JPEG /Decode inversion only ever ran against 4-component assets,
  so the "info.components === 4" guard itself was never independently
  proven -- a 3-component asset with a matching Adobe transform now confirms
  the guard, not just the transform value, gates the inversion.
- Font and image resource naming ("/F1", "/Im1") was only checked for
  existence, never for which underlying object each name actually pointed
  at, so removing the sort-by-name/sort-by-id step left the tests green.
- Several "only when non-empty" branches (optional-content ON/OFF arrays,
  AcroForm's own field-count guard) were exercised exclusively with
  non-empty input, so the boundary itself was never distinguished from an
  unconditional branch.
- A group AcroForm field carrying more than one widget must stay a single
  object (multi-widget splitting is a terminal-field concept); nothing had
  ever exercised a group with more than one widget to prove that guard is
  real, as opposed to redundant.
- objectContainsReference's dict and stream branches were exercised only
  through an array-shaped residue row.
…-by-name

preparePassthroughImage's own /Type, /Subtype, /ColorSpace, and
/BitsPerComponent entries were never checked directly, and the JBIG2-vs-JPX
branch (objectContainsReference's own filter === "jbig2" check) had no test
proving a JPX asset gets neither key -- only that a JBIG2 one gets both.

resolveDestinationArray's own destination lookup was only ever exercised with
a single-entry destinations table, so a predicate that ignored the name
entirely and returned the first entry would have passed unnoticed; the
internal-link Annot dict's own /Type, /Border, and error-message text were
similarly unchecked.
…rm /FT and /Ff

The outline tree's own /Type, /Parent, /Prev, /Next, /First, /Last, and
/Count entries were exercised by the existing round-trip test but never
checked directly -- read.ts's own outline walk doesn't depend on most of
them, so a wrong or blanked-out key name went unnoticed.

An attachment's optional /Desc entry was only ever exercised with a
description present, so the "carries no description" branch was never
distinguished from an unconditional one; radio, button, and signature field
types had no test naming their own /FT value; and the /Ff flag bits
(read-only, pushbutton, radio, combo) had no test at all, independently or
combined.
checked/value only had one combination exercised (checked: true with no
value); the field's own three other meaningfully distinct outcomes -- an
unchecked box, a box with neither flag set, and an explicit export value
overriding checked in either direction -- had no test naming the /V PDF name
each one actually produces.
…ibute

/Type /StructElem, /P (parent reference), and the per-element /Lang override
were exercised by the existing round-trip test but never checked directly --
read.ts's own structure walk doesn't depend on /Type or /P at all, so a
wrong or blanked-out key name went unnoticed, and no test carried a language
attribute at all.
…guard

Summing zero extender parts is exactly 0, and extenders.length *
minConnectorOverlap is exactly 0 too when extenders is empty, so
growthPerRepeat is always 0 in that case and the growthPerRepeat <= 0
guard already returns the same minimum on its own. Add a test proving
the no-parts case still returns undefined rather than a hollow
zero-size construction.
The regex's capturing group is not itself optional, so a successful
match always populates match[1] (with the empty string in the
degenerate zero-width case) -- there is no absent-group case for the
?? "" fallback to actually handle.
…'s own placement offset

No real vendored composite in this suite's own fonts ever sets
SCALED_COMPONENT_OFFSET (bit 11) without also setting
UNSCALED_COMPONENT_OFFSET (bit 12) -- Microsoft's own OpenType
toolchain never emits that combination, only Apple's does -- so this
placement path was only reachable through a hand-built fixture.
Exercises the unreadable-sfnt, missing-required-table, and
no-readable-cmap-subtable error paths -- invariant checks on this
package's own build output, never reachable through the real vendored
font. loadMathFont() caches its result in a module-scoped variable, so
each case uses vi.resetModules() plus a dynamic re-import to get a
clean, uncached instance, with vi.doMock failing exactly one real
dependency while every other real parser still runs underneath it.
…-naming gaps

Adds direct coverage, against a synthetic catalog, for an /OCGs entry
that fails to resolve to a dictionary (reported and skipped), and for
mintLayerName skipping an already-claimed layerN name so two distinct
groups can never collide onto the same layer.
…rser

Builds a minimal but structurally real 'MATH' table field-by-field --
the 10-byte header, a zero-filled MathConstants subtable, a zero-filled
MathGlyphInfo subtable, and an optional MathVariants subtable built
from a per-axis coverage/construction description -- the same
not-mocked, real-byte-parsing approach cmap-table.test.ts's own
buildFontWithCmapSubtable already uses.
…Date

Covers decodePdfString's UTF-16BE-with-BOM, plain-ASCII, and
zero-byte paths, and parsePdfDate's undefined/non-date/full/partial
inputs -- including ISO 32000-1 7.9.4's every-field-after-the-year
default and the per-field partial-default cases (a year-only date, a
year+month+day date, and a date with a sign and hour but no offset
minute).
@Mearman
Mearman force-pushed the feat/100-percent-mutation-pdf-codec branch from 9d0f97e to 7858055 Compare September 15, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant