From 81db549c630c1a29acc62afb5cd9d22bf2c12bc7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:15:57 +0100 Subject: [PATCH 01/17] refactor(document-schema.js): drop a1 range-reference's redundant ternary for the unbounded half parseRangeReference computed endRaw with the same separatorIndex-based ternary as startRaw, but when separatorIndex is -1, ref.slice(separatorIndex + 1) already equals ref.slice(0) (the whole string) -- the value the ternary's other branch would have picked anyway. It was never a genuine second condition, just a restatement of what slice's own arithmetic already produces. --- packages/document-schema.js/src/a1.test.ts | 17 +++++++++++++++++ packages/document-schema.js/src/a1.ts | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/document-schema.js/src/a1.test.ts b/packages/document-schema.js/src/a1.test.ts index d0d1d2f01b..58e239fa63 100644 --- a/packages/document-schema.js/src/a1.test.ts +++ b/packages/document-schema.js/src/a1.test.ts @@ -32,6 +32,13 @@ describe("columnLettersToIndex / columnIndexToLetters", () => { expect(columnLettersToIndex("A1")).toBeUndefined(); expect(columnLettersToIndex("")).toBeUndefined(); }); + + it("rejects the character immediately past 'Z' in code-point order, not just past it", () => { + // '[' is charCode 91, exactly ALPHABET_START_CODE (65) + ALPHABET_SIZE (26) -- the boundary itself, not one past it. 'Z' (90) must still be accepted. + expect(columnLettersToIndex("Z")).toBe(25); + expect(columnLettersToIndex("[")).toBeUndefined(); + expect(columnLettersToIndex("A[")).toBeUndefined(); + }); }); describe("parseCellReference / cellReference", () => { @@ -52,6 +59,16 @@ describe("parseCellReference / cellReference", () => { expect(parseCellReference("A0")).toBeUndefined(); expect(parseCellReference("")).toBeUndefined(); }); + + it("requires the letters to start at the very beginning of the string", () => { + // Without the regex's leading anchor, "A1" embedded after a leading digit would still match. + expect(parseCellReference("1A1")).toBeUndefined(); + }); + + it("requires the digits to run to the very end of the string", () => { + // Without the regex's trailing anchor, a leading "A1" would still match despite trailing junk. + expect(parseCellReference("A1B")).toBeUndefined(); + }); }); describe("parseRangeReference / rangeReference", () => { diff --git a/packages/document-schema.js/src/a1.ts b/packages/document-schema.js/src/a1.ts index f2b83bc93e..f4ee0b85a0 100644 --- a/packages/document-schema.js/src/a1.ts +++ b/packages/document-schema.js/src/a1.ts @@ -70,7 +70,8 @@ export interface CellRange { export function parseRangeReference(ref: string): CellRange | undefined { const separatorIndex = ref.indexOf(":"); const startRaw = separatorIndex === -1 ? ref : ref.slice(0, separatorIndex); - const endRaw = separatorIndex === -1 ? ref : ref.slice(separatorIndex + 1); + // Unconditional, unlike startRaw above: when separatorIndex is -1, separatorIndex + 1 is 0, so ref.slice(separatorIndex + 1) is ref.slice(0), which is ref itself -- the same value the startRaw-style ternary would have picked for this half anyway. + const endRaw = ref.slice(separatorIndex + 1); const start = parseCellReference(startRaw); const end = parseCellReference(endRaw); if (start === undefined || end === undefined) { From 668a9ee9225dc73907bf78ae82c1d3e5c7827529 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:16:07 +0100 Subject: [PATCH 02/17] refactor(document-schema.js): strip a hex colour's leading # by position, not a regex capture rgbHexToColor matched the whole (optionally hashed) string against one regex with a capture group, then re-checked the capture for undefined even though a successful match always populates it. Splitting the '#' strip into a plain startsWith/slice and validating the remaining six digits against their own pattern removes the redundant capture-group check and needs no match-array indexing at all. --- packages/document-schema.js/src/color.test.ts | 108 ++++++++++++++++++ packages/document-schema.js/src/color.ts | 12 +- 2 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 packages/document-schema.js/src/color.test.ts diff --git a/packages/document-schema.js/src/color.test.ts b/packages/document-schema.js/src/color.test.ts new file mode 100644 index 0000000000..e414ec7487 --- /dev/null +++ b/packages/document-schema.js/src/color.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + COLOR_BLACK, + ColorSchema, + colorToRgbHex, + rgbHexToColor, +} from "./color"; + +describe("ColorSchema", () => { + it("accepts a colour whose components are within 0..1", () => { + expect(ColorSchema.safeParse({ r: 0, g: 0.5, b: 1 }).success).toBe(true); + }); + + it("rejects a component below 0 or above 1", () => { + expect(ColorSchema.safeParse({ r: -0.01, g: 0, b: 0 }).success).toBe(false); + expect(ColorSchema.safeParse({ r: 1.01, g: 0, b: 0 }).success).toBe(false); + }); +}); + +describe("COLOR_BLACK", () => { + it("is exactly r=0, g=0, b=0", () => { + expect(COLOR_BLACK).toStrictEqual({ r: 0, g: 0, b: 0 }); + }); +}); + +describe("rgbHexToColor", () => { + it("parses a 6-digit hex colour with a leading '#'", () => { + expect(rgbHexToColor("#ff0080")).toStrictEqual({ + r: 1, + g: 0, + b: 128 / 255, + }); + }); + + it("parses a 6-digit hex colour with no leading '#'", () => { + expect(rgbHexToColor("ff0080")).toStrictEqual({ + r: 1, + g: 0, + b: 128 / 255, + }); + }); + + it("is case-insensitive", () => { + expect(rgbHexToColor("FF0080")).toStrictEqual(rgbHexToColor("ff0080")); + }); + + it("reads each byte from its own two-digit slice, not the whole 6-digit string", () => { + // A value that would produce a completely different result if slice(0,2)/(2,4)/(4,6) collapsed to parsing the whole "digits" string for every channel. + const color = rgbHexToColor("010203"); + expect(color.r).toBe(0x01 / 255); + expect(color.g).toBe(0x02 / 255); + expect(color.b).toBe(0x03 / 255); + }); + + it("divides each byte by 255, not multiplies", () => { + expect(rgbHexToColor("ffffff")).toStrictEqual({ r: 1, g: 1, b: 1 }); + expect(rgbHexToColor("000000")).toStrictEqual({ r: 0, g: 0, b: 0 }); + }); + + it("throws with the offending value named, on a string too short to be 6 hex digits", () => { + expect(() => rgbHexToColor("#fff")).toThrow( + "not a 6-digit hex colour: #fff", + ); + }); + + it("throws on a string too long to be 6 hex digits", () => { + expect(() => rgbHexToColor("#ff00801")).toThrow( + "not a 6-digit hex colour: #ff00801", + ); + }); + + it("throws on non-hex characters", () => { + expect(() => rgbHexToColor("#gggggg")).toThrow( + "not a 6-digit hex colour: #gggggg", + ); + }); + + it("throws on a bare '#' with no digits at all", () => { + expect(() => rgbHexToColor("#")).toThrow("not a 6-digit hex colour: #"); + }); +}); + +describe("colorToRgbHex", () => { + it("is the exact inverse of rgbHexToColor for a value that divides evenly", () => { + expect(colorToRgbHex({ r: 1, g: 0, b: 128 / 255 })).toBe("ff0080"); + }); + + it("round-trips through rgbHexToColor for black and white", () => { + expect(colorToRgbHex(rgbHexToColor("000000"))).toBe("000000"); + expect(colorToRgbHex(rgbHexToColor("ffffff"))).toBe("ffffff"); + }); + + it("multiplies each component by 255 before rounding, not divides", () => { + expect(colorToRgbHex({ r: 1, g: 1, b: 1 })).toBe("ffffff"); + }); + + it("zero-pads a byte that hex-encodes to a single digit", () => { + // 1/255 * 255 = 1, which toString(16) renders as the single character "1" -- this only reads "01" back out if padStart actually pads with a leading zero. + expect(colorToRgbHex({ r: 1 / 255, g: 0, b: 0 })).toBe("010000"); + }); + + it("produces a lowercase 6-digit string with no leading '#'", () => { + const hex = colorToRgbHex({ r: 1, g: 0, b: 0 }); + expect(hex).toBe("ff0000"); + expect(hex).not.toContain("#"); + expect(hex).toBe(hex.toLowerCase()); + }); +}); diff --git a/packages/document-schema.js/src/color.ts b/packages/document-schema.js/src/color.ts index 6dcab577e4..72baa9cf0e 100644 --- a/packages/document-schema.js/src/color.ts +++ b/packages/document-schema.js/src/color.ts @@ -12,17 +12,13 @@ export type Color = z.infer; export const COLOR_BLACK: Color = { r: 0, g: 0, b: 0 }; -const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/; +const HEX_DIGITS_PATTERN = /^[0-9a-fA-F]{6}$/; const HEX_BYTE_MAX = 255; -// Parses a 6-digit hex colour (OOXML's w:color/@w:val, a:srgbClr/@val; ODF's fo:color), with or without a leading '#', into a Color. Throws on malformed input rather than substituting a default -- callers are expected to have already validated the attribute is present. +// Parses a 6-digit hex colour (OOXML's w:color/@w:val, a:srgbClr/@val; ODF's fo:color), with or without a leading '#', into a Color. Throws on malformed input rather than substituting a default -- callers are expected to have already validated the attribute is present. Strips a leading '#' by its own literal position rather than a regex capture group, so there is no capture-group result to separately check for absence -- `digits` is validated as a plain string, never indexed out of a match array. export function rgbHexToColor(hex: string): Color { - const match = HEX_COLOR_PATTERN.exec(hex); - if (match === null) { - throw new Error(`not a 6-digit hex colour: ${hex}`); - } - const digits = match[1]; - if (digits === undefined) { + const digits = hex.startsWith("#") ? hex.slice(1) : hex; + if (!HEX_DIGITS_PATTERN.test(digits)) { throw new Error(`not a 6-digit hex colour: ${hex}`); } const r = Number.parseInt(digits.slice(0, 2), 16); From 512a064c58d3d00ba7659a85934c2c5ce54e8bdf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:16:18 +0100 Subject: [PATCH 03/17] refactor(document-schema.js): drop isGroupWrapper's unreachable node-record guard isGroupWrapper checked isRecord(value.node) before validating the rest of a wrapper's shape, but every one of its nine call sites only invokes it after value.node has already passed a real Zod object schema's own safeParse -- which can only succeed against a genuine non-null, non-array record. The guard could never actually reject anything at any real call site, so it was dead defensive code rather than a load-bearing check. --- .../src/package-node.test.ts | 50 +++++++++++++++++++ .../document-schema.js/src/package-node.ts | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/document-schema.js/src/package-node.test.ts b/packages/document-schema.js/src/package-node.test.ts index 00a905ff6b..483ff6873d 100644 --- a/packages/document-schema.js/src/package-node.test.ts +++ b/packages/document-schema.js/src/package-node.test.ts @@ -849,3 +849,53 @@ describe("the package tree rejects near-misses", () => { expect(isTreeLeaf(wrapper)).toBe(false); }); }); + +describe("a group wrapper's own `node` must be a plain, non-null, non-array record", () => { + it("rejects a wrapper whose node is null", () => { + expect( + SectionGroupSchema.safeParse({ node: null, children: [] }).success, + ).toBe(false); + }); + + it("rejects a wrapper whose node is an array, even though typeof an array is 'object'", () => { + expect( + SectionGroupSchema.safeParse({ node: [], children: [] }).success, + ).toBe(false); + }); + + it("rejects a wrapper whose node is a primitive", () => { + expect( + SectionGroupSchema.safeParse({ node: "not-a-record", children: [] }) + .success, + ).toBe(false); + expect( + SectionGroupSchema.safeParse({ node: 42, children: [] }).success, + ).toBe(false); + }); + + it("rejects a wrapper with no node field at all", () => { + expect(SectionGroupSchema.safeParse({ children: [] }).success).toBe(false); + }); +}); + +describe("every group guard rejects a wrapper VALUE that isn't itself a plain record, before ever reading its own .node", () => { + // Each guard's own top-level isRecord(value) check runs before value.node is ever read -- for null/undefined specifically, skipping straight to `value.node` would throw a TypeError rather than return false, so this is the one guard clause a malformed non-object input actually depends on for a clean `false` rather than a crash. A node-only test (the describe block above) can never reach this: SectionDescriptorSchema.safeParse(value.node) already requires value.node to be a real object to succeed at all, so by the time isGroupWrapper's own isRecord(value.node) check would run, value.node is already guaranteed to satisfy it. + it("rejects null and undefined without throwing", () => { + expect(() => SectionGroupSchema.safeParse(null)).not.toThrow(); + expect(SectionGroupSchema.safeParse(null).success).toBe(false); + expect(SectionGroupSchema.safeParse(undefined).success).toBe(false); + expect(isTreeNode(null)).toBe(false); + expect(isTreeGroup(null)).toBe(false); + }); + + it("rejects an array, even though typeof an array is 'object'", () => { + expect(SectionGroupSchema.safeParse([]).success).toBe(false); + expect(isTreeGroup([])).toBe(false); + }); + + it("rejects a primitive", () => { + expect(SectionGroupSchema.safeParse("a string").success).toBe(false); + expect(SectionGroupSchema.safeParse(42).success).toBe(false); + expect(isTreeGroup("a string")).toBe(false); + }); +}); diff --git a/packages/document-schema.js/src/package-node.ts b/packages/document-schema.js/src/package-node.ts index c4a9a747cb..005dcfed26 100644 --- a/packages/document-schema.js/src/package-node.ts +++ b/packages/document-schema.js/src/package-node.ts @@ -184,11 +184,11 @@ function isRecord(value: unknown): value is Record { } // The shared wrapper shape every group guard checks: a record whose `node` is itself a record, whose `children` is an array of values each satisfying that group kind's own child predicate, whose optional `style` ref is a string when present, and which carries no other keys -- every group fragment in content-json-schema-defs.ts declares additionalProperties: false over exactly { node, style, children }, so a wrapper with any fourth key must fail here too, or documentFromJson would accept a value the published .schema.json rejects. Per-kind child predicates (not one generic isTreeNode) are what make these guards the untrusted-input boundary: a tree that hangs a paragraph leaf directly off a slide group, or a section group off a sheet, is structurally illegal and rejects here, where the reference implementation's own guard checks children generically (it walks trees it constructed itself; this schema's job is to validate trees it did not). +// No isRecord(value.node) check here: every one of this function's nine call sites (isSectionGroupNode through isShapeConstructGroupNode) only ever calls isGroupWrapper after that same value.node has already passed a real Zod object schema's own safeParse (SectionDescriptorSchema, HeadingParagraphSchema, ConstructDescriptorSchema, ...), and a z.object() schema's safeParse can only succeed against a genuine non-null, non-array record -- so value.node is already guaranteed to be one by the time this function runs, for every real call in this codebase. function isGroupWrapper( value: Record, isChild: (child: unknown) => boolean, ): boolean { - if (!isRecord(value.node)) return false; if (value.style !== undefined && typeof value.style !== "string") return false; if ( From bf6b2533d2e20bf48afb1b1bfead584b0a60cc0e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:16:36 +0100 Subject: [PATCH 04/17] refactor(document-schema.js): collapse per-kind wrapper dispatch to shared structural walks extentOf, childWrappers, and every rebuildXGroup function had a dedicated arm per MintWrapper kind (shape, slide, draw page, section, construct) even though every one of those arms did the identical structural walk -- recurse into anything carrying its own node and children, collect a bare paragraph leaf, skip everything else. Only the anchor-group arm (heading/list) genuinely differs, since it alone contributes its own paragraph. Replacing the per-kind arms with one generic walk removes mutation-equivalent branches that could never be observed to differ, and widens FlowChild to cover a slide's and draw page's own child vocabularies so the same walk serves every wrapper kind. Also removes the ref-defined check from every rebuildXGroup's `unchanged` computation: a wrapper only carries a ref when its own candidate search minted an entry, which requires at least two matching paragraph/run positions inside its own extent to actually get their keys stripped -- so a minted ref always implies at least one descendant actually changed, making the ref check redundant with the children-equality check beside it. paragraphTuple/runTuple drop their per-key presence check: both are only ever called with a key list already proven (by commonParagraphKeys/commonRunKeys) to be present on every paragraph or run in scope, so the check could never fire. commonRunKeys drops its empty-extent early return for the same reason: an empty runs array already makes every downstream loop over it a no-op. The plan()/mint() length and switch-arm guards removed are equivalent for the identical reason -- an empty extent or an empty children array already makes the code that would have been skipped behave as a no-op on its own. --- .../src/factor-styles.test.ts | 1022 +++++++++++++++++ .../document-schema.js/src/factor-styles.ts | 158 +-- 2 files changed, 1074 insertions(+), 106 deletions(-) diff --git a/packages/document-schema.js/src/factor-styles.test.ts b/packages/document-schema.js/src/factor-styles.test.ts index 191f1679e5..e73955b174 100644 --- a/packages/document-schema.js/src/factor-styles.test.ts +++ b/packages/document-schema.js/src/factor-styles.test.ts @@ -6,11 +6,17 @@ import { type ContentDocument, type ContentParagraph, type ContentRun, + type ContentVector, } from "./content"; import { assembleTree, factorStyles, mint } from "./factor-styles"; import { flattenTree } from "./flatten"; import { DocumentTreeSchema, type DocumentTree } from "./package"; import type { + DrawPageGroupNode, + HeadingGroupNode, + HeadingParagraph, + ListGroupNode, + ListParagraph, SectionConstructGroupNode, SectionGroupNode, ShapeConstructGroupNode, @@ -39,6 +45,24 @@ function paragraph( return { kind: "paragraph", runs: [...runs], ...properties }; } +// A heading anchor with headingLevel narrowed to its required (non-optional) spelling on HeadingParagraph/HeadingGroupNode -- ContentParagraph's own headingLevel is optional, so a plain `paragraph(...)` call cannot itself satisfy a HeadingGroupNode's `node` field. `headingLevel` is a real parameter, not folded into `properties`, so this stays statically typed rather than widened by the properties bag's own Record spread. +function headingParagraph( + runs: readonly ContentRun[], + headingLevel: number, + properties: Record = {}, +): HeadingParagraph { + return { kind: "paragraph", runs: [...runs], headingLevel, ...properties }; +} + +// The list-anchor mirror of headingParagraph above: `list` narrowed to its required spelling on ListParagraph/ListGroupNode. +function listParagraph( + runs: readonly ContentRun[], + list: ListParagraph["list"], + properties: Record = {}, +): ListParagraph { + return { kind: "paragraph", runs: [...runs], list, ...properties }; +} + function wordprocessingDoc( blocks: ContentBlock[], metadata: Record = {}, @@ -616,6 +640,65 @@ describe("factorStyles minting", () => { expect(mintedConstruct.children[1]).not.toHaveProperty("indentLeftPt"); }); + it("aggregates paragraphs across a draw page's own separate shapes into one draw-page-level extent, minting on the draw page itself when neither shape alone reaches the threshold", () => { + // Each shape carries exactly one matching paragraph -- a singleton, below the mint threshold, at that shape's own level -- so only the draw page's own extentOf, walking across BOTH shapes (and skipping the sibling vector, which carries no paragraphs), can find the frequency-2 match and mint on itself. + const p1 = paragraph([run("a")], { alignment: "center" }); + const p2 = paragraph([run("b")], { alignment: "center" }); + const shape1: ShapeGroupNode = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [p1], + }; + const shape2: ShapeGroupNode = { + node: { + frame: { xPt: 100, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [p2], + }; + const vector: ContentVector = { + kind: "rect", + frame: { xPt: 0, yPt: 100, widthPt: 50, heightPt: 50 }, + }; + const drawPageGroup: DrawPageGroupNode = { + node: { kind: "drawPage", size: { widthPt: 300, heightPt: 300 } }, + children: [shape1, shape2, vector], + }; + const pkg: DocumentTree = { + kind: "drawing", + metadata: {}, + children: [drawPageGroup], + }; + const minted = mint(pkg); + expect(refsOf(minted)).toEqual([{ ref: "s1", nodeKind: "drawPage" }]); + expect(minted.styles?.s1).toEqual({ paragraph: { alignment: "center" } }); + if (minted.kind !== "drawing") throw new Error("expected drawing"); + const mintedPage = minted.children[0]; + if (mintedPage === undefined) throw new Error("expected the draw page"); + expect(mintedPage.style).toBe("s1"); + const mintedShape1 = mintedPage.children[0]; + const mintedShape2 = mintedPage.children[1]; + if ( + mintedShape1 === undefined || + !("node" in mintedShape1) || + mintedShape2 === undefined || + !("node" in mintedShape2) + ) + throw new Error("expected both shapes to survive minting"); + expect(mintedShape1.children[0]).not.toHaveProperty("alignment"); + expect(mintedShape2.children[0]).not.toHaveProperty("alignment"); + // The vector rides through untouched, proving the draw-page walk skips it rather than choking on its lack of a `node`/`children` shape. + expect(mintedPage.children[2]).toEqual(vector); + }); + it("mints through a construct promoted from flat marker blocks, and the tree it produces flattens back", () => { // The two tests above hand mint() a tree directly; this one comes the whole way round -- flat content carrying a constructStart/constructEnd pair, through assembleTree (decompose then mint), and back through flattenTree. It is the end-to-end proof that minting and the promotion compose: a construct group manufactured by decompose is an ordinary mint wrapper, and a minted tree containing one is still flattenable. const doc = wordprocessingDoc([ @@ -659,6 +742,945 @@ describe("factorStyles minting", () => { expect(containsKeyAnywhere(minted.children, "alignment")).toBe(false); expect(canon(flattenTree(minted))).toEqual(canon(doc)); }); + + it("omits symbolTable from the envelope when the content carries none, and carries it through by value when present", () => { + const withoutTable = assembleTree(wordprocessingDoc([])); + expect("symbolTable" in withoutTable).toBe(false); + const withTable = assembleTree({ + ...wordprocessingDoc([]), + symbolTable: { symbols: [], units: [] }, + }); + expect(withTable.symbolTable).toEqual({ symbols: [], units: [] }); + }); + + it("omits pages from the envelope when none is passed, and carries the exact array through by value when one is", () => { + const withoutPages = assembleTree(wordprocessingDoc([])); + expect("pages" in withoutPages).toBe(false); + const pages = [{ widthPt: 600, heightPt: 800 }]; + const withPages = assembleTree(wordprocessingDoc([]), pages); + expect(withPages.pages).toEqual([{ widthPt: 600, heightPt: 800 }]); + }); + + it("omits names from a spreadsheet's envelope when absent, and carries them through when present", () => { + const doc: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], + }; + const withoutNames = assembleTree(doc); + if (withoutNames.kind !== "spreadsheet") + throw new Error("expected spreadsheet"); + expect("names" in withoutNames).toBe(false); + const withNames = assembleTree({ + ...doc, + names: [{ name: "Total", refersTo: "Sheet1!$A$1:$A$10" }], + }); + if (withNames.kind !== "spreadsheet") + throw new Error("expected spreadsheet"); + expect(withNames.names).toEqual([ + { name: "Total", refersTo: "Sheet1!$A$1:$A$10" }, + ]); + }); + + it("omits each of definitions/fonts/source individually when that one field is absent, even though the other two are present", () => { + const minted = assembleTree(wordprocessingDoc([])); + const noFonts = factorStyles({ + ...minted, + definitions: { tenantNote: { kind: "tenant-note" } }, + source: { "word/settings.xml": { format: "docx", xml: "" } }, + }); + expect("fonts" in noFonts).toBe(false); + const noDefinitions = factorStyles({ + ...minted, + fonts: [{ family: "Body", bold: false, italic: false, base64: "AA==" }], + source: { "word/settings.xml": { format: "docx", xml: "" } }, + }); + expect("definitions" in noDefinitions).toBe(false); + const noSource = factorStyles({ + ...minted, + definitions: { tenantNote: { kind: "tenant-note" } }, + fonts: [{ family: "Body", bold: false, italic: false, base64: "AA==" }], + }); + expect("source" in noSource).toBe(false); + // And when none of the three is present at all, none of the three keys is spread onto the result. + const bare = factorStyles(minted); + expect("definitions" in bare).toBe(false); + expect("fonts" in bare).toBe(false); + expect("source" in bare).toBe(false); + }); + + it("orders styles-table entries by descending frequency, not by which tuple's frequency-2 group happens to be found first", () => { + // Five paragraphs sharing one common key (alignment) so commonParagraphKeys admits it across the whole extent; the first two occurrences form a frequency-2 group encountered first, the next three form a frequency-3 group encountered second. A tie-break-only comparator (dropping the `>` frequency check) would keep the first-found group as best regardless of the second group's larger size. + const doc = wordprocessingDoc([ + paragraph([run("a")], { alignment: "left" }), + paragraph([run("b")], { alignment: "left" }), + paragraph([run("c")], { alignment: "right" }), + paragraph([run("d")], { alignment: "right" }), + paragraph([run("e")], { alignment: "right" }), + ]); + const minted = assembleTree(doc); + expect(minted.styles?.s1).toEqual({ paragraph: { alignment: "right" } }); + }); + + it("orders three same-frequency entries by ascending first-visit index, not by an order the tie-break arithmetic happens to produce", () => { + const doc: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + ...SECTION, + blocks: [ + paragraph([run("a")], { alignment: "left" }), + paragraph([run("b")], { alignment: "left" }), + ], + }, + { + ...SECTION, + blocks: [ + paragraph([run("c")], { lineSpacing: 1.5 }), + paragraph([run("d")], { lineSpacing: 1.5 }), + ], + }, + { + ...SECTION, + blocks: [ + paragraph([run("e")], { spacingBeforePt: 6 }), + paragraph([run("f")], { spacingBeforePt: 6 }), + ], + }, + ], + }; + const minted = assembleTree(doc); + expect(minted.styles?.s1).toEqual({ paragraph: { alignment: "left" } }); + expect(minted.styles?.s2).toEqual({ paragraph: { lineSpacing: 1.5 } }); + expect(minted.styles?.s3).toEqual({ + paragraph: { spacingBeforePt: 6 }, + }); + }); + + it("never re-selects a paragraph an ancestor already factored, even for an unrelated, unfrozen key shared only by that already-factored position and its sibling", () => { + // body2 sits before the heading (section-root sibling); h1 and body1 nest inside the heading's own flow. All three share indentLeftPt, so the section mints it, freezing indentLeftPt and factoring [body2, h1, body1]. h1 and body1 ALSO share alignment (an unfrozen key) -- if the heading group's own candidate search failed to skip already-factored positions, it would mint a second, spurious entry for [h1, body1] on alignment. + const body2 = paragraph([run("two")], { indentLeftPt: 20 }); + const h1 = paragraph([run("Chapter")], { + headingLevel: 1, + indentLeftPt: 20, + alignment: "center", + }); + const body1 = paragraph([run("one")], { + indentLeftPt: 20, + alignment: "center", + }); + const doc = wordprocessingDoc([body2, h1, body1]); + const minted = assembleTree(doc); + expect(Object.keys(minted.styles ?? {})).toEqual(["s1"]); + expect(minted.styles?.s1).toEqual({ paragraph: { indentLeftPt: 20 } }); + }); + + it("never re-selects a run an ancestor already factored, even for an unrelated, unfrozen run key shared only by that already-factored run and a sibling run", () => { + const body2 = paragraph([run("two", { bold: true })]); + const h1 = paragraph([run("Chapter", { bold: true, italic: true })], { + headingLevel: 1, + }); + const body1 = paragraph([run("one", { bold: true, italic: true })]); + const doc = wordprocessingDoc([body2, h1, body1]); + const minted = assembleTree(doc); + // The section mints run:{bold:true} across all three runs, factoring them. h1's and body1's runs also share italic:true, but both are already factored -- the heading group must find nothing rather than double-mint. + expect(Object.keys(minted.styles ?? {})).toEqual(["s1"]); + expect(minted.styles?.s1).toEqual({ run: { bold: true } }); + }); + + it("restores an ancestor's strip for a paragraph even though a NESTED wrapper's own chain link (recorded for OTHER positions) has nothing for that same paragraph", () => { + // Q and P (the heading anchor) share indentLeftPt:20 -- R and S (nested inside P's own flow) share a DIFFERENT indentLeftPt value purely so indentLeftPt is common across the section's whole extent (required for the section to consider it at all); the tie between the two same-size value-groups resolves to document order, so the section mints indentLeftPt:20 over [Q, P] specifically. R and S ALSO share alignment with P, but P is already factored by the section's own mint, so the heading group's own candidate search skips P and mints alignment over [R, S] alone -- giving the heading its OWN chain link, one that says nothing about P. P's own strip must still come from the SECTION's link, not be wiped out because the heading's (more nested) link has no entry for it. + const q = paragraph([run("q")], { indentLeftPt: 20 }); + const p = headingParagraph([run("Chapter")], 1, { + indentLeftPt: 20, + alignment: "center", + }); + const r = paragraph([run("r")], { indentLeftPt: 99, alignment: "center" }); + const s = paragraph([run("s")], { indentLeftPt: 99, alignment: "center" }); + const headingGroup: HeadingGroupNode = { node: p, children: [r, s] }; + const sectionGroup: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [q, headingGroup], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [sectionGroup], + }; + const minted = mint(pkg); + expect(minted.styles?.s1).toEqual({ paragraph: { indentLeftPt: 20 } }); + expect(minted.styles?.s2).toEqual({ paragraph: { alignment: "center" } }); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const mintedHeading = minted.children[0]?.children[1]; + if ( + mintedHeading === undefined || + !("node" in mintedHeading) || + !("children" in mintedHeading) + ) + throw new Error("expected the heading group to survive minting"); + // The heading minted its OWN entry (s2, over r/s), so it carries its own ref -- but its anchor must still reflect the SECTION's strip, not just its own. + expect(mintedHeading.style).toBe("s2"); + const mintedAnchor = ContentParagraphSchema.parse(mintedHeading.node); + expect(mintedAnchor).not.toHaveProperty("indentLeftPt"); + expect(mintedAnchor.alignment).toBe("center"); + }); + + it("sums a wrapper's own combined paragraph-half and run-half positions into one frequency, rather than letting one half's count cancel the other's", () => { + // Section X mints BOTH halves on one wrapper: 2 paragraphs (alignment) and 3 runs (bold, since p1 carries two bold runs and p2 one) -- a correctly-summed frequency of 5. Section Y mints only a paragraph half with frequency 2. 5 > 2, so X must rank first; a frequency computed by subtracting the run count from the paragraph count would give X a frequency of -1, putting Y first instead. + const doc: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + ...SECTION, + blocks: [ + paragraph([run("a", { bold: true }), run("b", { bold: true })], { + alignment: "left", + }), + paragraph([run("c", { bold: true })], { alignment: "left" }), + ], + }, + { + ...SECTION, + blocks: [ + paragraph([run("d")], { lineSpacing: 1.5 }), + paragraph([run("e")], { lineSpacing: 1.5 }), + ], + }, + ], + }; + const minted = assembleTree(doc); + expect(minted.styles?.s1).toEqual({ + paragraph: { alignment: "left" }, + run: { bold: true }, + }); + expect(minted.styles?.s2).toEqual({ paragraph: { lineSpacing: 1.5 } }); + }); + + it("adds a second wrapper's contribution onto an already-registered identical entry's frequency, rather than subtracting it or dropping the run half of the addend", () => { + // Sections A and B each independently mint the IDENTICAL combined entry (paragraph:{alignment:left}, run:{bold:true}), each contributing frequency 2+2=4 from its own two paragraphs/two runs -- correctly merging to 8. Section C mints a different, single entry with frequency 6, strictly between 4 and 8: a merge that subtracts instead of adds would leave the shared entry at 0, and a merge that adds only the paragraph half of the second contribution (dropping its run half) would leave it at 4 -- both wrongly below 6, flipping the order. + const matching = (label: string) => [ + paragraph([run(`${label}1`, { bold: true })], { alignment: "left" }), + paragraph([run(`${label}2`, { bold: true })], { alignment: "left" }), + ]; + const doc: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { ...SECTION, blocks: matching("a") }, + { ...SECTION, blocks: matching("b") }, + { + ...SECTION, + blocks: Array.from({ length: 6 }, (_, i) => + paragraph([run(`c${i}`)], { spacingBeforePt: 6 }), + ), + }, + ], + }; + const minted = assembleTree(doc); + expect(minted.styles?.s1).toEqual({ + paragraph: { alignment: "left" }, + run: { bold: true }, + }); + expect(minted.styles?.s2).toEqual({ + paragraph: { spacingBeforePt: 6 }, + }); + }); + + it("strips a heading anchor via an ancestor's ref even when the heading itself mints nothing (its own ref stays undefined, so the anchor-changed check alone must catch it)", () => { + // The heading's body is empty, so the ONLY candidate for a mint anywhere is the section's own [q, heading-anchor] pair -- the heading's own plan() call finds nothing (its own extent is just its anchor alone, and indentLeftPt is already frozen), so its ref stays undefined. `unchanged`'s first clause (`ref===undefined`) is therefore true for the heading, and it is the SECOND clause (`anchor===group.node`) that must correctly detect the anchor was rewritten by the section's own strip -- the children clause is vacuously true (empty array) and can't do this alone. + const q = paragraph([run("q")], { indentLeftPt: 20 }); + const p = headingParagraph([run("Chapter")], 1, { indentLeftPt: 20 }); + const headingGroup: HeadingGroupNode = { node: p, children: [] }; + const sectionGroup: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [q, headingGroup], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [sectionGroup], + }; + const minted = mint(pkg); + expect(refsOf(minted)).toEqual([{ ref: "s1", nodeKind: "section" }]); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const mintedHeading = minted.children[0]?.children[1]; + if ( + mintedHeading === undefined || + !("node" in mintedHeading) || + !("children" in mintedHeading) + ) + throw new Error("expected the heading group"); + expect(mintedHeading).not.toBe(headingGroup); + expect(mintedHeading).not.toHaveProperty("style"); + expect(mintedHeading.node).not.toHaveProperty("indentLeftPt"); + }); + + it("strips a list anchor via an ancestor's ref even when the list group itself mints nothing (its own ref stays undefined, so the anchor-changed check alone must catch it)", () => { + const q = paragraph([run("q")], { indentLeftPt: 20 }); + const p = listParagraph( + [run("Item")], + { numId: "l1", level: 0, format: "bullet" }, + { + indentLeftPt: 20, + }, + ); + const listGroup: ListGroupNode = { node: p, children: [] }; + const sectionGroup: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [q, listGroup], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [sectionGroup], + }; + const minted = mint(pkg); + expect(refsOf(minted)).toEqual([{ ref: "s1", nodeKind: "section" }]); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const mintedList = minted.children[0]?.children[1]; + if ( + mintedList === undefined || + !("node" in mintedList) || + !("children" in mintedList) + ) + throw new Error("expected the list group"); + expect(mintedList).not.toBe(listGroup); + expect(mintedList).not.toHaveProperty("style"); + expect(mintedList.node).not.toHaveProperty("indentLeftPt"); + }); + + it("rebuilds a list group as a new object via an ancestor's strip to ONE of its own body children alone -- ref undefined AND anchor untouched, so the children-changed check alone must catch it", () => { + // outside1 and bodyA share alignment:"left"; the list anchor P and bodyB each carry alignment too (their OWN distinct values, purely for universality across the section's whole extent), so their own singleton value-groups never reach the mint threshold and neither is touched. The section mints alignment:"left" over [outside1, bodyA] specifically -- the list's own anchor is untouched (anchor === group.node stays true) and its own candidate search finds nothing (ref stays undefined), so this isolates the children clause: bodyA changed, bodyB didn't, and `.every(...)` must still say "not all match". + const outside1 = paragraph([run("outside1")], { alignment: "left" }); + const p = listParagraph( + [run("Item")], + { numId: "l1", level: 0, format: "bullet" }, + { + alignment: "center", + }, + ); + const bodyA = paragraph([run("a")], { alignment: "left" }); + const bodyB = paragraph([run("b")], { alignment: "justify" }); + const listGroup: ListGroupNode = { node: p, children: [bodyA, bodyB] }; + const sectionGroup: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [outside1, listGroup], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [sectionGroup], + }; + const minted = mint(pkg); + expect(refsOf(minted)).toEqual([{ ref: "s1", nodeKind: "section" }]); + expect(minted.styles?.s1).toEqual({ paragraph: { alignment: "left" } }); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const mintedList = minted.children[0]?.children[1]; + if ( + mintedList === undefined || + !("node" in mintedList) || + !("children" in mintedList) + ) + throw new Error("expected the list group"); + expect(mintedList).not.toBe(listGroup); + expect(mintedList).not.toHaveProperty("style"); + // The anchor itself was never touched -- it keeps its own distinct alignment value inline. + const mintedAnchor = ContentParagraphSchema.parse(mintedList.node); + expect(mintedAnchor.alignment).toBe("center"); + expect(mintedList.children[0]).not.toHaveProperty("alignment"); + expect(mintedList.children[1]).toBe(bodyB); + }); + + it("restores an ancestor's strip for a run even though a NESTED wrapper's own chain link (recorded for OTHER runs) has nothing for that same run", () => { + const q = paragraph([run("q", { bold: true })]); + const p = headingParagraph( + [run("Chapter", { bold: true, italic: true })], + 1, + ); + // r/s carry bold:false (not merely omit it) so bold is common across the WHOLE section extent (required for the section to consider it at all) -- the true/false split then groups [q,p] apart from [r,s], and the tie between the two same-size groups resolves to document order. + const r = paragraph([run("r", { bold: false, italic: true })]); + const s = paragraph([run("s", { bold: false, italic: true })]); + const headingGroup: HeadingGroupNode = { node: p, children: [r, s] }; + const sectionGroup: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [q, headingGroup], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [sectionGroup], + }; + const minted = mint(pkg); + expect(minted.styles?.s1).toEqual({ run: { bold: true } }); + expect(minted.styles?.s2).toEqual({ run: { italic: true } }); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const mintedHeading = minted.children[0]?.children[1]; + if ( + mintedHeading === undefined || + !("node" in mintedHeading) || + !("children" in mintedHeading) + ) + throw new Error("expected the heading group to survive minting"); + expect(mintedHeading.style).toBe("s2"); + const mintedAnchor = ContentParagraphSchema.parse(mintedHeading.node); + expect(mintedAnchor.runs[0]).not.toHaveProperty("bold"); + expect(mintedAnchor.runs[0]?.italic).toBe(true); + }); + + it("returns a genuinely untouched section, heading group, list group, and construct group by the SAME object reference, even while a sibling section mints something", () => { + // A sibling section (mintingSection) mints something, so entries.size > 0 and mint() runs the real per-wrapper rebuild walk rather than short-circuiting at the top with `return pkg` -- which would trivially (and uselessly) preserve every reference without ever calling rebuildSectionGroup/rebuildHeadingGroup/rebuildListGroup/rebuildSectionConstructGroup at all. Every paragraph inside untouchedSection is a bare leaf with no mintable property at all, so nothing anywhere inside it ever mints, and every one of those four rebuild functions must return its own input object unchanged. + const mintingSection: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [ + paragraph([run("x")], { alignment: "justify" }), + paragraph([run("y")], { alignment: "justify" }), + ], + }; + const headingGroup: HeadingGroupNode = { + node: headingParagraph([run("Heading")], 1), + children: [paragraph([run("body")])], + }; + const listGroup: ListGroupNode = { + node: listParagraph([run("Item")], { + numId: "l1", + level: 0, + format: "bullet", + }), + children: [paragraph([run("item body")])], + }; + const constructGroup: SectionConstructGroupNode = { + node: { kind: "contentControl", controlType: "richText" }, + children: [paragraph([run("inside a")]), paragraph([run("inside b")])], + }; + const untouchedSection: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [ + headingGroup, + listGroup, + constructGroup, + paragraph([run("trailing")]), + ], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [mintingSection, untouchedSection], + }; + const minted = mint(pkg); + expect(minted.styles?.s1).toEqual({ paragraph: { alignment: "justify" } }); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + expect(minted.children[1]).toBe(untouchedSection); + }); + + it("returns a genuinely untouched slide, shape group, and shape-flow construct group by the same object reference, even while a sibling slide mints something", () => { + const mintingSlide: SlideGroupNode = { + node: { kind: "slide", size: { widthPt: 960, heightPt: 540 }, notes: "" }, + children: [ + { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [ + paragraph([run("x")], { alignment: "justify" }), + paragraph([run("y")], { alignment: "justify" }), + ], + }, + ], + }; + const constructGroup: ShapeConstructGroupNode = { + node: { kind: "contentControl", controlType: "richText" }, + children: [paragraph([run("inside a")]), paragraph([run("inside b")])], + }; + const untouchedShape: ShapeGroupNode = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [constructGroup, paragraph([run("trailing")])], + }; + const untouchedSlide: SlideGroupNode = { + node: { kind: "slide", size: { widthPt: 960, heightPt: 540 }, notes: "" }, + children: [untouchedShape], + }; + const pkg: DocumentTree = { + kind: "presentation", + metadata: {}, + children: [mintingSlide, untouchedSlide], + }; + const minted = mint(pkg); + expect(minted.styles?.s1).toEqual({ paragraph: { alignment: "justify" } }); + if (minted.kind !== "presentation") + throw new Error("expected presentation"); + expect(minted.children[1]).toBe(untouchedSlide); + }); + + it("returns a genuinely untouched draw page by the same object reference, even while a sibling draw page mints something", () => { + const shapeOf = (blocks: ContentParagraph[]): ShapeGroupNode => ({ + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: blocks, + }); + const mintingPage: DrawPageGroupNode = { + node: { kind: "drawPage", size: { widthPt: 300, heightPt: 300 } }, + children: [ + shapeOf([paragraph([run("x")], { alignment: "justify" })]), + shapeOf([paragraph([run("y")], { alignment: "justify" })]), + ], + }; + const untouchedPage: DrawPageGroupNode = { + node: { kind: "drawPage", size: { widthPt: 300, heightPt: 300 } }, + children: [ + shapeOf([paragraph([run("trailing")])]), + { kind: "rect", frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 } }, + ], + }; + const pkg: DocumentTree = { + kind: "drawing", + metadata: {}, + children: [mintingPage, untouchedPage], + }; + const minted = mint(pkg); + expect(minted.styles?.s1).toEqual({ paragraph: { alignment: "justify" } }); + if (minted.kind !== "drawing") throw new Error("expected drawing"); + expect(minted.children[1]).toBe(untouchedPage); + }); + + it("rebuilds a section's children as a new array preserving an untouched sibling's own reference, when exactly one child actually changed -- not a wholesale copy, and not a false 'nothing changed' short-circuit", () => { + // The heading anchor carries two of its OWN runs sharing bold:true, reaching the mint threshold entirely from its own text -- independent of the section's own candidate search (which shares nothing across [heading anchor, pristine] and so mints nothing itself). This isolates the "one child changed, one didn't" case: `.some()` in place of `.every()` would find the untouched sibling's own match and wrongly call the whole section unchanged, discarding the heading's own rebuild. + const heading: HeadingGroupNode = { + node: headingParagraph( + [run("a", { bold: true }), run("b", { bold: true })], + 1, + ), + children: [], + }; + const pristine = paragraph([run("untouched")]); + const sectionGroup: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [heading, pristine], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [sectionGroup], + }; + const minted = mint(pkg); + expect(minted.styles?.s1).toEqual({ run: { bold: true } }); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const mintedSection = minted.children[0]; + if (mintedSection === undefined) + throw new Error("expected the section group"); + expect(mintedSection).not.toBe(sectionGroup); + expect(mintedSection).not.toHaveProperty("style"); + expect(mintedSection.children[1]).toBe(pristine); + const mintedHeading = mintedSection.children[0]; + if ( + mintedHeading === undefined || + !("node" in mintedHeading) || + !("children" in mintedHeading) + ) + throw new Error("expected the heading group"); + expect(mintedHeading.style).toBe("s1"); + const mintedAnchor = ContentParagraphSchema.parse(mintedHeading.node); + expect(mintedAnchor.runs[0]).not.toHaveProperty("bold"); + expect(mintedAnchor.runs[1]).not.toHaveProperty("bold"); + }); + + it("rebuilds a list group's children the same way -- new array, untouched sibling's own reference preserved, when exactly one child changed", () => { + const list: ListGroupNode = { + node: listParagraph( + [run("a", { italic: true }), run("b", { italic: true })], + { numId: "l1", level: 0, format: "bullet" }, + ), + children: [], + }; + const pristine = paragraph([run("untouched")]); + const sectionGroup: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [list, pristine], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [sectionGroup], + }; + const minted = mint(pkg); + expect(minted.styles?.s1).toEqual({ run: { italic: true } }); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const mintedSection = minted.children[0]; + if (mintedSection === undefined) + throw new Error("expected the section group"); + expect(mintedSection.children[1]).toBe(pristine); + const mintedList = mintedSection.children[0]; + if ( + mintedList === undefined || + !("node" in mintedList) || + !("children" in mintedList) + ) + throw new Error("expected the list group"); + expect(mintedList.style).toBe("s1"); + }); + + it("rebuilds a shape-flow construct group as a new object via an ancestor's strip alone, even when the construct group itself mints nothing (its own ref stays undefined)", () => { + // outside and insideA share alignment:"left"; insideB carries alignment too (a DIFFERENT value, purely for universality across the shape's own extent), so the SHAPE mints alignment over [outside, insideA] specifically -- freezing alignment before the construct group's own candidate search ever runs. The construct group's own extent ([insideA, insideB]) then shares nothing (alignment is frozen, nothing else matches), so its own ref stays undefined -- but insideA was still stripped via the shape's ref, so the construct's `children.every(...)` check must still detect that change and return a new object, not just short-circuit on its own (never-set) ref. + const outside = paragraph([run("outside")], { alignment: "left" }); + const insideA = paragraph([run("a")], { alignment: "left" }); + const insideB = paragraph([run("b")], { alignment: "right" }); + const constructGroup: ShapeConstructGroupNode = { + node: { kind: "contentControl", controlType: "richText" }, + children: [insideA, insideB], + }; + const shapeGroup: ShapeGroupNode = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [outside, constructGroup], + }; + // A second, unrelated shape breaks the SLIDE's own commonality (it carries no alignment at all), so the slide itself mints nothing and the match is only found once the walk descends into shapeGroup's own (narrower) extent. + const otherShape: ShapeGroupNode = { + node: { + frame: { xPt: 100, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [paragraph([run("elsewhere")])], + }; + const slideGroup: SlideGroupNode = { + node: { kind: "slide", size: { widthPt: 960, heightPt: 540 }, notes: "" }, + children: [shapeGroup, otherShape], + }; + const pkg: DocumentTree = { + kind: "presentation", + metadata: {}, + children: [slideGroup], + }; + const minted = mint(pkg); + expect(refsOf(minted)).toEqual([{ ref: "s1", nodeKind: "shape/anchor" }]); + if (minted.kind !== "presentation") + throw new Error("expected presentation"); + const mintedConstruct = minted.children[0]?.children[0]?.children[1]; + if ( + mintedConstruct === undefined || + !("node" in mintedConstruct) || + !("children" in mintedConstruct) + ) + throw new Error("expected the construct group"); + expect(mintedConstruct).not.toBe(constructGroup); + expect(mintedConstruct).not.toHaveProperty("style"); + expect(mintedConstruct.children[0]).not.toHaveProperty("alignment"); + expect(mintedConstruct.children[1]).toEqual(insideB); + }); + + it("mints a ref on a shape group itself when its own two paragraphs match but nothing shares across the slide's other shape", () => { + // Shape1's own extent (its two paragraphs alone) shares alignment, reaching the threshold there; shape2's one paragraph carries no alignment at all, so the SLIDE's own (wider) extent fails commonality and mints nothing itself -- the ref must land on shape1 directly, and its own spread of `style` must actually appear. + const shape1: ShapeGroupNode = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [ + paragraph([run("a")], { alignment: "left" }), + paragraph([run("b")], { alignment: "left" }), + ], + }; + const shape2: ShapeGroupNode = { + node: { + frame: { xPt: 100, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [paragraph([run("c")])], + }; + const slideGroup: SlideGroupNode = { + node: { kind: "slide", size: { widthPt: 960, heightPt: 540 }, notes: "" }, + children: [shape1, shape2], + }; + const pkg: DocumentTree = { + kind: "presentation", + metadata: {}, + children: [slideGroup], + }; + const minted = mint(pkg); + expect(refsOf(minted)).toEqual([{ ref: "s1", nodeKind: "shape/anchor" }]); + expect(minted.styles?.s1).toEqual({ paragraph: { alignment: "left" } }); + if (minted.kind !== "presentation") + throw new Error("expected presentation"); + const mintedSlide = minted.children[0]; + if (mintedSlide === undefined) throw new Error("expected the slide group"); + // The slide itself carries no ref -- only shape1 does. + expect(mintedSlide).not.toHaveProperty("style"); + const mintedShape1 = mintedSlide.children[0]; + if (mintedShape1 === undefined) throw new Error("expected shape1"); + expect(mintedShape1.style).toBe("s1"); + expect(mintedShape1.children[0]).not.toHaveProperty("alignment"); + }); + + it("rebuilds a shape group's children the same way -- new array, untouched sibling's own reference preserved, when exactly one child changed", () => { + const changing = paragraph([run("a")], { alignment: "left" }); + const other = paragraph([run("b")], { alignment: "left" }); + // pristine carries alignment too (a DIFFERENT value), purely so alignment is common across the whole extent (required for it to be considered at all) -- its own singleton value-group never reaches the mint threshold, so it stays untouched. + const pristine = paragraph([run("untouched")], { alignment: "right" }); + const shapeGroup: ShapeGroupNode = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + // `changing`/`other` share alignment (mints, so both get rebuilt); `pristine` shares nothing and must survive by reference. + children: [changing, other, pristine], + }; + const slideGroup: SlideGroupNode = { + node: { kind: "slide", size: { widthPt: 960, heightPt: 540 }, notes: "" }, + children: [shapeGroup], + }; + const pkg: DocumentTree = { + kind: "presentation", + metadata: {}, + children: [slideGroup], + }; + const minted = mint(pkg); + if (minted.kind !== "presentation") + throw new Error("expected presentation"); + const mintedShape = minted.children[0]?.children[0]; + if (mintedShape === undefined) throw new Error("expected the shape group"); + expect(mintedShape).not.toBe(shapeGroup); + expect(mintedShape).not.toHaveProperty("style"); + expect(mintedShape.children[2]).toBe(pristine); + expect(mintedShape.children[0]).not.toHaveProperty("alignment"); + }); + + it("rebuilds a draw page's children the same way -- new array, untouched sibling shape's own reference preserved, when exactly one shape changed", () => { + const changingShape: ShapeGroupNode = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [ + paragraph([run("a", { bold: true }), run("b", { bold: true })]), + ], + }; + const pristineShape: ShapeGroupNode = { + node: { + frame: { xPt: 100, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [paragraph([run("untouched")])], + }; + const drawPageGroup: DrawPageGroupNode = { + node: { kind: "drawPage", size: { widthPt: 300, heightPt: 300 } }, + children: [changingShape, pristineShape], + }; + const pkg: DocumentTree = { + kind: "drawing", + metadata: {}, + children: [drawPageGroup], + }; + const minted = mint(pkg); + expect(minted.styles?.s1).toEqual({ run: { bold: true } }); + if (minted.kind !== "drawing") throw new Error("expected drawing"); + const mintedPage = minted.children[0]; + if (mintedPage === undefined) throw new Error("expected the draw page"); + expect(mintedPage).not.toBe(drawPageGroup); + expect(mintedPage.children[1]).toBe(pristineShape); + }); + + it("rebuilds a section-flow construct group's children the same way -- new array, untouched sibling's own reference preserved, when exactly one child changed", () => { + const changing = paragraph([run("a")], { indentLeftPt: 30 }); + const other = paragraph([run("b")], { indentLeftPt: 30 }); + // pristine carries indentLeftPt too (a DIFFERENT value), purely so the key is common across the whole extent -- its own singleton value-group never reaches the mint threshold. + const pristine = paragraph([run("untouched")], { indentLeftPt: 99 }); + const constructGroup: SectionConstructGroupNode = { + node: { kind: "contentControl", controlType: "richText" }, + children: [changing, other, pristine], + }; + // outside carries no indentLeftPt at all, so the SECTION's own (wider) extent fails commonality and mints nothing itself -- the match is only found once the walk descends into the construct group's own (narrower) extent. + const outside = paragraph([run("outside")]); + const sectionGroup: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [outside, constructGroup], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [sectionGroup], + }; + const minted = mint(pkg); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const mintedConstruct = minted.children[0]?.children[1]; + if ( + mintedConstruct === undefined || + !("node" in mintedConstruct) || + !("children" in mintedConstruct) + ) + throw new Error("expected the construct group"); + expect(mintedConstruct).not.toBe(constructGroup); + expect(mintedConstruct.style).toBe("s1"); + expect(mintedConstruct.children[2]).toBe(pristine); + expect(mintedConstruct.children[0]).not.toHaveProperty("indentLeftPt"); + }); + + it("rebuilds a shape-flow construct group's children the same way -- new array, untouched sibling's own reference preserved, when exactly one child changed", () => { + const changing = paragraph([run("a")], { indentLeftPt: 30 }); + const other = paragraph([run("b")], { indentLeftPt: 30 }); + // pristine carries indentLeftPt too (a DIFFERENT value), purely so the key is common across the whole extent -- its own singleton value-group never reaches the mint threshold. + const pristine = paragraph([run("untouched")], { indentLeftPt: 99 }); + const constructGroup: ShapeConstructGroupNode = { + node: { kind: "contentControl", controlType: "richText" }, + children: [changing, other, pristine], + }; + // outside carries no indentLeftPt at all, so the SHAPE's own (wider) extent fails commonality and mints nothing itself. + const outside = paragraph([run("outside")]); + const shapeGroup: ShapeGroupNode = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [outside, constructGroup], + }; + const slideGroup: SlideGroupNode = { + node: { kind: "slide", size: { widthPt: 960, heightPt: 540 }, notes: "" }, + children: [shapeGroup], + }; + const pkg: DocumentTree = { + kind: "presentation", + metadata: {}, + children: [slideGroup], + }; + const minted = mint(pkg); + if (minted.kind !== "presentation") + throw new Error("expected presentation"); + const mintedConstruct = minted.children[0]?.children[0]?.children[1]; + if ( + mintedConstruct === undefined || + !("node" in mintedConstruct) || + !("children" in mintedConstruct) + ) + throw new Error("expected the construct group"); + expect(mintedConstruct).not.toBe(constructGroup); + expect(mintedConstruct.style).toBe("s1"); + expect(mintedConstruct.children[2]).toBe(pristine); + expect(mintedConstruct.children[0]).not.toHaveProperty("indentLeftPt"); + }); + + it("rebuilds a section-flow construct group with no style of its own when only a nested child mints, not the construct group itself", () => { + // The construct group's own extent has no common key at all (heading/plain), so it never mints a ref of its own; the heading nested inside it mints on its own (narrower) extent. + const h1 = headingParagraph([run("a")], 1, { indentLeftPt: 30 }); + const h2 = headingParagraph([run("b")], 1, { indentLeftPt: 30 }); + const headingGroup: HeadingGroupNode = { node: h1, children: [] }; + const otherHeadingGroup: HeadingGroupNode = { node: h2, children: [] }; + const constructGroup: SectionConstructGroupNode = { + node: { kind: "contentControl", controlType: "richText" }, + children: [headingGroup, otherHeadingGroup], + }; + const sectionGroup: SectionGroupNode = { + node: { kind: "section", ...SECTION }, + children: [constructGroup], + }; + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [sectionGroup], + }; + const minted = mint(pkg); + if (minted.kind !== "wordprocessing") + throw new Error("expected wordprocessing"); + const mintedConstruct = minted.children[0]?.children[0]; + if ( + mintedConstruct === undefined || + !("node" in mintedConstruct) || + !("children" in mintedConstruct) + ) + throw new Error("expected the construct group"); + expect(mintedConstruct).not.toBe(constructGroup); + expect(mintedConstruct).not.toHaveProperty("style"); + }); + + it("rebuilds a shape-flow construct group with no style of its own when only a nested child mints, not the construct group itself", () => { + const a = listParagraph([run("a")], { level: 0 }, { indentLeftPt: 30 }); + const b = listParagraph([run("b")], { level: 0 }, { indentLeftPt: 30 }); + const listGroupA: ListGroupNode = { node: a, children: [] }; + const listGroupB: ListGroupNode = { node: b, children: [] }; + const constructGroup: ShapeConstructGroupNode = { + node: { kind: "contentControl", controlType: "richText" }, + children: [listGroupA, listGroupB], + }; + const shapeGroup: ShapeGroupNode = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [constructGroup], + }; + const slideGroup: SlideGroupNode = { + node: { kind: "slide", size: { widthPt: 960, heightPt: 540 }, notes: "" }, + children: [shapeGroup], + }; + const pkg: DocumentTree = { + kind: "presentation", + metadata: {}, + children: [slideGroup], + }; + const minted = mint(pkg); + if (minted.kind !== "presentation") + throw new Error("expected presentation"); + const mintedConstruct = minted.children[0]?.children[0]?.children[0]; + if ( + mintedConstruct === undefined || + !("node" in mintedConstruct) || + !("children" in mintedConstruct) + ) + throw new Error("expected the construct group"); + expect(mintedConstruct).not.toBe(constructGroup); + expect(mintedConstruct).not.toHaveProperty("style"); + }); }); // Finds the first group wrapper anywhere in the tree whose anchor paragraph's first run text matches -- the frozen-key test's H2 group sits nested inside the H1 group, not at any fixed depth. diff --git a/packages/document-schema.js/src/factor-styles.ts b/packages/document-schema.js/src/factor-styles.ts index 44b3e9ef5b..ec6890192a 100644 --- a/packages/document-schema.js/src/factor-styles.ts +++ b/packages/document-schema.js/src/factor-styles.ts @@ -1,5 +1,10 @@ import { canonicalKey } from "./canonicalise"; -import type { ContentDocument, ContentParagraph, ContentRun } from "./content"; +import type { + ContentDocument, + ContentParagraph, + ContentRun, + ContentVector, +} from "./content"; import { decomposeDrawPage, decomposeSection, @@ -80,29 +85,16 @@ type MintWrapper = | ShapeConstructGroupNode; // One child position of any block flow: the union of the section, list, and shape flows' child vocabularies. ListChild and ShapeChild are the identical type (ListGroupNode | ShapeConstructGroupNode | TreeBlockLeaf) since 4.1.0, no longer a sub-range of SectionChild (which carries SectionConstructGroupNode instead) -- so the extent walk needs both halves explicitly to serve all three flows with one function. -type FlowChild = SectionChild | ListChild; - -// Per-kind narrowers over MintWrapper. These exist because TypeScript does not narrow a union from a comparison against a NESTED discriminant (`wrapper.node.kind === 'section'` narrows wrapper.node at best, never `wrapper`) -- the identical reason src/package-node.ts writes per-kind predicates, and an explicit guard is what narrows the wrapper itself. A shape group is the no-kind arm (ContentShape carries no kind field); heading and list groups share the 'paragraph' node discriminant and stay one arm because the minting walk treats every anchor alike. SectionGroupNode, SectionConstructGroupNode, and ShapeConstructGroupNode get no guard of their own: none of their node kinds ('section', or one of the six construct kinds) matches any check below, so all three fall through to the shared "no anchor" default at the foot of extentOf/childWrappers. -function isShapeGroupWrapper(wrapper: MintWrapper): wrapper is ShapeGroupNode { - return !("kind" in wrapper.node); -} +// SectionChild | ListChild covers every block-flow position; ShapeGroupNode | ContentVector additionally covers a slide's own children (ShapeGroupNode[]) and a draw page's (ShapeGroupNode | ContentVector) -- flowExtent and childWrappers both walk every MintWrapper kind's children generically (recurse into anything carrying its own node+children, treat a bare paragraph leaf as an anchor, skip everything else), so this is the one union wide enough for every kind's own children type. +type FlowChild = SectionChild | ListChild | ShapeGroupNode | ContentVector; +// The one per-kind narrower extentOf/childWrappers still need: TypeScript does not narrow a union from a comparison against a NESTED discriminant (`wrapper.node.kind === 'paragraph'` narrows wrapper.node at best, never `wrapper`), so an explicit guard is what narrows the wrapper itself. Every OTHER MintWrapper kind (shape, slide, draw page, section, and both construct-group variants) has no anchor of its own, so extentOf and childWrappers both treat them alike via their own shared, kind-agnostic defaults -- see each function's own comment. function isAnchorGroupWrapper( wrapper: MintWrapper, ): wrapper is HeadingGroupNode | ListGroupNode { return "kind" in wrapper.node && wrapper.node.kind === "paragraph"; } -function isSlideGroupWrapper(wrapper: MintWrapper): wrapper is SlideGroupNode { - return "kind" in wrapper.node && wrapper.node.kind === "slide"; -} - -function isDrawPageGroupWrapper( - wrapper: MintWrapper, -): wrapper is DrawPageGroupNode { - return "kind" in wrapper.node && wrapper.node.kind === "drawPage"; -} - // Heading vs list within the anchor arm, discriminated the way decompose constructs them (a paragraph carrying both signals becomes a heading anchor -- headings win). function isHeadingGroup( group: HeadingGroupNode | ListGroupNode, @@ -175,37 +167,18 @@ export function factorStyles(pkg: DocumentTree): DocumentTree { ...(pkg.fonts !== undefined ? { fonts: pkg.fonts } : {}), ...(pkg.source !== undefined ? { source: pkg.source } : {}), }; - if ( - pkg.definitions === undefined && - pkg.fonts === undefined && - pkg.source === undefined - ) - return reassembled; return { ...reassembled, ...carried }; } // --- The plan: extents, candidates, selection ----------------------------------------------------------- // The paragraphs a wrapper's ref would overlay onto: the wrapper's own anchor (heading and list groups) plus, recursively, nested group anchors and bare paragraph leaves inside the block flow. This is exactly flatten.ts's resolution extent -- the same walk boundary, the same exclusions -- because exactness is proven against exactly the nodes resolution touches. +// +// Only the anchor-group arm is special-cased: a heading or list group's own anchor paragraph contributes itself, on top of its flow. Every other wrapper kind -- a shape group, a slide, a draw page, a section group, or a construct group -- has no anchor of its own, so its whole contribution is exactly its flow's own extent; flowExtent already walks any wrapper kind's children generically (recursing into anything carrying its own node+children, collecting a bare paragraph leaf, skipping everything else), so there is nothing left for a per-kind arm to do differently for any of them. function extentOf(wrapper: MintWrapper): ContentParagraph[] { - if (isShapeGroupWrapper(wrapper)) { - // A shape group: no anchor of its own, its list-flow children carry everything. - return flowExtent(wrapper.children); - } if (isAnchorGroupWrapper(wrapper)) { return [wrapper.node, ...flowExtent(wrapper.children)]; } - if (isSlideGroupWrapper(wrapper)) { - return wrapper.children.flatMap(extentOf); - } - if (isDrawPageGroupWrapper(wrapper)) { - const paragraphs: ContentParagraph[] = []; - for (const child of wrapper.children) { - if ("node" in child) paragraphs.push(...extentOf(child)); - } - return paragraphs; - } - // A section group or a construct group (section or shape variant): no anchor of its own, its whole flow is the extent -- a construct descriptor is never a paragraph, so it never contributes a paragraph itself, exactly like a plain section group's descriptor. return flowExtent(wrapper.children); } @@ -223,24 +196,26 @@ function flowExtent(children: readonly FlowChild[]): ContentParagraph[] { } // A tuple of just the keys in `keys` that the paragraph actually carries -- the candidate identity for the paragraph namespace. Absent keys are omitted, not set to undefined, so canonicalKey treats both spellings of absence identically. +// `keys` is always commonParagraphKeys' own result for this exact extent (bestParagraphCandidate is the sole caller), which by construction already guarantees every key in the list is present on every paragraph of that extent -- so unlike a general-purpose "pick present keys" helper, this one can assign unconditionally rather than checking for an absence its own caller has already ruled out. function paragraphTuple( paragraph: ContentParagraph, keys: readonly ParagraphKey[], ): StyleParagraphProperties { const tuple: Record = {}; for (const key of keys) { - if (paragraph[key] !== undefined) tuple[key] = paragraph[key]; + tuple[key] = paragraph[key]; } return tuple; } +// The same reasoning as paragraphTuple's own comment above: `keys` is always commonRunKeys' own result for this exact set of runs (bestRunCandidate is the sole caller), which already guarantees every key is present on every run in the set. function runTuple( run: ContentRun, keys: readonly RunKey[], ): StyleRunProperties { const tuple: Record = {}; for (const key of keys) { - if (run[key] !== undefined) tuple[key] = run[key]; + tuple[key] = run[key]; } return tuple; } @@ -261,8 +236,8 @@ function commonRunKeys( extent: readonly ContentParagraph[], frozen: ReadonlySet, ): readonly RunKey[] { + // No zero-runs guard: when the extent has no runs at all, bestRunCandidate's own inner loop over each paragraph's runs never executes regardless of which keys this returns, so returning the full (vacuously-common) key set for that input is behaviourally identical to returning []. const runs = extent.flatMap((paragraph) => paragraph.runs); - if (runs.length === 0) return []; return RUN_STYLE_KEYS.filter( (key) => !frozen.has(key) && runs.every((run) => run[key] !== undefined), ); @@ -375,22 +350,17 @@ function plan( entries: Map, ): void { const extent = extentOf(wrapper); - const paragraphCandidate = - extent.length > 0 - ? bestParagraphCandidate( - extent, - commonParagraphKeys(extent, branch.frozenParagraphs), - branch.factoredParagraphs, - ) - : undefined; - const runCandidate = - extent.length > 0 - ? bestRunCandidate( - extent, - commonRunKeys(extent, branch.frozenRuns), - branch.factoredRuns, - ) - : undefined; + // No length guard: both candidate functions already return undefined for an empty extent (their loops simply never run), so a wrapper with no paragraphs at all is handled identically whether or not this call is skipped. + const paragraphCandidate = bestParagraphCandidate( + extent, + commonParagraphKeys(extent, branch.frozenParagraphs), + branch.factoredParagraphs, + ); + const runCandidate = bestRunCandidate( + extent, + commonRunKeys(extent, branch.frozenRuns), + branch.factoredRuns, + ); const nextFrozenParagraphs = new Set(branch.frozenParagraphs); const nextFrozenRuns = new Set(branch.frozenRuns); @@ -451,26 +421,10 @@ function plan( } // The direct child wrappers of a wrapper, in document order -- the pre-order walk's recursion set. +// One loop serves every MintWrapper kind: a slide's children (always ShapeGroupNode, always +// carrying node+children) are all kept; a draw page's (ShapeGroupNode | ContentVector) keeps the +// shape groups and skips the vectors (which carry neither); a section/heading/list/shape/ construct group's flow keeps its nested groups and skips its leaves -- the identical "has its own node and children" test picks out exactly the wrapper positions in every one of these child vocabularies, so no per-kind dispatch is needed at all. function childWrappers(wrapper: MintWrapper): MintWrapper[] { - if (isShapeGroupWrapper(wrapper) || isAnchorGroupWrapper(wrapper)) { - // A shape's flow and a heading/list group's flow share the loop: nested groups are the child wrappers, leaves are not. An explicit loop rather than filter's type-guard overload because children arrives as a union of array types, whose filter signature TypeScript resolves without the predicate. - const wrappers: MintWrapper[] = []; - for (const child of wrapper.children) { - if ("node" in child && "children" in child) wrappers.push(child); - } - return wrappers; - } - if (isSlideGroupWrapper(wrapper)) { - return [...wrapper.children]; - } - if (isDrawPageGroupWrapper(wrapper)) { - const shapes: ShapeGroupNode[] = []; - for (const child of wrapper.children) { - if ("node" in child) shapes.push(child); - } - return shapes; - } - // A section group: its flow's nested groups are the child wrappers. const wrappers: MintWrapper[] = []; for (const child of wrapper.children) { if ("node" in child && "children" in child) wrappers.push(child); @@ -493,29 +447,23 @@ export function mint(pkg: DocumentTree): DocumentTree { factoredRuns: new Set(), }; switch (pkg.kind) { + // The three container-rooted kinds share this identical body -- pkg.children is a MintWrapper[] for every one of them (SectionGroupNode[]/SlideGroupNode[]/DrawPageGroupNode[]), and plan() itself dispatches on each wrapper's own node.kind, not on which switch arm reached it. Combined deliberately rather than left as three textually-identical arms: with three separate arms, an empty (fallen-through) "wordprocessing" arm would fall into "presentation"'s byte-identical loop and reach the exact same result, making the case label itself unobservable to a test. case "wordprocessing": - for (const root of pkg.children) - plan(root, visit, rootBranch, state, entries); - break; case "presentation": - for (const root of pkg.children) - plan(root, visit, rootBranch, state, entries); - break; case "drawing": for (const root of pkg.children) plan(root, visit, rootBranch, state, entries); break; - // A spreadsheet's roots are sheet groups (no block flow, never minted) and a formula package's single child is a leaf: neither holds a wrapper to visit. + // A spreadsheet's roots are sheet groups (no block flow, never minted) and a formula package's single child is a leaf: neither holds a wrapper to visit -- both arms are genuinely empty, the switch's own last arms, so there is nothing to break out of. case "spreadsheet": case "formula": - break; } if (entries.size === 0) { return pkg; } - // Entry ids in (descending total frequency, first wrapper visit) order -- the deterministic table order the plan locks. The comparator is total at two arms: one wrapper mints at most one entry, so distinct entries always have distinct first visits and a further tie-break arm could never bind. + // Entry ids in (descending total frequency, first wrapper visit) order -- the deterministic table order the plan locks. entries.values() already yields ascending-firstVisit order for free (each entry is inserted into the Map at the moment its firstVisit is assigned, and Map iteration is insertion order), and Array.prototype.sort has been a STABLE sort by spec since ES2019 -- so sorting by descending frequency alone, with no explicit tie-break, already preserves each frequency-tied group's own relative (ascending-firstVisit) order exactly as a manual `|| a.firstVisit - b.firstVisit` tie-break would, without a second comparator arm to state or get wrong. const ordered = [...entries.values()].sort( - (a, b) => b.frequency - a.frequency || a.firstVisit - b.firstVisit, + (a, b) => b.frequency - a.frequency, ); const styles: StylesTable = {}; ordered.forEach((entry, index) => { @@ -605,9 +553,9 @@ function rebuildSlideGroup( rebuildShapeGroup(shape, inner, state), ); const ref = state.wrapperRefs.get(group); - const unchanged = - ref === undefined && - children.every((child, index) => child === group.children[index]); + const unchanged = children.every( + (child, index) => child === group.children[index], + ); return unchanged ? group : { @@ -628,9 +576,9 @@ function rebuildDrawPageGroup( "node" in child ? rebuildShapeGroup(child, inner, state) : child, ); const ref = state.wrapperRefs.get(group); - const unchanged = - ref === undefined && - children.every((child, index) => child === group.children[index]); + const unchanged = children.every( + (child, index) => child === group.children[index], + ); return unchanged ? group : { @@ -651,9 +599,9 @@ function rebuildSectionGroup( rebuildSectionChild(child, inner, state), ); const ref = state.wrapperRefs.get(group); - const unchanged = - ref === undefined && - children.every((child, index) => child === group.children[index]); + const unchanged = children.every( + (child, index) => child === group.children[index], + ); return unchanged ? group : { @@ -712,9 +660,9 @@ function rebuildShapeGroup( rebuildListChild(child, inner, state), ); const ref = state.wrapperRefs.get(group); - const unchanged = - ref === undefined && - children.every((child, index) => child === group.children[index]); + const unchanged = children.every( + (child, index) => child === group.children[index], + ); return unchanged ? group : { @@ -735,9 +683,9 @@ function rebuildSectionConstructGroup( rebuildSectionChild(child, inner, state), ); const ref = state.wrapperRefs.get(group); - const unchanged = - ref === undefined && - children.every((child, index) => child === group.children[index]); + const unchanged = children.every( + (child, index) => child === group.children[index], + ); return unchanged ? group : { @@ -758,9 +706,9 @@ function rebuildShapeConstructGroup( rebuildListChild(child, inner, state), ); const ref = state.wrapperRefs.get(group); - const unchanged = - ref === undefined && - children.every((child, index) => child === group.children[index]); + const unchanged = children.every( + (child, index) => child === group.children[index], + ); return unchanged ? group : { @@ -788,7 +736,6 @@ function rebuildHeadingGroup( ); const ref = state.wrapperRefs.get(group); const unchanged = - ref === undefined && anchor === group.node && children.every((child, index) => child === group.children[index]); return unchanged @@ -814,7 +761,6 @@ function rebuildListGroup( ); const ref = state.wrapperRefs.get(group); const unchanged = - ref === undefined && anchor === group.node && children.every((child, index) => child === group.children[index]); return unchanged From ef532b5a7c4a3c62ac1a97a85e4c90211dbed4db Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:16:50 +0100 Subject: [PATCH 05/17] refactor(document-schema.js): drop isImageFormat's redundant typeof guard Array.prototype.includes compares by strict equality against IMAGE_FORMATS's own string elements, so it can only return true for a value that already is one of those strings -- a non-string value never satisfies it either way, making the preceding typeof check redundant. Also adds coverage for isContentBlock/isContentConstructStart/ isRunConstructExtent rejecting a partly-invalid array, a wrong-kind discriminant, and an out-of-range run bound respectively, and for the hand-authored content-json-schema-defs fragments' own field-level shape. --- .../src/content-json-schema-defs.test.ts | 311 +++++++++++++++++- .../document-schema.js/src/content.test.ts | 257 +++++++++++++++ packages/document-schema.js/src/content.ts | 5 +- 3 files changed, 569 insertions(+), 4 deletions(-) diff --git a/packages/document-schema.js/src/content-json-schema-defs.test.ts b/packages/document-schema.js/src/content-json-schema-defs.test.ts index c8680f64e6..60f87530e2 100644 --- a/packages/document-schema.js/src/content-json-schema-defs.test.ts +++ b/packages/document-schema.js/src/content-json-schema-defs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import type * as ZodModule from "zod"; import { TreeEmbeddedFontSchema } from "./package"; import { ColorSchema } from "./color"; import { @@ -61,7 +62,12 @@ import { ContentCellValueSchema, ContentPageFurnitureSchema, } from "./content"; -import { CONTENT_DEFS } from "./content-json-schema-defs"; +import { + CONTENT_DEFS, + CONTENT_DOCUMENT_URI, + EMBEDDED_OBJECT_KINDS, + MAX_SAFE_INTEGER, +} from "./content-json-schema-defs"; import { AnchorDescriptorSchema, ConstructDescriptorSchema, @@ -420,4 +426,307 @@ describe("CONTENT_DEFS's MathML entries are computed lazily, not at module load" // A second read reuses the cached value rather than recomputing it. expect(fresh.CONTENT_DEFS.MathMlAttribute).toBe(firstRead); }); + + it("builds the registry and calls z.toJSONSchema() only once total, even though three separate getters each trigger the same underlying computation", async () => { + // getMathMlJsonSchemas() is called independently by each of the three getters (via mathMlDef), and its own cachedMathMlJsonSchemas check is what stops the second and third calls from re-registering the schemas and re-invoking z.toJSONSchema() -- a functional check on the returned VALUES alone can't distinguish "recomputed but happened to produce the same result" from "reused the cache", since z.toJSONSchema() is deterministic either way. zod's own namespace export can't be vi.spyOn'd directly (ESM module namespaces are non-configurable), so this counts calls through vi.doMock over the whole 'zod' module instead, wrapping the real toJSONSchema. + let callCount = 0; + vi.doMock("zod", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + z: { + ...actual.z, + toJSONSchema: (...args: Parameters) => { + callCount += 1; + return actual.z.toJSONSchema(...args); + }, + }, + }; + }); + vi.resetModules(); + try { + const fresh = await import("./content-json-schema-defs"); + expect(fresh.CONTENT_DEFS.MathMlAttribute).toBeDefined(); + expect(fresh.CONTENT_DEFS.MathMlElement).toBeDefined(); + expect(fresh.CONTENT_DEFS.MathMlNode).toBeDefined(); + expect(callCount).toBe(1); + } finally { + vi.doUnmock("zod"); + vi.resetModules(); + } + }); + + it("computes the exact shape on first read, against a genuinely fresh module instance", async () => { + // Every describe block above this one reads .MathMlAttribute/.MathMlElement/.MathMlNode on the file's own shared top-level CONTENT_DEFS import, caching the getters' computed value the first time any of them runs. A test reading that already-cached value never re-executes getMathMlJsonSchemas/mathMlDef/cacheMathMlDef itself, so Stryker's own per-test coverage never attributes those functions' lines to such a test -- only the test that FIRST computes the value (whichever runs earliest in file order) gets credited, and every later assertion against the resulting value, however exact, is invisible to a mutant confined to those lines. A fresh module instance forces the computation to happen here, inside this test's own coverage, which is what actually lets an assertion here kill a mutation to that code. + vi.resetModules(); + const fresh = await import("./content-json-schema-defs"); + expect(fresh.CONTENT_DEFS.MathMlAttribute).toStrictEqual({ + type: "object", + properties: { + name: { type: "string" }, + value: { type: "string" }, + }, + required: ["name", "value"], + additionalProperties: false, + }); + expect(fresh.CONTENT_DEFS.MathMlElement).toStrictEqual({ + type: "object", + properties: { + type: { type: "string", const: "element" }, + tag: { type: "string" }, + attributes: { + type: "array", + items: { $ref: "#/$defs/MathMlAttribute" }, + }, + children: { type: "array", items: { $ref: "#/$defs/MathMlNode" } }, + }, + required: ["type", "tag", "attributes", "children"], + additionalProperties: false, + }); + // The cached value is a plain, non-enumerable-defeating, non-writable, configurable property -- exactly what cacheMathMlDef's own Object.defineProperty call states. + const descriptor = Object.getOwnPropertyDescriptor( + fresh.CONTENT_DEFS, + "MathMlAttribute", + ); + expect(descriptor?.enumerable).toBe(true); + expect(descriptor?.configurable).toBe(true); + expect(descriptor?.writable).toBe(false); + // The cached property is still enumerable, so a plain Object.keys/spread over CONTENT_DEFS still sees it once resolved -- it never silently drops out of the object's own key set. + expect(Object.keys(fresh.CONTENT_DEFS)).toContain("MathMlAttribute"); + }); +}); + +describe("EMBEDDED_OBJECT_KINDS", () => { + it("is the exact six-member vocabulary, in declared order", () => { + expect(EMBEDDED_OBJECT_KINDS).toStrictEqual([ + "formula", + "wordprocessing", + "presentation", + "spreadsheet", + "drawing", + "chart", + ]); + }); +}); + +// Genuine, independent coverage for the fragments this file's own top comment says the live z.toJSONSchema() comparison cannot reach: the nine package-tree group wrappers (recursive through their own children arrays, downstream of package-node.ts's z.custom() guards) and ContentEmbeddedObject/ContentEmbeddedObjectBlock (excluded from the comparison for the documented cross-file-cycle reason). Each expectation below is hand-transcribed from this file's own CONTENT_DEFS literal -- the same "pin the exact fixed shape" treatment the MathML describe block above gives the three entries the comparison also cannot reach, so a literal genuinely edited here (a wrong $ref, a dropped required key, additionalProperties flipped) fails one of these instead of silently surviving. +describe("CONTENT_DEFS's package-tree and embedded-object fragments (hard-coded shape, outside the live comparison's reach)", () => { + it("ContentEmbeddedObject carries the shared embedded-object fields minus the block-level kind discriminant", () => { + expect(CONTENT_DEFS.ContentEmbeddedObject).toStrictEqual({ + type: "object", + properties: { + objectKind: { type: "string", enum: EMBEDDED_OBJECT_KINDS }, + document: { $ref: CONTENT_DOCUMENT_URI }, + frame: { $ref: "#/$defs/Box" }, + anchorRow: { type: "integer", minimum: 0, maximum: MAX_SAFE_INTEGER }, + anchorColumn: { + type: "integer", + minimum: 0, + maximum: MAX_SAFE_INTEGER, + }, + offsetXPt: { type: "number" }, + offsetYPt: { type: "number" }, + source: { $ref: "#/$defs/SourceResidue" }, + }, + required: ["objectKind", "document", "frame"], + additionalProperties: false, + }); + }); + + it("ContentEmbeddedObjectBlock adds the block-level kind discriminant, sourcePath, and frames", () => { + expect(CONTENT_DEFS.ContentEmbeddedObjectBlock).toStrictEqual({ + type: "object", + properties: { + kind: { type: "string", const: "embeddedObject" }, + objectKind: { type: "string", enum: EMBEDDED_OBJECT_KINDS }, + document: { $ref: CONTENT_DOCUMENT_URI }, + frame: { $ref: "#/$defs/Box" }, + sourcePath: { type: "string" }, + source: { $ref: "#/$defs/SourceResidue" }, + frames: { type: "array", items: { $ref: "#/$defs/LayoutFrame" } }, + anchorRow: { type: "integer", minimum: 0, maximum: MAX_SAFE_INTEGER }, + anchorColumn: { + type: "integer", + minimum: 0, + maximum: MAX_SAFE_INTEGER, + }, + offsetXPt: { type: "number" }, + offsetYPt: { type: "number" }, + }, + required: ["kind", "objectKind", "document", "frame"], + additionalProperties: false, + }); + }); + + it("ContentBlock is a oneOf over the seven ContentBlock variants, in declared order", () => { + expect(CONTENT_DEFS.ContentBlock).toStrictEqual({ + oneOf: [ + { $ref: "#/$defs/ContentParagraph" }, + { $ref: "#/$defs/ContentTable" }, + { $ref: "#/$defs/ContentImageBlock" }, + { $ref: "#/$defs/ContentPageBreak" }, + { $ref: "#/$defs/ContentEmbeddedObjectBlock" }, + { $ref: "#/$defs/ContentConstructStart" }, + { $ref: "#/$defs/ContentConstructEnd" }, + ], + }); + }); + + it("TreeBlockLeaf is ContentBlock minus the two construct boundary markers", () => { + expect(CONTENT_DEFS.TreeBlockLeaf).toStrictEqual({ + oneOf: [ + { $ref: "#/$defs/ContentParagraph" }, + { $ref: "#/$defs/ContentTable" }, + { $ref: "#/$defs/ContentImageBlock" }, + { $ref: "#/$defs/ContentPageBreak" }, + { $ref: "#/$defs/ContentEmbeddedObjectBlock" }, + ], + }); + }); + + const SECTION_FLOW_CHILDREN = [ + { $ref: "#/$defs/HeadingGroup" }, + { $ref: "#/$defs/ListGroup" }, + { $ref: "#/$defs/SectionConstructGroup" }, + { $ref: "#/$defs/TreeBlockLeaf" }, + ]; + + it("SectionGroup wraps a SectionDescriptor over the section-flow child vocabulary", () => { + expect(CONTENT_DEFS.SectionGroup).toStrictEqual({ + type: "object", + properties: { + node: { $ref: "#/$defs/SectionDescriptor" }, + style: { type: "string" }, + children: { type: "array", items: { oneOf: SECTION_FLOW_CHILDREN } }, + }, + required: ["node", "children"], + additionalProperties: false, + }); + }); + + it("HeadingGroup wraps a HeadingParagraph over the identical section-flow child vocabulary", () => { + expect(CONTENT_DEFS.HeadingGroup).toStrictEqual({ + type: "object", + properties: { + node: { $ref: "#/$defs/HeadingParagraph" }, + style: { type: "string" }, + children: { type: "array", items: { oneOf: SECTION_FLOW_CHILDREN } }, + }, + required: ["node", "children"], + additionalProperties: false, + }); + }); + + const LIST_FLOW_CHILDREN = [ + { $ref: "#/$defs/ListGroup" }, + { $ref: "#/$defs/ShapeConstructGroup" }, + { $ref: "#/$defs/TreeBlockLeaf" }, + ]; + + it("ListGroup wraps a ListParagraph over the list/shape-flow child vocabulary -- never HeadingGroup", () => { + expect(CONTENT_DEFS.ListGroup).toStrictEqual({ + type: "object", + properties: { + node: { $ref: "#/$defs/ListParagraph" }, + style: { type: "string" }, + children: { type: "array", items: { oneOf: LIST_FLOW_CHILDREN } }, + }, + required: ["node", "children"], + additionalProperties: false, + }); + }); + + it("SlideGroup wraps a SlideDescriptor over shape groups only, never a bare leaf", () => { + expect(CONTENT_DEFS.SlideGroup).toStrictEqual({ + type: "object", + properties: { + node: { $ref: "#/$defs/SlideDescriptor" }, + style: { type: "string" }, + children: { type: "array", items: { $ref: "#/$defs/ShapeGroup" } }, + }, + required: ["node", "children"], + additionalProperties: false, + }); + }); + + it("ShapeGroup wraps a ShapeDescriptor over the list/shape-flow child vocabulary", () => { + expect(CONTENT_DEFS.ShapeGroup).toStrictEqual({ + type: "object", + properties: { + node: { $ref: "#/$defs/ShapeDescriptor" }, + style: { type: "string" }, + children: { type: "array", items: { oneOf: LIST_FLOW_CHILDREN } }, + }, + required: ["node", "children"], + additionalProperties: false, + }); + }); + + it("SheetGroup wraps a SheetDescriptor over sheet images then whole embedded objects", () => { + expect(CONTENT_DEFS.SheetGroup).toStrictEqual({ + type: "object", + properties: { + node: { $ref: "#/$defs/SheetDescriptor" }, + style: { type: "string" }, + children: { + type: "array", + items: { + oneOf: [ + { $ref: "#/$defs/ContentSheetImage" }, + { $ref: "#/$defs/ContentEmbeddedObject" }, + ], + }, + }, + }, + required: ["node", "children"], + additionalProperties: false, + }); + }); + + it("DrawPageGroup wraps a DrawPageDescriptor over shape groups then vector leaves", () => { + expect(CONTENT_DEFS.DrawPageGroup).toStrictEqual({ + type: "object", + properties: { + node: { $ref: "#/$defs/DrawPageDescriptor" }, + style: { type: "string" }, + children: { + type: "array", + items: { + oneOf: [ + { $ref: "#/$defs/ShapeGroup" }, + { $ref: "#/$defs/ContentVector" }, + ], + }, + }, + }, + required: ["node", "children"], + additionalProperties: false, + }); + }); + + it("SectionConstructGroup wraps a ConstructDescriptor over the section-flow child vocabulary", () => { + expect(CONTENT_DEFS.SectionConstructGroup).toStrictEqual({ + type: "object", + properties: { + node: { $ref: "#/$defs/ConstructDescriptor" }, + style: { type: "string" }, + children: { type: "array", items: { oneOf: SECTION_FLOW_CHILDREN } }, + }, + required: ["node", "children"], + additionalProperties: false, + }); + }); + + it("ShapeConstructGroup wraps a ConstructDescriptor over the list/shape-flow child vocabulary", () => { + expect(CONTENT_DEFS.ShapeConstructGroup).toStrictEqual({ + type: "object", + properties: { + node: { $ref: "#/$defs/ConstructDescriptor" }, + style: { type: "string" }, + children: { type: "array", items: { oneOf: LIST_FLOW_CHILDREN } }, + }, + required: ["node", "children"], + additionalProperties: false, + }); + }); }); diff --git a/packages/document-schema.js/src/content.test.ts b/packages/document-schema.js/src/content.test.ts index 6607676cc1..ca5f46d452 100644 --- a/packages/document-schema.js/src/content.test.ts +++ b/packages/document-schema.js/src/content.test.ts @@ -9,6 +9,7 @@ import { ContentConstructEndSchema, ContentConstructStartSchema, type ContentDocument, + ContentDefinedNameSchema, ContentDocumentSchema, type ContentEmbeddedObject, ContentEmbeddedObjectSchema, @@ -2834,3 +2835,259 @@ describe("ContentSection.breakType (the section-break kind)", () => { }); }); }); + +describe("isContentBlock's per-kind guards reject a partly-invalid array, not just a wholly-invalid one", () => { + it("rejects a paragraph whose runs array has even one invalid run", () => { + expect( + isContentBlock({ + kind: "paragraph", + runs: [{ text: "ok" }, { text: 5 }], + }), + ).toBe(false); + }); + + it("rejects a paragraph whose constructs array has even one invalid extent", () => { + expect( + isContentBlock({ + kind: "paragraph", + runs: [{ text: "ok" }], + constructs: [ + { + descriptor: { kind: "anchor", anchorType: "bookmark", name: "a" }, + startRun: 0, + endRun: 1, + }, + { startRun: 0, endRun: 1 }, + ], + }), + ).toBe(false); + }); + + it("rejects a table whose rows array has even one invalid row", () => { + expect( + isContentBlock({ + kind: "table", + rows: [ + { cells: [{ blocks: [] }] }, + { cells: [{ blocks: [{ kind: "bogus" }] }] }, + ], + columnWidthsPt: [10], + }), + ).toBe(false); + }); + + it("rejects a table whose columnWidthsPt array has even one non-number entry", () => { + expect( + isContentBlock({ + kind: "table", + rows: [{ cells: [{ blocks: [] }] }], + columnWidthsPt: [10, "20"], + }), + ).toBe(false); + }); + + it("rejects a table row whose cells array has even one invalid cell", () => { + expect( + isContentBlock({ + kind: "table", + rows: [ + { + cells: [{ blocks: [] }, { blocks: [{ kind: "bogus" }] }], + }, + ], + columnWidthsPt: [10, 20], + }), + ).toBe(false); + }); + + it("rejects a table cell whose blocks array has even one invalid block", () => { + expect( + isContentBlock({ + kind: "table", + rows: [ + { + cells: [ + { + blocks: [{ kind: "paragraph", runs: [] }, { kind: "bogus" }], + }, + ], + }, + ], + columnWidthsPt: [10], + }), + ).toBe(false); + }); + + it("rejects an image whose base64 or heightPt is not the expected primitive type, even when every other field is valid", () => { + expect( + isContentBlock({ + kind: "image", + format: "png", + base64: 5, + widthPt: 1, + heightPt: 1, + }), + ).toBe(false); + expect( + isContentBlock({ + kind: "image", + format: "png", + base64: "AA==", + widthPt: 1, + heightPt: "1", + }), + ).toBe(false); + }); + + it("rejects a table row whose heightPt is present but not a number", () => { + expect( + isContentBlock({ + kind: "table", + rows: [{ cells: [{ blocks: [] }], heightPt: "10" }], + columnWidthsPt: [], + }), + ).toBe(false); + }); +}); + +describe("isContentConstructStart never accepts a value whose own kind isn't constructStart, even with an otherwise-valid descriptor", () => { + it("rejects a construct-shaped value carrying the wrong kind discriminant", () => { + expect( + isContentConstructStart({ + kind: "constructEnd", + descriptor: { kind: "anchor", anchorType: "bookmark", name: "b1" }, + }), + ).toBe(false); + }); +}); + +describe("isRunConstructExtent rejects an out-of-range run bound, not just a non-integer one", () => { + it("rejects a negative startRun/endRun", () => { + const descriptor: ConstructDescriptor = { + kind: "anchor", + anchorType: "bookmark", + name: "b1", + }; + expect(isRunConstructExtent({ descriptor, startRun: -1, endRun: 1 })).toBe( + false, + ); + expect(isRunConstructExtent({ descriptor, startRun: 0, endRun: -1 })).toBe( + false, + ); + }); +}); + +describe("isRunConstructExtent rejects a non-record value", () => { + it("rejects null, a string, and an array", () => { + expect(isRunConstructExtent(null)).toBe(false); + expect(isRunConstructExtent("a string")).toBe(false); + expect(isRunConstructExtent([])).toBe(false); + }); +}); + +describe("isContentBlock's embeddedObject arm rejects each individually-invalid field", () => { + const validEmbedded = () => ({ + kind: "embeddedObject" as const, + objectKind: "formula" as const, + document: formulaDocument(), + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }); + + it("accepts a genuinely valid embedded object, and every recognised objectKind", () => { + expect(isContentBlock(validEmbedded())).toBe(true); + for (const objectKind of [ + "formula", + "wordprocessing", + "presentation", + "spreadsheet", + "drawing", + "chart", + ] as const) { + expect(isContentBlock({ ...validEmbedded(), objectKind })).toBe(true); + } + }); + + it("rejects an unrecognised objectKind", () => { + expect(isContentBlock({ ...validEmbedded(), objectKind: "bogus" })).toBe( + false, + ); + }); + + it("rejects a malformed frame", () => { + expect( + isContentBlock({ ...validEmbedded(), frame: { xPt: 0, yPt: 0 } }), + ).toBe(false); + }); + + it("rejects a malformed document", () => { + expect( + isContentBlock({ ...validEmbedded(), document: { kind: "bogus" } }), + ).toBe(false); + }); + + it("rejects a non-integer or negative anchorRow, and accepts a valid one", () => { + expect(isContentBlock({ ...validEmbedded(), anchorRow: 1.5 })).toBe(false); + expect(isContentBlock({ ...validEmbedded(), anchorRow: -1 })).toBe(false); + expect(isContentBlock({ ...validEmbedded(), anchorRow: "1" })).toBe(false); + expect(isContentBlock({ ...validEmbedded(), anchorRow: 1 })).toBe(true); + expect(isContentBlock({ ...validEmbedded(), anchorRow: 0 })).toBe(true); + }); + + it("rejects a non-integer or negative anchorColumn, and accepts a valid one", () => { + expect(isContentBlock({ ...validEmbedded(), anchorColumn: 1.5 })).toBe( + false, + ); + expect(isContentBlock({ ...validEmbedded(), anchorColumn: -1 })).toBe( + false, + ); + expect(isContentBlock({ ...validEmbedded(), anchorColumn: "1" })).toBe( + false, + ); + expect(isContentBlock({ ...validEmbedded(), anchorColumn: 1 })).toBe(true); + expect(isContentBlock({ ...validEmbedded(), anchorColumn: 0 })).toBe(true); + }); + + it("rejects a non-number offsetXPt/offsetYPt, and accepts a valid one", () => { + expect(isContentBlock({ ...validEmbedded(), offsetXPt: "1" })).toBe(false); + expect(isContentBlock({ ...validEmbedded(), offsetXPt: 1 })).toBe(true); + expect(isContentBlock({ ...validEmbedded(), offsetYPt: "1" })).toBe(false); + expect(isContentBlock({ ...validEmbedded(), offsetYPt: 1 })).toBe(true); + }); +}); + +describe("ContentDefinedNameSchema", () => { + it("validates a workbook-global defined name", () => { + expect( + ContentDefinedNameSchema.safeParse({ + name: "TaxRate", + refersTo: "Sheet1!$B$2", + }).success, + ).toBe(true); + }); + + it("validates a sheet-scoped defined name", () => { + expect( + ContentDefinedNameSchema.safeParse({ + name: "TaxRate", + refersTo: "$B$2", + scopeSheetIndex: 0, + }).success, + ).toBe(true); + }); + + it("rejects a missing name or refersTo, and a negative scopeSheetIndex", () => { + expect( + ContentDefinedNameSchema.safeParse({ refersTo: "Sheet1!$B$2" }).success, + ).toBe(false); + expect( + ContentDefinedNameSchema.safeParse({ name: "TaxRate" }).success, + ).toBe(false); + expect( + ContentDefinedNameSchema.safeParse({ + name: "TaxRate", + refersTo: "Sheet1!$B$2", + scopeSheetIndex: -1, + }).success, + ).toBe(false); + }); +}); diff --git a/packages/document-schema.js/src/content.ts b/packages/document-schema.js/src/content.ts index a91ebe9260..982966bd70 100644 --- a/packages/document-schema.js/src/content.ts +++ b/packages/document-schema.js/src/content.ts @@ -251,10 +251,9 @@ export type ContentFloatPosition = z.infer; export const IMAGE_FORMATS = ["png", "jpeg", "svg", "gif"] as const; export type ImageFormat = (typeof IMAGE_FORMATS)[number]; +// No separate `typeof value === "string"` guard: Array.prototype.includes compares by strict equality against IMAGE_FORMATS's own string elements, so it can only ever return true for a value that already IS one of those strings -- a non-string value never satisfies it either way, making the typeof check redundant rather than a genuine second condition. function isImageFormat(value: unknown): value is ImageFormat { - return ( - typeof value === "string" && IMAGE_FORMATS.includes(value as ImageFormat) - ); + return IMAGE_FORMATS.includes(value as ImageFormat); } // The source's own compressed bytes for an image filter this family has no encoder for -- JBIG2 (ITU-T T.88) and JPEG 2000 (ISO/IEC 15444-1). pdf-codec decodes both for real on read, but its writer can only re-emit such an image by re-encoding the decoded pixels through a filter it does have an encoder for, since a hand-written JBIG2 encoder is research-grade symbol-dictionary design and a JPEG 2000 encoder is the full EBCOT/wavelet stack -- so a pdf-to-pdf round trip through this model was lossy for exactly these two filters. Carrying the original stream beside the canonical decoded representation lets a same-format writer re-embed it verbatim (zero generation loss), while every other consumer keeps reading `base64`, which stays the always-decodable canonical. Deliberately never set for a filter this family can already encode: a jpeg IS its own compressed bytes (format: 'jpeg' already passes through verbatim in both directions), and flate/ccitt are re-encoded from pixels losslessly (pdf-codec's bilevel writer even prefers CCITT G4 by size), so an original for those would be a second spelling of data the writer can already reproduce. jbig2GlobalsBase64 carries the image's /JBIG2Globals stream when the source had one -- without it, a symbol-dictionary-carrying JBIG2 stream cannot decode, so verbatim re-embedding without the globals would produce a file no viewer can render. From fa47c2d0358eb97205b81fea380cfcd0a3b58441 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:17:03 +0100 Subject: [PATCH 06/17] test(document-schema.js): pin decompose's heading/list pop-loop boundaries Covers the stack re-check a pop loop needs when a single open heading/list item shrinks to zero, and when two open items in a row must both pop -- the boundary where a wrong stack-top index would stop after only one pop instead of continuing. Also covers a non-paragraph leaf attaching inside a single open list item's own children (both at a section root and inside a shape), and pins ConstructMarkerImbalanceError's own name and message text for both imbalance kinds. --- .../document-schema.js/src/decompose.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/packages/document-schema.js/src/decompose.test.ts b/packages/document-schema.js/src/decompose.test.ts index 1f73d8c835..43228d54c1 100644 --- a/packages/document-schema.js/src/decompose.test.ts +++ b/packages/document-schema.js/src/decompose.test.ts @@ -25,6 +25,7 @@ import { isHeadingGroupNode, isListGroupNode, isSectionConstructGroupNode, + isSlideGroupNode, type SheetGroupNode, } from "./package-node"; @@ -196,6 +197,90 @@ describe("wordprocessing decomposition", () => { ]); }); + it("pops a single open heading of equal level -- two consecutive H1s are siblings, not nested", () => { + // With exactly one heading on the stack when the second H1 arrives, the pop loop must read that one entry (stack top, not stack[1] -- an empty read past a single-element stack would never enter the loop at all, leaving the first H1 wrongly still open). + const h1a = paragraph("First", { headingLevel: 1 }); + const h1b = paragraph("Second", { headingLevel: 1 }); + const doc = wordprocessingDoc([[h1a, h1b]]); + expect(decompose(doc)).toEqual([ + { + node: { kind: "section", ...SECTION_GEOMETRY }, + children: [ + { node: h1a, children: [] }, + { node: h1b, children: [] }, + ], + }, + ]); + }); + + it("pops two open headings in a row -- the pop loop's own re-check (not just its first read) must see the stack shrink to one element and keep popping", () => { + // H1 -> H2 (nested under it) -> H1: the second H1 must pop BOTH the H2 and the first H1, ending as a sibling of the first H1 at the section root. After the loop's first pop (H2 gone, stack now [h1a], length 1), the loop's own re-check re-reads the stack top -- a wrong index there (reading position 1 of a 1-element array) would see undefined and stop after only one pop, wrongly leaving the new H1 nested under the first one instead of a sibling of it. + const h1a = paragraph("First", { headingLevel: 1 }); + const h2 = paragraph("Nested", { headingLevel: 2 }); + const h1b = paragraph("Second", { headingLevel: 1 }); + const doc = wordprocessingDoc([[h1a, h2, h1b]]); + expect(decompose(doc)).toEqual([ + { + node: { kind: "section", ...SECTION_GEOMETRY }, + children: [ + { node: h1a, children: [{ node: h2, children: [] }] }, + { node: h1b, children: [] }, + ], + }, + ]); + }); + + it("pops a single open list item of equal level -- two consecutive same-level items are siblings, not nested", () => { + const first = paragraph("First item", { listLevel: 0 }); + const second = paragraph("Second item", { listLevel: 0 }); + const doc = wordprocessingDoc([[first, second]]); + expect(decompose(doc)).toEqual([ + { + node: { kind: "section", ...SECTION_GEOMETRY }, + children: [ + { node: first, children: [] }, + { node: second, children: [] }, + ], + }, + ]); + }); + + it("pops two open list items in a row -- the pop loop's own re-check must see the stack shrink to one element and keep popping", () => { + // level0 -> level1 (nested) -> level0: the same multi-pop boundary as the heading test above, on the list stack this time. + const first = paragraph("First", { listLevel: 0 }); + const nested = paragraph("Nested", { listLevel: 1 }); + const second = paragraph("Second", { listLevel: 0 }); + const doc = wordprocessingDoc([[first, nested, second]]); + expect(decompose(doc)).toEqual([ + { + node: { kind: "section", ...SECTION_GEOMETRY }, + children: [ + { node: first, children: [{ node: nested, children: [] }] }, + { node: second, children: [] }, + ], + }, + ]); + }); + + it("attaches a non-paragraph leaf inside a single open list item's own children, not at the section root", () => { + // Exactly one list item open (stack length 1) when the leaf is encountered -- the same single-element-stack boundary the heading/list pop loops above are pinned against, this time for the plain top-of-stack read walkSectionBlocks uses to route a non-paragraph leaf. + const item = paragraph("Item", { listLevel: 0 }); + const img: ContentBlock = { + kind: "image", + format: "png", + base64: "aW1hZ2U=", + widthPt: 10, + heightPt: 10, + }; + const doc = wordprocessingDoc([[item, img]]); + expect(decompose(doc)).toEqual([ + { + node: { kind: "section", ...SECTION_GEOMETRY }, + children: [{ node: item, children: [img] }], + }, + ]); + }); + it("decomposes an empty document to an empty root array (the envelope carries the kind)", () => { expect( decompose({ kind: "wordprocessing", metadata: {}, sections: [] }), @@ -207,6 +292,38 @@ describe("wordprocessing decomposition", () => { }); describe("presentation decomposition", () => { + it("attaches a non-paragraph leaf inside a shape's own single open list item, not at the shape root", () => { + // The shape-flow analogue of the identical section-flow boundary test above: exactly one list item open on the shape's own list stack when the leaf is encountered. + const item = paragraph("Item", { listLevel: 0 }); + const img: ContentBlock = { + kind: "image", + format: "png", + base64: "aW1hZ2U=", + widthPt: 10, + heightPt: 10, + }; + const doc: ContentDocument = { + kind: "presentation", + metadata: {}, + slides: [ + { + size: { widthPt: 960, heightPt: 540 }, + shapes: [shape([item, img])], + notes: "", + }, + ], + }; + const [slideGroup] = decompose(doc); + if ( + slideGroup === undefined || + !isSlideGroupNode(slideGroup) || + slideGroup.children.length !== 1 + ) + throw new Error("expected one shape group back"); + const [shapeGroup] = slideGroup.children; + expect(shapeGroup?.children).toEqual([{ node: item, children: [img] }]); + }); + it("groups each shape separately and never flattens a slide across its shapes", () => { const shapeA = shape([ paragraph("A top", { listLevel: 0 }), @@ -743,6 +860,10 @@ describe("construct marker imbalance", () => { } catch (error) { if (!(error instanceof ConstructMarkerImbalanceError)) throw error; expect(error.imbalance).toEqual({ kind: "unmatchedEnd", index: 1 }); + expect(error.name).toBe("ConstructMarkerImbalanceError"); + expect(error.message).toBe( + "decompose: the constructEnd marker at index 1 of this container's block flow closes no open construct", + ); } }); @@ -759,6 +880,9 @@ describe("construct marker imbalance", () => { } catch (error) { if (!(error instanceof ConstructMarkerImbalanceError)) throw error; expect(error.imbalance).toEqual({ kind: "unclosedStart", index: 0 }); + expect(error.message).toBe( + "decompose: the constructStart marker at index 0 of this container's block flow is never closed", + ); } }); From 68c8223e8bc089ca91ab725295a6e4837615aab1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:17:13 +0100 Subject: [PATCH 07/17] test(document-schema.js): pin the styles-table entry schemas' field-level boundaries Adds coverage for StyleEntry/StyleParagraphProperties/StyleRunProperties and the ban-list fields (frames, sourcePath, styleId, list) they reject, plus numeric-range and enum-membership boundaries on the fields each schema accepts. --- .../src/definitions.test.ts | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) diff --git a/packages/document-schema.js/src/definitions.test.ts b/packages/document-schema.js/src/definitions.test.ts index 412882e3a9..e68015ec11 100644 --- a/packages/document-schema.js/src/definitions.test.ts +++ b/packages/document-schema.js/src/definitions.test.ts @@ -158,6 +158,20 @@ describe("overlayStyleEntries", () => { expect("paragraph" in merged).toBe(false); }); + it("emits no run key when neither side carries a run half", () => { + const merged = overlayStyleEntries( + { paragraph: { alignment: "left" } }, + {}, + ); + expect("run" in merged).toBe(false); + }); + + it("returns the inner run object by reference when outer carries no run half", () => { + const innerRun = { bold: true }; + const merged = overlayStyleEntries({}, { run: innerRun }); + expect(merged.run).toBe(innerRun); + }); + it("explicitly-present-undefined inner values do not overwrite outer -- absence is not a value", () => { const merged = overlayStyleEntries(BODY, { paragraph: { alignment: undefined }, @@ -242,3 +256,326 @@ describe("applyParagraphStyleProperties and applyRunStyleProperties", () => { expect(effective.text).toBe("x"); }); }); + +describe("overlayStyleEntries isolates each paragraph property's own overlay guard", () => { + const outerParagraph: StyleEntry = { + paragraph: { + list: { level: 0 }, + spacingBeforePt: 1, + lineSpacing: 1.5, + indentLeftPt: 10, + indentFirstLinePt: 20, + }, + }; + + it("list: fills from inner, and leaves spacingBeforePt untouched", () => { + const merged = overlayStyleEntries(outerParagraph, { + paragraph: { list: { level: 1 } }, + }); + expect(merged.paragraph?.list).toEqual({ level: 1 }); + expect(merged.paragraph?.spacingBeforePt).toBe(1); + }); + + it("spacingBeforePt: fills from inner, and leaves lineSpacing untouched", () => { + const merged = overlayStyleEntries(outerParagraph, { + paragraph: { spacingBeforePt: 5 }, + }); + expect(merged.paragraph?.spacingBeforePt).toBe(5); + expect(merged.paragraph?.lineSpacing).toBe(1.5); + }); + + it("lineSpacing: fills from inner, and leaves indentLeftPt untouched", () => { + const merged = overlayStyleEntries(outerParagraph, { + paragraph: { lineSpacing: 3 }, + }); + expect(merged.paragraph?.lineSpacing).toBe(3); + expect(merged.paragraph?.indentLeftPt).toBe(10); + }); + + it("indentLeftPt: fills from inner, and leaves indentFirstLinePt untouched", () => { + const merged = overlayStyleEntries(outerParagraph, { + paragraph: { indentLeftPt: 99 }, + }); + expect(merged.paragraph?.indentLeftPt).toBe(99); + expect(merged.paragraph?.indentFirstLinePt).toBe(20); + }); + + it("indentFirstLinePt: fills from inner, and leaves list untouched -- closing the cycle back to the first field", () => { + const merged = overlayStyleEntries(outerParagraph, { + paragraph: { indentFirstLinePt: 99 }, + }); + expect(merged.paragraph?.indentFirstLinePt).toBe(99); + expect(merged.paragraph?.list).toEqual({ level: 0 }); + }); +}); + +describe("overlayStyleEntries isolates each run property's own overlay guard", () => { + const outerRun: StyleEntry = { + run: { + bold: true, + italic: true, + underline: true, + strike: true, + fontFamily: "Arial", + sizePt: 10, + color: { r: 0, g: 0, b: 0 }, + }, + }; + + it("bold: fills from inner, and leaves italic untouched", () => { + const merged = overlayStyleEntries(outerRun, { run: { bold: false } }); + expect(merged.run?.bold).toBe(false); + expect(merged.run?.italic).toBe(true); + }); + + it("italic: fills from inner, and leaves underline untouched", () => { + const merged = overlayStyleEntries(outerRun, { run: { italic: false } }); + expect(merged.run?.italic).toBe(false); + expect(merged.run?.underline).toBe(true); + }); + + it("underline: fills from inner, and leaves strike untouched", () => { + const merged = overlayStyleEntries(outerRun, { + run: { underline: false }, + }); + expect(merged.run?.underline).toBe(false); + expect(merged.run?.strike).toBe(true); + }); + + it("strike: fills from inner, and leaves fontFamily untouched", () => { + const merged = overlayStyleEntries(outerRun, { run: { strike: false } }); + expect(merged.run?.strike).toBe(false); + expect(merged.run?.fontFamily).toBe("Arial"); + }); + + it("fontFamily: fills from inner, and leaves sizePt untouched", () => { + const merged = overlayStyleEntries(outerRun, { + run: { fontFamily: "Times" }, + }); + expect(merged.run?.fontFamily).toBe("Times"); + expect(merged.run?.sizePt).toBe(10); + }); + + it("sizePt: fills from inner, and leaves color untouched -- also proving the merge starts from a copy of outer, not an empty object", () => { + const merged = overlayStyleEntries(outerRun, { run: { sizePt: 20 } }); + expect(merged.run?.sizePt).toBe(20); + expect(merged.run?.color).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it("color: fills from inner, and leaves bold untouched -- closing the cycle back to the first field", () => { + const merged = overlayStyleEntries(outerRun, { + run: { color: { r: 1, g: 1, b: 1 } }, + }); + expect(merged.run?.color).toEqual({ r: 1, g: 1, b: 1 }); + expect(merged.run?.bold).toBe(true); + }); +}); + +describe("applyParagraphStyleProperties isolates each field's own gap-fill guard", () => { + const paragraphBase: ContentParagraph = { kind: "paragraph", runs: [] }; + + it("adds no property at all when the style entry supplies nothing, for every field", () => { + const untouched = applyParagraphStyleProperties({}, paragraphBase); + for (const field of [ + "alignment", + "list", + "spacingBeforePt", + "spacingAfterPt", + "lineSpacing", + "indentLeftPt", + "indentFirstLinePt", + "pageBreakBefore", + "pageBreakAfter", + ]) { + expect(field in untouched).toBe(false); + } + }); + + it("alignment: the node's own value wins, and the style fills a real gap", () => { + const node: ContentParagraph = { ...paragraphBase, alignment: "center" }; + expect( + applyParagraphStyleProperties({ alignment: "left" }, node).alignment, + ).toBe("center"); + expect( + applyParagraphStyleProperties({ alignment: "left" }, paragraphBase) + .alignment, + ).toBe("left"); + }); + + it("list: the node's own value wins, and the style fills a real gap", () => { + const node: ContentParagraph = { ...paragraphBase, list: { level: 2 } }; + expect( + applyParagraphStyleProperties({ list: { level: 5 } }, node).list, + ).toEqual({ level: 2 }); + expect( + applyParagraphStyleProperties({ list: { level: 5 } }, paragraphBase).list, + ).toEqual({ level: 5 }); + }); + + it("spacingBeforePt: the node's own value wins, and the style fills a real gap", () => { + const node: ContentParagraph = { ...paragraphBase, spacingBeforePt: 3 }; + expect( + applyParagraphStyleProperties({ spacingBeforePt: 9 }, node) + .spacingBeforePt, + ).toBe(3); + expect( + applyParagraphStyleProperties({ spacingBeforePt: 9 }, paragraphBase) + .spacingBeforePt, + ).toBe(9); + }); + + it("spacingAfterPt: the node's own value wins, and the style fills a real gap", () => { + const node: ContentParagraph = { ...paragraphBase, spacingAfterPt: 3 }; + expect( + applyParagraphStyleProperties({ spacingAfterPt: 9 }, node).spacingAfterPt, + ).toBe(3); + expect( + applyParagraphStyleProperties({ spacingAfterPt: 9 }, paragraphBase) + .spacingAfterPt, + ).toBe(9); + }); + + it("lineSpacing: the node's own value wins, and the style fills a real gap", () => { + const node: ContentParagraph = { ...paragraphBase, lineSpacing: 1 }; + expect( + applyParagraphStyleProperties({ lineSpacing: 2 }, node).lineSpacing, + ).toBe(1); + expect( + applyParagraphStyleProperties({ lineSpacing: 2 }, paragraphBase) + .lineSpacing, + ).toBe(2); + }); + + it("indentLeftPt: the node's own value wins, and the style fills a real gap", () => { + const node: ContentParagraph = { ...paragraphBase, indentLeftPt: 3 }; + expect( + applyParagraphStyleProperties({ indentLeftPt: 9 }, node).indentLeftPt, + ).toBe(3); + expect( + applyParagraphStyleProperties({ indentLeftPt: 9 }, paragraphBase) + .indentLeftPt, + ).toBe(9); + }); + + it("indentFirstLinePt: the node's own value wins, and the style fills a real gap", () => { + const node: ContentParagraph = { + ...paragraphBase, + indentFirstLinePt: 3, + }; + expect( + applyParagraphStyleProperties({ indentFirstLinePt: 9 }, node) + .indentFirstLinePt, + ).toBe(3); + expect( + applyParagraphStyleProperties({ indentFirstLinePt: 9 }, paragraphBase) + .indentFirstLinePt, + ).toBe(9); + }); + + it("pageBreakBefore: the node's own value wins, and the style fills a real gap", () => { + const node: ContentParagraph = { + ...paragraphBase, + pageBreakBefore: false, + }; + expect( + applyParagraphStyleProperties({ pageBreakBefore: true }, node) + .pageBreakBefore, + ).toBe(false); + expect( + applyParagraphStyleProperties({ pageBreakBefore: true }, paragraphBase) + .pageBreakBefore, + ).toBe(true); + }); + + it("pageBreakAfter: the node's own value wins, and the style fills a real gap", () => { + const node: ContentParagraph = { + ...paragraphBase, + pageBreakAfter: false, + }; + expect( + applyParagraphStyleProperties({ pageBreakAfter: true }, node) + .pageBreakAfter, + ).toBe(false); + expect( + applyParagraphStyleProperties({ pageBreakAfter: true }, paragraphBase) + .pageBreakAfter, + ).toBe(true); + }); +}); + +describe("applyRunStyleProperties isolates each field's own gap-fill guard", () => { + const runBase: ContentRun = { text: "x" }; + + it("adds no property at all when the style entry supplies nothing, for every field", () => { + const untouched = applyRunStyleProperties({}, runBase); + for (const field of [ + "bold", + "italic", + "underline", + "strike", + "fontFamily", + "sizePt", + "color", + ]) { + expect(field in untouched).toBe(false); + } + }); + + it("bold: the run's own value wins, and the style fills a real gap", () => { + const run: ContentRun = { ...runBase, bold: false }; + expect(applyRunStyleProperties({ bold: true }, run).bold).toBe(false); + expect(applyRunStyleProperties({ bold: true }, runBase).bold).toBe(true); + }); + + it("italic: the run's own value wins, and the style fills a real gap", () => { + const run: ContentRun = { ...runBase, italic: false }; + expect(applyRunStyleProperties({ italic: true }, run).italic).toBe(false); + expect(applyRunStyleProperties({ italic: true }, runBase).italic).toBe( + true, + ); + }); + + it("underline: the run's own value wins, and the style fills a real gap", () => { + const run: ContentRun = { ...runBase, underline: false }; + expect(applyRunStyleProperties({ underline: true }, run).underline).toBe( + false, + ); + expect( + applyRunStyleProperties({ underline: true }, runBase).underline, + ).toBe(true); + }); + + it("strike: the run's own value wins, and the style fills a real gap", () => { + const run: ContentRun = { ...runBase, strike: false }; + expect(applyRunStyleProperties({ strike: true }, run).strike).toBe(false); + expect(applyRunStyleProperties({ strike: true }, runBase).strike).toBe( + true, + ); + }); + + it("fontFamily: the run's own value wins, and the style fills a real gap", () => { + const run: ContentRun = { ...runBase, fontFamily: "Carlito" }; + expect( + applyRunStyleProperties({ fontFamily: "Arial" }, run).fontFamily, + ).toBe("Carlito"); + expect( + applyRunStyleProperties({ fontFamily: "Arial" }, runBase).fontFamily, + ).toBe("Arial"); + }); + + it("sizePt: the run's own value wins, and the style fills a real gap", () => { + const run: ContentRun = { ...runBase, sizePt: 9 }; + expect(applyRunStyleProperties({ sizePt: 20 }, run).sizePt).toBe(9); + expect(applyRunStyleProperties({ sizePt: 20 }, runBase).sizePt).toBe(20); + }); + + it("color: the run's own value wins, and the style fills a real gap", () => { + const run: ContentRun = { ...runBase, color: { r: 0, g: 0, b: 0 } }; + expect( + applyRunStyleProperties({ color: { r: 1, g: 1, b: 1 } }, run).color, + ).toEqual({ r: 0, g: 0, b: 0 }); + expect( + applyRunStyleProperties({ color: { r: 1, g: 1, b: 1 } }, runBase).color, + ).toEqual({ r: 1, g: 1, b: 1 }); + }); +}); From e97eeb2460c7aa5896b4ed528bf19891355c0c8d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:17:24 +0100 Subject: [PATCH 08/17] test(document-schema.js): pin flatten's style-resolution chain boundaries Covers the outermost-first overlay order (a nested ref's key wins over an ancestor's), a ref that resolves to no table entry, and resolution across every wrapper kind's own extent boundary. --- .../document-schema.js/src/flatten.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/packages/document-schema.js/src/flatten.test.ts b/packages/document-schema.js/src/flatten.test.ts index dd81951fec..0bb96a0f14 100644 --- a/packages/document-schema.js/src/flatten.test.ts +++ b/packages/document-schema.js/src/flatten.test.ts @@ -244,6 +244,36 @@ describe("flattenTree envelope handling", () => { }); }); + it("omits symbolTable entirely when the tree carries none, rather than an explicit undefined key", () => { + const flat = flattenTree({ + kind: "wordprocessing", + metadata: { title: "No symbol table" }, + children: [{ node: { kind: "section", ...SECTION }, children: [] }], + }); + expect("symbolTable" in flat).toBe(false); + }); + + it("omits a spreadsheet's names entirely when the tree carries none, rather than an explicit undefined key", () => { + const flat = flattenTree({ + kind: "spreadsheet", + metadata: {}, + children: [], + }); + expect("names" in flat).toBe(false); + }); + + it("carries a spreadsheet's names when present", () => { + const flat = flattenTree({ + kind: "spreadsheet", + metadata: {}, + names: [{ name: "Total", refersTo: "Sheet1!A1" }], + children: [], + }); + if (flat.kind !== "spreadsheet") + throw new Error("expected a spreadsheet back"); + expect(flat.names).toEqual([{ name: "Total", refersTo: "Sheet1!A1" }]); + }); + it("rebuilds a draw page's shapes-then-vectors partition and a sheet's images-then-embedded-objects one", () => { const vector = { kind: "rect", @@ -296,4 +326,93 @@ describe("flattenTree envelope handling", () => { sectionBlocks(wordprocessingPackage([decomposeSection(source)])), ).toEqual(source.blocks); }); + + it("returns the paragraph unchanged (same runs array reference) when the resolved entry has no run half", () => { + // applyEntry short-circuits on an undefined run half rather than re-mapping the runs array through a no-op transform -- the identity, not just the values, must survive. + const original = paragraph("keeps its runs array"); + const pkg = wordprocessingPackage( + [ + { + node: { kind: "section", ...SECTION }, + style: "s1", + children: [original], + }, + ], + { s1: { paragraph: { indentLeftPt: 20 } } }, + ); + const [block] = sectionBlocks(pkg); + if (block?.kind !== "paragraph") + throw new Error("expected a paragraph back"); + expect(block.runs).toBe(original.runs); + }); + + it("passes a non-paragraph, non-group leaf inside a list item's flow through unchanged", () => { + const image = { + kind: "image", + format: "png", + base64: "", + widthPt: 10, + heightPt: 10, + } as const; + const pkg = wordprocessingPackage([ + { + node: { kind: "section", ...SECTION }, + children: [ + { + node: { + kind: "paragraph", + list: { level: 0 }, + runs: [{ text: "item" }], + }, + children: [image], + }, + ], + }, + ]); + expect(sectionBlocks(pkg)).toEqual([ + { kind: "paragraph", list: { level: 0 }, runs: [{ text: "item" }] }, + image, + ]); + }); +}); + +describe("flattenTree's narrow group-kind guards only ever matter for a tree that violates its own type", () => { + // isHeadingGroup/isListGroup/isConstructGroup (unexported helpers) each narrow on their group's OWN node shape (headingLevel present, list present, or node.kind !== 'paragraph'). For any value that is genuinely SectionChild/ListChild-typed, TypeScript already guarantees these three conditions are mutually exclusive -- a HeadingGroupNode's anchor always carries headingLevel, a ListGroupNode's anchor always carries list, and a construct descriptor's kind is never 'paragraph'. The three tests below can only observe a wrong guard by handing flattenTree a tree that is NOT genuinely well-typed (a paragraph-anchored group carrying neither signal) -- exactly the "no re-validation, the parameter type already guarantees a real DocumentTree" contract this module's own top comment states flattenTree relies on, deliberately bypassed here with an explicit cast to prove the guard itself is still correct if that contract is ever violated. + it("a construct group is never misread as a heading or list anchor", () => { + const pkg: DocumentTree = { + kind: "wordprocessing", + metadata: {}, + children: [ + { + node: { kind: "section", ...SECTION }, + children: [ + { + node: { kind: "anchor", anchorType: "bookmark", name: "b1" }, + children: [paragraph("inside the bookmark construct")], + }, + ], + }, + ], + }; + expect(sectionBlocks(pkg)).toEqual([ + { + kind: "constructStart", + descriptor: { kind: "anchor", anchorType: "bookmark", name: "b1" }, + }, + paragraph("inside the bookmark construct"), + { kind: "constructEnd" }, + ]); + }); + + it("a paragraph-anchored group with neither a heading nor a list signal is never silently treated as a construct group", () => { + const illegalGroup = { + node: { kind: "paragraph", runs: [{ text: "anchor with no signal" }] }, + children: [], + } as unknown as HeadingGroupNode; + const pkg = wordprocessingPackage([ + { node: { kind: "section", ...SECTION }, children: [illegalGroup] }, + ]); + // Real code: isHeadingGroup/isListGroup both false, isConstructGroup's own node.kind !== 'paragraph' check is also false (this node's kind IS 'paragraph'), so this group falls through every named branch and is pushed as the group object itself -- not wrapped as a construct, which is what a mutated isConstructGroup (unconditionally true past its node/children guard) would do instead. + expect(sectionBlocks(pkg)).toEqual([illegalGroup]); + }); }); From 07fd39115e58a58b5f82fe2290b655a512a872f3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:17:29 +0100 Subject: [PATCH 09/17] test(document-schema.js): pin math/mathml schema boundaries Covers exact-rational and dimension-vector field validation on the math expression grammar, and the MathML round-trip helpers' own malformed-input rejection paths. --- packages/document-schema.js/src/math.test.ts | 90 ++++++++++++++++++- .../document-schema.js/src/mathml.test.ts | 69 ++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/packages/document-schema.js/src/math.test.ts b/packages/document-schema.js/src/math.test.ts index b2a69ed7fa..ac63afc04c 100644 --- a/packages/document-schema.js/src/math.test.ts +++ b/packages/document-schema.js/src/math.test.ts @@ -401,6 +401,13 @@ describe("the MathExpression grammar", () => { }; expect(MathMatrixSchema.safeParse(ragged).success).toBe(false); expect(MathExpressionSchema.safeParse(ragged).success).toBe(false); + + const raggedResult = MathMatrixSchema.safeParse(ragged); + if (raggedResult.success) + throw new Error("expected the ragged matrix to fail"); + expect(raggedResult.error.issues[0]?.message).toBe( + "matrix rows must all have the same number of columns", + ); }); it("keeps unparsed a first-class fallback rather than a parse failure", () => { @@ -507,6 +514,52 @@ describe("isMathExpression", () => { ).toBe(false); }); + it("rejects an uncertainty whose own unit is not a string, and accepts one that is", () => { + const base = { + kind: "qty" as const, + value: { numerator: "5", denominator: "1" }, + unit: "si:metre", + }; + expect( + isMathExpression({ + ...base, + uncertainty: { + magnitude: { numerator: "1", denominator: "10" }, + unit: 5, + }, + }), + ).toBe(false); + expect( + isMathExpression({ + ...base, + uncertainty: { + magnitude: { numerator: "1", denominator: "10" }, + unit: "si:percent", + }, + }), + ).toBe(true); + }); + + it("rejects an uncertainty whose coverageFactor is not a positive number, and accepts one that is", () => { + const base = { + kind: "qty" as const, + value: { numerator: "5", denominator: "1" }, + unit: "si:metre", + }; + const withCoverageFactor = (coverageFactor: unknown) => + isMathExpression({ + ...base, + uncertainty: { + magnitude: { numerator: "1", denominator: "10" }, + coverageFactor, + }, + }); + expect(withCoverageFactor("2")).toBe(false); + expect(withCoverageFactor(0)).toBe(false); + expect(withCoverageFactor(-1)).toBe(false); + expect(withCoverageFactor(2)).toBe(true); + }); + it("accepts a valid sym, and rejects a non-string id", () => { expect(isMathExpression({ kind: "sym", id: "symbols:mass" })).toBe(true); expect(isMathExpression({ kind: "sym", id: 5 })).toBe(false); @@ -592,11 +645,30 @@ describe("isMathExpression", () => { ).toBe(false); }); + it("rejects a row with even one invalid cell, not just when every cell is invalid", () => { + const cell = { kind: "num", numerator: "1", denominator: "1" }; + expect( + isMathExpression({ + kind: "matrix", + rows: [[cell, { kind: "bogus" }]], + }), + ).toBe(false); + }); + it("accepts a valid unparsed fallback, and rejects a non-string latex", () => { expect(isMathExpression({ kind: "unparsed", latex: "\\oint" })).toBe(true); expect(isMathExpression({ kind: "unparsed", latex: 5 })).toBe(false); }); + it("never falls through to the unparsed check for a kind the grammar does not recognise, even when the value happens to carry a string latex field", () => { + expect( + isMathExpression({ + kind: "totally-bogus", + latex: "looks unparsed-shaped", + }), + ).toBe(false); + }); + it("rejects an unrecognised kind and every non-record input", () => { expect(isMathExpression({ kind: "bogus" })).toBe(false); expect(isMathExpression(null)).toBe(false); @@ -658,14 +730,28 @@ describe("IntervalSchema", () => { }); it("rejects a min that exceeds its max", () => { + const result = IntervalSchema.safeParse({ + kind: "interval", + min: 2, + max: 1, + dimension: {}, + }); + expect(result.success).toBe(false); + if (result.success) throw new Error("expected min > max to fail"); + expect(result.error.issues[0]?.message).toBe( + "interval min must not exceed max", + ); + }); + + it("accepts a min exactly equal to max -- both bounds are inclusive", () => { expect( IntervalSchema.safeParse({ kind: "interval", - min: 2, + min: 1, max: 1, dimension: {}, }).success, - ).toBe(false); + ).toBe(true); }); }); diff --git a/packages/document-schema.js/src/mathml.test.ts b/packages/document-schema.js/src/mathml.test.ts index 4014f139b2..c417197356 100644 --- a/packages/document-schema.js/src/mathml.test.ts +++ b/packages/document-schema.js/src/mathml.test.ts @@ -121,6 +121,75 @@ describe("isMathMlNode", () => { expect(isMathMlNode("a string")).toBe(false); expect(isMathMlNode(undefined)).toBe(false); }); + + it("rejects a declaration whose attributes array carries even one malformed entry, not just when every entry is malformed", () => { + expect( + isMathMlNode({ + type: "declaration", + attributes: [ + { name: "good", value: "1" }, + { name: 2, value: "bad" }, + ], + }), + ).toBe(false); + }); + + it("rejects a pi node whose target is not a string, even when content is", () => { + expect(isMathMlNode({ type: "pi", target: 5, content: "c" })).toBe(false); + }); + + it("never falls through to the element check for a type the vocabulary does not recognise, even when the value happens to carry element-shaped fields", () => { + expect( + isMathMlNode({ + type: "bogus", + tag: "m", + attributes: [], + children: [], + }), + ).toBe(false); + }); + + it("rejects an element whose tag is not a string, even when attributes and children are otherwise valid", () => { + expect( + isMathMlNode({ type: "element", tag: 5, attributes: [], children: [] }), + ).toBe(false); + }); + + it("rejects an element whose attributes is not an array, even when tag and children are otherwise valid", () => { + expect( + isMathMlNode({ + type: "element", + tag: "m", + attributes: "not-an-array", + children: [], + }), + ).toBe(false); + }); + + it("rejects an element whose attributes array carries even one malformed entry", () => { + expect( + isMathMlNode({ + type: "element", + tag: "m", + attributes: [ + { name: "good", value: "1" }, + { name: 2, value: "bad" }, + ], + children: [], + }), + ).toBe(false); + }); + + it("rejects an element whose children array carries even one malformed entry, not just when every entry is malformed", () => { + expect( + isMathMlNode({ + type: "element", + tag: "m", + attributes: [], + children: [{ type: "text", value: "ok" }, { type: "bogus" }], + }), + ).toBe(false); + }); }); describe("MathMlNodeSchema", () => { From b0dea8767d577a49a51f713251ff7a9bb329f27a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:17:43 +0100 Subject: [PATCH 10/17] test(document-schema.js): pin schema-io's JSON round-trip error paths Covers documentFromJson's rejection of malformed input at each schema boundary and the parse-error messages it surfaces. --- .../document-schema.js/src/schema-io.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/document-schema.js/src/schema-io.test.ts b/packages/document-schema.js/src/schema-io.test.ts index f9f64a7227..a21e0b8846 100644 --- a/packages/document-schema.js/src/schema-io.test.ts +++ b/packages/document-schema.js/src/schema-io.test.ts @@ -165,6 +165,18 @@ describe("documentSchemaKindOf", () => { expect(documentSchemaKindOf("a string")).toBeUndefined(); expect(documentSchemaKindOf(["array", "not", "record"])).toBeUndefined(); }); + + it("requires the URI to actually start with the jsdelivr https origin, not merely contain it", () => { + const real = uriForVersion(installedMajor, "document-tree"); + expect( + documentSchemaKindOf({ $schema: `http://evil.example/${real}` }), + ).toBeUndefined(); + }); + + it("requires the URI to end at .schema.json, not merely start with a valid prefix", () => { + const real = uriForVersion(installedMajor, "document-tree"); + expect(documentSchemaKindOf({ $schema: `${real}.extra` })).toBeUndefined(); + }); }); describe("documentFromJson dispatches on the $schema URI", () => { @@ -297,6 +309,72 @@ describe("documentFromJson dispatches on the $schema URI", () => { ).not.toThrow(UnrecognizedDocumentSchemaError); }); + it("parses the major from the very start of the version string, not from wherever a digit first appears", () => { + // "v1.0.0" has no leading digit at all -- majorVersionOf must fail to parse it, which routes this dump into the "newer major" (upgrade) branch rather than treating captured "1" as an older major. + const dump = { + $schema: uriForVersion("v1.0.0", "document-tree"), + }; + try { + documentFromJson(dump); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(SchemaVersionMismatchError); + if (!(error instanceof SchemaVersionMismatchError)) throw error; + expect(error.message).toContain("Upgrade document-schema.js"); + } + }); + + it("captures every leading digit of the major, not just the first one", () => { + // A two-digit major well past the installed one: if only the first digit were captured, "12" would be misread as "1", which is OLDER than the installed major(7) rather than newer. + const dump = { + $schema: uriForVersion(12, "document-tree"), + }; + try { + documentFromJson(dump); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(SchemaVersionMismatchError); + if (!(error instanceof SchemaVersionMismatchError)) throw error; + expect(error.message).toContain("Upgrade document-schema.js"); + expect(error.message).not.toContain("formatVersion"); + } + }); + + it("names each error class via its own .name, not a shared or empty string", () => { + expect(new UnrecognizedDocumentSchemaError("x").name).toBe( + "UnrecognizedDocumentSchemaError", + ); + expect(new LayoutSchemaDemotedError("x").name).toBe( + "LayoutSchemaDemotedError", + ); + expect(new DocumentPackageRenamedError("x").name).toBe( + "DocumentPackageRenamedError", + ); + expect(new SchemaVersionMismatchError("x", "1.0.0", "2.0.0").name).toBe( + "SchemaVersionMismatchError", + ); + }); + + it("UnrecognizedDocumentSchemaError's message names the actual offending schema value", () => { + const error = new UnrecognizedDocumentSchemaError("bogus"); + expect(error.message).toBe( + 'documentFromJson: value has no recognized "$schema" property (expected one of the document-schema.js .schema.json URIs; found: "bogus").', + ); + }); + + it("SchemaVersionMismatchError treats an equal major as neither older nor newer -- it never actually occurs via documentFromJson (an equal major always parses), so this pins the class's own boundary behaviour directly", () => { + const error = new SchemaVersionMismatchError("s", "7.0.0", "7.0.0"); + expect(error.message).toContain("Upgrade document-schema.js"); + expect(error.message).not.toContain("formatVersion"); + }); + + it("SchemaVersionMismatchError's message names the actual dump and installed versions, not just the branching suffix", () => { + const error = new SchemaVersionMismatchError("s", "10.2.1", "9.0.0"); + expect(error.message).toBe( + "documentFromJson: this dump's $schema pins document-schema.js@10.2.1, but the installed release is @9.0.0, and a dump only parses under the major that wrote it. Upgrade document-schema.js to read it.", + ); + }); + it("a bare DocumentTreeSchema.parse does not version-discriminate: it structurally validates whatever it is handed", () => { // The documented contract (src/schema-io.ts): a direct parse validates structure only. This value carries a foreign version's $schema, which documentFromJson would refuse -- the direct parse accepts, because the installed schema's shape tolerates and strips the unknown $schema key and the tree underneath is valid. const foreignTagged = { From e9812a3638848a7cf1ea2a05bca7e9c715a18482 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:17:49 +0100 Subject: [PATCH 11/17] test(document-schema.js): add canonicalise's own dedicated unit suite canonicalise's stable-key-ordering and NaN/undefined-normalisation behaviour was previously exercised only incidentally through callers (factor-styles' candidate grouping); this pins the function's own contract directly. --- .../src/canonicalise.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 packages/document-schema.js/src/canonicalise.test.ts diff --git a/packages/document-schema.js/src/canonicalise.test.ts b/packages/document-schema.js/src/canonicalise.test.ts new file mode 100644 index 0000000000..8e3e0770db --- /dev/null +++ b/packages/document-schema.js/src/canonicalise.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { canonicalise, canonicalKey } from "./canonicalise"; + +describe("canonicalise", () => { + it("rebuilds a plain object with its keys sorted ascending", () => { + const input = { c: 1, a: 2, b: 3 }; + expect(canonicalise(input)).toStrictEqual({ a: 2, b: 3, c: 1 }); + expect(Object.keys(canonicalise(input) as object)).toStrictEqual([ + "a", + "b", + "c", + ]); + }); + + it("recurses into nested object values, sorting their keys too", () => { + const input = { z: { y: 1, x: 2 } }; + expect(Object.keys((canonicalise(input) as { z: object }).z)).toStrictEqual( + ["x", "y"], + ); + }); + + it("maps over arrays in order without sorting elements", () => { + const input = [{ b: 1, a: 2 }, 3, "text"]; + expect(canonicalise(input)).toStrictEqual([{ a: 2, b: 1 }, 3, "text"]); + }); + + it("does not treat an array as a record -- array elements are mapped, never treated as object keys", () => { + const input = [1, 2, 3]; + const result = canonicalise(input); + expect(Array.isArray(result)).toBe(true); + expect(result).toStrictEqual([1, 2, 3]); + }); + + it("returns null as-is rather than as an empty sorted object", () => { + expect(canonicalise(null)).toBeNull(); + }); + + it("returns primitives unchanged", () => { + expect(canonicalise(42)).toBe(42); + expect(canonicalise("hello")).toBe("hello"); + expect(canonicalise(true)).toBe(true); + expect(canonicalise(undefined)).toBeUndefined(); + }); + + it("produces a fresh structure, never the same object reference", () => { + const input = { a: 1 }; + expect(canonicalise(input)).not.toBe(input); + }); +}); + +describe("canonicalKey", () => { + it("is insensitive to the input object's own key construction order", () => { + const first = { a: 1, b: 2 }; + const second = { b: 2, a: 1 }; + expect(canonicalKey(first)).toBe(canonicalKey(second)); + }); + + it("distinguishes values that actually differ", () => { + expect(canonicalKey({ a: 1 })).not.toBe(canonicalKey({ a: 2 })); + }); + + it("treats an absent optional key and an explicit undefined value as identical", () => { + const absent: { a: number; b?: number } = { a: 1 }; + const explicitUndefined: { a: number; b?: number } = { a: 1, b: undefined }; + expect(canonicalKey(absent)).toBe(canonicalKey(explicitUndefined)); + }); + + it("round-trips through JSON.stringify of the canonicalised value", () => { + const value = { z: 1, a: [3, 2, 1] }; + expect(canonicalKey(value)).toBe(JSON.stringify(canonicalise(value))); + }); +}); From 359fbe1f0b41e51fac1d80f00018e93881cdde45 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:17:55 +0100 Subject: [PATCH 12/17] test(document-schema.js): add style.ts's own dedicated unit suite Pins the style-property-merging helpers' gap-fill-never-overwrite contract directly, previously exercised only incidentally through flatten's own resolution tests. --- packages/document-schema.js/src/style.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 packages/document-schema.js/src/style.test.ts diff --git a/packages/document-schema.js/src/style.test.ts b/packages/document-schema.js/src/style.test.ts new file mode 100644 index 0000000000..c1b9840e17 --- /dev/null +++ b/packages/document-schema.js/src/style.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + AlignmentSchema, + DEFAULT_LAYOUT_FONT, + LayoutFontSchema, + TextDirectionSchema, +} from "./style"; + +describe("AlignmentSchema", () => { + it("accepts every alignment keyword", () => { + for (const value of ["left", "center", "right", "justify"]) { + expect(AlignmentSchema.safeParse(value).success).toBe(true); + } + }); + + it("rejects a keyword outside the closed vocabulary", () => { + expect(AlignmentSchema.safeParse("middle").success).toBe(false); + }); +}); + +describe("TextDirectionSchema", () => { + it("accepts ltr and rtl", () => { + expect(TextDirectionSchema.safeParse("ltr").success).toBe(true); + expect(TextDirectionSchema.safeParse("rtl").success).toBe(true); + }); + + it("rejects a value outside the two-member vocabulary", () => { + expect(TextDirectionSchema.safeParse("ttb").success).toBe(false); + }); +}); + +describe("LayoutFontSchema", () => { + it("accepts the normal/bold weight pair and rejects a third weight", () => { + const base = { family: "Arial", weight: "normal", style: "normal" }; + expect( + LayoutFontSchema.safeParse({ ...base, weight: "normal" }).success, + ).toBe(true); + expect( + LayoutFontSchema.safeParse({ ...base, weight: "bold" }).success, + ).toBe(true); + expect( + LayoutFontSchema.safeParse({ ...base, weight: "heavy" }).success, + ).toBe(false); + }); + + it("accepts the normal/italic style pair and rejects a third style", () => { + const base = { family: "Arial", weight: "normal", style: "normal" }; + expect( + LayoutFontSchema.safeParse({ ...base, style: "normal" }).success, + ).toBe(true); + expect( + LayoutFontSchema.safeParse({ ...base, style: "italic" }).success, + ).toBe(true); + expect( + LayoutFontSchema.safeParse({ ...base, style: "oblique" }).success, + ).toBe(false); + }); + + it("requires a string family", () => { + expect( + LayoutFontSchema.safeParse({ + family: "Times", + weight: "normal", + style: "normal", + }).success, + ).toBe(true); + expect( + LayoutFontSchema.safeParse({ + family: 7, + weight: "normal", + style: "normal", + }).success, + ).toBe(false); + }); +}); + +describe("DEFAULT_LAYOUT_FONT", () => { + it("is exactly Helvetica, normal weight, normal style", () => { + expect(DEFAULT_LAYOUT_FONT).toStrictEqual({ + family: "Helvetica", + weight: "normal", + style: "normal", + }); + }); + + it("validates against LayoutFontSchema itself", () => { + expect(LayoutFontSchema.safeParse(DEFAULT_LAYOUT_FONT).success).toBe(true); + }); +}); From 96b1e9d8d2e348014bfad0851995a64592417c6c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 14:18:26 +0100 Subject: [PATCH 13/17] fix(document-schema.js): disable ignoreStatic so module-load-time mutants activate @stryker-mutator/vitest-runner activates a non-static mutant inside a beforeAll() hook, which runs strictly after a test file's top-level imports have already evaluated. A mutation inside a top-level z.object({...}) or object-literal declaration therefore executes before the activation switch is ever set, so it can never be observed regardless of what a covering test asserts. Confirmed directly: CONTENT_DEFS.Color's r field mutated to {} reproducibly survived Stryker's sandbox while the identical edit, applied by hand and run through plain vitest run, failed the exact test Stryker itself says covers it -- proving the miss was this activation-timing gap, not a missing test. ignoreStatic: false routes static mutants through the synchronous "static" activation mode instead, set before any test file's own imports run, at the cost of re-running each static mutant's whole related suite (measured at ~92% of this package's dry-run time, since 57% of its mutants are static by construction -- it is almost entirely schema declarations). breakThreshold stays provisional pending a full run under the corrected configuration. --- packages/document-schema.js/stryker.config.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/document-schema.js/stryker.config.ts b/packages/document-schema.js/stryker.config.ts index ee2152b560..914c485498 100644 --- a/packages/document-schema.js/stryker.config.ts +++ b/packages/document-schema.js/stryker.config.ts @@ -1,7 +1,11 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; -export default packageStrykerConfig({ - vitestConfigFile: "vitest.unit.config.ts", - // First CI-measured baseline: 23.03% of 3822 valid mutants, timeout share 0.2% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. - breakThreshold: 22, -}); +export default { + ...packageStrykerConfig({ + vitestConfigFile: "vitest.unit.config.ts", + // Provisional -- raised to 100 once a stable, fully-killed run confirms it (see the ignoreStatic override below for why the old 22 baseline undercounted what this package can actually reach). + breakThreshold: 1, + }), + // This package is almost entirely Zod schema declarations and hand-authored JSON-schema object literals -- module-load-time (static) constructs by construction. @stryker-mutator/vitest-runner's own activation mechanism (stryker-setup.js) sets its `activeMutant` switch inside a `beforeAll()` hook for any mutant NOT given the (synchronous) "static" activation mode -- but a "hybrid" mutant (Stryker's own term: static AND also covered by a specific test's per-test coverage instrumentation, which happens whenever a test reads the resulting value, e.g. `CONTENT_DEFS.Color` or `ColorSchema`) gets ignoreStatic's "runtime" activation instead of "static" activation, per MutantTestPlanner#planMutant's own branching. `beforeAll()` runs strictly after a test file's top-level imports have already evaluated, so for a mutation inside a top-level `z.object({...})` or object-literal declaration, the switch is never active yet when that declaration actually runs -- the mutant can NEVER be observed, no matter what the covering test asserts. Confirmed directly against this package (2026-09): CONTENT_DEFS.Color's `r` field mutated to `{}` reproducibly survives Stryker's own sandbox while the identical edit, applied by hand and run through plain `vitest run`, fails the exact test Stryker says covers it (content-json-schema-defs.test.ts's "Color: hand-authored fragment matches...") -- proving the test is sound and the miss is the activation-timing bug above, not a test gap. `ignoreStatic: false` routes every static (and hybrid-static) mutant through the OTHER branch of the same function instead, which activates via the synchronous "static" mode (set before any test file's imports run at all), so module-load-time code is finally mutated when it's supposed to be. The performance cost this trades away (this package's own dry run measured 57% of mutants as static, ~92% of a full run's time) is exactly why every OTHER package in this workspace keeps ignoreStatic enabled -- this package is the deliberate, evidenced exception, not a precedent to copy elsewhere without the same measurement. + ignoreStatic: false, +}; From 04888b4c1924655346224ee36d5f953d30a79b5b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:35:11 +0100 Subject: [PATCH 14/17] refactor(document-schema.js): drop mathMlDef's unreachable undefined guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMathMlJsonSchemas() registers exactly the three ids mathMlDef ever looks up before calling z.toJSONSchema(), so the registry's conversion result always carries an entry for each — the undefined branch could never be hit by any real input, only by a mutation flipping the lookup itself, which the existing tests already catch via the resulting value. --- .../document-schema.js/src/content-json-schema-defs.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/document-schema.js/src/content-json-schema-defs.ts b/packages/document-schema.js/src/content-json-schema-defs.ts index 587f6ce5b2..b3e609618f 100644 --- a/packages/document-schema.js/src/content-json-schema-defs.ts +++ b/packages/document-schema.js/src/content-json-schema-defs.ts @@ -52,15 +52,12 @@ function getMathMlJsonSchemas(): Record { } // Strips the $schema/$id root markers z.toJSONSchema() stamps onto every registry entry (each is generated as its own standalone root) -- an artefact of generation, not a real structural difference from a fragment nested inside another schema's own $defs, matching content-json-schema-defs.test.ts's own withoutRootMarkers. +// +// The non-null assertion on the lookup below is exactly the case this package's own eslint config turns nonNullAssertion off for: getMathMlJsonSchemas() adds precisely these three ids to the registry before calling z.toJSONSchema() on it, and a registry's own conversion result carries an entry for every schema registered onto it -- id is never anything other than one of those three literal strings, so the lookup can never actually miss. A defensive undefined check here would be unreachable by any real input, not a genuine safety net. function mathMlDef( id: "MathMlAttribute" | "MathMlElement" | "MathMlNode", ): JsonSchema { - const generated = getMathMlJsonSchemas()[id]; - if (generated === undefined) { - throw new Error( - `z.toJSONSchema() produced no schema for registered id "${id}"`, - ); - } + const generated = getMathMlJsonSchemas()[id]!; const stripped = { ...generated }; delete stripped.$schema; delete stripped.$id; From 49593c5b0b4a0fe9d28675380d58f0a9df313f09 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:35:47 +0100 Subject: [PATCH 15/17] refactor(document-schema.js): drop factor-styles's unreachable defensive checks Remove MintedEntry's firstVisit field and the visit-index bookkeeping that fed it: entries.values() already yields ascending pre-order-visit order for free, since each entry is inserted into the Map at the moment plan() first mints it and Map iteration is insertion order, so the recorded index was a duplicate of information the Map already carries. Also strip the throwing guards in assertHeadingAnchor and assertListAnchor. PARAGRAPH_STYLE_KEYS, the only source a strip's key list is ever drawn from, never contains headingLevel or list, so stripping can never remove either grouping signal and no real input could ever reach the throw. Add a regression test pinning that a paragraph gains no style property when only a nested descendant's style changed. --- .../src/factor-styles.test.ts | 2 ++ .../document-schema.js/src/factor-styles.ts | 28 ++++++------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/document-schema.js/src/factor-styles.test.ts b/packages/document-schema.js/src/factor-styles.test.ts index e73955b174..7ef6d8df6d 100644 --- a/packages/document-schema.js/src/factor-styles.test.ts +++ b/packages/document-schema.js/src/factor-styles.test.ts @@ -1522,6 +1522,8 @@ describe("factorStyles minting", () => { const mintedPage = minted.children[0]; if (mintedPage === undefined) throw new Error("expected the draw page"); expect(mintedPage).not.toBe(drawPageGroup); + // The draw page's own extent shares no key across both shapes (bold isn't on pristineShape's run at all), so its own candidate search finds nothing and its ref stays undefined -- it must not carry a style property just because a NESTED shape changed. + expect(mintedPage).not.toHaveProperty("style"); expect(mintedPage.children[1]).toBe(pristineShape); }); diff --git a/packages/document-schema.js/src/factor-styles.ts b/packages/document-schema.js/src/factor-styles.ts index ec6890192a..dbc7f5e42e 100644 --- a/packages/document-schema.js/src/factor-styles.ts +++ b/packages/document-schema.js/src/factor-styles.ts @@ -333,18 +333,16 @@ interface Branch { readonly factoredRuns: ReadonlySet; } -// One accumulated table entry: its resolved content, the wrappers referencing it, and the ordering inputs (total stripped positions and the first wrapper's pre-order visit index). +// One accumulated table entry: its resolved content, the wrappers referencing it, and the total stripped positions feeding its frequency. Entry ORDER (the "first occurrence" tie-break) needs no field of its own here: entries.values() already yields ascending pre-order-visit order for free, since each entry is inserted into the Map at the moment plan() first visits the wrapper that mints it, and Map iteration is insertion order -- see mint()'s own sort comment. interface MintedEntry { readonly content: StyleEntry; readonly wrappers: MintWrapper[]; frequency: number; - firstVisit: number; } -// Visits one wrapper outermost-first: selects at most one entry here, records its strips against this wrapper, freezes its keys for everything below, then recurses into the child wrappers with the branch bookkeeping extended (copy-on-descend, so sibling branches stay independent). `visit.index` numbers wrappers in pre-order -- the "first occurrence" arm of the entry ordering rule. +// Visits one wrapper outermost-first: selects at most one entry here, records its strips against this wrapper, freezes its keys for everything below, then recurses into the child wrappers with the branch bookkeeping extended (copy-on-descend, so sibling branches stay independent). function plan( wrapper: MintWrapper, - visit: { index: number }, branch: Branch, state: MintState, entries: Map, @@ -382,7 +380,6 @@ function plan( frequency: (paragraphCandidate?.positions.length ?? 0) + (runCandidate?.positions.length ?? 0), - firstVisit: visit.index, }); } else { existing.wrappers.push(wrapper); @@ -408,7 +405,6 @@ function plan( state.wrapperStrips.set(wrapper, strips); } - visit.index += 1; const next: Branch = { frozenParagraphs: nextFrozenParagraphs, frozenRuns: nextFrozenRuns, @@ -416,7 +412,7 @@ function plan( factoredRuns: nextFactoredRuns, }; for (const child of childWrappers(wrapper)) { - plan(child, visit, next, state, entries); + plan(child, next, state, entries); } } @@ -439,7 +435,6 @@ export function mint(pkg: DocumentTree): DocumentTree { wrapperStrips: new Map(), }; const entries = new Map(); - const visit = { index: 0 }; const rootBranch: Branch = { frozenParagraphs: new Set(), frozenRuns: new Set(), @@ -451,8 +446,7 @@ export function mint(pkg: DocumentTree): DocumentTree { case "wordprocessing": case "presentation": case "drawing": - for (const root of pkg.children) - plan(root, visit, rootBranch, state, entries); + for (const root of pkg.children) plan(root, rootBranch, state, entries); break; // A spreadsheet's roots are sheet groups (no block flow, never minted) and a formula package's single child is a leaf: neither holds a wrapper to visit -- both arms are genuinely empty, the switch's own last arms, so there is nothing to break out of. case "spreadsheet": @@ -461,7 +455,7 @@ export function mint(pkg: DocumentTree): DocumentTree { if (entries.size === 0) { return pkg; } - // Entry ids in (descending total frequency, first wrapper visit) order -- the deterministic table order the plan locks. entries.values() already yields ascending-firstVisit order for free (each entry is inserted into the Map at the moment its firstVisit is assigned, and Map iteration is insertion order), and Array.prototype.sort has been a STABLE sort by spec since ES2019 -- so sorting by descending frequency alone, with no explicit tie-break, already preserves each frequency-tied group's own relative (ascending-firstVisit) order exactly as a manual `|| a.firstVisit - b.firstVisit` tie-break would, without a second comparator arm to state or get wrong. + // Entry ids in (descending total frequency, first wrapper visit) order -- the deterministic table order the plan locks. entries.values() already yields ascending pre-order-visit order for free (each entry is inserted into the Map at the moment plan() first mints it, and Map iteration is insertion order), and Array.prototype.sort has been a STABLE sort by spec since ES2019 -- so sorting by descending frequency alone, with no explicit tie-break, already preserves each frequency-tied group's own relative (ascending pre-order-visit) insertion order exactly as a manual first-visit tie-break would, without a second comparator arm -- or a recorded firstVisit field -- to state or get wrong. const ordered = [...entries.values()].sort( (a, b) => b.frequency - a.frequency, ); @@ -768,23 +762,17 @@ function rebuildListGroup( : { node: anchor, ...(ref !== undefined ? { style: ref } : {}), children }; } -// rebuildParagraph is typed on the loose ContentParagraph, so a rebuilt anchor comes back with its REQUIRED grouping signal widened to optional; these assertions re-narrow it without a cast, exactly as flatten.ts does for resolved anchors. Stripping only ever deletes mintable style keys (never headingLevel or list -- see the module doc), so the signal always survives; the throw is the loud guard if that contract ever broke. +// rebuildParagraph is typed on the loose ContentParagraph, so a rebuilt anchor comes back with its REQUIRED grouping signal widened to optional; these assertions re-narrow it without a cast, exactly as flatten.ts does for resolved anchors. No runtime check backs the narrowing: PARAGRAPH_STYLE_KEYS (the only source stripParagraphKeys ever draws a strip's key list from, transitively through commonParagraphKeys/bestParagraphCandidate) never contains "headingLevel" or "list" -- see the module doc -- so stripping can never remove either signal, and a runtime guard here could never observe a paragraph that actually lost it. Adding one anyway would be exactly the untestable defensive branch this package's own mutation-testing gate forbids: a check with no reachable failing input is dead code, not a safety net. function assertHeadingAnchor( paragraph: ContentParagraph, ): asserts paragraph is HeadingParagraph { - if (paragraph.headingLevel === undefined) - throw new Error( - "factorStyles: stripping dropped a heading anchor's headingLevel", - ); + void paragraph; } function assertListAnchor( paragraph: ContentParagraph, ): asserts paragraph is ListGroupNode["node"] { - if (paragraph.list === undefined) - throw new Error( - "factorStyles: stripping dropped a list anchor's list membership", - ); + void paragraph; } // One paragraph (leaf or anchor): stripped -- copied sans its minted keys -- when a wrapper on its chain factored it (chain-scoped, so an aliased position is stripped by its own branch's minter, never another branch's), with its runs rebuilt through the same copy-or-share rule. Returns the same object when nothing under it changed. From ebbb2597111b4e9ac63bb91f49812a9c164f5024 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:01:16 +0100 Subject: [PATCH 16/17] refactor(document-schema.js): make rebuildParagraph generic over the anchor type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace assertHeadingAnchor/assertListAnchor, two runtime no-op assertion functions used only to re-narrow rebuildParagraph's loosened ContentParagraph return back to a heading/list anchor's own required type, with a generic rebuildParagraph

that preserves the input's exact type through the round trip. The two assertion functions carried no runtime check, since the narrowing they performed can never actually fail per the module's own documented invariant, so their bodies were true no-ops. A Stryker BlockStatement mutant reducing an already-inert body to an empty block is therefore unobservable by any test by construction, not a missing test — removing the no-op function entirely is the fix, not trying to make it fail differently. --- .../document-schema.js/src/factor-styles.ts | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/document-schema.js/src/factor-styles.ts b/packages/document-schema.js/src/factor-styles.ts index dbc7f5e42e..d1cbefabf7 100644 --- a/packages/document-schema.js/src/factor-styles.ts +++ b/packages/document-schema.js/src/factor-styles.ts @@ -23,7 +23,6 @@ import type { DocumentTree } from "./package"; import type { DrawPageGroupNode, HeadingGroupNode, - HeadingParagraph, ListChild, ListGroupNode, SectionChild, @@ -724,7 +723,6 @@ function rebuildHeadingGroup( ): HeadingGroupNode { const inner = innerChain(group, chain, state); const anchor = rebuildParagraph(group.node, inner); - assertHeadingAnchor(anchor); const children = group.children.map((child) => rebuildChild(child, inner, state), ); @@ -749,7 +747,6 @@ function rebuildListGroup( ): ListGroupNode { const inner = innerChain(group, chain, state); const anchor = rebuildParagraph(group.node, inner); - assertListAnchor(anchor); const children = group.children.map((child) => rebuildChild(child, inner, state), ); @@ -762,24 +759,13 @@ function rebuildListGroup( : { node: anchor, ...(ref !== undefined ? { style: ref } : {}), children }; } -// rebuildParagraph is typed on the loose ContentParagraph, so a rebuilt anchor comes back with its REQUIRED grouping signal widened to optional; these assertions re-narrow it without a cast, exactly as flatten.ts does for resolved anchors. No runtime check backs the narrowing: PARAGRAPH_STYLE_KEYS (the only source stripParagraphKeys ever draws a strip's key list from, transitively through commonParagraphKeys/bestParagraphCandidate) never contains "headingLevel" or "list" -- see the module doc -- so stripping can never remove either signal, and a runtime guard here could never observe a paragraph that actually lost it. Adding one anyway would be exactly the untestable defensive branch this package's own mutation-testing gate forbids: a check with no reachable failing input is dead code, not a safety net. -function assertHeadingAnchor( - paragraph: ContentParagraph, -): asserts paragraph is HeadingParagraph { - void paragraph; -} - -function assertListAnchor( - paragraph: ContentParagraph, -): asserts paragraph is ListGroupNode["node"] { - void paragraph; -} - // One paragraph (leaf or anchor): stripped -- copied sans its minted keys -- when a wrapper on its chain factored it (chain-scoped, so an aliased position is stripped by its own branch's minter, never another branch's), with its runs rebuilt through the same copy-or-share rule. Returns the same object when nothing under it changed. -function rebuildParagraph( - paragraph: ContentParagraph, +// +// Generic over the paragraph's own type (rather than fixed to the loose ContentParagraph) so a heading or list anchor keeps its REQUIRED grouping signal (headingLevel/list) through the round trip with no re-narrowing at the call site -- neither an assertion function nor a banned `as` cast. This is sound with no runtime check because PARAGRAPH_STYLE_KEYS (the only source stripParagraphKeys ever draws a strip's key list from, transitively through commonParagraphKeys/bestParagraphCandidate) never contains "headingLevel" or "list" -- see the module doc -- so stripping can never touch either signal, on a HeadingParagraph, a ListParagraph, or a bare ContentParagraph leaf alike. +function rebuildParagraph

( + paragraph: P, chain: ChainStrips, -): ContentParagraph { +): P { const strips = paragraphStripsOf(chain, paragraph); const base = strips === undefined ? paragraph : stripParagraphKeys(paragraph, strips); @@ -796,12 +782,12 @@ function rebuildParagraph( return { ...base, runs }; } -// Copies a paragraph sans the named keys -- copy-then-delete, never destructuring the keys out (an unused binding) and never mutating the input (decompose embedded the caller's own node objects, and the layout pass's frames ride on them). Every mintable paragraph key is optional on ContentParagraph, so the deletes are type-honest. -function stripParagraphKeys( - paragraph: ContentParagraph, +// Copies a paragraph sans the named keys -- copy-then-delete, never destructuring the keys out (an unused binding) and never mutating the input (decompose embedded the caller's own node objects, and the layout pass's frames ride on them). Every mintable paragraph key is optional on ContentParagraph, so the deletes are type-honest. Generic for the same reason rebuildParagraph is: preserves a heading/list anchor's own narrower type through the copy. +function stripParagraphKeys

( + paragraph: P, keys: readonly ParagraphKey[], -): ContentParagraph { - const copy: ContentParagraph = { ...paragraph }; +): P { + const copy = { ...paragraph }; for (const key of keys) Reflect.deleteProperty(copy, key); return copy; } From 5fd88c80d5b3ab99d2b6e344ec3cb82f6c40c971 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:02:14 +0100 Subject: [PATCH 17/17] test(document-schema.js): raise the mutation break threshold to 100 A clean run now kills or times out every mutant across the package with zero survived and zero no-coverage, confirming the provisional threshold can be replaced with the real gate. --- packages/document-schema.js/stryker.config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/document-schema.js/stryker.config.ts b/packages/document-schema.js/stryker.config.ts index 914c485498..93d6e2978f 100644 --- a/packages/document-schema.js/stryker.config.ts +++ b/packages/document-schema.js/stryker.config.ts @@ -3,8 +3,7 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default { ...packageStrykerConfig({ vitestConfigFile: "vitest.unit.config.ts", - // Provisional -- raised to 100 once a stable, fully-killed run confirms it (see the ignoreStatic override below for why the old 22 baseline undercounted what this package can actually reach). - breakThreshold: 1, + breakThreshold: 100, }), // This package is almost entirely Zod schema declarations and hand-authored JSON-schema object literals -- module-load-time (static) constructs by construction. @stryker-mutator/vitest-runner's own activation mechanism (stryker-setup.js) sets its `activeMutant` switch inside a `beforeAll()` hook for any mutant NOT given the (synchronous) "static" activation mode -- but a "hybrid" mutant (Stryker's own term: static AND also covered by a specific test's per-test coverage instrumentation, which happens whenever a test reads the resulting value, e.g. `CONTENT_DEFS.Color` or `ColorSchema`) gets ignoreStatic's "runtime" activation instead of "static" activation, per MutantTestPlanner#planMutant's own branching. `beforeAll()` runs strictly after a test file's top-level imports have already evaluated, so for a mutation inside a top-level `z.object({...})` or object-literal declaration, the switch is never active yet when that declaration actually runs -- the mutant can NEVER be observed, no matter what the covering test asserts. Confirmed directly against this package (2026-09): CONTENT_DEFS.Color's `r` field mutated to `{}` reproducibly survives Stryker's own sandbox while the identical edit, applied by hand and run through plain `vitest run`, fails the exact test Stryker says covers it (content-json-schema-defs.test.ts's "Color: hand-authored fragment matches...") -- proving the test is sound and the miss is the activation-timing bug above, not a test gap. `ignoreStatic: false` routes every static (and hybrid-static) mutant through the OTHER branch of the same function instead, which activates via the synchronous "static" mode (set before any test file's imports run at all), so module-load-time code is finally mutated when it's supposed to be. The performance cost this trades away (this package's own dry run measured 57% of mutants as static, ~92% of a full run's time) is exactly why every OTHER package in this workspace keeps ignoreStatic enabled -- this package is the deliberate, evidenced exception, not a precedent to copy elsewhere without the same measurement. ignoreStatic: false,