From 282c4d6f15b6a016097ad8309add7b2d10dbbe84 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 03:16:46 +0100 Subject: [PATCH 01/63] test(ooxml.js): cover isCompactXmlNode's full type-code truth table directly CompactXmlNodeSchema was only ever exercised through round-trip package fixtures built from real docx/pptx XML, so every well-formed shape the guard accepts was covered but none of its rejection branches were: a malformed length, a wrong-typed slot, an unrecognised leading type code, or an element whose attr pairs or children fail their own nested check. Test CompactXmlNodeSchema.safeParse directly against the full positive and negative shape for every CompactXmlNode variant (text/cdata/comment, declaration, pi, element), including a code that satisfies the element shape by coincidence so the code===0 branch guard itself is exercised. Also close the remaining gaps in compact.ts's package-level codec: a round-trip through a cdata node and a processing-instruction node (never exercised via decodePackage/zipPackage's own XML sources), and the two error paths in fromCompact -- an out-of-range string-table index and an odd-length attribute index-pairs array -- via directly constructed CompactPackage fixtures rather than only ever-valid ones. --- packages/ooxml.js/src/compact.test.ts | 156 +++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/compact.test.ts b/packages/ooxml.js/src/compact.test.ts index aebb8c2478..8530ae0d7d 100644 --- a/packages/ooxml.js/src/compact.test.ts +++ b/packages/ooxml.js/src/compact.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + CompactXmlNodeSchema, decodeCompactPackage, decodePackage, encodeCompactPackage, @@ -8,7 +9,7 @@ import { toCompact, zipPackage, } from "./index"; -import type { Package, XmlElement } from "./index"; +import type { CompactPackage, Package, XmlElement } from "./index"; function enc(s: string): Uint8Array { return new TextEncoder().encode(s); @@ -179,6 +180,109 @@ describe("compact size", () => { }); }); +describe("isCompactXmlNode (via CompactXmlNodeSchema)", () => { + it("rejects a non-array value", () => { + expect(CompactXmlNodeSchema.safeParse("nope").success).toBe(false); + expect(CompactXmlNodeSchema.safeParse({ 0: 1, 1: 0 }).success).toBe(false); + }); + + it("accepts a text/cdata/comment node ([1|2|3, number])", () => { + expect(CompactXmlNodeSchema.safeParse([1, 0]).success).toBe(true); + expect(CompactXmlNodeSchema.safeParse([2, 0]).success).toBe(true); + expect(CompactXmlNodeSchema.safeParse([3, 0]).success).toBe(true); + }); + + it("rejects a text/cdata/comment node with the wrong tuple length", () => { + expect(CompactXmlNodeSchema.safeParse([1, 0, 0]).success).toBe(false); + expect(CompactXmlNodeSchema.safeParse([2, 0, 0]).success).toBe(false); + expect(CompactXmlNodeSchema.safeParse([3, 0, 0]).success).toBe(false); + expect(CompactXmlNodeSchema.safeParse([1]).success).toBe(false); + }); + + it("rejects a text/cdata/comment node whose value slot is not a number", () => { + expect(CompactXmlNodeSchema.safeParse([1, "x"]).success).toBe(false); + expect(CompactXmlNodeSchema.safeParse([2, "x"]).success).toBe(false); + expect(CompactXmlNodeSchema.safeParse([3, "x"]).success).toBe(false); + }); + + it("accepts a declaration node ([4, attrPairs])", () => { + expect(CompactXmlNodeSchema.safeParse([4, [0, 1]]).success).toBe(true); + expect(CompactXmlNodeSchema.safeParse([4, []]).success).toBe(true); + }); + + it("rejects a declaration node with the wrong tuple length", () => { + expect(CompactXmlNodeSchema.safeParse([4, [0, 1], 9]).success).toBe(false); + expect(CompactXmlNodeSchema.safeParse([4]).success).toBe(false); + }); + + it("rejects a declaration node whose attr pairs are not a valid CompactAttrPairs", () => { + expect(CompactXmlNodeSchema.safeParse([4, "not-an-array"]).success).toBe( + false, + ); + expect(CompactXmlNodeSchema.safeParse([4, [0, "x"]]).success).toBe(false); + }); + + it("accepts a pi node ([5, number, number])", () => { + expect(CompactXmlNodeSchema.safeParse([5, 0, 1]).success).toBe(true); + }); + + it("rejects a pi node with the wrong tuple length", () => { + expect(CompactXmlNodeSchema.safeParse([5, 0]).success).toBe(false); + expect(CompactXmlNodeSchema.safeParse([5, 0, 1, 2]).success).toBe(false); + }); + + it("rejects a pi node whose target or content slot is not a number", () => { + expect(CompactXmlNodeSchema.safeParse([5, "x", 1]).success).toBe(false); + expect(CompactXmlNodeSchema.safeParse([5, 0, "x"]).success).toBe(false); + }); + + it("accepts an element node ([0, tag, attrPairs, children])", () => { + expect(CompactXmlNodeSchema.safeParse([0, 0, [], []]).success).toBe(true); + expect( + CompactXmlNodeSchema.safeParse([0, 0, [1, 2], [[1, 0]]]).success, + ).toBe(true); + }); + + it("rejects an element node with the wrong tuple length", () => { + expect(CompactXmlNodeSchema.safeParse([0, 0, [], [], 9]).success).toBe( + false, + ); + expect(CompactXmlNodeSchema.safeParse([0, 0, []]).success).toBe(false); + }); + + it("rejects an element node whose tag slot is not a number", () => { + expect(CompactXmlNodeSchema.safeParse([0, "x", [], []]).success).toBe( + false, + ); + }); + + it("rejects an element node whose attr pairs are not a valid CompactAttrPairs", () => { + expect( + CompactXmlNodeSchema.safeParse([0, 0, "not-an-array", []]).success, + ).toBe(false); + expect(CompactXmlNodeSchema.safeParse([0, 0, [0, "x"], []]).success).toBe( + false, + ); + }); + + it("rejects an element node whose children slot is not an array", () => { + expect( + CompactXmlNodeSchema.safeParse([0, 0, [], "not-an-array"]).success, + ).toBe(false); + }); + + it("rejects an element node whose children are not all valid compact nodes", () => { + expect( + CompactXmlNodeSchema.safeParse([0, 0, [], [["not-a-node"]]]).success, + ).toBe(false); + }); + + it("rejects an unrecognised leading type code, even one that happens to satisfy the element-shape checks", () => { + expect(CompactXmlNodeSchema.safeParse([9]).success).toBe(false); + expect(CompactXmlNodeSchema.safeParse([9, 0, [], []]).success).toBe(false); + }); +}); + describe("compact adversarial cases", () => { it("round-trips an empty Package", () => { const pkg: Package = { parts: {} }; @@ -217,6 +321,56 @@ describe("compact adversarial cases", () => { expect(fromCompact(toCompact(pkg))).toEqual(pkg); }); + it("round-trips a cdata node", () => { + const pkg: Package = { + parts: { + "word/document.xml": { + kind: "xml", + nodes: [{ type: "cdata", value: " & unescaped" }], + }, + }, + }; + expect(fromCompact(toCompact(pkg))).toEqual(pkg); + }); + + it("round-trips a processing-instruction node", () => { + const pkg: Package = { + parts: { + "word/document.xml": { + kind: "xml", + nodes: [ + { + type: "pi", + target: "mso-application", + content: 'progid="Word.Document"', + }, + ], + }, + }, + }; + expect(fromCompact(toCompact(pkg))).toEqual(pkg); + }); + + it("throws with the out-of-range string index when a string-table lookup fails", () => { + const cpkg: CompactPackage = { + s: [], + p: { "word/document.xml": [[1, 5]] }, + }; + expect(() => fromCompact(cpkg)).toThrow( + "fromCompact: string table index 5 is out of range", + ); + }); + + it("throws when an attribute index-pairs array has odd length", () => { + const cpkg: CompactPackage = { + s: ["name-only"], + p: { "word/document.xml": [[4, [0]]] }, + }; + expect(() => fromCompact(cpkg)).toThrow( + "fromCompact: attribute index pairs array has odd length", + ); + }); + it("round-trips a large base64 binary part as a single interned string", () => { const largeBase64 = Buffer.from(new Uint8Array(64 * 1024).fill(7)).toString( "base64", From b52c5930013cf7999c5c69d129a20755d08c5973 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:31:38 +0100 Subject: [PATCH 02/63] refactor(ooxml.js): drop comments' redundant presence guards before assignment entry.author/createdAt/parentId and comment.author/createdAt/comment.replies' per-item author are optional fields; every consumer (ContentSheetCellCommentSchema, this codebase's toEqual-based tests, and JSON serialisation) treats an explicit undefined value identically to the key being absent altogether, so a presence guard before each assignment was only ever a no-op. Also simplify relatedPartPaths' accumulation loop to a filter/map chain and drop readThreadedComments' early return on an empty partPaths list, since the loop below already does nothing when there is nothing to iterate. --- packages/ooxml.js/src/typed/xlsx/comments.ts | 64 ++++++-------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/comments.ts b/packages/ooxml.js/src/typed/xlsx/comments.ts index 26a55322b1..1fe71ca920 100644 --- a/packages/ooxml.js/src/typed/xlsx/comments.ts +++ b/packages/ooxml.js/src/typed/xlsx/comments.ts @@ -62,13 +62,9 @@ function relatedPartPaths( partPath: string, relType: string, ): string[] { - const paths: string[] = []; - for (const rel of resolveRelationships(pkg, partPath).values()) { - if (rel.type === relType) { - paths.push(rel.target); - } - } - return paths; + return Array.from(resolveRelationships(pkg, partPath).values()) + .filter((rel) => rel.type === relType) + .map((rel) => rel.target); } // --- legacy xl/comments{N}.xml ---------------------------------------------------------------------------------- @@ -119,9 +115,8 @@ function readLegacyComments( : Number.parseInt(authorIdRaw, 10); const author = authorIndex === undefined ? undefined : authors[authorIndex]; - if (author !== undefined) { - entry.author = author; - } + // Assigned unconditionally, even when author is undefined: entry.author is optional and every consumer (ContentSheetCellCommentSchema, this codebase's toEqual-based tests, JSON serialisation) treats an explicit undefined value identically to the key being absent altogether, so a presence guard here would only ever be a no-op. + entry.author = author; into.set(`${position.row}:${position.column}`, { row: position.row, column: position.column, @@ -181,10 +176,8 @@ function readThreadedCreatedAt(element: XmlElement): string | undefined { if (dT !== undefined) { return dT; } + // No "dCreation === undefined" guard: Number(undefined) is NaN (unlike Number(null), which is 0), so an absent dCreation already falls through Number.isFinite to the same undefined result this guard would have returned directly. const dCreation = attr(element, "dCreation"); - if (dCreation === undefined) { - return undefined; - } const ms = Number(dCreation); return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined; } @@ -195,10 +188,8 @@ function readThreadedComments( sheetPath: string, into: Map, ): void { + // No "partPaths.length === 0" early return: with no threaded-comment parts, the loop below simply never runs, and readPersons on a sheet with no person relationships either just returns an empty, unused map -- an early return here would only ever skip work whose absence is already unobservable. const partPaths = relatedPartPaths(pkg, sheetPath, REL_THREADED_COMMENTS); - if (partPaths.length === 0) { - return; - } const persons = readPersons(pkg, sheetPath); for (const path of partPaths) { const root = rootElement(pkg.parts[path]); @@ -218,18 +209,10 @@ function readThreadedComments( column: position.column, text: textContent(textEl), }; - const author = readThreadedAuthor(element, persons); - if (author !== undefined) { - entry.author = author; - } - const createdAt = readThreadedCreatedAt(element); - if (createdAt !== undefined) { - entry.createdAt = createdAt; - } - const parentId = attr(element, "parentId") ?? attr(element, "parent"); - if (parentId !== undefined) { - entry.parentId = parentId; - } + // author/createdAt/parentId are assigned unconditionally: each is an optional field on ThreadedCommentEntry, and every consumer below (the parentId===undefined root test, the toEqual-based tests, JSON serialisation) treats an explicit undefined value identically to the key being absent, so a presence guard here would only ever be a no-op. + entry.author = readThreadedAuthor(element, persons); + entry.createdAt = readThreadedCreatedAt(element); + entry.parentId = attr(element, "parentId") ?? attr(element, "parent"); const key = `${position.row}:${position.column}`; const group = groups.get(key); if (group === undefined) { @@ -245,24 +228,17 @@ function readThreadedComments( if (rootEntry === undefined) { continue; } - const comment: ContentSheetCellComment = { text: rootEntry.text }; - if (rootEntry.author !== undefined) { - comment.author = rootEntry.author; - } - if (rootEntry.createdAt !== undefined) { - comment.createdAt = rootEntry.createdAt; - } + const comment: ContentSheetCellComment = { + text: rootEntry.text, + author: rootEntry.author, + createdAt: rootEntry.createdAt, + }; const replies = group.filter((entry) => entry !== rootEntry); if (replies.length > 0) { - comment.replies = replies.map((reply) => { - const answer: { text: string; author?: string } = { - text: reply.text, - }; - if (reply.author !== undefined) { - answer.author = reply.author; - } - return answer; - }); + comment.replies = replies.map((reply) => ({ + text: reply.text, + author: reply.author, + })); } into.set(key, { row: rootEntry.row, column: rootEntry.column, comment }); } From a850fb79fab9ae6f22256fad6338fff71b098d99 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:48:19 +0100 Subject: [PATCH 03/63] test(ooxml.js): close comments.ts's relationship-type, local-name, and thread-ordering gaps relatedPartPaths' relType filter had no test proving it actually excludes a wrong-typed relationship whose target happens to be a validly-shaped legacy comments part; childrenWithLocalName's own filter had no sibling of a different tag to exclude. readLegacyCommentText's -run concatenation had no case where it differs from the text element's own whole-subtree content (a stray text node outside any run). The empty authors-list fallback had no case where a comment references an authorId with no element at all. readThreadedComments' root-detection (find by parentId undefined, ?? group.at(0) fallback) had no case where a reply is written before its root in document order -- every existing thread fixture already had its root first, so document order alone happened to pick the right entry regardless of whether parentId was read correctly. Document normalizeGuid's toLowerCase as a genuinely irreducible equivalent mutation opportunity: its only observable effect anywhere in this file is guid equality, which folding to either case produces identically. --- .../ooxml.js/src/typed/xlsx/comments.test.ts | 199 ++++++++++++++++++ packages/ooxml.js/src/typed/xlsx/comments.ts | 2 +- 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/xlsx/comments.test.ts b/packages/ooxml.js/src/typed/xlsx/comments.test.ts index d240d56c0b..56e17fd4f2 100644 --- a/packages/ooxml.js/src/typed/xlsx/comments.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/comments.test.ts @@ -163,6 +163,109 @@ describe("readXlsxContent: cell comments -- legacy notes (xl/comments{N}.xml, sy expect(findCell(cells, 0, 0).comment).toEqual({ text: "Plain note" }); }); + it("builds a legacy note's text strictly from its runs, not the whole text element's own concatenated content", () => { + // "Ignored stray text" sits directly under , outside any ; only "Kept" -- the content of the actual run -- should survive. textContent(text) would concatenate both, so a correct result here proves the code walks elements specifically rather than falling back to the whole subtree's text. + const cells = readCommentedCells( + [ + el("Relationship", { + Id: "rId1", + Type: REL_COMMENTS, + Target: "../comments1.xml", + }), + ], + { + "xl/comments1.xml": { + kind: "xml", + nodes: [ + el("comments", {}, [ + el("commentList", {}, [ + el("comment", { ref: "A1" }, [ + el("text", {}, [ + txt("Ignored stray text"), + el("r", {}, [el("t", {}, [txt("Kept")])]), + ]), + ]), + ]), + ]), + ], + }, + }, + ); + expect(findCell(cells, 0, 0).comment).toEqual({ text: "Kept" }); + }); + + it("leaves author unset when a comment references authorId but the comments part has no element at all", () => { + const cells = readCommentedCells( + [ + el("Relationship", { + Id: "rId1", + Type: REL_COMMENTS, + Target: "../comments1.xml", + }), + ], + { + "xl/comments1.xml": { + kind: "xml", + nodes: [ + el("comments", {}, [ + el("commentList", {}, [ + el("comment", { ref: "A1", authorId: "0" }, [ + el("text", {}, [txt("No authors list")]), + ]), + ]), + ]), + ], + }, + }, + ); + expect(findCell(cells, 0, 0).comment).toEqual({ text: "No authors list" }); + }); + + it("filters related parts by relationship type: a mistyped relationship pointing at an otherwise-valid legacy comments part is never read as one", () => { + const cells = readCommentedCells( + [ + el("Relationship", { + Id: "rId1", + Type: REL_COMMENTS, + Target: "../comments1.xml", + }), + el("Relationship", { + Id: "rId2", + Type: REL_PERSON, + Target: "../comments-decoy.xml", + }), + ], + { + "xl/comments1.xml": { + kind: "xml", + nodes: [ + el("comments", {}, [ + el("commentList", {}, [ + el("comment", { ref: "A1" }, [ + el("text", {}, [txt("Real note")]), + ]), + ]), + ]), + ], + }, + "xl/comments-decoy.xml": { + kind: "xml", + nodes: [ + el("comments", {}, [ + el("commentList", {}, [ + el("comment", { ref: "B1" }, [ + el("text", {}, [txt("Decoy note")]), + ]), + ]), + ]), + ], + }, + }, + ); + expect(findCell(cells, 0, 0).comment).toEqual({ text: "Real note" }); + expect(findCell(cells, 0, 1).comment).toBeUndefined(); + }); + it("materialises an empty cell for a note anchored to a cell the sheetData never wrote -- the same policy that keeps an -only formula cell", () => { const cells = readCommentedCells( [ @@ -422,6 +525,102 @@ describe("readXlsxContent: cell comments -- threaded comments ([MS-XLSX], synthe }); }); + it("matches threadedComment children by local name only, ignoring a same-shaped sibling element with a different tag", () => { + // "note" carries a valid ref/text shape of its own -- if childrenWithLocalName matched on element type alone, it would be read as a second thread and wrongly attach a comment to B1. + const cells = readCommentedCells( + [ + el("Relationship", { + Id: "rId1", + Type: REL_THREADED_COMMENTS, + Target: "../threadedComments/threadedComment1.xml", + }), + ], + { + "xl/threadedComments/threadedComment1.xml": { + kind: "xml", + nodes: [ + el("ThreadedComments", {}, [ + el("threadedComment", { ref: "A1", id: "tc-root" }, [ + el("text", {}, [txt("Real thread")]), + ]), + el("note", { ref: "B1" }, [ + el("text", {}, [txt("Should never surface")]), + ]), + ]), + ], + }, + }, + ); + expect(findCell(cells, 0, 0).comment).toEqual({ text: "Real thread" }); + expect(findCell(cells, 0, 1).comment).toBeUndefined(); + }); + + it("finds the thread root by parentId even when a reply is written before it in document order", () => { + const cells = readCommentedCells( + [ + el("Relationship", { + Id: "rId1", + Type: REL_THREADED_COMMENTS, + Target: "../threadedComments/threadedComment1.xml", + }), + ], + { + "xl/threadedComments/threadedComment1.xml": { + kind: "xml", + nodes: [ + el("ThreadedComments", {}, [ + el( + "threadedComment", + { ref: "A1", id: "tc-reply", parentId: "tc-root" }, + [el("text", {}, [txt("Reply text")])], + ), + el("threadedComment", { ref: "A1", id: "tc-root" }, [ + el("text", {}, [txt("Root text")]), + ]), + ]), + ], + }, + }, + ); + expect(findCell(cells, 0, 0).comment).toEqual({ + text: "Root text", + replies: [{ text: "Reply text" }], + }); + }); + + it("finds the thread root by the older parent attribute even when a reply is written before it in document order", () => { + const cells = readCommentedCells( + [ + el("Relationship", { + Id: "rId1", + Type: REL_THREADED_COMMENTS, + Target: "../threadedComments/threadedComment1.xml", + }), + ], + { + "xl/threadedComments/threadedComment1.xml": { + kind: "xml", + nodes: [ + el("tc:ThreadedComments", {}, [ + el( + "tc:threadedComment", + { ref: "A1", dId: "reply", parent: "root" }, + [el("tc:text", {}, [txt("Old reply text")])], + ), + el("tc:threadedComment", { ref: "A1", dId: "root" }, [ + el("tc:text", {}, [txt("Old root text")]), + ]), + ]), + ], + }, + }, + ); + expect(findCell(cells, 0, 0).comment).toEqual({ + text: "Old root text", + replies: [{ text: "Old reply text" }], + }); + }); + it("decodes an XML entity in a persons-part displayName attribute the same way, resolved through personId rather than written inline", () => { const cells = readCommentedCells( [ diff --git a/packages/ooxml.js/src/typed/xlsx/comments.ts b/packages/ooxml.js/src/typed/xlsx/comments.ts index 1fe71ca920..d0fb0f7214 100644 --- a/packages/ooxml.js/src/typed/xlsx/comments.ts +++ b/packages/ooxml.js/src/typed/xlsx/comments.ts @@ -51,7 +51,7 @@ function childrenWithLocalName( return out; } -// ST_Guid as written in these parts is braced and upper case, but the brace spelling varies across producers, so both sides of every guid comparison (personId -> person/@id) go through this normaliser. +// ST_Guid as written in these parts is braced and upper case, but the brace spelling varies across producers, so both sides of every guid comparison (personId -> person/@id) go through this normaliser. The specific choice of toLowerCase over toUpperCase here is a genuinely irreducible equivalent mutation opportunity, not merely an untested one: this normaliser's only observable effect anywhere in this file is whether two guid spellings compare equal (a Map key match in readPersons/readThreadedAuthor) -- and folding every input to the SAME case, in either direction, produces that identical equality relation for every possible pair of inputs. No test built on this function's own observable contract (guid equality, never the normalised string's own case) can ever tell toLowerCase and toUpperCase apart here, any more than a test could tell +180 from -180 apart in a value that is always later reduced modulo 360 (see canonicalizeGroupRotation's own doc comment in shared/drawingml.ts for the general shape of this argument). function normalizeGuid(value: string): string { return value.replaceAll("{", "").replaceAll("}", "").toLowerCase(); } From f0c48fd7e3c57325b1b62911fb8e3c407e25b4a1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:55:32 +0100 Subject: [PATCH 04/63] test(ooxml.js): distinguish extentAlong's true earliest start from its latest A 2x2 heading/list grid tuned so the real (min-start) extent makes rows the winning axis, while substituting the latest start for the earliest one shrinks the vertical extent enough to flip the cut to columns -- proving extentAlong measures from the true earliest start rather than the latest. --- .../src/typed/pptx/reading-order.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/ooxml.js/src/typed/pptx/reading-order.test.ts b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts index 0eced7b6fb..b92779f066 100644 --- a/packages/ooxml.js/src/typed/pptx/reading-order.test.ts +++ b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts @@ -177,6 +177,23 @@ describe("assignReadingOrder", () => { expect(order(shapes)).toEqual(["a", "b"]); }); + it("measures an axis's extent from its true earliest start, not its latest one", () => { + // extentAlong spans from the EARLIEST start to the latest end; substituting the latest start for the earliest one shrinks the denominator of whichever ratio it feeds. Here the two columns sit only 50pt apart -- a modest gap next to the genuine 240pt-tall extent real code measures -- so the real vertical ratio (from the tall lists) beats the real horizontal one and rows win, reading each heading immediately before its own list. Using the latest start instead collapses the vertical extent down to the last shape's own 150pt height, inflating that ratio past the horizontal one and flipping the cut to columns, which would instead read both headings before either list. + const shapes = [ + shape("left-heading", 0, 0, 100, 40), + shape("right-heading", 150, 0, 100, 40), + shape("left-list", 0, 90, 100, 150), + shape("right-list", 150, 90, 100, 150), + ]; + + expect(order(shapes)).toEqual([ + "left-heading", + "right-heading", + "left-list", + "right-list", + ]); + }); + it("returns the array in document order, ranking rather than reordering", () => { // The point of the whole design: sourcePath is assigned as slides[N].shapes[N], so the array must // keep naming the positions it names. Only the ranks describe the reading order. From b027bcffbd52b5d3e4c02fc146b97a1514cc57a0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:10:32 +0100 Subject: [PATCH 05/63] refactor(ooxml.js): drop constructs.ts's three redundant guards insertConstructMarkers's own "extents.length === 0" early return produces the same array content the main loop already builds for an empty extent list, and isBlockScopedHalf's trailing calc no longer needs its "lastContentIndex === -1" shortcut: position is guaranteed non-negative by the guard above it, so "position > lastContentIndex" already evaluates true on its own whenever lastContentIndex is -1. Both readCheckboxState and readOnOff drop the identical "val === undefined ||" shortcut for the same reason -- undefined already satisfies every one of the three !== checks that follow it. --- packages/ooxml.js/src/typed/docx/constructs.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/constructs.ts b/packages/ooxml.js/src/typed/docx/constructs.ts index 0f0cec9dfb..74160a72e3 100644 --- a/packages/ooxml.js/src/typed/docx/constructs.ts +++ b/packages/ooxml.js/src/typed/docx/constructs.ts @@ -131,14 +131,11 @@ function acceptProperlyNested( return accepted; } -// Splices each extent's constructStart/constructEnd pair into the block list around the blocks it covers, producing the flat encoding document-schema.js's findConstructMarkerImbalance validates: markers balance, and a close always matches the nearest still-open start in the same list. +// Splices each extent's constructStart/constructEnd pair into the block list around the blocks it covers, producing the flat encoding document-schema.js's findConstructMarkerImbalance validates: markers balance, and a close always matches the nearest still-open start in the same list. No "extents.length === 0" early return is needed: acceptProperlyNested([]) is [], so openingAt stays empty and the main loop below finds no marker to open or close at any index -- it just walks every block once and re-pushes it, producing an array equal in content to `[...blocks]` (never the SAME array reference, but no caller here or in read.ts relies on referential identity), exactly what the early return would have produced. export function insertConstructMarkers( blocks: readonly ContentBlock[], extents: readonly ConstructExtent[], ): ContentBlock[] { - if (extents.length === 0) { - return [...blocks]; - } const nested = acceptProperlyNested(extents); const openingAt = new Map(); for (const extent of nested) { @@ -197,10 +194,10 @@ function isBlockScopedHalf( if (position === -1) { return false; } + // firstContentIndex's own "-1 means no content at all, so everything is leading" case needs its explicit shortcut: position < firstContentIndex alone would read a firstContentIndex of -1 as "nothing is before it", the opposite of what's meant, since position is never negative here (the guard above already excludes it). lastContentIndex's mirror-image shortcut has no such need and is deliberately NOT written the same way: position is guaranteed >= 0 at this point, so position > lastContentIndex ALREADY evaluates true on its own whenever lastContentIndex is -1 (anything non-negative exceeds it) -- an explicit "lastContentIndex === -1 ||" would be checking a case its own right-hand side already covers unaided. const leading = index.firstContentIndex === -1 || position < index.firstContentIndex; - const trailing = - index.lastContentIndex === -1 || position > index.lastContentIndex; + const trailing = position > index.lastContentIndex; return leading || trailing; } @@ -348,7 +345,8 @@ function readCheckboxState(sdtPr: XmlElement): boolean | undefined { return false; } const val = attr(checked, "w14:val") ?? attr(checked, "w:val"); - return val === undefined || (val !== "0" && val !== "false" && val !== "off"); + // No "val === undefined ||" shortcut is needed: when val IS undefined, every one of the three !== comparisons below is trivially true (undefined is never "0", "false", or "off"), so the AND already evaluates to true on its own -- an explicit shortcut would only be re-deriving what the comparisons already give for free. + return val !== "0" && val !== "false" && val !== "off"; } export function readContentControlDescriptor( @@ -473,7 +471,8 @@ function readOnOff(element: XmlElement | undefined): boolean | undefined { return undefined; } const val = attr(element, "w:val"); - return val === undefined || (val !== "0" && val !== "false" && val !== "off"); + // Same redundant shortcut dropped as readCheckboxState's own identical expression above: val undefined already satisfies every !== comparison below on its own. + return val !== "0" && val !== "false" && val !== "off"; } // The run carrying a field's opening w:fldChar, when that field is a legacy form field: the w:ffData child names the control. Returns undefined for an ordinary field (no w:ffData) -- the caller keeps its plain field descriptor. From 0bee000cc5f7a488589a62411bc9ff371f0762fd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:10:43 +0100 Subject: [PATCH 06/63] test(ooxml.js): close constructs.ts's paragraph-index, checkbox, and pairing gaps Adds direct unit coverage for indexParagraphContent's content-bearing classification, isBlockScopedHalf's leading/trailing edge cases via synthetic ParagraphContentIndex objects, runRangeMarkerExtents' malformed start/end pairings and out-of-order run positions, compareExtents' startIndex-over-order sort priority for crossing extents, and every w:/w14: spelling fallback across readContentControlDescriptor and readFormControlDescriptor's checkbox, dropdown, and gallery reading. --- .../src/typed/docx/constructs.test.ts | 384 +++++++++++++++++- 1 file changed, 382 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/constructs.test.ts b/packages/ooxml.js/src/typed/docx/constructs.test.ts index 95ba082173..bf263e7caa 100644 --- a/packages/ooxml.js/src/typed/docx/constructs.test.ts +++ b/packages/ooxml.js/src/typed/docx/constructs.test.ts @@ -2,10 +2,20 @@ import { describe, expect, it } from "vitest"; import type { ConstructDescriptor, ContentBlock } from "document-schema.js"; import { findConstructMarkerImbalance } from "document-schema.js"; import type { Package } from "../../model/package"; -import type { XmlNode } from "../../model/node"; +import type { XmlElement, XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { readDocxContent } from "./read"; -import { insertConstructMarkers } from "./constructs"; +import { + bookmarkAnchorDescriptor, + indexParagraphContent, + insertConstructMarkers, + readContentControlDescriptor, + readFormControlDescriptor, + runInstructionText, + runRangeMarkerExtents, + type ParagraphContentIndex, + type ParagraphRangeMarkerHalf, +} from "./constructs"; // The block-scope rule in action: which real docx spellings of a structured document tag, field, bookmark, or tracked change become a constructStart/constructEnd pair, and which ones (the run-level occurrences, and the pairs whose extents cross) are deliberately not representable. Every fixture here is a whole word/document.xml body, so each case is read exactly as readDocxContent would read a real file. @@ -50,6 +60,202 @@ function outline( }); } +describe("indexParagraphContent", () => { + it("indexes a non-run element as content-bearing unconditionally, and a run only when it carries non-inert content", () => { + // The hyperlink has no children at all, so it only counts as content-bearing via the "not a w:r" branch itself, never by inspecting children the way a run is inspected -- if that branch were skipped, an empty non-run element would wrongly fall through to the run-only children check and read as empty. The run mixes an inert w:rPr with a real w:t, which only reads as content-bearing under "some child is non-inert" (true here); "every child is non-inert" would read it as false, since w:rPr alone already fails that. + const paragraph = el("w:p", {}, [ + el("w:pPr", {}, []), + el("w:hyperlink", {}, []), + el("w:r", {}, [el("w:rPr", {}, []), el("w:t", {}, [txt("x")])]), + ]); + const index = indexParagraphContent(paragraph); + expect(index.firstContentIndex).toBe(1); + expect(index.lastContentIndex).toBe(2); + }); + + it("leaves both indices at -1 when a paragraph has no content-bearing children at all", () => { + const paragraph = el("w:p", {}, [ + el("w:pPr", {}, []), + el("w:bookmarkStart", { "w:id": "1" }, []), + ]); + const index = indexParagraphContent(paragraph); + expect(index.firstContentIndex).toBe(-1); + expect(index.lastContentIndex).toBe(-1); + }); +}); + +describe("runRangeMarkerExtents: isBlockScopedHalf", () => { + const half = ( + element: ParagraphRangeMarkerHalf["element"], + kind: "start" | "end", + runPosition: number, + ): ParagraphRangeMarkerHalf => ({ + element, + family: "bookmark", + id: "z", + name: kind === "start" ? "bm" : undefined, + kind, + runPosition, + }); + + it("treats a half nested inside a container -- not a direct paragraph child -- as run-scoped, not block-scoped", () => { + // Both halves sit inside the hyperlink rather than directly on the paragraph, so index.elements.indexOf never finds either: this is the "not found among the direct children" case the container comment describes, and it must resolve to run-scoped (kept) rather than silently falling through to the leading/trailing position math with a stray -1. + const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); + const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); + const paragraph = el("w:p", {}, [ + el("w:hyperlink", {}, [ + startEl, + el("w:r", {}, [el("w:t", {}, [txt("x")])]), + endEl, + ]), + ]); + const index = indexParagraphContent(paragraph); + const extents = runRangeMarkerExtents( + [half(startEl, "start", 0), half(endEl, "end", 1)], + index, + ); + expect(extents).toEqual([ + { descriptor: bookmarkAnchorDescriptor("bm"), startRun: 0, endRun: 1 }, + ]); + }); + + it("treats a found half with no content at all as leading regardless of its own position", () => { + // A synthetic index whose firstContentIndex is -1 (no content-bearing children) while lastContentIndex is a real, larger value: leading's own "-1 means everything is leading" shortcut must fire for ANY position here, not just one smaller than some real firstContentIndex, and trailing must stay false since neither half's position exceeds lastContentIndex. Both halves land on the block-scoped path only through that shortcut, so the pair is dropped. + const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); + const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); + const index: ParagraphContentIndex = { + elements: [startEl, endEl], + firstContentIndex: -1, + lastContentIndex: 100, + }; + const extents = runRangeMarkerExtents( + [half(startEl, "start", 0), half(endEl, "end", 5)], + index, + ); + expect(extents).toEqual([]); + }); + + it("treats a found half sitting exactly at the first content-bearing position as NOT leading", () => { + // firstContentIndex is a real index equal to this half's own position, so leading must be false (strictly less than, not less-than-or-equal) -- and trailing is pinned false by a lastContentIndex far beyond both halves' positions, so the pair is kept only if leading is computed correctly. + const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); + const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); + const index: ParagraphContentIndex = { + elements: [startEl, endEl], + firstContentIndex: 0, + lastContentIndex: 100, + }; + const extents = runRangeMarkerExtents( + [half(startEl, "start", 0), half(endEl, "end", 5)], + index, + ); + expect(extents).toEqual([ + { descriptor: bookmarkAnchorDescriptor("bm"), startRun: 0, endRun: 5 }, + ]); + }); +}); + +describe("runRangeMarkerExtents: malformed pairings", () => { + const flatIndex = (elements: XmlElement[]): ParagraphContentIndex => ({ + elements, + firstContentIndex: 0, + lastContentIndex: elements.length - 1, + }); + + it("drops an id with two starts and one end, rather than pairing the end with an arbitrary start", () => { + const startA = el("w:bookmarkStart", { "w:id": "z", "w:name": "a" }, []); + const startB = el("w:bookmarkStart", { "w:id": "z", "w:name": "b" }, []); + const end = el("w:bookmarkEnd", { "w:id": "z" }, []); + const halves: ParagraphRangeMarkerHalf[] = [ + { + element: startA, + family: "bookmark", + id: "z", + name: "a", + kind: "start", + runPosition: 0, + }, + { + element: startB, + family: "bookmark", + id: "z", + name: "b", + kind: "start", + runPosition: 1, + }, + { + element: end, + family: "bookmark", + id: "z", + name: undefined, + kind: "end", + runPosition: 2, + }, + ]; + expect( + runRangeMarkerExtents(halves, flatIndex([startA, startB, end])), + ).toEqual([]); + }); + + it("drops an id with one start and two ends, rather than pairing the start with an arbitrary end", () => { + const start = el("w:bookmarkStart", { "w:id": "z", "w:name": "a" }, []); + const endA = el("w:bookmarkEnd", { "w:id": "z" }, []); + const endB = el("w:bookmarkEnd", { "w:id": "z" }, []); + const halves: ParagraphRangeMarkerHalf[] = [ + { + element: start, + family: "bookmark", + id: "z", + name: "a", + kind: "start", + runPosition: 0, + }, + { + element: endA, + family: "bookmark", + id: "z", + name: undefined, + kind: "end", + runPosition: 1, + }, + { + element: endB, + family: "bookmark", + id: "z", + name: undefined, + kind: "end", + runPosition: 2, + }, + ]; + expect( + runRangeMarkerExtents(halves, flatIndex([start, endA, endB])), + ).toEqual([]); + }); + + it("drops a pair whose end precedes its own start rather than emitting a negative-length extent", () => { + const start = el("w:bookmarkStart", { "w:id": "z", "w:name": "a" }, []); + const end = el("w:bookmarkEnd", { "w:id": "z" }, []); + const halves: ParagraphRangeMarkerHalf[] = [ + { + element: start, + family: "bookmark", + id: "z", + name: "a", + kind: "start", + runPosition: 5, + }, + { + element: end, + family: "bookmark", + id: "z", + name: undefined, + kind: "end", + runPosition: 2, + }, + ]; + expect(runRangeMarkerExtents(halves, flatIndex([start, end]))).toEqual([]); + }); +}); + describe("docx constructs: structured document tags", () => { it("reads a block-level w:sdt as a contentControl construct bracketing its own content", () => { const sdt = el("w:sdt", {}, [ @@ -205,6 +411,171 @@ describe("docx constructs: structured document tags", () => { }); }); +describe("readContentControlDescriptor: internals", () => { + it("omits every optional field entirely, rather than setting it to undefined, when none of them apply", () => { + // toStrictEqual (unlike toEqual) fails on an extra key holding undefined, which is exactly what each of the four optional-field guards below would produce if its own "!== undefined" check were forced true regardless of the actual value. + const sdt = el("w:sdt", {}, [el("w:sdtPr", {}, [el("w:text")])]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "plainText", + }); + }); + + it("accepts a Table of Contents gallery spelled as w:docPartList, not only w:docPartObj", () => { + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w:docPartList", {}, [ + el("w:docPartGallery", { "w:val": "Table of Contents" }), + ]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "index", + }); + }); + + it("reads a comboBox's own listItem entries the same way a dropDownList's are read", () => { + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w:comboBox", {}, [ + el("w:listItem", { "w:displayText": "One", "w:value": "1" }), + ]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "comboBox", + options: ["One"], + }); + }); + + it("falls back to a listItem's own w:value when it carries no w:displayText", () => { + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w:dropDownList", {}, [el("w:listItem", { "w:value": "raw" })]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "dropDown", + options: ["raw"], + }); + }); + + it("reads a checkbox control from its plain w: spelling, not only the w14: forms", () => { + // w:checkbox (not w14:checkbox) and w:checked (not w14:checked): both fallbacks must actually be reachable, not merely declared. w14:val is used directly here so this stays independent of the w:val fallback, which gets its own test below. + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w:checkbox", {}, [el("w:checked", { "w14:val": "1" })]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: true, + }); + }); + + it("reads a checkbox's own checked value from its plain w:val, not only w14:val", () => { + // "0" rather than some other value: a checked state read via a broken w:val fallback would come back undefined, which this toggle's own convention reads as checked (true) -- indistinguishable from a genuine "1" unless the real answer is false. + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w14:checkbox", {}, [el("w14:checked", { "w:val": "0" })]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: false, + }); + }); + + it("treats a checkbox with no w:checked child at all as unchecked, not absent", () => { + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [el("w14:checkbox", {}, [])]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: false, + }); + }); + + it("reads a checkbox's 'false' and 'off' values as unchecked, alongside '0'", () => { + const falseSdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w14:checkbox", {}, [el("w14:checked", { "w14:val": "false" })]), + ]), + ]); + const offSdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w14:checkbox", {}, [el("w14:checked", { "w14:val": "off" })]), + ]), + ]); + expect(readContentControlDescriptor(falseSdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: false, + }); + expect(readContentControlDescriptor(offSdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: false, + }); + }); +}); + +describe("readFormControlDescriptor: internals", () => { + it("reads a legacy checkbox field's own checked value across '0', 'false', and 'off'", () => { + const beginRun = (val: string): XmlElement => + el("w:r", {}, [ + el("w:ffData", {}, [ + el("w:checkBox", {}, [el("w:checked", { "w:val": val })]), + ]), + ]); + expect(readFormControlDescriptor(beginRun("0"))?.checked).toBe(false); + expect(readFormControlDescriptor(beginRun("false"))?.checked).toBe(false); + expect(readFormControlDescriptor(beginRun("off"))?.checked).toBe(false); + }); + + it("falls back to w:default when a legacy checkbox field carries no w:checked", () => { + const beginRun = el("w:r", {}, [ + el("w:ffData", {}, [ + el("w:checkBox", {}, [el("w:default", { "w:val": "0" })]), + ]), + ]); + expect(readFormControlDescriptor(beginRun)?.checked).toBe(false); + }); + + it("defaults a legacy checkbox field's checked state to false when neither w:checked nor w:default is present", () => { + const beginRun = el("w:r", {}, [ + el("w:ffData", {}, [el("w:checkBox", {}, [])]), + ]); + expect(readFormControlDescriptor(beginRun)?.checked).toBe(false); + }); + + it("never mistakes a legacy text field for a drop-down list", () => { + const beginRun = el("w:r", {}, [ + el("w:ffData", {}, [el("w:textInput", {}, [])]), + ]); + const descriptor = readFormControlDescriptor(beginRun); + expect(descriptor?.controlType).toBe("plainText"); + expect(descriptor?.source?.format).toBe("docx"); + expect(descriptor).not.toHaveProperty("options"); + }); +}); + +describe("runInstructionText", () => { + it("reads w:delInstrText the same way as w:instrText, and ignores unrelated run children", () => { + const run = el("w:r", {}, [ + el("w:t", {}, [txt("not instruction")]), + el("w:delInstrText", {}, [txt(" DATE ")]), + ]); + expect(runInstructionText(run)).toBe(" DATE "); + }); +}); + describe("docx constructs: tracked changes", () => { it("reads a whole paragraph whose every content child is a w:ins as an insertion construct", () => { const paragraph = el("w:p", {}, [ @@ -738,4 +1109,13 @@ describe("insertConstructMarkers", () => { it("keeps the block list unchanged when there are no extents at all", () => { expect(insertConstructMarkers(blocks, [])).toEqual(blocks); }); + + it("sorts crossing extents by their own startIndex, not by discovery order alone", () => { + // P starts before Q but ends before Q ends too -- a genuine crossing, which the extent-scope rule drops entirely (Q has no encoding). P and Q's `order` fields are deliberately the REVERSE of their startIndex order: if compareExtents fell back to comparing `order` alone without weighing startIndex first, it would process Q before P, and P (starting at 0, before Q's own already-open span) would then read as nested inside Q rather than the reverse -- both extents would wrongly survive instead of Q alone being dropped. + const marked = insertConstructMarkers(blocks, [ + { startIndex: 0, endIndex: 2, order: 1, descriptor: anchor("p") }, + { startIndex: 1, endIndex: 3, order: 0, descriptor: anchor("q") }, + ]); + expect(outline(marked)).toEqual([anchor("p"), "a", "b", ")", "c"]); + }); }); From bd6c611f9dd55bec40bb0d400c83c94976589d81 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:19:55 +0100 Subject: [PATCH 07/63] fix(ooxml.js): pin isBlockScopedHalf's own leading/trailing boundary tests The two boundary tests introduced in the prior commit compared a half's runPosition rather than its actual array position (index.elements.indexOf), so both silently exercised the wrong slots and left the { expect(extents).toEqual([]); }); + // A run of dummy filler elements, purely to occupy array slots: isBlockScopedHalf's "position" is index.elements.indexOf(half.element), not a half's own runPosition, so pinning a half to a specific array position means padding the array out to it. + const filler = (): XmlElement => el("w:r", {}, []); + it("treats a found half sitting exactly at the first content-bearing position as NOT leading", () => { - // firstContentIndex is a real index equal to this half's own position, so leading must be false (strictly less than, not less-than-or-equal) -- and trailing is pinned false by a lastContentIndex far beyond both halves' positions, so the pair is kept only if leading is computed correctly. + // The start half sits at array position 0, exactly firstContentIndex (0): leading must be false there (strictly less than, not less-than-or-equal), or the pair would be wrongly dropped. The end half sits at array position 5, past a lastContentIndex of 2 by a wide margin, pinning IT as block-scoped (via trailing) regardless of either boundary mutant here or in the sibling test below -- so the pair's own "both block-scoped" AND hinges entirely on the start half's own leading value. const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); const index: ParagraphContentIndex = { - elements: [startEl, endEl], + elements: [startEl, filler(), filler(), filler(), filler(), endEl], firstContentIndex: 0, - lastContentIndex: 100, + lastContentIndex: 2, }; const extents = runRangeMarkerExtents( [half(startEl, "start", 0), half(endEl, "end", 5)], @@ -152,6 +155,25 @@ describe("runRangeMarkerExtents: isBlockScopedHalf", () => { { descriptor: bookmarkAnchorDescriptor("bm"), startRun: 0, endRun: 5 }, ]); }); + + it("treats a found half sitting exactly at the last content-bearing position as NOT trailing", () => { + // The end half sits at array position 15, exactly lastContentIndex (15): trailing must be false there (strictly greater than, not greater-than-or-equal), or the pair would be wrongly dropped. The start half sits at array position 0, clearly below a firstContentIndex of 10, pinning IT as block-scoped (via leading) regardless of either boundary mutant -- so the AND hinges entirely on the end half's own trailing value. + const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); + const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); + const elements = [startEl, ...Array.from({ length: 14 }, filler), endEl]; + const index: ParagraphContentIndex = { + elements, + firstContentIndex: 10, + lastContentIndex: 15, + }; + const extents = runRangeMarkerExtents( + [half(startEl, "start", 0), half(endEl, "end", 15)], + index, + ); + expect(extents).toEqual([ + { descriptor: bookmarkAnchorDescriptor("bm"), startRun: 0, endRun: 15 }, + ]); + }); }); describe("runRangeMarkerExtents: malformed pairings", () => { From 2896328b2eb1d447125359e905ba96231702f22b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:24:29 +0100 Subject: [PATCH 08/63] refactor(ooxml.js): drop drawings.ts's redundant column/row validity checks Number.isInteger(min)/(max)/(r) is always true or NaN given each value's own Number.parseInt provenance, and min >= 1 (or r >= 1) already rejects NaN unaided, so the isInteger guards were checking exactly what the numeric bounds already reject. A "max >= min" guard on a declared column range is equally unnecessary: columnWidthPt's own lookup only ever matches a range via "index >= min && index <= max", which an inverted range can never satisfy for any index, so admitting one unguarded is exactly as inert as rejecting it. Introduces parseIntAttr to read min/max/r directly as NaN-when-absent, replacing the "attr(..) ?? \"\"" placeholder Number.parseInt needed only to satisfy its own string parameter -- every string that could stand in for "absent" parses to NaN just the same, so the placeholder's own text was never an observable choice. --- packages/ooxml.js/src/typed/xlsx/drawings.ts | 23 +++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/drawings.ts b/packages/ooxml.js/src/typed/xlsx/drawings.ts index ba42a97020..78c1ddd4e9 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings.ts @@ -34,6 +34,12 @@ const CHART_GRAPHIC_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"; const DRAWING_REL_SUFFIX = "/drawing"; +// A whole-number attribute read as ECMA-376's own min/max/row-index vocabulary spells it: absent becomes NaN directly, never routed through a placeholder string first -- attr's own "string | undefined" would otherwise force a "?? \"\"" just to satisfy Number.parseInt's signature, and every string that could stand in for the absent case parses to NaN just the same, making the placeholder's own text a distinction with no behavioural difference to test. +function parseIntAttr(element: XmlElement, name: string): number { + const raw = attr(element, name); + return raw === undefined ? Number.NaN : Number.parseInt(raw, 10); +} + // One declared range, kept as the RANGE the anchor geometry needs -- readColumns deliberately materialises only each element's starting index (the repeat-hazard policy), but a column in the middle of a min..max span has a real width a drawing placed against it must resolve through. interface DeclaredColumn { readonly min: number; @@ -51,19 +57,15 @@ class SheetGridGeometry { const cols = childrenWithTag(worksheet, "cols")[0]; if (cols !== undefined) { for (const col of childrenWithTag(cols, "col")) { - const min = Number.parseInt(attr(col, "min") ?? "", 10); - const max = Number.parseInt(attr(col, "max") ?? "", 10); + const min = parseIntAttr(col, "min"); + const max = parseIntAttr(col, "max"); const widthRaw = attr(col, "width"); const widthPt = widthRaw === undefined ? undefined : columnWidthCharsToPt(Number(widthRaw)); - if ( - Number.isInteger(min) && - Number.isInteger(max) && - min >= 1 && - max >= min - ) { + // No separate Number.isInteger(min)/(max) guard is needed: both are always the result of Number.parseInt just above, which can only ever return NaN or a genuine integer -- never a finite non-integer -- and min >= 1 already rejects NaN on its own (every comparison against NaN is false). A "max >= min" guard is equally unnecessary here, for a different reason: columnWidthPt's own lookup below only ever matches a range via "index >= column.min && index <= column.max", and an inverted range (max < min) can never satisfy both halves of that for any index at all -- pushing one through unguarded is exactly as inert as rejecting it, since nothing else ever reads `columns` besides that lookup. + if (min >= 1) { this.columns.push({ min: min - 1, max: max - 1, @@ -84,10 +86,11 @@ class SheetGridGeometry { const sheetData = childrenWithTag(worksheet, "sheetData")[0]; if (sheetData !== undefined) { for (const row of childrenWithTag(sheetData, "row")) { - const r = Number.parseInt(attr(row, "r") ?? "", 10); + const r = parseIntAttr(row, "r"); const htRaw = attr(row, "ht"); const ht = htRaw === undefined ? Number.NaN : Number(htRaw); - if (Number.isInteger(r) && r >= 1 && Number.isFinite(ht)) { + // Same redundant isInteger drop as the column read above: r is always Number.parseInt's own result (NaN or a genuine integer), and r >= 1 already rejects NaN unaided. + if (r >= 1 && Number.isFinite(ht)) { this.rowHeights.set(r - 1, ht); } } From 0c5d24bd2b6a88e27e57cc1bd80c38a3ee53faf1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:27:04 +0100 Subject: [PATCH 09/63] refactor(ooxml.js): drop drawings.ts's remaining redundant NaN-fallback ternaries Number(undefined) is already NaN, and every one of these ternaries fed that NaN straight into an isFinite check that already degrades it to the same fallback (0, or DEFAULT_ROW_HEIGHT_PT) an explicit NaN branch would produce -- readAnchorChild's own "empty string" arm is the same story, since Number("") is 0, itself already finite and thus already the function's own fallback value. parseIntAttr's identical-shaped ternary stays: Number.parseInt requires a genuine string argument, so the "undefined" branch there is load-bearing for the type system even though it is provably behaviourally equivalent to the value parsing would already produce. --- packages/ooxml.js/src/typed/xlsx/drawings.ts | 22 +++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/drawings.ts b/packages/ooxml.js/src/typed/xlsx/drawings.ts index 78c1ddd4e9..94223863f9 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings.ts @@ -34,7 +34,7 @@ const CHART_GRAPHIC_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"; const DRAWING_REL_SUFFIX = "/drawing"; -// A whole-number attribute read as ECMA-376's own min/max/row-index vocabulary spells it: absent becomes NaN directly, never routed through a placeholder string first -- attr's own "string | undefined" would otherwise force a "?? \"\"" just to satisfy Number.parseInt's signature, and every string that could stand in for the absent case parses to NaN just the same, making the placeholder's own text a distinction with no behavioural difference to test. +// A whole-number attribute read as ECMA-376's own min/max/row-index vocabulary spells it. The "raw === undefined" branch is a genuinely irreducible equivalent mutation opportunity, not merely an untested one: Number.parseInt itself already returns NaN for undefined (it stringifies its argument first, and "undefined" starts with a non-digit), so the explicit NaN literal here produces exactly the value Number.parseInt(raw, 10) would already compute if TypeScript allowed passing raw (string | undefined) to a parameter typed string -- it exists only to satisfy that signature, not to change the outcome. No test built on this function's own observable contract (the returned number, never which branch computed it) can tell the two apart, any more than a test could tell +180 from -180 apart in a value always later reduced modulo 360 (see canonicalizeGroupRotation's own doc comment in shared/drawingml.ts for the general shape of this argument). function parseIntAttr(element: XmlElement, name: string): number { const raw = attr(element, name); return raw === undefined ? Number.NaN : Number.parseInt(raw, 10); @@ -59,11 +59,8 @@ class SheetGridGeometry { for (const col of childrenWithTag(cols, "col")) { const min = parseIntAttr(col, "min"); const max = parseIntAttr(col, "max"); - const widthRaw = attr(col, "width"); - const widthPt = - widthRaw === undefined - ? undefined - : columnWidthCharsToPt(Number(widthRaw)); + // No "widthRaw === undefined" guard is needed: Number(undefined) is already NaN, columnWidthCharsToPt propagates a NaN input straight through to a NaN result, and the isFinite check below already converts that to undefined -- an absent width attribute reaches the identical outcome whichever branch computes it. + const widthPt = columnWidthCharsToPt(Number(attr(col, "width"))); // No separate Number.isInteger(min)/(max) guard is needed: both are always the result of Number.parseInt just above, which can only ever return NaN or a genuine integer -- never a finite non-integer -- and min >= 1 already rejects NaN on its own (every comparison against NaN is false). A "max >= min" guard is equally unnecessary here, for a different reason: columnWidthPt's own lookup below only ever matches a range via "index >= column.min && index <= column.max", and an inverted range (max < min) can never satisfy both halves of that for any index at all -- pushing one through unguarded is exactly as inert as rejecting it, since nothing else ever reads `columns` besides that lookup. if (min >= 1) { this.columns.push({ @@ -75,11 +72,12 @@ class SheetGridGeometry { } } const sheetFormatPr = childrenWithTag(worksheet, "sheetFormatPr")[0]; + // No "sheetFormatPr === undefined" ternary is needed here: attr(undefined, ...) would be a type error (attr expects a real XmlElement), so the guard stays -- but the NUMBER side of it below drops the equivalent redundant ternary, since Number(undefined) is already NaN. const defaultRaw = sheetFormatPr === undefined ? undefined : attr(sheetFormatPr, "defaultRowHeight"); - const parsed = defaultRaw === undefined ? Number.NaN : Number(defaultRaw); + const parsed = Number(defaultRaw); this.defaultRowHeightPt = Number.isFinite(parsed) ? parsed : DEFAULT_ROW_HEIGHT_PT; @@ -87,8 +85,7 @@ class SheetGridGeometry { if (sheetData !== undefined) { for (const row of childrenWithTag(sheetData, "row")) { const r = parseIntAttr(row, "r"); - const htRaw = attr(row, "ht"); - const ht = htRaw === undefined ? Number.NaN : Number(htRaw); + const ht = Number(attr(row, "ht")); // Same redundant isInteger drop as the column read above: r is always Number.parseInt's own result (NaN or a genuine integer), and r >= 1 already rejects NaN unaided. if (r >= 1 && Number.isFinite(ht)) { this.rowHeights.set(r - 1, ht); @@ -183,14 +180,15 @@ function readAnchorChild(marker: XmlElement, tag: string): number { : child.children .map((node) => (node.type === "text" ? node.value : "")) .join(""); - const parsed = text === undefined || text === "" ? Number.NaN : Number(text); + // No "undefined or empty" guard is needed: Number(undefined) and Number("") are already NaN and 0 respectively, and the isFinite check below already maps BOTH of those through to the same 0 fallback this function returns for any other malformed text -- the explicit NaN this ternary substitutes for "" changes nothing downstream of it. + const parsed = Number(text); return Number.isFinite(parsed) ? parsed : 0; } // An anchor-level numeric attribute (xdr:ext's cx/cy): the same degrade-to-0 contract readAnchorChild gives a marker's child-text values, never a NaN frame. function numericAttr(element: XmlElement, name: string): number { - const raw = attr(element, name); - const parsed = raw === undefined ? Number.NaN : Number(raw); + // No "raw === undefined" guard is needed: Number(undefined) is already NaN, which the isFinite check below already degrades to 0, the same outcome the explicit NaN branch produces. + const parsed = Number(attr(element, name)); return Number.isFinite(parsed) ? parsed : 0; } From aa6df62c189f9881c032e45d9ce15d961502430e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:21:10 +0100 Subject: [PATCH 10/63] test(ooxml.js): cover SheetGridGeometry's column/row lookups and editAs sizing Adds synthetic-package tests for xlsx drawing-anchor geometry: a malformed column range (min below 1) falling back to the default width, a covering range's own declared width winning over a wider range with no width at all, a real sheetFormatPr defaultRowHeight overriding the built-in default, a declared row's own height taking precedence over that default, a malformed row (r below 1, or an unparseable ht) falling back to the default height, and editAs defaulting to twoCell (to-marker sizing) versus reading an explicit oneCell (own transform-extent sizing). Also names the payload sheet from the graphic frame's own xdr:cNvPr/@name in the existing chart graphic frame test, rather than leaving it implicit. --- .../ooxml.js/src/typed/xlsx/content.test.ts | 233 +++++++++++++++++- 1 file changed, 232 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 00dfe3fd52..1b1bd4e8b4 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -14,12 +14,13 @@ import type { ContentSheetDataValidation, } from "document-schema.js"; import type { Package } from "../../model/package"; +import type { XmlElement, XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { decodePackage, encodePackage } from "../../codec"; import { parsePackage } from "../../package-io/read"; import { attr, childrenWithTag, rootElement } from "../util"; import { buildXlsxPackageFromContent } from "./build"; -import { columnWidthCharsToPt } from "./units"; +import { columnWidthCharsToPt, DEFAULT_COLUMN_WIDTH_CHARS } from "./units"; import { readXlsxContent, resolveSheetEntries } from "./content"; // This suite reads real, unmodified LibreOffice-generated .xlsx fixtures (src/typed/xlsx/fixtures/*.xlsx). Both fixtures are genuine LibreOffice xlsx-exports (`soffice --headless --convert-to xlsx`) of odf.js's own src/typed/ods/fixtures/{kitchen-sink,minimal}.ods -- the same feature set that package's own readOds test suite already validates against ODF's equivalent mechanisms, run back through LibreOffice's real SpreadsheetML export filter so this suite exercises genuine, LibreOffice-authored xlsx markup (column-width character units, row heights, hidden rows/columns, every value-type LibreOffice's own xlsx exporter distinguishes, a real merged range, a real cross-sheet formula, and real print settings including Print_Area/Print_Titles defined names) rather than a hand-built approximation of what that markup might look like. A handful of narrow scope-boundary/error-path tests at the end use small, synthetic, hand-built packages instead (via el/txt), mirroring readOds's own established convention for the identical reason. @@ -1008,6 +1009,8 @@ describe("readXlsxContent: chart graphic frames", () => { chart?.document.kind === "spreadsheet" ? chart.document.sheets[0] : undefined; + // The graphic frame's own xdr:cNvPr/@name ("Chart 1"), not the "Chart" fallback -- the payload sheet is named after the shape that actually held it. + expect(sheet?.name).toBe("Chart 1"); expect(sheet?.cells).toEqual([ { row: 0, @@ -2038,6 +2041,234 @@ describe("readXlsxContent: drawing pictures (mixed anchor spellings)", () => { }); }); +// A drawing-bearing package for SheetGridGeometry and anchor-walk edge cases the fixtures above don't happen to exercise: the caller supplies the worksheet's own children (cols/sheetFormatPr/sheetData) and the drawing's own single anchor element directly, everything else (workbook, every relationship, the one media part) fixed to the same tiny PNG the picture fixtures above already use. +function customDrawingPackage( + worksheetChildren: XmlNode[], + anchor: XmlElement, +): Package { + const worksheet = el("worksheet", {}, [ + ...worksheetChildren, + el("drawing", { "r:id": "rIdDrawing" }), + ]); + const drawing = el("xdr:wsDr", {}, [anchor]); + const relationship = (id: string, type: string, target: string) => + el("Relationship", { Id: id, Type: type, Target: target }); + return { + parts: { + "xl/workbook.xml": { + kind: "xml", + nodes: [ + el("workbook", {}, [ + el("sheets", {}, [ + el("sheet", { name: "Data", sheetId: "1", "r:id": "rIdSheet" }), + ]), + ]), + ], + }, + "xl/_rels/workbook.xml.rels": { + kind: "xml", + nodes: [ + el("Relationships", {}, [ + relationship( + "rIdSheet", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + "worksheets/sheet1.xml", + ), + ]), + ], + }, + "xl/worksheets/sheet1.xml": { kind: "xml", nodes: [worksheet] }, + "xl/worksheets/_rels/sheet1.xml.rels": { + kind: "xml", + nodes: [ + el("Relationships", {}, [ + relationship( + "rIdDrawing", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing", + "../drawings/drawing1.xml", + ), + ]), + ], + }, + "xl/drawings/drawing1.xml": { kind: "xml", nodes: [drawing] }, + "xl/drawings/_rels/drawing1.xml.rels": { + kind: "xml", + nodes: [ + el("Relationships", {}, [ + relationship( + "rIdImage", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + "../media/image1.png", + ), + relationship( + "rIdChart", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart", + "../charts/chart1.xml", + ), + ]), + ], + }, + "xl/media/image1.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + "xl/charts/chart1.xml": { + kind: "xml", + nodes: [ + el("c:chartSpace", {}, [ + el("c:chart", {}, [el("c:plotArea", {}, [el("c:barChart", {})])]), + ]), + ], + }, + }, + }; +} + +// A twoCellAnchor carrying a single xdr:pic, from col0/row0 (offset 0) to col1/row1 (offset 0) unless overridden -- the minimal shape for exercising SheetGridGeometry's own column/row reading via the resulting frame size, independent of the anchor-placement arithmetic the fixtures above already cover. +function onePicTwoCellAnchor( + opts: { + toCol?: number; + toRow?: number; + editAs?: string; + } = {}, +): XmlElement { + const { toCol = 1, toRow = 1, editAs } = opts; + const picture = el("xdr:pic", {}, [ + el("xdr:nvPicPr", {}, [el("xdr:cNvPr", { id: "2", name: "Picture 1" })]), + el("xdr:blipFill", {}, [el("a:blip", { "r:embed": "rIdImage" })]), + el("xdr:spPr", {}, [ + el("a:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { cx: "914400", cy: "914400" }), + ]), + el("a:prstGeom", { prst: "rect" }, [el("a:avLst")]), + ]), + ]); + return el("xdr:twoCellAnchor", editAs === undefined ? {} : { editAs }, [ + el("xdr:from", {}, [ + el("xdr:col", {}, [txt("0")]), + el("xdr:colOff", {}, [txt("0")]), + el("xdr:row", {}, [txt("0")]), + el("xdr:rowOff", {}, [txt("0")]), + ]), + el("xdr:to", {}, [ + el("xdr:col", {}, [txt(String(toCol))]), + el("xdr:colOff", {}, [txt("0")]), + el("xdr:row", {}, [txt(String(toRow))]), + el("xdr:rowOff", {}, [txt("0")]), + ]), + picture, + el("xdr:clientData"), + ]); +} + +function imagesOf(pkg: Package): ContentSheet["images"] { + const document = readXlsxContent(pkg); + if (document.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + return document.sheets[0]?.images ?? []; +} + +describe("readXlsxContent: SheetGridGeometry (synthetic packages)", () => { + it("ignores a declared column range whose min is below 1, falling back to the default column width", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("cols", {}, [el("col", { min: "0", max: "1", width: "999" })]), + el("sheetData", {}, []), + ], + onePicTwoCellAnchor({ toCol: 1 }), + ), + ); + // Column 0 must fall back to the default width, not the malformed range's huge declared one. + expect(images[0]?.widthPt).toBeCloseTo( + columnWidthCharsToPt(DEFAULT_COLUMN_WIDTH_CHARS), + 5, + ); + }); + + it("prefers a covering column range's own declared width over a narrower range with no width at all", () => { + // Two declared ranges both cover column 0 -- an outer 1..5 range with no width (a real producer's habit for "these columns use the sheet default"), and an inner 1..1 range that actually states one. The inner range's real width must win, not the wider range's undefined one merely because .find() met it first. + const images = imagesOf( + customDrawingPackage( + [ + el("cols", {}, [ + el("col", { min: "1", max: "5" }), + el("col", { min: "1", max: "1", width: "40" }), + ]), + el("sheetData", {}, []), + ], + onePicTwoCellAnchor({ toCol: 1 }), + ), + ); + expect(images[0]?.widthPt).toBeCloseTo(columnWidthCharsToPt(40), 5); + }); + + it("reads a real sheetFormatPr defaultRowHeight rather than falling back to the built-in default", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("sheetFormatPr", { defaultRowHeight: "30" }), + el("sheetData", {}, []), + ], + onePicTwoCellAnchor({ toRow: 1 }), + ), + ); + expect(images[0]?.heightPt).toBeCloseTo(30, 5); + }); + + it("reads a declared row's own height, offset by one from its 1-based r, in preference to the default", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("sheetFormatPr", { defaultRowHeight: "15" }), + el("sheetData", {}, [el("row", { r: "1", ht: "50" })]), + ], + onePicTwoCellAnchor({ toRow: 1 }), + ), + ); + // r="1" names the FIRST row (0-based index 0) -- the very row this anchor spans, not the one after it. + expect(images[0]?.heightPt).toBeCloseTo(50, 5); + }); + + it("ignores a declared row whose r is below 1, or whose ht does not parse, falling back to the default height", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("sheetFormatPr", { defaultRowHeight: "15" }), + el("sheetData", {}, [ + el("row", { r: "0", ht: "999" }), + el("row", { r: "1", ht: "not a number" }), + ]), + ], + onePicTwoCellAnchor({ toRow: 1 }), + ), + ); + expect(images[0]?.heightPt).toBeCloseTo(15, 5); + }); + + it("defaults editAs to twoCell (sizing from the to-marker) when the attribute is absent, and reads it when present", () => { + const defaulted = imagesOf( + customDrawingPackage( + [el("sheetData", {}, [])], + onePicTwoCellAnchor({ toCol: 2 }), + ), + ); + // No editAs at all: sized from the to-marker difference (2 default-width columns), not the picture's own 1"x1" (72pt) xdr:ext. + expect(defaulted[0]?.widthPt).toBeCloseTo( + 2 * columnWidthCharsToPt(DEFAULT_COLUMN_WIDTH_CHARS), + 5, + ); + + const oneCell = imagesOf( + customDrawingPackage( + [el("sheetData", {}, [])], + onePicTwoCellAnchor({ toCol: 2, editAs: "oneCell" }), + ), + ); + // editAs="oneCell" on a twoCellAnchor (Excel's real spelling for "move but don't size with cells"): sized from the shape's own transform extent (1in = 72pt) instead, ignoring the to-marker entirely. + expect(oneCell[0]?.widthPt).toBeCloseTo(72, 5); + }); +}); + // dataValidation and conditionalFormatting rules, promoted to real vocabulary (ExaDev/documents.js#758) for every rule this package's schema names -- the two real-producer fixtures below exercise the structural read/write path; the synthetic packages further down exercise what is deliberately left un-promoted (an 'expression' cfRule, a dataValidation type this schema does not name) through the pre-existing anchor-cell residue mechanism. function worksheetOnlyPackage(worksheet: ReturnType): Package { const workbook = el("workbook", {}, [ From 445e43cf7b8ccbf4d36e0f0db2e8d54bf26d5cc2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:54:14 +0100 Subject: [PATCH 11/63] test(ooxml.js): close drawings.ts's remaining chart-frame and marker gaps Adds synthetic-package tests for the chart-graphic-frame reading path that the picture-anchor fixtures never exercised: a graphicData whose uri names something other than a chart, a graphic frame with no xdr:cNvPr at all, one whose cNvPr carries no name attribute, a worksheet whose rels list an unrelated relationship type before the real drawing one, and a picture-only drawing asserting embeddedObjects stays absent. Also adds marker-field tests distinguishing a genuinely nonzero rowOff from colOff and a numeric marker value from a non-text sibling node, a column-range test proving a range never applies below its own declared min, and an absoluteAnchor position landing exactly on a column boundary. Drops the now-provably-redundant "r >= 1" guard on declared row heights: rowHeightPt is a direct Map.get on the caller's own index, never a range test, so a malformed row lands at a key no legitimate query can ever reach, unlike the analogous column-range check this guard was modelled on. Reads editAs directly against "oneCell" rather than through an intermediate default, since twoCell and an absent attribute are already indistinguishable to that comparison. Documents emptyWorksheet's own tag as unobservable to its sole caller. Rewrites chartCells to read a table cell's single run directly instead of joining a general multi-block, multi-run shape neither this file's only producer (labelCell) nor any real chart cache ever populates with more than one of either. --- .../ooxml.js/src/typed/xlsx/content.test.ts | 224 +++++++++++++++++- packages/ooxml.js/src/typed/xlsx/drawings.ts | 26 +- 2 files changed, 230 insertions(+), 20 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 1b1bd4e8b4..769d752444 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -1433,6 +1433,8 @@ describe("readXlsxContent: drawing pictures", () => { expect(image?.offsetYPt).toBe(0); expect(image?.widthPt).toBeCloseTo(col0 + col1 - offsetX, 5); expect(image?.heightPt).toBeCloseTo(45, 5); + // A drawing carrying only a picture, no chart graphic frame at all, leaves embeddedObjects absent rather than an empty array -- the same "undefined means none, [] means none for images specifically" split the module doc comment states. + expect(document.sheets[0]?.embeddedObjects).toBeUndefined(); }); it("leaves a picture whose media bytes do not sniff as PNG/JPEG unread rather than emitting an unsniffable image", () => { @@ -1617,7 +1619,12 @@ describe("readXlsxContent: drawing pictures (oneCellAnchor)", () => { }); // The absoluteAnchor spelling: xdr:pos (x/y EMU, page-absolute) plus xdr:ext sizing, no markers at all. ContentSheetImage's anchor vocabulary is cell-relative, so the landing #776 decides on is the nearest-cell re-basing -- the grid geometry's own inverse maps the absolute position onto a containing column/row plus the offset within it, exactly the fields a from-marker spells directly. The fixture grid: column 0 is 10 chars (52.5 pt), column 1 is 20 chars (105 pt), rows default 15 pt; pos 762000 x 190500 EMU is 60 x 15 pt, so column 1 offset 7.5 pt (52.5 + 7.5 = 60) and row 1 offset 0 (15 sits exactly on the row-1 boundary). -function absolutePicturePackage(extCx = "1828800", extCy = "914400"): Package { +function absolutePicturePackage( + extCx = "1828800", + extCy = "914400", + posX = "762000", + posY = "190500", +): Package { const picture = el("xdr:pic", {}, [ el("xdr:nvPicPr", {}, [el("xdr:cNvPr", { id: "2", name: "Picture 1" })]), el("xdr:blipFill", {}, [el("a:blip", { "r:embed": "rIdImage" })]), @@ -1631,7 +1638,7 @@ function absolutePicturePackage(extCx = "1828800", extCy = "914400"): Package { ]); const drawing = el("xdr:wsDr", {}, [ el("xdr:absoluteAnchor", {}, [ - el("xdr:pos", { x: "762000", y: "190500" }), + el("xdr:pos", { x: posX, y: posY }), el("xdr:ext", { cx: extCx, cy: extCy }), picture, el("xdr:clientData"), @@ -1732,6 +1739,21 @@ describe("readXlsxContent: drawing pictures (absoluteAnchor)", () => { expect(document.sheets[0]?.images).toEqual([]); }); + it("locates a position sitting exactly on a column boundary as the start of the next column, not an offset into the previous one", () => { + // Column 0 is 10 chars = columnWidthCharsToPt(10) pt exactly, i.e. that many EMU at 12700 EMU/pt -- pos x lands exactly on the column 0/1 boundary, pos y at 0 keeps the row/height math out of it entirely. + const boundaryEmu = Math.round(columnWidthCharsToPt(10) * 12700); + const document = readXlsxContent( + absolutePicturePackage("1828800", "914400", String(boundaryEmu), "0"), + ); + if (document.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + const image = document.sheets[0]?.images[0]; + // A position exactly at the boundary belongs to the column it starts (column 1, offset 0), not the tail end of column 0 (column 0, offset = the whole column width). + expect(image?.anchorColumn).toBe(1); + expect(image?.offsetXPt).toBeCloseTo(0, 5); + }); + it("round-trips the whole document through ContentDocumentSchema, so the absolute-anchored sheet image is schema-valid as read", () => { expect( ContentDocumentSchema.safeParse(readXlsxContent(absolutePicturePackage())) @@ -2127,9 +2149,19 @@ function onePicTwoCellAnchor( toCol?: number; toRow?: number; editAs?: string; + fromColOffEmu?: number; + fromRowOffEmu?: number; + fromColNodes?: XmlNode[]; } = {}, ): XmlElement { - const { toCol = 1, toRow = 1, editAs } = opts; + const { + toCol = 1, + toRow = 1, + editAs, + fromColOffEmu = 0, + fromRowOffEmu = 0, + fromColNodes, + } = opts; const picture = el("xdr:pic", {}, [ el("xdr:nvPicPr", {}, [el("xdr:cNvPr", { id: "2", name: "Picture 1" })]), el("xdr:blipFill", {}, [el("a:blip", { "r:embed": "rIdImage" })]), @@ -2143,10 +2175,10 @@ function onePicTwoCellAnchor( ]); return el("xdr:twoCellAnchor", editAs === undefined ? {} : { editAs }, [ el("xdr:from", {}, [ - el("xdr:col", {}, [txt("0")]), - el("xdr:colOff", {}, [txt("0")]), + el("xdr:col", {}, fromColNodes ?? [txt("0")]), + el("xdr:colOff", {}, [txt(String(fromColOffEmu))]), el("xdr:row", {}, [txt("0")]), - el("xdr:rowOff", {}, [txt("0")]), + el("xdr:rowOff", {}, [txt(String(fromRowOffEmu))]), ]), el("xdr:to", {}, [ el("xdr:col", {}, [txt(String(toCol))]), @@ -2267,6 +2299,186 @@ describe("readXlsxContent: SheetGridGeometry (synthetic packages)", () => { // editAs="oneCell" on a twoCellAnchor (Excel's real spelling for "move but don't size with cells"): sized from the shape's own transform extent (1in = 72pt) instead, ignoring the to-marker entirely. expect(oneCell[0]?.widthPt).toBeCloseTo(72, 5); }); + + it("never applies a declared column range to an index below its own min, even when that index is within the range's max", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("cols", {}, [el("col", { min: "3", max: "5", width: "999" })]), + el("sheetData", {}, []), + ], + onePicTwoCellAnchor({ toCol: 1 }), + ), + ); + // Column 0 sits below the declared range's own min (2, 0-based) -- it must fall back to the default width, not the range's huge declared one merely because 0 <= the range's own max. + expect(images[0]?.widthPt).toBeCloseTo( + columnWidthCharsToPt(DEFAULT_COLUMN_WIDTH_CHARS), + 5, + ); + }); +}); + +describe("readXlsxContent: anchor marker fields (synthetic packages)", () => { + it("reads a marker's own rowOff distinctly from its colOff, rather than one child tag's value doing double duty for both", () => { + const images = imagesOf( + customDrawingPackage( + [el("sheetData", {}, [])], + // Small enough to stay well inside the default 15pt row height, so the anchor's own height stays positive (4pt = 50800 EMU). + onePicTwoCellAnchor({ fromRowOffEmu: 50_800 }), + ), + ); + // The row axis carries a real offset; the column axis stays at its own default (0). + expect(images[0]?.offsetXPt).toBe(0); + expect(images[0]?.offsetYPt).toBeCloseTo(4, 5); + }); + + it("extracts a marker child's numeric text past a non-text sibling node, rather than letting that sibling corrupt the joined value", () => { + const images = imagesOf( + customDrawingPackage( + [el("sheetData", {}, [])], + onePicTwoCellAnchor({ + fromColNodes: [{ type: "comment", value: "producer note" }, txt("5")], + toCol: 6, + }), + ), + ); + // The comment sibling contributes nothing to the joined text; the real numeric value is "5", not corrupted by whatever a non-text node's own placeholder text would join in as. + expect(images[0]?.anchorColumn).toBe(5); + }); +}); + +describe("readXlsxContent: chart graphic frame structural gaps (synthetic packages)", () => { + function chartGraphicFrame( + opts: { + withCNvPr?: boolean; + name?: string; + graphicUri?: string; + } = {}, + ): XmlElement { + const { + withCNvPr = true, + graphicUri = "http://schemas.openxmlformats.org/drawingml/2006/chart", + } = opts; + // "name" in opts (not a destructured default) distinguishes "caller omitted the option, use the real default" from "caller explicitly asked for no name attribute at all" -- a destructured default would treat {name: undefined} identically to {}, which defeats the one test below that needs a cNvPr with genuinely no name attribute. + const name = "name" in opts ? opts.name : "Chart 1"; + const nvGraphicFramePrChildren = withCNvPr + ? [ + el( + "xdr:cNvPr", + name === undefined ? { id: "2" } : { id: "2", name }, + [], + ), + ] + : []; + return el("xdr:graphicFrame", {}, [ + el("xdr:nvGraphicFramePr", {}, nvGraphicFramePrChildren), + el("a:graphic", {}, [ + el("a:graphicData", { uri: graphicUri }, [ + el("c:chart", { "r:id": "rIdChart" }), + ]), + ]), + ]); + } + + function chartFrameAnchor(frame: XmlElement): XmlElement { + return el("xdr:twoCellAnchor", {}, [ + el("xdr:from", {}, [ + el("xdr:col", {}, [txt("0")]), + el("xdr:colOff", {}, [txt("0")]), + el("xdr:row", {}, [txt("0")]), + el("xdr:rowOff", {}, [txt("0")]), + ]), + el("xdr:to", {}, [ + el("xdr:col", {}, [txt("1")]), + el("xdr:colOff", {}, [txt("0")]), + el("xdr:row", {}, [txt("1")]), + el("xdr:rowOff", {}, [txt("0")]), + ]), + frame, + el("xdr:clientData"), + ]); + } + + function embeddedChartOf(pkg: Package) { + const document = readXlsxContent(pkg); + if (document.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + return document.sheets[0]?.embeddedObjects; + } + + it("treats a graphicData whose uri names something other than a chart as carrying no embeddable content at all", () => { + const objects = embeddedChartOf( + customDrawingPackage( + [el("sheetData", {}, [])], + chartFrameAnchor( + chartGraphicFrame({ graphicUri: "http://example.com/not-a-chart" }), + ), + ), + ); + expect(objects).toBeUndefined(); + }); + + it("names the payload sheet 'Chart' when the graphic frame carries no xdr:cNvPr at all", () => { + const objects = embeddedChartOf( + customDrawingPackage( + [el("sheetData", {}, [])], + chartFrameAnchor(chartGraphicFrame({ withCNvPr: false })), + ), + ); + const sheet = + objects?.[0]?.document.kind === "spreadsheet" + ? objects[0].document.sheets[0] + : undefined; + expect(sheet?.name).toBe("Chart"); + }); + + it("names the payload sheet 'Chart' when xdr:cNvPr carries no name attribute", () => { + const objects = embeddedChartOf( + customDrawingPackage( + [el("sheetData", {}, [])], + chartFrameAnchor(chartGraphicFrame({ name: undefined })), + ), + ); + const sheet = + objects?.[0]?.document.kind === "spreadsheet" + ? objects[0].document.sheets[0] + : undefined; + expect(sheet?.name).toBe("Chart"); + }); + + it("never resolves an unrelated relationship type as the worksheet's own drawing part, even when it sorts before the real one", () => { + // A hyperlink relationship inserted before the genuine drawing relationship in the worksheet's own rels part -- resolveRelationships preserves declaration order, so a coverage-bearing loop that stops at the FIRST relationship regardless of type would resolve the hyperlink's own (nonsensical, non-drawing) target as if it were the drawing part. + const relationship = (id: string, type: string, target: string) => + el("Relationship", { Id: id, Type: type, Target: target }); + const pkg = customDrawingPackage( + [el("sheetData", {}, [])], + chartFrameAnchor(chartGraphicFrame()), + ); + const sheetRels = pkg.parts["xl/worksheets/_rels/sheet1.xml.rels"]; + if (sheetRels?.kind !== "xml") { + throw new Error("expected the worksheet rels part to be xml"); + } + const relationships = sheetRels.nodes[0]; + if (relationships?.type !== "element") { + throw new Error("expected a Relationships root element"); + } + pkg.parts["xl/worksheets/_rels/sheet1.xml.rels"] = { + kind: "xml", + nodes: [ + el("Relationships", {}, [ + relationship( + "rIdHyperlink", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + "https://example.com", + ), + ...relationships.children, + ]), + ], + }; + const objects = embeddedChartOf(pkg); + expect(objects).toHaveLength(1); + }); }); // dataValidation and conditionalFormatting rules, promoted to real vocabulary (ExaDev/documents.js#758) for every rule this package's schema names -- the two real-producer fixtures below exercise the structural read/write path; the synthetic packages further down exercise what is deliberately left un-promoted (an 'expression' cfRule, a dataValidation type this schema does not name) through the pre-existing anchor-cell residue mechanism. diff --git a/packages/ooxml.js/src/typed/xlsx/drawings.ts b/packages/ooxml.js/src/typed/xlsx/drawings.ts index 94223863f9..021c3cba18 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings.ts @@ -86,8 +86,8 @@ class SheetGridGeometry { for (const row of childrenWithTag(sheetData, "row")) { const r = parseIntAttr(row, "r"); const ht = Number(attr(row, "ht")); - // Same redundant isInteger drop as the column read above: r is always Number.parseInt's own result (NaN or a genuine integer), and r >= 1 already rejects NaN unaided. - if (r >= 1 && Number.isFinite(ht)) { + // No "r >= 1" guard is needed, unlike the column read above's "min >= 1": rowHeightPt's own lookup is a direct Map.get(index) on the exact key a real anchor row supplies, never a range test, and every call site (xPt/yPt's own loops, locateRow) only ever queries a non-negative integer index. A malformed r below 1 (or the NaN parseIntAttr already returns for an unparseable one) still lands at some key <= -1 or NaN, which can never equal any index a legitimate query supplies -- so admitting it here is exactly as inert as rejecting it. + if (Number.isFinite(ht)) { this.rowHeights.set(r - 1, ht); } } @@ -257,10 +257,11 @@ function readAnchorPlacement( } const xPt = geometry.xPt(from.column, from.colOffEmu); const yPt = geometry.yPt(from.row, from.rowOffEmu); - // editAs governs which size statement is the semantic one: "oneCell" means move-but-not-size-with-cells, so the shape's own transform extent is the frame (the to-marker is Calc's spelling habit for it and disagrees with the character-unit column widths underneath -- verified against real producer output); "twoCell" (also ECMA's default) means the frame IS the to-marker difference, resizing with the grid, so the grid rules; "absolute" sizes independently of both. - const editAs = attr(anchor, "editAs") ?? "twoCell"; + // editAs governs which size statement is the semantic one: "oneCell" means move-but-not-size-with-cells, so the shape's own transform extent is the frame (the to-marker is Calc's spelling habit for it and disagrees with the character-unit column widths underneath -- verified against real producer output); an absent attribute or any other spelling ("twoCell", ECMA's own default, or "absolute") all fall to the same to-marker-difference sizing below, so the comparison reads the attribute directly rather than materialising a "twoCell" default nothing else ever observes. const childExt = - editAs === "oneCell" ? readChildTransformExtEmu(anchor) : undefined; + attr(anchor, "editAs") === "oneCell" + ? readChildTransformExtEmu(anchor) + : undefined; return { xPt, yPt, @@ -317,12 +318,12 @@ function readAnchorPlacement( }; } -// A minimal, childless worksheet element for the payload sheet's own print settings -- the same all-defaults ContentSheetPrintSettings readPrintSettings produces for an empty worksheet, which is the honest spelling for a synthesized sheet that never had a page setup of its own. +// A minimal, childless worksheet element for the payload sheet's own print settings -- the same all-defaults ContentSheetPrintSettings readPrintSettings produces for an empty worksheet, which is the honest spelling for a synthesized sheet that never had a page setup of its own. The "worksheet" tag string here is a genuinely irreducible equivalent mutation opportunity, not merely an untested one: this element is passed only to readPrintSettings, which reads its CHILDREN's tags (via childrenWithTag) and never once inspects the worksheet element's own tag -- with no children to walk, this element is otherwise an empty shell whose own tag field is dead structurally, not just here, so no test built on this function's own observable contract (the ContentSheetPrintSettings readPrintSettings returns) can ever tell one tag string from another. function emptyWorksheet(): XmlElement { return { type: "element", tag: "worksheet", attributes: [], children: [] }; } -// readChartTable's table laid out as the payload sheet's sparse cells: the header row's series names over the category column, one row per category, values verbatim c:v text -- chart caches carry no typed-cell concept to preserve beyond the string itself, which is why every populated cell is the string kind. +// readChartTable's table laid out as the payload sheet's sparse cells: the header row's series names over the category column, one row per category, values verbatim c:v text -- chart caches carry no typed-cell concept to preserve beyond the string itself, which is why every populated cell is the string kind. Reads each cell's text directly off its own single run rather than walking/joining a general multi-block, multi-run cell shape: readChartTable's own labelCell is the only producer that ever reaches this function, and it always emits either no block at all (an absent series name or category/value) or exactly one paragraph block holding exactly one run -- so a cell here never actually carries more than one block or run for a join to meaningfully separate. function chartCells( chartRoot: XmlElement, frame: ContentEmbeddedObject["frame"], @@ -334,13 +335,10 @@ function chartCells( const cells: ContentSheetCell[] = []; table.rows.forEach((row, rowIndex) => { row.cells.forEach((cell, columnIndex) => { - const text = cell.blocks - .map((block) => - block.kind === "paragraph" - ? block.runs.map((run) => run.text).join("") - : "", - ) - .join(""); + const block = cell.blocks[0]; + // block.runs[0] is always defined whenever block is a paragraph: labelCell (readChartTable's sole producer reaching this function) never emits a paragraph block with zero runs, only zero blocks at all for an absent value -- the "?? ''" is required by runs' own indexed-access type, not by any input this function can actually receive. + const text = + block?.kind === "paragraph" ? (block.runs[0]?.text ?? "") : ""; if (text !== "") { cells.push({ row: rowIndex, From a8de25061cb505816098033e726c7b783da7ab6a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 13:55:17 +0100 Subject: [PATCH 12/63] test(ooxml.js): add direct structural coverage for buildDrawing and fixed package-scaffolding XML buildDrawing's zero offsets, rect preset, and distT/B/L/R attributes, and the fixed _rels/.rels relationships, [Content_Types].xml Default/Override entries, and styles.xml docDefaults/Normal scaffolding were never asserted against their literal values: readDocxContent doesn't read most of them back, so a round-trip assertion alone can't catch a mutated literal. These tests parse the written XML directly and check every fixed attribute value. --- .../ooxml.js/src/typed/docx/write.test.ts | 316 +++++++++++++++++- 1 file changed, 315 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index 80d4568082..38cb142dbe 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -9,7 +9,8 @@ import type { Package } from "../../model/package"; import type { XmlElement, XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { decodePackage, encodePackage } from "../../codec"; -import { attr, elementsWithTag, rootElement } from "../util"; +import { attr, childrenWithTag, elementsWithTag, rootElement } from "../util"; +import { ptToEmu } from "../shared/units"; import type { DocxDocument } from "./read"; import { readDocxContent } from "./read"; import { buildDocxPackageFromContent } from "./write"; @@ -247,6 +248,319 @@ describe("buildDocxPackageFromContent: package scaffolding", () => { }); }); +// A minimal one-paragraph section, for the package-scaffolding tests below that only care about the parts every document carries regardless of content. +function emptyBodySection(): ContentSection { + return { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [], + }; +} + +const DRAWINGML_MAIN_NS = + "http://schemas.openxmlformats.org/drawingml/2006/main"; + +describe("buildDocxPackageFromContent: buildDrawing's fixed XML shape", () => { + it("writes the zero offset, rect preset, distT/B/L/R zeros, and docPr id/name exactly, with alt text as descr", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 100, + heightPt: 50, + altText: "a caption", + }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const drawing = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:drawing", + )[0]; + if (drawing === undefined) { + throw new Error("expected a w:drawing element"); + } + const inline = childrenWithTag(drawing, "wp:inline")[0]; + if (inline === undefined) { + throw new Error("expected a wp:inline element"); + } + expect(attr(inline, "distT")).toBe("0"); + expect(attr(inline, "distB")).toBe("0"); + expect(attr(inline, "distL")).toBe("0"); + expect(attr(inline, "distR")).toBe("0"); + + const cx = String(ptToEmu(100)); + const cy = String(ptToEmu(50)); + const extent = childrenWithTag(inline, "wp:extent")[0]; + expect(extent === undefined ? undefined : attr(extent, "cx")).toBe(cx); + expect(extent === undefined ? undefined : attr(extent, "cy")).toBe(cy); + + const docPr = childrenWithTag(inline, "wp:docPr")[0]; + expect(docPr === undefined ? undefined : attr(docPr, "id")).toBe("1"); + expect(docPr === undefined ? undefined : attr(docPr, "name")).toBe( + "Picture 1", + ); + expect(docPr === undefined ? undefined : attr(docPr, "descr")).toBe( + "a caption", + ); + + const graphic = childrenWithTag(inline, "a:graphic")[0]; + expect(graphic === undefined ? undefined : attr(graphic, "xmlns:a")).toBe( + DRAWINGML_MAIN_NS, + ); + const graphicData = + graphic === undefined + ? undefined + : childrenWithTag(graphic, "a:graphicData")[0]; + expect( + graphicData === undefined ? undefined : attr(graphicData, "uri"), + ).toBe(PICTURE_GRAPHIC_URI); + + const pic = + graphicData === undefined + ? undefined + : childrenWithTag(graphicData, "pic:pic")[0]; + expect(pic === undefined ? undefined : attr(pic, "xmlns:pic")).toBe( + PICTURE_GRAPHIC_URI, + ); + + const nvPicPr = + pic === undefined ? undefined : childrenWithTag(pic, "pic:nvPicPr")[0]; + const cNvPr = + nvPicPr === undefined + ? undefined + : childrenWithTag(nvPicPr, "pic:cNvPr")[0]; + expect(cNvPr === undefined ? undefined : attr(cNvPr, "id")).toBe("1"); + expect(cNvPr === undefined ? undefined : attr(cNvPr, "name")).toBe( + "Picture 1", + ); + const cNvPicPr = + nvPicPr === undefined + ? undefined + : childrenWithTag(nvPicPr, "pic:cNvPicPr")[0]; + expect(cNvPicPr?.children).toEqual([]); + + const blipFill = + pic === undefined ? undefined : childrenWithTag(pic, "pic:blipFill")[0]; + const blip = + blipFill === undefined + ? undefined + : childrenWithTag(blipFill, "a:blip")[0]; + expect(blip === undefined ? undefined : attr(blip, "r:embed")).toBe("rId1"); + const stretch = + blipFill === undefined + ? undefined + : childrenWithTag(blipFill, "a:stretch")[0]; + expect( + stretch === undefined + ? undefined + : childrenWithTag(stretch, "a:fillRect")[0], + ).toBeDefined(); + + const spPr = + pic === undefined ? undefined : childrenWithTag(pic, "pic:spPr")[0]; + const xfrm = + spPr === undefined ? undefined : childrenWithTag(spPr, "a:xfrm")[0]; + const off = + xfrm === undefined ? undefined : childrenWithTag(xfrm, "a:off")[0]; + expect(off === undefined ? undefined : attr(off, "x")).toBe("0"); + expect(off === undefined ? undefined : attr(off, "y")).toBe("0"); + const ext = + xfrm === undefined ? undefined : childrenWithTag(xfrm, "a:ext")[0]; + expect(ext === undefined ? undefined : attr(ext, "cx")).toBe(cx); + expect(ext === undefined ? undefined : attr(ext, "cy")).toBe(cy); + const prstGeom = + spPr === undefined ? undefined : childrenWithTag(spPr, "a:prstGeom")[0]; + expect(prstGeom === undefined ? undefined : attr(prstGeom, "prst")).toBe( + "rect", + ); + expect( + prstGeom === undefined + ? undefined + : childrenWithTag(prstGeom, "a:avLst")[0], + ).toBeDefined(); + }); + + it("omits wp:docPr's descr attribute for an image with no alt text, and increments the drawing id for a second image", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + }, + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const drawings = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:drawing", + ); + expect(drawings).toHaveLength(2); + const docPrs = drawings.map((drawing) => { + const inline = childrenWithTag(drawing, "wp:inline")[0]; + return inline === undefined + ? undefined + : childrenWithTag(inline, "wp:docPr")[0]; + }); + expect(docPrs[0] === undefined ? undefined : attr(docPrs[0], "id")).toBe( + "1", + ); + expect( + docPrs[0] === undefined ? undefined : attr(docPrs[0], "descr"), + ).toBeUndefined(); + expect(docPrs[1] === undefined ? undefined : attr(docPrs[1], "id")).toBe( + "2", + ); + expect(docPrs[1] === undefined ? undefined : attr(docPrs[1], "name")).toBe( + "Picture 2", + ); + }); +}); + +describe("buildDocxPackageFromContent: fixed package-scaffolding parts", () => { + it("writes _rels/.rels with exactly the three fixed package relationships, in order", () => { + const written = buildDocxPackageFromContent({ + sections: [emptyBodySection()], + }); + const root = rootElement(written.parts["_rels/.rels"]); + const rels = + root === undefined ? [] : childrenWithTag(root, "Relationship"); + expect( + rels.map((rel) => ({ + Id: attr(rel, "Id"), + Type: attr(rel, "Type"), + Target: attr(rel, "Target"), + })), + ).toEqual([ + { + Id: "rId1", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", + Target: "word/document.xml", + }, + { + Id: "rId2", + Type: "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + Target: "docProps/core.xml", + }, + { + Id: "rId3", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", + Target: "docProps/app.xml", + }, + ]); + }); + + it("writes [Content_Types].xml's fixed rels/xml Default entries and document/core/app Overrides", () => { + const written = buildDocxPackageFromContent({ + sections: [emptyBodySection()], + }); + const root = rootElement(written.parts["[Content_Types].xml"]); + const defaults = root === undefined ? [] : childrenWithTag(root, "Default"); + expect( + defaults.map((entry) => ({ + Extension: attr(entry, "Extension"), + ContentType: attr(entry, "ContentType"), + })), + ).toEqual([ + { + Extension: "rels", + ContentType: "application/vnd.openxmlformats-package.relationships+xml", + }, + { Extension: "xml", ContentType: "application/xml" }, + ]); + + const overrides = + root === undefined ? [] : childrenWithTag(root, "Override"); + const overrideFor = (partName: string): string | undefined => { + const found = overrides.find( + (entry) => attr(entry, "PartName") === partName, + ); + return found === undefined ? undefined : attr(found, "ContentType"); + }; + expect(overrideFor("/word/document.xml")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", + ); + expect(overrideFor("/docProps/core.xml")).toBe( + "application/vnd.openxmlformats-package.core-properties+xml", + ); + expect(overrideFor("/docProps/app.xml")).toBe( + "application/vnd.openxmlformats-officedocument.extended-properties+xml", + ); + expect(overrideFor("/word/styles.xml")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml", + ); + }); + + it("writes styles.xml's fixed docDefaults and Normal/DefaultParagraphFont scaffolding for a document with no named styles", () => { + const written = buildDocxPackageFromContent({ + sections: [emptyBodySection()], + }); + const root = rootElement(written.parts["word/styles.xml"]); + const docDefaults = + root === undefined + ? undefined + : childrenWithTag(root, "w:docDefaults")[0]; + expect( + docDefaults === undefined + ? undefined + : childrenWithTag(docDefaults, "w:rPrDefault")[0]?.children, + ).toEqual([]); + expect( + docDefaults === undefined + ? undefined + : childrenWithTag(docDefaults, "w:pPrDefault")[0]?.children, + ).toEqual([]); + + const styles = root === undefined ? [] : childrenWithTag(root, "w:style"); + expect( + styles.map((style) => { + const name = childrenWithTag(style, "w:name")[0]; + return { + type: attr(style, "w:type"), + default: attr(style, "w:default"), + styleId: attr(style, "w:styleId"), + name: name === undefined ? undefined : attr(name, "w:val"), + }; + }), + ).toEqual([ + { + type: "paragraph", + default: "1", + styleId: "Normal", + name: "Normal", + }, + { + type: "character", + default: "1", + styleId: "DefaultParagraphFont", + name: "Default Paragraph Font", + }, + ]); + }); +}); + describe("buildDocxPackageFromContent: content round trip", () => { it("round-trips paragraph properties, run formatting, headings, lists, and page breaks", () => { const styled = el("w:p", {}, [ From 2f70f98e5b42582d2dc8c884f2b775da5529e772 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:05:54 +0100 Subject: [PATCH 13/63] test(ooxml.js): close content.ts's row/column, span, and residue mutation gaps Adds direct synthetic-package coverage for sheetFormatDefaultRowHeightPt, readColumns, and readRows: default row height fallback, 1-based min/r lower bounds, the 1-based-to-0-based index subtraction, and hidden flags. Covers deriveDisplayText/resolveNumericValue's exact per-kind displayText output (dateTime, percentage, false boolean, symbol-only currency), readCellValue's boolean parsing and NaN handling, and merged-range colSpan/rowSpan arithmetic anchored away from row/column 0 so subtraction and addition mutants actually diverge. Adds a hasOwn() helper and uses it wherever a test needs to prove a key is genuinely absent from a ContentSheetCell/ContentSheet, since toBeUndefined() cannot distinguish an absent key from one explicitly assigned undefined. Removes redundant guards whose branches the surrounding NaN-fallback arithmetic already collapses to the identical result (sheetFormatDefaultRowHeightPt's raw-undefined check, readColumns' widthRaw-defined check, readRows' htRaw-undefined check), and the two early-return size checks in applyCellComments/applyCellResidueRules, whose absence only skips pointless work over an empty collection rather than changing any observable output. Documents two remaining genuinely irreducible equivalent mutants (the sqref-split regex's + quantifier, and fallbackEmptyWorksheet's own tag string, matching drawings.ts's identically-shaped case) with the exact reasoning that makes them unobservable through this module's own contract. --- .../ooxml.js/src/typed/xlsx/content.test.ts | 361 +++++++++++++++++- packages/ooxml.js/src/typed/xlsx/content.ts | 34 +- 2 files changed, 374 insertions(+), 21 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 769d752444..116f36ac4a 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -20,9 +20,18 @@ import { decodePackage, encodePackage } from "../../codec"; import { parsePackage } from "../../package-io/read"; import { attr, childrenWithTag, rootElement } from "../util"; import { buildXlsxPackageFromContent } from "./build"; -import { columnWidthCharsToPt, DEFAULT_COLUMN_WIDTH_CHARS } from "./units"; +import { + columnWidthCharsToPt, + DEFAULT_COLUMN_WIDTH_CHARS, + DEFAULT_ROW_HEIGHT_PT, +} from "./units"; import { readXlsxContent, resolveSheetEntries } from "./content"; +// True precisely when `key` is an own property of `obj`, regardless of whether its value is `undefined` -- unlike `toBeUndefined()`, which is satisfied identically by a key holding `undefined` and by the key's own absence, and so cannot distinguish "never assigned" from "assigned undefined". Several of readCell's own optional-field copies (font/background/borders/alignment/verticalAlignment/numberFormatCode) are guarded by a presence check specifically to avoid ever assigning the key at all when the source has nothing to offer, and only a key-existence assertion can prove that guard is doing real work rather than being a no-op the object shape would be identical without. +function hasOwn(obj: object, key: string): boolean { + return Object.hasOwn(obj, key); +} + // This suite reads real, unmodified LibreOffice-generated .xlsx fixtures (src/typed/xlsx/fixtures/*.xlsx). Both fixtures are genuine LibreOffice xlsx-exports (`soffice --headless --convert-to xlsx`) of odf.js's own src/typed/ods/fixtures/{kitchen-sink,minimal}.ods -- the same feature set that package's own readOds test suite already validates against ODF's equivalent mechanisms, run back through LibreOffice's real SpreadsheetML export filter so this suite exercises genuine, LibreOffice-authored xlsx markup (column-width character units, row heights, hidden rows/columns, every value-type LibreOffice's own xlsx exporter distinguishes, a real merged range, a real cross-sheet formula, and real print settings including Print_Area/Print_Titles defined names) rather than a hand-built approximation of what that markup might look like. A handful of narrow scope-boundary/error-path tests at the end use small, synthetic, hand-built packages instead (via el/txt), mirroring readOds's own established convention for the identical reason. const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); @@ -846,6 +855,331 @@ describe("readXlsxContent: cell decoration (background/borders/alignment/vertica )?.background, ).toBeUndefined(); }); + + it("omits the font/background/borders/alignment/verticalAlignment keys entirely on a cell whose s index carries none of them -- not merely assigned undefined", () => { + const cell = readDecoratedCell( + styledSheet, + el("c", { r: "A1", s: "0" }, [el("v", {}, [txt("42")])]), + ); + expect(cell).toBeDefined(); + if (cell === undefined) { + throw new Error("expected a cell"); + } + expect(hasOwn(cell, "font")).toBe(false); + expect(hasOwn(cell, "background")).toBe(false); + expect(hasOwn(cell, "borders")).toBe(false); + expect(hasOwn(cell, "alignment")).toBe(false); + expect(hasOwn(cell, "verticalAlignment")).toBe(false); + }); + + it("sets the numberFormatCode key when the cell's style resolves one, verbatim", () => { + const cell = readDecoratedCell( + styledSheet, + el("c", { r: "A1", s: "1" }, [el("v", {}, [txt("42")])]), + ); + expect(hasOwn(cell ?? {}, "numberFormatCode")).toBe(true); + }); + + it("omits numberFormatCode entirely (not merely as undefined) for an out-of-range style index that resolves to no entry at all", () => { + const cell = readDecoratedCell( + styledSheet, + el("c", { r: "A1", s: "99" }, [el("v", {}, [txt("42")])]), + ); + expect(hasOwn(cell ?? {}, "numberFormatCode")).toBe(false); + }); + + it("omits numberFormatCode entirely (not merely as undefined) for a resolvable style entry whose own numFmtId names no code anywhere", () => { + const noCodeSheet = el("styleSheet", {}, [ + el("cellXfs", {}, [ + el("xf", { numFmtId: "0" }), + el("xf", { numFmtId: "999" }), + ]), + ]); + const cell = readDecoratedCell( + noCodeSheet, + el("c", { r: "A1", s: "1" }, [el("v", {}, [txt("42")])]), + ); + expect(hasOwn(cell ?? {}, "numberFormatCode")).toBe(false); + }); +}); + +// Every one of readColumns/readRows/sheetFormatDefaultRowHeightPt's own conditional branches and index arithmetic, exercised directly against small synthetic worksheets -- the kitchen-sink fixture's own real rows/columns don't happen to visit every boundary (a 0-based min, a non-numeric width, a row number exactly at its own lower bound) these functions guard against. +function readSheetFromWorksheet( + worksheet: ReturnType, +): ContentSheet { + const result = readXlsxContent(buildMinimalPackage(worksheet)); + if (result.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + const sheet = result.sheets[0]; + if (sheet === undefined) { + throw new Error("expected a sheet"); + } + return sheet; +} + +describe("readXlsxContent: row/column geometry edge cases (synthetic packages)", () => { + it("falls back to DEFAULT_ROW_HEIGHT_PT for a row with no ht attribute when the worksheet carries no sheetFormatPr at all", () => { + const worksheet = el("worksheet", {}, [ + el("sheetData", {}, [el("row", { r: "1" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows).toEqual([ + { index: 0, heightPt: DEFAULT_ROW_HEIGHT_PT }, + ]); + }); + + it("falls back to the sheetFormatPr's own declared defaultRowHeight, not the package-wide default, for a row with no ht of its own", () => { + const worksheet = el("worksheet", {}, [ + el("sheetFormatPr", { defaultRowHeight: "22.5" }), + el("sheetData", {}, [el("row", { r: "1" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows).toEqual([ + { index: 0, heightPt: 22.5 }, + ]); + }); + + it("prefers a row's own ht over the sheetFormatPr default", () => { + const worksheet = el("worksheet", {}, [ + el("sheetFormatPr", { defaultRowHeight: "22.5" }), + el("sheetData", {}, [el("row", { r: "1", ht: "30" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows).toEqual([ + { index: 0, heightPt: 30 }, + ]); + }); + + it("drops a row whose own r is 0 (below CT_Row/@r's 1-based lower bound) but keeps one whose r is exactly 1", () => { + const worksheet = el("worksheet", {}, [ + el("sheetData", {}, [el("row", { r: "0" }), el("row", { r: "1" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows).toEqual([ + { index: 0, heightPt: DEFAULT_ROW_HEIGHT_PT }, + ]); + }); + + it('recovers row index 4 -- not 6 -- from r="5", proving the 1-based-to-0-based conversion subtracts rather than adds', () => { + const worksheet = el("worksheet", {}, [ + el("sheetData", {}, [el("row", { r: "5" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows[0]?.index).toBe(4); + }); + + it("marks a row hidden only when its own hidden attribute reads true, never as a side effect of any other attribute", () => { + const worksheet = el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1", hidden: "true" }), + el("row", { r: "2" }), + ]), + ]); + const rows = readSheetFromWorksheet(worksheet).rows; + expect(rows[0]).toEqual({ + index: 0, + heightPt: DEFAULT_ROW_HEIGHT_PT, + hidden: true, + }); + expect(hasOwn(rows[1] ?? {}, "hidden")).toBe(false); + }); + + it("drops a whose min is 0 (below CT_Col/@min's 1-based lower bound) but keeps one whose min is exactly 1", () => { + const worksheet = el("worksheet", {}, [ + el("cols", {}, [ + el("col", { min: "0", max: "0" }), + el("col", { min: "1", max: "1" }), + ]), + el("sheetData", {}), + ]); + expect(readSheetFromWorksheet(worksheet).columns).toEqual([{ index: 0 }]); + }); + + it("sets widthPt from a numeric width attribute, and omits the key entirely when width is absent", () => { + const worksheet = el("worksheet", {}, [ + el("cols", {}, [ + el("col", { min: "1", max: "1", width: "20" }), + el("col", { min: "2", max: "2" }), + ]), + el("sheetData", {}), + ]); + const columns = readSheetFromWorksheet(worksheet).columns; + expect(columns[0]?.widthPt).toBeCloseTo(columnWidthCharsToPt(20), 10); + expect(hasOwn(columns[1] ?? {}, "widthPt")).toBe(false); + }); + + it("omits widthPt for a non-numeric width attribute, rather than reporting a NaN width", () => { + const worksheet = el("worksheet", {}, [ + el("cols", {}, [ + el("col", { min: "1", max: "1", width: "not-a-number" }), + ]), + el("sheetData", {}), + ]); + expect( + hasOwn(readSheetFromWorksheet(worksheet).columns[0] ?? {}, "widthPt"), + ).toBe(false); + }); + + it("marks a column hidden only when its own hidden attribute reads true", () => { + const worksheet = el("worksheet", {}, [ + el("cols", {}, [ + el("col", { min: "1", max: "1", hidden: "true" }), + el("col", { min: "2", max: "2" }), + ]), + el("sheetData", {}), + ]); + const columns = readSheetFromWorksheet(worksheet).columns; + expect(columns[0]).toEqual({ index: 0, hidden: true }); + expect(hasOwn(columns[1] ?? {}, "hidden")).toBe(false); + }); +}); + +describe("readXlsxContent: readSheet's own optional-field keys are absent, not undefined, when a sheet carries none of them", () => { + it("omits embeddedObjects/dataValidations/conditionalFormats entirely from a sheet with no drawing, validation, or conditional format at all", () => { + const sheet = readSheetFromWorksheet( + el("worksheet", {}, [el("sheetData", {})]), + ); + expect(hasOwn(sheet, "embeddedObjects")).toBe(false); + expect(hasOwn(sheet, "dataValidations")).toBe(false); + expect(hasOwn(sheet, "conditionalFormats")).toBe(false); + }); +}); + +describe("readXlsxContent: deriveDisplayText/resolveNumericValue exact per-kind coverage (synthetic packages)", () => { + it("renders a numeric-format dateTime cell's displayText as its ISO spelling, not the boolean-branch TRUE/FALSE fallthrough text", () => { + const cell = readStyledCell( + "yyyy-mm-dd hh:mm:ss", + numericCell("46234.604166666666667"), + ); + expect(cell?.displayText).toBe("2026-07-31T14:30:00"); + }); + + it("renders a percentage cell's displayText as the raw stored fraction, not TRUE", () => { + const cell = readStyledCell("0.00%", numericCell("0.4256")); + expect(cell?.displayText).toBe("0.4256"); + }); + + it("omits the currency key entirely (not merely as undefined) when the format names money by symbol alone", () => { + const cell = readStyledCell("[$£-809]#,##0.00", numericCell("99.99")); + expect(cell?.value.kind).toBe("currency"); + expect(hasOwn(cell?.value ?? {}, "currency")).toBe(false); + }); + + it('renders FALSE, not just "not TRUE", for a false boolean cell', () => { + expect( + readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1", t: "b" }, [el("v", {}, [txt("0")])]), + ]), + ]), + ]), + ).cells[0], + ).toMatchObject({ + value: { kind: "boolean", value: false }, + displayText: "FALSE", + }); + }); +}); + +describe("readXlsxContent: readCellValue's boolean/numeric branch precision (synthetic packages)", () => { + it('reads t="b" true from an upper-, lower-, or mixed-case spelling of "true", not just the literal "1"', () => { + for (const raw of ["TRUE", "True", "true"]) { + const { cells } = readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1", t: "b" }, [el("v", {}, [txt(raw)])]), + ]), + ]), + ]), + ); + expect(cells[0]?.value).toEqual({ kind: "boolean", value: true }); + } + }); + + it('reads t="b" as false for any raw text that is neither "1" nor a case-insensitive "true"', () => { + const { cells } = readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1", t: "b" }, [el("v", {}, [txt("false")])]), + ]), + ]), + ]), + ); + expect(cells[0]?.value).toEqual({ kind: "boolean", value: false }); + }); + + it("drops an untyped cell whose text is not a parseable number at all, rather than reporting NaN", () => { + const { cells } = readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1" }, [el("v", {}, [txt("not-a-number")])]), + ]), + ]), + ]), + ); + expect(cells).toEqual([]); + }); +}); + +describe("readXlsxContent: readCell's formula key presence (synthetic packages)", () => { + it("omits the formula key entirely for a plain value cell with no child", () => { + const { cells } = readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1" }, [el("v", {}, [txt("42")])]), + ]), + ]), + ]), + ); + expect(hasOwn(cells[0] ?? {}, "formula")).toBe(false); + }); +}); + +describe("readXlsxContent: merged-range span arithmetic (synthetic packages)", () => { + // Anchored at B2, not A1: with a zero-valued start, endColumn-startColumn and endColumn+startColumn (the ArithmeticOperator mutant's own replacement) coincide, so a genuine test needs a nonzero start on both axes to actually distinguish subtraction from addition. + function mergedWorksheet( + ref: string, + anchorRef: string, + ): ReturnType { + return el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "2" }, [ + el("c", { r: anchorRef }, [el("v", {}, [txt("1")])]), + ]), + ]), + el("mergeCells", {}, [el("mergeCell", { ref })]), + ]); + } + + it("computes colSpan and rowSpan from the true end-minus-start distance, not an end-plus-start sum, for a merge anchored away from row/column 0", () => { + const { cells } = readFirstCell(mergedWorksheet("B2:D4", "B2")); + const anchor = cells[0]; + expect(anchor?.colSpan).toBe(3); + expect(anchor?.rowSpan).toBe(3); + }); + + it("sets colSpan alone for a 1-row, multi-column merge, never fabricating a rowSpan", () => { + const { cells } = readFirstCell(mergedWorksheet("B2:D2", "B2")); + const anchor = cells[0]; + expect(anchor?.colSpan).toBe(3); + expect(hasOwn(anchor ?? {}, "rowSpan")).toBe(false); + }); + + it("sets rowSpan alone for a 1-column, multi-row merge, never fabricating a colSpan", () => { + const { cells } = readFirstCell(mergedWorksheet("B2:B4", "B2")); + const anchor = cells[0]; + expect(anchor?.rowSpan).toBe(3); + expect(hasOwn(anchor ?? {}, "colSpan")).toBe(false); + }); + + it("sets neither colSpan nor rowSpan for a single-cell 'merge' (B2:B2) -- a span of exactly 1 on both axes", () => { + const { cells } = readFirstCell(mergedWorksheet("B2:B2", "B2")); + const anchor = cells[0]; + expect(hasOwn(anchor ?? {}, "colSpan")).toBe(false); + expect(hasOwn(anchor ?? {}, "rowSpan")).toBe(false); + }); }); // A chart graphic frame reached the way a real workbook reaches one: the worksheet's own names a drawing part through the worksheet's relationships, the drawing's xdr:twoCellAnchor carries an xdr:graphicFrame whose a:graphicData names the chart part through the DRAWING's relationships. The anchor geometry resolves through the sheet's own declared column widths and row heights, exactly as a spreadsheet renderer would place it. @@ -2675,6 +3009,31 @@ describe("readXlsxContent: dataValidation and conditionalFormatting -- what is N ).toBeUndefined(); }); + it("leaves a rule whose sqref is the empty string unattached, the same as one that does not parse at all", () => { + const cells = readFirstCellOf( + el("worksheet", {}, [ + el("sheetData", {}, []), + el("dataValidations", { count: "1" }, [ + el("dataValidation", { type: "none", sqref: "" }), + ]), + ]), + ); + expect(cells).toEqual([]); + }); + + it("materialises a residue-only anchor cell as kind empty with an empty displayText, not a placeholder marker string", () => { + const cells = readFirstCellOf( + el("worksheet", {}, [ + el("sheetData", {}, []), + el("dataValidations", { count: "1" }, [ + el("dataValidation", { type: "none", sqref: "F6" }), + ]), + ]), + ); + const anchor = cells.find((cell) => cell.row === 5 && cell.column === 5); + expect(anchor).toMatchObject({ value: { kind: "empty" }, displayText: "" }); + }); + it("keeps the first residue-eligible rule when two anchor at the same cell -- one residue slot per cell -- and leaves a rule whose sqref does not parse unattached", () => { const cells = readFirstCellOf( el("worksheet", {}, [ diff --git a/packages/ooxml.js/src/typed/xlsx/content.ts b/packages/ooxml.js/src/typed/xlsx/content.ts index 22e45d3f10..7945e2e75b 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.ts @@ -84,9 +84,7 @@ function sheetFormatDefaultRowHeightPt(worksheet: XmlElement): number { sheetFormatPr === undefined ? undefined : attr(sheetFormatPr, "defaultRowHeight"); - if (raw === undefined) { - return DEFAULT_ROW_HEIGHT_PT; - } + // No "raw === undefined" guard: Number(undefined) is NaN (unlike Number(null), which is 0), so an absent defaultRowHeight already falls through Number.isFinite to the same DEFAULT_ROW_HEIGHT_PT result this guard would have returned directly. const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : DEFAULT_ROW_HEIGHT_PT; } @@ -105,13 +103,12 @@ function readColumns(worksheet: XmlElement): ContentSheetColumn[] { continue; } const column: ContentSheetColumn = { index: min - 1 }; + // No "widthRaw !== undefined" guard: Number(undefined) is NaN, and columnWidthCharsToPt's own arithmetic propagates a NaN input straight through to a NaN result, so an absent width already falls through the Number.isFinite check below to the same "no widthPt" outcome this guard would have skipped to directly. const widthRaw = attr(col, "width"); - if (widthRaw !== undefined) { - const widthPt = columnWidthCharsToPt(Number(widthRaw)); - // widthPt is optional -- absent means "no declared width, use the application default" (document-schema.js's own ContentSheetColumn doc comment), not a fabricated 0; a element with no width attribute at all (e.g. one that exists purely to declare `hidden`) must not report a zero-width column. - if (Number.isFinite(widthPt)) { - column.widthPt = widthPt; - } + const widthPt = columnWidthCharsToPt(Number(widthRaw)); + // widthPt is optional -- absent means "no declared width, use the application default" (document-schema.js's own ContentSheetColumn doc comment), not a fabricated 0; a element with no width attribute at all (e.g. one that exists purely to declare `hidden`) must not report a zero-width column. + if (Number.isFinite(widthPt)) { + column.widthPt = widthPt; } if (readXmlBool(attr(col, "hidden"))) { column.hidden = true; @@ -140,8 +137,9 @@ function readRows(worksheet: XmlElement): ContentSheetRow[] { ) { continue; } + // No "htRaw === undefined" guard: Number(undefined) is NaN, so an absent ht already falls through the Number.isFinite check below to the same fallbackHeightPt result this guard would have selected directly. const htRaw = attr(row, "ht"); - const heightPt = htRaw === undefined ? fallbackHeightPt : Number(htRaw); + const heightPt = Number(htRaw); const contentRow: ContentSheetRow = { index: rowNumber - 1, heightPt: Number.isFinite(heightPt) ? heightPt : fallbackHeightPt, @@ -260,9 +258,8 @@ function resolveNumericValue( ? { kind: "number", value: num } : { kind: "dateTime", value: iso }; } + // elapsedTime/text/number are grouped in one case list, not three separate returns of the identical literal, deliberately: an elapsed-time format ([h]:mm:ss) is a DURATION, which may legitimately exceed 24 hours -- ContentCellValue's own 'time' variant is explicitly a wall-clock time of day and has no duration sibling to carry this instead, so the raw day-fraction number is kept rather than folded into a wrong-kind time; 'text' and 'number' formats carry no reclassification information at all. Because all three produce the exact same {kind:"number", value:num} object, any mutation that moves 'elapsedTime' between this group and the one above (or duplicates/reorders the case labels) is genuinely unobservable through this function's own return value for every possible input -- not a gap a differently-shaped test could close, so the three are stated once rather than left as separate case blocks Stryker could find spurious "move this label" mutations between. case "elapsedTime": - // An elapsed-time format ([h]:mm:ss) is a DURATION, which may legitimately exceed 24 hours -- ContentCellValue's own 'time' variant is explicitly a wall-clock time of day and has no duration sibling to carry this instead, so the raw day-fraction number is kept rather than folded into a wrong-kind time. - return { kind: "number", value: num }; case "text": case "number": return { kind: "number", value: num }; @@ -446,9 +443,7 @@ function applyCellComments( comments: ReadonlyMap, cells: ContentSheetCell[], ): void { - if (comments.size === 0) { - return; - } + // No "comments.size === 0" early return: with no comments, the two loops below simply never do anything (building an unused, empty byPosition map, then iterating a genuinely empty comments Map) -- `cells` comes back byte-for-byte unchanged either way, so an early return here would only ever skip work whose absence is already unobservable. const byPosition = new Map(); for (const cell of cells) { byPosition.set(`${cell.row}:${cell.column}`, cell); @@ -467,7 +462,7 @@ function applyCellComments( comment, }; cells.push(materialised); - byPosition.set(key, materialised); + // No `byPosition.set(key, materialised)` here (unlike applyCellResidueRules' own identically-shaped materialise branch below): `comments`'s keys are already unique (it is a Map), so no later iteration of this same loop can ever look up `key` again -- recording it would only ever be read by nothing. } } @@ -476,15 +471,14 @@ function applyCellResidueRules( cells: ContentSheetCell[], rules: readonly XmlElement[], ): void { - if (rules.length === 0) { - return; - } + // No "rules.length === 0" early return: with no rules, the two loops below simply never do anything (building an unused, empty byPosition map, then iterating a genuinely empty rules array) -- `cells` comes back byte-for-byte unchanged either way, so an early return here would only ever skip work whose absence is already unobservable. const byPosition = new Map(); for (const cell of cells) { byPosition.set(`${cell.row}:${cell.column}`, cell); } for (const rule of rules) { const sqref = attr(rule, "sqref"); + // The regex's own "+" (one-or-more, versus a single whitespace character) is a genuinely irreducible equivalent mutation opportunity here, not merely an untested one: only index [0] of the split result is ever read, and the substring BEFORE the first regex match is identical regardless of how many whitespace characters that first match itself consumes -- \s and \s+ always start matching at the same position, so [0] can never differ between them for any input, only the LATER elements of the split array (never read here) can. const firstToken = sqref === undefined ? undefined : sqref.split(/\s+/)[0]; const range = firstToken === undefined || firstToken === "" @@ -566,7 +560,7 @@ function readSheet( }; } -// A minimal, childless element, used only as readPrintSettings' own input when a in xl/workbook.xml points at a part the package doesn't actually have (a malformed package) -- gives the same all-defaults ContentSheetPrintSettings a genuinely empty worksheet would produce, without readPrintSettings itself needing an `undefined`-worksheet branch. +// A minimal, childless element, used only as readPrintSettings' own input when a in xl/workbook.xml points at a part the package doesn't actually have (a malformed package) -- gives the same all-defaults ContentSheetPrintSettings a genuinely empty worksheet would produce, without readPrintSettings itself needing an `undefined`-worksheet branch. The "worksheet" tag string itself is a genuinely irreducible equivalent mutation opportunity, not merely an untested one, matching drawings.ts's own identically-shaped emptyWorksheet: readPrintSettings only ever reads this element's CHILDREN's tags (via childrenWithTag), never its own tag, so with no children to walk it is an otherwise-empty shell whose own tag field is dead structurally -- no test built on readPrintSettings' own observable output can ever tell one tag string from another here. function fallbackEmptyWorksheet(): XmlElement { return { type: "element", tag: "worksheet", attributes: [], children: [] }; } From 76fa96a31051554c6bf7bc9e7c2cf1cc106001a5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:11:24 +0100 Subject: [PATCH 14/63] test(ooxml.js): reach content.ts's genuine mutation ceiling Removes applyCellResidueRules' own empty-firstToken disjunct: parseRangeReference('') already returns undefined rather than throwing, so the check was a redundant special case of the undefined branch beside it, which stays load-bearing on its own. Asserts displayText, not just value, for a true boolean cell, closing the one real remaining gap in deriveDisplayText. Documents deriveDisplayText's own "empty" case as a structurally required but genuinely unreachable switch arm: ContentCellValue's type still includes "empty" as a member, so the case must stay for the function to type-check as returning string unconditionally, even though neither of its two real call sites can ever pass one. --- packages/ooxml.js/src/typed/xlsx/content.test.ts | 1 + packages/ooxml.js/src/typed/xlsx/content.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 116f36ac4a..ca9bd076e3 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -1092,6 +1092,7 @@ describe("readXlsxContent: readCellValue's boolean/numeric branch precision (syn ]), ); expect(cells[0]?.value).toEqual({ kind: "boolean", value: true }); + expect(cells[0]?.displayText).toBe("TRUE"); } }); diff --git a/packages/ooxml.js/src/typed/xlsx/content.ts b/packages/ooxml.js/src/typed/xlsx/content.ts index 7945e2e75b..9403eda7e5 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.ts @@ -181,6 +181,7 @@ function deriveDisplayText(value: ContentCellValue): string { case "time": case "dateTime": return value.value; + // This branch is genuinely unreachable through either of this function's own two call sites (both below): the boolean case always passes a value of kind "boolean", and the numeric case always passes whatever resolveNumericValue itself returns, which is one of number/percentage/currency/date/time/dateTime/elapsedTime -- never "empty". It stays here, and its own return value stays untestable, purely because ContentCellValue's declared type still includes "empty" as a member: removing this case would make the switch non-exhaustive over that type and this function would no longer type-check as returning `string` unconditionally. This is the same shape of irreducible gap as localName's own "no colon" branch (comments.ts) -- a case the type system requires but no real call site can ever actually reach. case "empty": return ""; } @@ -480,10 +481,9 @@ function applyCellResidueRules( const sqref = attr(rule, "sqref"); // The regex's own "+" (one-or-more, versus a single whitespace character) is a genuinely irreducible equivalent mutation opportunity here, not merely an untested one: only index [0] of the split result is ever read, and the substring BEFORE the first regex match is identical regardless of how many whitespace characters that first match itself consumes -- \s and \s+ always start matching at the same position, so [0] can never differ between them for any input, only the LATER elements of the split array (never read here) can. const firstToken = sqref === undefined ? undefined : sqref.split(/\s+/)[0]; + // No "firstToken === ''" disjunct: parseRangeReference('') already returns undefined rather than throwing (verified directly against document-schema.js's own implementation), so an empty firstToken already falls through to the identical `range === undefined` outcome this disjunct would have short-circuited to. The `undefined` check alone stays load-bearing: parseRangeReference(undefined) throws, unlike the empty-string case. const range = - firstToken === undefined || firstToken === "" - ? undefined - : parseRangeReference(firstToken); + firstToken === undefined ? undefined : parseRangeReference(firstToken); if (range === undefined) { continue; } From 7beba7a0b8cdfffc64789b502ccc0971a9318956 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:23:24 +0100 Subject: [PATCH 15/63] test(ooxml.js): close styles.ts's decoration key-presence and signature mutation gaps Adds direct unit coverage for every optional-field presence check in the read side (font/fill/border/alignment key absence, not just value, proven with hasOwn rather than toBeUndefined), for the exact val-string behaviour of readFontToggle/readFontUnderline, for a non-integer numFmtId/sizePt leaving the code/sizePt unresolvable, and for colorFromElement's own validation (invalid hex, a too-short rgb attribute). Adds write-side tests proving every one of CellFormatTable's own signature segments (each font/fill/border/alignment flag) actually distinguishes two otherwise-identical entries, rather than trusting that the signature strings alone don't collapse two different inputs onto the same interned index; and that a font/fill/border interned twice under different number formats still caches to a single declared entry. Removes readFontTableEntry's own redundant szVal-undefined guard: Number(undefined) is NaN, so an absent already falls through the Number.isFinite check below to the same "no sizePt" result this guard would have selected directly. Documents two remaining genuinely irreducible equivalent mutants in colorFromElement (the raw.length >= 6 vs > 6 boundary, and the hex regex's own anchors) with the exact reasoning that makes them unobservable given hex's own fixed construction. --- .../ooxml.js/src/typed/xlsx/styles.test.ts | 641 ++++++++++++++++++ packages/ooxml.js/src/typed/xlsx/styles.ts | 8 +- 2 files changed, 646 insertions(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index 737cc41e18..a3c68e10fb 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.test.ts @@ -9,8 +9,10 @@ import { CellFormatTable, DEFAULT_CELL_FORMAT_INDEX, GENERAL_NUM_FMT_ID, + colorFromElement, readCellFormatCodes, readCellStyles, + readColorRgb, } from "./styles"; const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); @@ -19,6 +21,11 @@ function stylesPackage(styleSheet: ReturnType): Package { return { parts: { "xl/styles.xml": { kind: "xml", nodes: [styleSheet] } } }; } +// True precisely when `key` is an own property of `obj`, regardless of whether its value is `undefined` -- unlike `toBeUndefined()`, which is satisfied identically by a key holding `undefined` and by the key's own absence, and so cannot distinguish "never assigned" from "assigned undefined". Several of this module's own optional-field copies are guarded by a presence check specifically to avoid ever assigning the key at all when the source has nothing to offer, and only a key-existence assertion can prove that guard is doing real work. +function hasOwn(obj: object, key: string): boolean { + return Object.hasOwn(obj, key); +} + describe("readCellFormatCodes: real LibreOffice output (kitchen-sink.xlsx)", () => { const pkg = parsePackage( new Uint8Array(readFileSync(join(FIXTURES_DIR, "kitchen-sink.xlsx"))), @@ -624,3 +631,637 @@ describe("CellFormatTable: interning the cell font alongside the number format", }); }); }); + +describe("readNumberFormatCodesById: a non-integer numFmtId registers no code", () => { + it("skips a whose numFmtId is not a parseable integer, leaving that id unresolvable", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("numFmts", {}, [ + el("numFmt", { numFmtId: "not-a-number", formatCode: "0.00" }), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "not-a-number" })]), + ]), + ); + expect(hasOwn(readCellStyles(pkg)[0] ?? {}, "numberFormatCode")).toBe( + false, + ); + }); +}); + +describe("readFontToggle/readFontUnderline: exact val-string behaviour", () => { + // Diffs a single font against a plain Calibri baseline with NO toggles at all, so bare presence (no val) and val="1" show up as an explicit `true` difference. A `val="0"`/`val="false"` toggle reads as `false`, which is indistinguishable from this baseline via a diff (false against false is no difference) -- those two cases use offToggleFont below instead, against an ALL-toggles-on baseline, so turning one off is what shows up as the difference. + function diffedToggleFont(toggle: ReturnType) { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("name", { val: "Calibri" })]), + el("font", {}, [toggle, el("name", { val: "Calibri" })]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + return readCellStyles(pkg)[0]?.font ?? {}; + } + + // Diffs a single font, WITH b/i/strike all on, against a baseline that ALSO has them all on -- so replacing one of the baseline's own toggles with an explicit val="0"/"false" version is what shows up as that one property's own false in the diff. + function offToggleFont(toggle: ReturnType) { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("b"), + el("i"), + el("strike"), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [toggle, el("name", { val: "Calibri" })]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + return readCellStyles(pkg)[0]?.font ?? {}; + } + + it("reads a bare with no val attribute as bold: true", () => { + expect(diffedToggleFont(el("b"))).toEqual({ bold: true }); + }); + + it('reads (anything other than "0"/"false") as bold: true', () => { + expect(diffedToggleFont(el("b", { val: "1" }))).toEqual({ bold: true }); + }); + + it('reads as bold: false, distinguishing the val attribute from a bare element', () => { + expect(offToggleFont(el("b", { val: "0" }, []))).toMatchObject({ + bold: false, + }); + }); + + it('reads as bold: false too, the alternate xsd:boolean spelling', () => { + expect(offToggleFont(el("b", { val: "false" }))).toMatchObject({ + bold: false, + }); + }); + + it('reads as italic: false, proving the "0" check is not bold-specific', () => { + expect(offToggleFont(el("i", { val: "0" }))).toMatchObject({ + italic: false, + }); + }); + + it('reads as strike: false', () => { + expect(offToggleFont(el("strike", { val: "false" }))).toMatchObject({ + strike: false, + }); + }); +}); + +describe("readFontTableEntry: sizePt on a non-numeric ", () => { + it("states no sizePt for a that does not parse as a number, rather than reporting NaN", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("name", { val: "Calibri" })]), + el("font", {}, [ + el("sz", { val: "not-a-number" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + expect(hasOwn(readCellStyles(pkg)[0]?.font ?? {}, "sizePt")).toBe(false); + }); +}); + +describe("contentFontOf: omits fontFamily/sizePt/color entirely (not merely as undefined) when they match the baseline", () => { + it("omits fontFamily when the entry's own name equals the baseline's, but still states bold", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("sz", { val: "11" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [ + el("b"), + el("sz", { val: "11" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + const font = readCellStyles(pkg)[0]?.font ?? {}; + expect(font).toMatchObject({ bold: true }); + expect(hasOwn(font, "fontFamily")).toBe(false); + expect(hasOwn(font, "sizePt")).toBe(false); + }); + + it("states a colour equal to the baseline's own resolved colour as absent, not restated", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("color", { rgb: "FFFF0000" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [ + el("b"), + el("color", { rgb: "FFFF0000" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + const font = readCellStyles(pkg)[0]?.font ?? {}; + expect(font).toEqual({ bold: true }); + expect(hasOwn(font, "color")).toBe(false); + }); +}); + +describe("colorFromElement/readColorRgb: hex length boundary and validation", () => { + it("returns undefined -- not a garbage colour -- for a 6-character rgb that is not valid hex", () => { + expect(colorFromElement(el("color", { rgb: "ZZZZZZ" }))).toBeUndefined(); + }); + + it("returns undefined for an rgb attribute shorter than 6 characters", () => { + expect(colorFromElement(el("color", { rgb: "FF00" }))).toBeUndefined(); + }); + + it("resolves an 8-digit AARRGGBB rgb by its last 6 (real) digits, dropping the alpha prefix", () => { + expect( + readColorRgb(el("x", {}, [el("color", { rgb: "80112233" })]), "color"), + ).toEqual({ r: 0x11 / 255, g: 0x22 / 255, b: 0x33 / 255 }); + }); + + it("returns undefined when the element carries no rgb attribute at all", () => { + expect( + readColorRgb(el("x", {}, [el("color", {})]), "color"), + ).toBeUndefined(); + }); + + // The regex's own "^"/"$" anchors are a genuinely irreducible equivalent mutation opportunity here, not merely an untested one: `hex` is constructed immediately above as either exactly 6 characters (raw.slice(-6), whenever raw.length >= 6) or fewer than 6 (raw itself, otherwise) -- never more. A {6}-quantified pattern can only ever match a 6-character string across its ENTIRE length regardless of anchors (there is no room for a partial match either before or after), and can never match a shorter one at all, so no input this function can ever construct `hex` from can tell an anchored and an unanchored match apart. The same reasoning makes the raw.length ">= 6" vs "> 6" boundary equivalent too: at raw.length exactly 6, slice(-6) returns the whole (unchanged) string, identical to what the ">" branch's bare `raw` would have returned directly. +}); + +describe("readFillBackground: fgColor/bgColor tag names and presence", () => { + it("falls back to bgColor for a solid fill whose fgColor is absent", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fills", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "solid" }, [ + el("bgColor", { rgb: "FF00FF00" }), + ]), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fillId: "0" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.background).toEqual({ + kind: "solid", + color: { r: 0, g: 1, b: 0 }, + }); + }); + + it("carries only foregroundColor (never a phantom backgroundColor) for a pattern fill with fgColor alone", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fills", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "darkGrid" }, [ + el("fgColor", { rgb: "FFFF0000" }), + ]), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fillId: "0" })]), + ]), + ); + const background = readCellStyles(pkg)[0]?.background ?? {}; + expect(hasOwn(background, "foregroundColor")).toBe(true); + expect(hasOwn(background, "backgroundColor")).toBe(false); + }); + + it("carries only backgroundColor (never a phantom foregroundColor) for a pattern fill with bgColor alone", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fills", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "darkGrid" }, [ + el("bgColor", { rgb: "FF0000FF" }), + ]), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fillId: "0" })]), + ]), + ); + const background = readCellStyles(pkg)[0]?.background ?? {}; + expect(hasOwn(background, "foregroundColor")).toBe(false); + expect(hasOwn(background, "backgroundColor")).toBe(true); + }); +}); + +describe('readBorderEdge: style="none" means no border, distinct from an absent style', () => { + it('reads undefined for an edge whose style is explicitly "none"', () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [ + el("left", { style: "none" }, [el("color", { rgb: "FF000000" })]), + el("right"), + el("top"), + el("bottom"), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.borders).toBeUndefined(); + }); +}); + +describe("readBorders: each edge's own presence is independent", () => { + it("returns undefined for a whose every edge resolves to no border at all", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [el("left"), el("right"), el("top"), el("bottom")]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.borders).toBeUndefined(); + }); + + it("carries exactly the right edge -- none of left/top/bottom -- for a border naming only right", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [ + el("left"), + el("right", { style: "thin" }, [el("color", { rgb: "FF000000" })]), + el("top"), + el("bottom"), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + const borders = readCellStyles(pkg)[0]?.borders ?? {}; + expect(hasOwn(borders, "left")).toBe(false); + expect(hasOwn(borders, "right")).toBe(true); + expect(hasOwn(borders, "top")).toBe(false); + expect(hasOwn(borders, "bottom")).toBe(false); + }); + + it("carries exactly the top edge for a border naming only top", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [ + el("left"), + el("right"), + el("top", { style: "thin" }, [el("color", { rgb: "FF000000" })]), + el("bottom"), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + const borders = readCellStyles(pkg)[0]?.borders ?? {}; + expect(hasOwn(borders, "top")).toBe(true); + expect(hasOwn(borders, "left")).toBe(false); + expect(hasOwn(borders, "right")).toBe(false); + expect(hasOwn(borders, "bottom")).toBe(false); + }); + + it("carries exactly the bottom edge for a border naming only bottom", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [ + el("left"), + el("right"), + el("top"), + el("bottom", { style: "thin" }, [el("color", { rgb: "FF000000" })]), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + const borders = readCellStyles(pkg)[0]?.borders ?? {}; + expect(hasOwn(borders, "bottom")).toBe(true); + expect(hasOwn(borders, "left")).toBe(false); + expect(hasOwn(borders, "right")).toBe(false); + expect(hasOwn(borders, "top")).toBe(false); + }); +}); + +describe("readHorizontalAlignment: every recognised member, not just center/right", () => { + function alignedEntry(horizontal: string) { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("cellXfs", {}, [ + el("xf", { numFmtId: "0" }, [el("alignment", { horizontal })]), + ]), + ]), + ); + return readCellStyles(pkg)[0]; + } + + it('reads horizontal="left"', () => { + expect(alignedEntry("left")?.alignment).toBe("left"); + }); + + it('reads horizontal="justify"', () => { + expect(alignedEntry("justify")?.alignment).toBe("justify"); + }); +}); + +describe("readCellStyles: numFmtId/numberFormatCode/alignment key presence", () => { + it("leaves numberFormatCode absent for a non-integer numFmtId on the xf itself", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("cellXfs", {}, [el("xf", { numFmtId: "not-a-number" })]), + ]), + ); + expect(hasOwn(readCellStyles(pkg)[0] ?? {}, "numberFormatCode")).toBe( + false, + ); + }); + + it("leaves alignment absent (not undefined) when the xf's own states no recognised horizontal value, but still states verticalAlignment", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("cellXfs", {}, [ + el("xf", { numFmtId: "0" }, [ + el("alignment", { horizontal: "fill", vertical: "top" }), + ]), + ]), + ]), + ); + const entry = readCellStyles(pkg)[0] ?? {}; + expect(hasOwn(entry, "alignment")).toBe(false); + expect(entry.verticalAlignment).toBe("top"); + }); + + it("leaves verticalAlignment absent when the xf's own states no recognised vertical value, but still states alignment", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("cellXfs", {}, [ + el("xf", { numFmtId: "0" }, [ + el("alignment", { horizontal: "center", vertical: "bottom" }), + ]), + ]), + ]), + ); + const entry = readCellStyles(pkg)[0] ?? {}; + expect(hasOwn(entry, "verticalAlignment")).toBe(false); + expect(entry.alignment).toBe("center"); + }); +}); + +describe("CellFormatTable: font signature isolates every one of its own segments", () => { + // Interns two fonts differing in exactly ONE property and asserts they mint DISTINCT font entries -- if a signature segment were ever dropped (a template literal collapsed, a boolean-to-string comparison broken), the two would wrongly collide onto the same fontId instead. + function internedFontIds( + fontA: { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strike?: boolean; + color?: { r: number; g: number; b: number }; + }, + fontB: typeof fontA, + ): [number, number] { + const table = new CellFormatTable(); + const a = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: fontA }, + ); + const b = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: fontB }, + ); + return [a, b]; + } + + it("bold alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ bold: true }, { bold: false }); + expect(a).not.toBe(b); + }); + + it("italic alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ italic: true }, { italic: false }); + expect(a).not.toBe(b); + }); + + it("underline alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ underline: true }, { underline: false }); + expect(a).not.toBe(b); + }); + + it("strike alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ strike: true }, { strike: false }); + expect(a).not.toBe(b); + }); + + it("colour alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds( + { color: { r: 1, g: 0, b: 0 } }, + { color: { r: 0, g: 0, b: 1 } }, + ); + expect(a).not.toBe(b); + }); + + it("declares underline as undefined, not false, for a ContentFont whose own underline is explicitly false", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: { underline: false, bold: true } }, + ); + expect(table.fontDeclarations()[1]?.underline).toBeUndefined(); + }); + + it("caches a font interned twice under DIFFERENT number formats to the same fontId, minting only one entry", () => { + const table = new CellFormatTable(); + const first = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: { bold: true } }, + ); + const second = table.intern( + { kind: "builtin", id: 9 }, + { font: { bold: true } }, + ); + expect(table.cellFormatRecords()[first]?.fontId).toBe( + table.cellFormatRecords()[second]?.fontId, + ); + // Exactly one real font entry beyond the default: had the font-level cache write been skipped, this second, differently-outer-keyed intern() would have missed the cache and minted a duplicate. + expect(table.fontDeclarations()).toHaveLength(2); + }); +}); + +describe("CellFormatTable: fill signature isolates colour, and caches across different outer formats", () => { + it("two different solid colours mint two distinct fill entries, not one shared by signature collapse", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + table.intern( + { kind: "builtin", id: 9 }, + { background: { kind: "solid", color: { r: 0, g: 0, b: 1 } } }, + ); + expect(table.fillDeclarations()).toEqual([ + { kind: "none" }, + { kind: "gray125" }, + { kind: "solid", rgb: "ff0000" }, + { kind: "solid", rgb: "0000ff" }, + ]); + }); + + it("two pattern fills differing only in backgroundColor mint two distinct entries", () => { + const table = new CellFormatTable(); + const shared = { + kind: "pattern" as const, + patternType: "darkGrid" as const, + foregroundColor: { r: 1, g: 0, b: 0 }, + }; + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { ...shared, backgroundColor: { r: 0, g: 0, b: 1 } } }, + ); + table.intern( + { kind: "builtin", id: 9 }, + { background: { ...shared, backgroundColor: { r: 0, g: 1, b: 0 } } }, + ); + expect(table.fillDeclarations()).toHaveLength(4); + }); + + it("caches a fill interned twice under different number formats to the same fillId, minting only one real entry", () => { + const table = new CellFormatTable(); + const first = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + const second = table.intern( + { kind: "builtin", id: 9 }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + expect(table.cellFormatRecords()[first]?.fillId).toBe( + table.cellFormatRecords()[second]?.fillId, + ); + expect(table.fillDeclarations()).toHaveLength(3); + }); +}); + +describe("CellFormatTable: border signature and caching across different outer formats", () => { + it("caches a border interned twice under different number formats to the same borderId, minting only one real entry", () => { + const table = new CellFormatTable(); + const border = { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } }; + const first = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { borders: border }, + ); + const second = table.intern( + { kind: "builtin", id: 9 }, + { borders: border }, + ); + expect(table.cellFormatRecords()[first]?.borderId).toBe( + table.cellFormatRecords()[second]?.borderId, + ); + expect(table.borderDeclarations()).toHaveLength(2); + }); + + it("writes a dashed border at thin weight as plain dashed, not mediumDashed -- the medium check is not a no-op", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + borders: { + left: { + color: { r: 0, g: 0, b: 0 }, + widthPt: 0.75, + style: "dashed", + }, + }, + }, + ); + expect(table.borderDeclarations()[1]).toEqual({ + edges: { left: { style: "dashed", rgb: "000000" } }, + }); + }); +}); + +describe("CellFormatTable: intern's own alignment-presence OR, not AND", () => { + it("still creates a record.alignment when only horizontal is given, with no vertical at all", () => { + const table = new CellFormatTable(); + const index = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { alignment: "center" }, + ); + expect(table.cellFormatRecords()[index]?.alignment).toEqual({ + horizontal: "center", + vertical: undefined, + }); + }); + + it("still creates a record.alignment when only vertical is given, with no horizontal at all", () => { + const table = new CellFormatTable(); + const index = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { verticalAlignment: "middle" }, + ); + expect(table.cellFormatRecords()[index]?.alignment).toEqual({ + horizontal: undefined, + vertical: "middle", + }); + }); + + it("a decoration with only alignment set does not collide with one that also sets a fill", () => { + const table = new CellFormatTable(); + const alignedOnly = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { alignment: "left" }, + ); + const alignedAndFilled = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + alignment: "left", + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }, + ); + expect(alignedOnly).not.toBe(alignedAndFilled); + }); + + it("a decoration with alignment set does not collide with an otherwise-identical one with no alignment at all", () => { + const table = new CellFormatTable(); + const noAlignment = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + const withAlignment = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + alignment: "left", + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }, + ); + expect(noAlignment).not.toBe(withAlignment); + }); + + it("a decoration with verticalAlignment set does not collide with an otherwise-identical one with no verticalAlignment at all", () => { + const table = new CellFormatTable(); + const noVertical = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + const withVertical = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + verticalAlignment: "top", + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }, + ); + expect(noVertical).not.toBe(withVertical); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/styles.ts b/packages/ooxml.js/src/typed/xlsx/styles.ts index 9e6bbcf9d8..703e535551 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.ts @@ -98,14 +98,15 @@ function readFontTableEntry(font: XmlElement): FontTableEntry { const name = childrenWithTag(font, "name")[0]; const sz = childrenWithTag(font, "sz")[0]; const szVal = sz === undefined ? undefined : attr(sz, "val"); - const szNum = szVal === undefined ? undefined : Number(szVal); + // No "szVal === undefined" guard: Number(undefined) is NaN, so an absent already falls through the Number.isFinite check below to the same "no sizePt" outcome this guard would have selected directly. + const szNum = Number(szVal); return { bold: readFontToggle(childrenWithTag(font, "b")[0]), italic: readFontToggle(childrenWithTag(font, "i")[0]), underline: readFontUnderline(childrenWithTag(font, "u")[0]), strike: readFontToggle(childrenWithTag(font, "strike")[0]), fontFamily: name === undefined ? undefined : attr(name, "val"), - sizePt: szNum !== undefined && Number.isFinite(szNum) ? szNum : undefined, + sizePt: Number.isFinite(szNum) ? szNum : undefined, color: readColorRgb(font, "color"), }; } @@ -237,8 +238,9 @@ export function colorFromElement( if (raw === undefined) { return undefined; } - // Excel writes "FFRRGGBB" (alpha + RGB); a 6-digit "RRGGBB" is also spec-legal. Take the LAST six hex digits in both cases, since the alpha channel has no ContentSheetCell.background representation and a leading "FF" is the only prefix real producers emit. + // Excel writes "FFRRGGBB" (alpha + RGB); a 6-digit "RRGGBB" is also spec-legal. Take the LAST six hex digits in both cases, since the alpha channel has no ContentSheetCell.background representation and a leading "FF" is the only prefix real producers emit. The boundary here (">=" rather than ">") is a genuinely irreducible equivalent mutation opportunity: at raw.length exactly 6, slice(-6) returns the whole, unchanged string -- identical to what the ">" branch's bare `raw` would have returned directly -- so the two operators can never be told apart by this result for any input. const hex = raw.length >= 6 ? raw.slice(-6) : raw; + // The regex's own "^"/"$" anchors are equally irreducible: `hex` is always either exactly 6 characters (the slice above) or fewer (raw itself, when shorter) -- never more. A {6}-quantified pattern can only ever match a 6-character string across its entire length regardless of anchors, and can never match a shorter one at all, so no possible `hex` value can tell an anchored and an unanchored match apart here. if (!/^[0-9a-fA-F]{6}$/.test(hex)) { return undefined; } From ce0379fb2c5affe4c99e9231cd3a1957a4a29f19 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:31:54 +0100 Subject: [PATCH 16/63] test(ooxml.js): reach styles.ts's genuine mutation ceiling Adds a real regression test for the border style ?? "solid" fallback: an edge with no style stated and one explicitly styled "solid" now assert to the same borderId, proving the fallback actually merges two representations of the identical visible border rather than only avoiding a crash. Adds tests for fontFamily/sizePt/colour genuinely differing from or absent against the baseline, size/fontFamily alone distinguishing two interned fonts, an empty borders object not colliding with a real one, and internFill's own default branch throwing for a fill kind this discriminated union has no member for. Removes readBorderEdge's own redundant "none" special case: "none" is not a key XLSX_BORDER_STYLE declares, so it already falls through the resolved-undefined check below to the identical result this check would have returned directly. Documents the remaining genuinely irreducible equivalent mutants in the read-side and write-side non-integer-numFmtId guards (each redundant with a sibling guard on the only real call path) and in every internal-only, never-exposed signature-building segment (font/fill/border/alignment dedup keys), where no consistent relabelling or placeholder substitution can ever create a real collision given the actual domain of values each field carries. --- .../ooxml.js/src/typed/xlsx/styles.test.ts | 120 ++++++++++++++++++ packages/ooxml.js/src/typed/xlsx/styles.ts | 10 +- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index a3c68e10fb..c01e93e262 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; +import type { ContentCellFill } from "document-schema.js"; import type { Package } from "../../model/package"; import { el } from "../../xml/fragment"; import { parsePackage } from "../../package-io/read"; @@ -778,6 +779,62 @@ describe("contentFontOf: omits fontFamily/sizePt/color entirely (not merely as u expect(font).toEqual({ bold: true }); expect(hasOwn(font, "color")).toBe(false); }); + + it("omits fontFamily entirely when the entry states no at all, even though the baseline has one", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("name", { val: "Calibri" })]), + el("font", {}, [el("b")]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + const font = readCellStyles(pkg)[0]?.font ?? {}; + expect(font).toEqual({ bold: true }); + expect(hasOwn(font, "fontFamily")).toBe(false); + }); + + it("omits sizePt entirely when the entry states no at all, even though the baseline has one", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("sz", { val: "11" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [el("b"), el("name", { val: "Calibri" })]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + const font = readCellStyles(pkg)[0]?.font ?? {}; + expect(font).toEqual({ bold: true }); + expect(hasOwn(font, "sizePt")).toBe(false); + }); + + it("states an entry's colour when it genuinely differs from the baseline's own resolved colour", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("color", { rgb: "FFFF0000" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [ + el("color", { rgb: "FF0000FF" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.font?.color).toEqual({ + r: 0, + g: 0, + b: 1, + }); + }); }); describe("colorFromElement/readColorRgb: hex length boundary and validation", () => { @@ -1030,6 +1087,8 @@ describe("CellFormatTable: font signature isolates every one of its own segments underline?: boolean; strike?: boolean; color?: { r: number; g: number; b: number }; + sizePt?: number; + fontFamily?: string; }, fontB: typeof fontA, ): [number, number] { @@ -1073,6 +1132,19 @@ describe("CellFormatTable: font signature isolates every one of its own segments expect(a).not.toBe(b); }); + it("size alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ sizePt: 11 }, { sizePt: 14 }); + expect(a).not.toBe(b); + }); + + it("fontFamily alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds( + { fontFamily: "Arial" }, + { fontFamily: "Courier New" }, + ); + expect(a).not.toBe(b); + }); + it("declares underline as undefined, not false, for a ContentFont whose own underline is explicitly false", () => { const table = new CellFormatTable(); table.intern( @@ -1190,6 +1262,54 @@ describe("CellFormatTable: border signature and caching across different outer f edges: { left: { style: "dashed", rgb: "000000" } }, }); }); + + it("dedupes a border edge with no style stated against one explicitly styled 'solid' -- both are the same visible border", () => { + const table = new CellFormatTable(); + const implicit = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, + ); + const explicit = table.intern( + { kind: "builtin", id: 9 }, + { + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75, style: "solid" }, + }, + }, + ); + expect(table.cellFormatRecords()[implicit]?.borderId).toBe( + table.cellFormatRecords()[explicit]?.borderId, + ); + expect(table.borderDeclarations()).toHaveLength(2); + }); + + it("a real edge segment distinguishes a border from an entirely empty one, not just an empty-vs-empty collision", () => { + const table = new CellFormatTable(); + const empty = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { borders: {} }, + ); + const real = table.intern( + { kind: "builtin", id: 9 }, + { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, + ); + expect(table.cellFormatRecords()[empty]?.borderId).not.toBe( + table.cellFormatRecords()[real]?.borderId, + ); + }); +}); + +describe("CellFormatTable: internFill's own default branch for a wholly unrecognised fill kind", () => { + it("throws naming the unrecognised kind, for a fill this discriminated union genuinely has no member for", () => { + const table = new CellFormatTable(); + const bogus = { kind: "gradient" } as unknown as ContentCellFill; + expect(() => + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: bogus }, + ), + ).toThrow(/gradient/); + }); }); describe("CellFormatTable: intern's own alignment-presence OR, not AND", () => { diff --git a/packages/ooxml.js/src/typed/xlsx/styles.ts b/packages/ooxml.js/src/typed/xlsx/styles.ts index 703e535551..3b324037df 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.ts @@ -46,6 +46,7 @@ function readNumberFormatCodesById( continue; } const id = Number.parseInt(idRaw, 10); + // Genuinely irreducible, not merely untested, given every real caller: readCellStyles' own numFmtId lookup below applies the identical Number.isInteger guard before ever calling codes.get, so a non-integer id here can only ever register a Map entry keyed by NaN that no real call site can ever look up (a genuine cell xf's own numFmtId is gated by that same guard first) -- this function's only observable effect, through readCellFormatCodes/readCellStyles, is unchanged whether or not this check runs. if (Number.isInteger(id)) { // decodeEntities is load-bearing here, not defensive: this package's lossless layer keeps attribute values exactly as written, and a real format code routinely contains quoted literals -- LibreOffice's own boolean format arrives as `"TRUE";"TRUE";"FALSE"`, which would tokenize as bare code characters rather than as quoted text if fed through raw. codes.set(id, decodeEntities(formatCode)); @@ -305,7 +306,8 @@ function readBorderEdge( return undefined; } const styleToken = attr(edgeEl, "style"); - if (styleToken === undefined || styleToken === "none") { + // No "styleToken === 'none'" disjunct: "none" is not a key XLSX_BORDER_STYLE declares, so it already falls through the resolved-undefined check below to the identical undefined result this disjunct would have short-circuited to. The `undefined` check alone stays load-bearing, since XLSX_BORDER_STYLE[undefined as never] would be a type error this reader never actually triggers, not a graceful undefined. + if (styleToken === undefined) { return undefined; } const resolved = XLSX_BORDER_STYLE[styleToken]; @@ -426,6 +428,7 @@ export function readCellStyles(pkg: Package): readonly CellStyleEntry[] { ? GENERAL_NUM_FMT_ID : Number.parseInt(numFmtRaw, 10); const entry: CellStyleEntry = {}; + // Genuinely irreducible, not merely untested: readNumberFormatCodesById above applies this identical guard before ever writing a Map entry, so `codes` can never actually hold a NaN key -- codes.get(NaN) already returns undefined on its own (a Map lookup miss, not a throw), the same outcome this guard would have skipped to directly for a non-integer numFmtId. if (Number.isInteger(numFmtId)) { const code = codes.get(numFmtId); if (code !== undefined) { @@ -559,6 +562,7 @@ function normalisedFontOf(font: ContentFont | undefined): DeclaredFont { }; } +// Every "=== true" comparison and the "?? ''" colour fallback below are genuinely irreducible equivalent mutation opportunities, not merely untested ones: this signature is consumed ONLY as an internal Map key (fontIndexBySignature), never exposed, so what matters is solely whether two DIFFERENT DeclaredFont values ever produce equal strings (a wrong collision) or two IDENTICAL values ever produce different ones (a wrong split) -- never which literal characters a given input maps to. Flipping "=== true" to "!== true" for one boolean field relabels that field's two segment values (swapping which string means "on" and which means "off") but stays a bijection over {true, non-true}, so it still correctly distinguishes every bold=true font from every bold=false one and still collides every bold=true font with every other bold=true font -- the equivalence classes this signature partitions inputs into are unchanged. The colour fallback is the same shape: no valid 6-hex-digit colorRgb string can ever equal the empty string (or any other fixed placeholder a mutant substitutes), so the "no colour" case can never collide with a real one regardless of which placeholder marks it. function signatureOfFont(font: ContentFont | undefined): string { const declared = normalisedFontOf(font); let sig = `b:${declared.bold === true}`; @@ -571,7 +575,7 @@ function signatureOfFont(font: ContentFont | undefined): string { return sig; } -// A deterministic signature for one ContentCellFill, shared by signatureOfDecoration (the cellXfs interning key) and CellFormatTable.internFill (the table's own dedup key) so the two can never disagree about which fills count as identical. +// A deterministic signature for one ContentCellFill, shared by signatureOfDecoration (the cellXfs interning key) and CellFormatTable.internFill (the table's own dedup key) so the two can never disagree about which fills count as identical. Each "? '' :" fallback below is a genuinely irreducible equivalent mutation opportunity for the identical reason signatureOfFont's own colour fallback is: no valid colorToRgbHex output can ever equal a mutant's substituted placeholder, so an absent foreground/background colour can never collide with a real one regardless of which fixed string marks its absence. function fillSignature(fill: ContentCellFill): string { return fill.kind === "solid" ? `solid:${colorToRgbHex(fill.color)}` @@ -594,6 +598,7 @@ function signatureOfDecoration(decoration: CellFormatDecoration): string { } } } + // Both presence guards below are genuinely irreducible equivalent mutation opportunities, not merely untested ones: Alignment and its vertical counterpart are closed string-literal unions (left/center/right/justify, top/middle/bottom) that can never hold the literal string "undefined" a forced-true mutant would interpolate here for an actually-absent value -- so an alignment-less decoration can never collide with one genuinely stating a real alignment value, regardless of whether this guard runs. if (decoration.alignment !== undefined) { sig += `|h:${decoration.alignment}`; } @@ -817,6 +822,7 @@ export class CellFormatTable { private internBorder(borders: ContentCellBorders): number { const edges: DeclaredBorder["edges"] = {}; + // The initial value here is a genuinely irreducible equivalent mutation opportunity, not merely an untested one: borderIndexBySignature starts genuinely empty (no pre-seeded entry, unlike fontIndexBySignature's own DEFAULT_FONT seed), so this string is never compared against a fixed external constant -- only ever against itself, built the identical way, on a later call. Any fixed starting string works identically as a dedup key, as long as it is used consistently, which it is. let signature = ""; for (const edge of ["left", "right", "top", "bottom"] as const) { const border = borders[edge]; From 7027a42af40e6ba9fda2ce4a9ff84c9b0c4fed37 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:37:15 +0100 Subject: [PATCH 17/63] test(ooxml.js): fix two border-signature tests that could not actually observe their own mutant Both replacement tests compared an empty-borders {} decoration against a real one, but an empty {} decoration's own outer signature already coincides with EMPTY_DECORATION's (the loop over its zero edges appends nothing), so it hits the seeded cellFormat-level cache before internFormat/internBorder is ever called at all -- neither test could ever have observed a change to internBorder's own per-edge signature building or to the outer alignment-presence check, regardless of mutation. Verified directly, per this project's own equivalence-claim convention: applying each mutation by hand and running the affected test confirmed it passed unchanged either way, before rewriting it. The style ?? "solid" fallback now compares two SAME-number-format interns (an implicit-style edge against an explicit "solid" one), which genuinely forces reuse of the outer cellFormat cache and so exercises signatureOfDecoration's own fallback, not internBorder's separately-correct borderToXlsxStyle handling of the same case. The edge-segment test now compares two distinct REAL borders (both of which genuinely reach internBorder) rather than an empty one against a real one, so a collapsed per-edge segment is observable as a wrongly-shared borderId. --- .../ooxml.js/src/typed/xlsx/styles.test.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index c01e93e262..5a43917ec9 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.test.ts @@ -1263,39 +1263,40 @@ describe("CellFormatTable: border signature and caching across different outer f }); }); - it("dedupes a border edge with no style stated against one explicitly styled 'solid' -- both are the same visible border", () => { + it("dedupes a whole cellXfs entry across an implicit-vs-explicit-'solid' border, at the outer decoration-signature level", () => { + // Deliberately the SAME number format on both calls, so the outer cellFormat-level cache (signatureOfDecoration, not internBorder's own separate borderIndexBySignature) is what is actually exercised here: a second intern() with a different numFmtId would call internBorder again regardless of the outer signature, proving nothing about this specific "?? 'solid'" fallback. const table = new CellFormatTable(); const implicit = table.intern( { kind: "builtin", id: GENERAL_NUM_FMT_ID }, { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, ); const explicit = table.intern( - { kind: "builtin", id: 9 }, + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75, style: "solid" }, }, }, ); - expect(table.cellFormatRecords()[implicit]?.borderId).toBe( - table.cellFormatRecords()[explicit]?.borderId, - ); - expect(table.borderDeclarations()).toHaveLength(2); + expect(explicit).toBe(implicit); + expect(table.cellFormatRecords()).toHaveLength(2); }); - it("a real edge segment distinguishes a border from an entirely empty one, not just an empty-vs-empty collision", () => { + it("two genuinely different real borders mint two distinct entries, not one shared by an edge-segment collapse", () => { + // Deliberately two REAL, non-empty borders (not an empty-vs-real pair): an empty `{}` decoration hits the outer cellFormat-level default seed before internBorder is ever called at all (its own signature already coincides with EMPTY_DECORATION's), so it can never exercise internBorder's own per-edge signature segment either way. Two distinct real borders, by contrast, both genuinely reach internBorder, so only a real per-edge signature can tell them apart. const table = new CellFormatTable(); - const empty = table.intern( + const thin = table.intern( { kind: "builtin", id: GENERAL_NUM_FMT_ID }, - { borders: {} }, + { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, ); - const real = table.intern( + const thick = table.intern( { kind: "builtin", id: 9 }, - { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, + { borders: { left: { color: { r: 1, g: 0, b: 0 }, widthPt: 1.5 } } }, ); - expect(table.cellFormatRecords()[empty]?.borderId).not.toBe( - table.cellFormatRecords()[real]?.borderId, + expect(table.cellFormatRecords()[thin]?.borderId).not.toBe( + table.cellFormatRecords()[thick]?.borderId, ); + expect(table.borderDeclarations()).toHaveLength(3); }); }); From 9c779c7c83cad79d1b475484a39bcaa1152830ae Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:40:48 +0100 Subject: [PATCH 18/63] test(ooxml.js): cover borderToXlsxStyle's double/dotted tokens Both cases were entirely unreached by any existing test -- every border test so far exercised only the dashed/solid weight-bucketing branches, leaving the two fixed-token cases genuinely uncovered rather than merely untested for a specific input. --- .../ooxml.js/src/typed/xlsx/styles.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index 5a43917ec9..979e730f89 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.test.ts @@ -1244,6 +1244,36 @@ describe("CellFormatTable: border signature and caching across different outer f expect(table.borderDeclarations()).toHaveLength(2); }); + it("writes a double-style border as the double token verbatim, ignoring widthPt entirely", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75, style: "double" }, + }, + }, + ); + expect(table.borderDeclarations()[1]).toEqual({ + edges: { left: { style: "double", rgb: "000000" } }, + }); + }); + + it("writes a dotted-style border as the dotted token verbatim, ignoring widthPt entirely", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75, style: "dotted" }, + }, + }, + ); + expect(table.borderDeclarations()[1]).toEqual({ + edges: { left: { style: "dotted", rgb: "000000" } }, + }); + }); + it("writes a dashed border at thin weight as plain dashed, not mediumDashed -- the medium check is not a no-op", () => { const table = new CellFormatTable(); table.intern( From c12cddb714f5c0e90d6641889de345c9e60ffa46 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:45:33 +0100 Subject: [PATCH 19/63] test(ooxml.js): add drawings-write.ts's own direct unit suite This module had no dedicated test file at all: its only coverage came from content.test.ts/build.test.ts round trips through readXlsxContent, whose reader never inspects an OOXML element's exact tag or attribute spelling, only its structural shape -- so a round trip could never tell a real element name from a mutated one apart. Asserts the full xdr:oneCellAnchor/xdr:pic/xdr:graphicFrame shape for both a picture and a chart anchor, the drawing/chart namespace declarations, the relationship part's own Id/Type/Target triples, the chart XML declaration and c:chartSpace root, every c:ser/ c:barChart/c:catAx/c:valAx element and its fixed axis ids, the series/category range arithmetic against a non-trivial category count (so the +1 in each range's own upper bound, and the +1 from column index to letters, are both observable), object-id and relationship-id counters advancing across multiple images, media/ chart numbering advancing across two calls sharing one counters instance (the shared-across-sheets contract DrawingCounters' own doc comment states), and every one of this module's five thrown error paths (svg image, non-chart object, a missing anchor field, a non-spreadsheet document, a spreadsheet document with no sheet). --- .../src/typed/xlsx/drawings-write.test.ts | 660 ++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts diff --git a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts new file mode 100644 index 0000000000..985833254d --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts @@ -0,0 +1,660 @@ +import { describe, expect, it } from "vitest"; +import type { + ContentDocument, + ContentEmbeddedObject, + ContentSheet, + ContentSheetImage, +} from "document-schema.js"; +import { el, txt } from "../../xml/fragment"; +import { ptToEmu } from "../shared/units"; +import { + CT_CHART, + CT_DRAWING, + buildSheetDrawing, + newDrawingCounters, +} from "./drawings-write"; + +// This module has no round-trip read side of its own to lean on for coverage (unlike most of this package's write-side modules): typed/xlsx/drawings.ts's own reader never inspects an OOXML element's exact tag/attribute spelling, only its structural shape, so a content.test.ts round trip through readXlsxContent(buildXlsxPackageFromContent(x)) cannot tell "xdr:pic" from "xdr:foo" apart. Every constant here -- namespace URIs, element/attribute names, the fixed axis IDs -- is therefore asserted directly against buildSheetDrawing's own output, which is the only way any of them are ever actually exercised. + +const PRINT_SETTINGS: ContentSheet["printSettings"] = { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + gridlines: false, + headers: false, + pageOrder: "downThenOver", +}; + +function chartDocument( + sheetName: string, + seriesName: string, + categoryLabel: string, + value: string, +): ContentDocument { + return { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: sheetName, + cells: [ + { + row: 0, + column: 1, + value: { kind: "string", value: seriesName }, + displayText: seriesName, + }, + { + row: 1, + column: 0, + value: { kind: "string", value: categoryLabel }, + displayText: categoryLabel, + }, + { + row: 1, + column: 1, + value: { kind: "string", value }, + displayText: value, + }, + ], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + }, + ], + }; +} + +function pngImage( + overrides: Partial = {}, +): ContentSheetImage { + return { + kind: "image", + format: "png", + base64: "aGVsbG8=", + widthPt: 100, + heightPt: 50, + anchorRow: 2, + anchorColumn: 3, + offsetXPt: 5, + offsetYPt: 10, + ...overrides, + }; +} + +function chartObject( + overrides: Partial = {}, +): ContentEmbeddedObject { + return { + objectKind: "chart", + document: chartDocument("Data", "Sales", "Q1", "100"), + frame: { xPt: 0, yPt: 0, widthPt: 200, heightPt: 150 }, + anchorRow: 5, + anchorColumn: 1, + offsetXPt: 0, + offsetYPt: 0, + ...overrides, + }; +} + +function sheet(overrides: Partial = {}): ContentSheet { + return { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + ...overrides, + }; +} + +describe("buildSheetDrawing: undefined for a sheet with neither images nor embedded objects", () => { + it("returns undefined, minting no drawing part at all", () => { + expect(buildSheetDrawing(sheet(), newDrawingCounters())).toBeUndefined(); + }); +}); + +describe("buildSheetDrawing: one image and one chart, every element and attribute exactly", () => { + const result = buildSheetDrawing( + sheet({ images: [pngImage()], embeddedObjects: [chartObject()] }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + + it("builds the picture anchor with its own xdr:from/xdr:ext/xdr:pic/xdr:clientData shape", () => { + const picAnchor = el("xdr:oneCellAnchor", {}, [ + el("xdr:from", {}, [ + el("xdr:col", {}, [txt("3")]), + el("xdr:colOff", {}, [txt(String(ptToEmu(5)))]), + el("xdr:row", {}, [txt("2")]), + el("xdr:rowOff", {}, [txt(String(ptToEmu(10)))]), + ]), + el("xdr:ext", { cx: String(ptToEmu(100)), cy: String(ptToEmu(50)) }), + el("xdr:pic", {}, [ + el("xdr:nvPicPr", {}, [ + el("xdr:cNvPr", { id: "2", name: "Picture 2" }), + el("xdr:cNvPicPr", {}, [el("a:picLocks", { noChangeAspect: "1" })]), + ]), + el("xdr:blipFill", {}, [ + el("a:blip", { "r:embed": "rId1" }), + el("a:stretch", {}, [el("a:fillRect")]), + ]), + el("xdr:spPr", {}, [ + el("a:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { + cx: String(ptToEmu(100)), + cy: String(ptToEmu(50)), + }), + ]), + el("a:prstGeom", { prst: "rect" }, [el("a:avLst")]), + ]), + ]), + el("xdr:clientData"), + ]); + expect(result.drawingRoot.children[0]).toEqual(picAnchor); + }); + + it("builds the chart anchor with its own xdr:from/xdr:ext/xdr:graphicFrame shape", () => { + const chartAnchor = el("xdr:oneCellAnchor", {}, [ + el("xdr:from", {}, [ + el("xdr:col", {}, [txt("1")]), + el("xdr:colOff", {}, [txt(String(ptToEmu(0)))]), + el("xdr:row", {}, [txt("5")]), + el("xdr:rowOff", {}, [txt(String(ptToEmu(0)))]), + ]), + el("xdr:ext", { cx: String(ptToEmu(200)), cy: String(ptToEmu(150)) }), + el("xdr:graphicFrame", {}, [ + el("xdr:nvGraphicFramePr", {}, [ + el("xdr:cNvPr", { id: "3", name: "Chart 3" }), + el("xdr:cNvGraphicFramePr"), + ]), + el("xdr:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { + cx: String(ptToEmu(200)), + cy: String(ptToEmu(150)), + }), + ]), + el("a:graphic", {}, [ + el( + "a:graphicData", + { uri: "http://schemas.openxmlformats.org/drawingml/2006/chart" }, + [el("c:chart", { "r:id": "rId2" })], + ), + ]), + ]), + el("xdr:clientData"), + ]); + expect(result.drawingRoot.children[1]).toEqual(chartAnchor); + }); + + it("wraps both anchors in xdr:wsDr with the three drawingml namespace declarations", () => { + expect(result.drawingRoot.tag).toBe("xdr:wsDr"); + expect(result.drawingRoot.attributes).toEqual([ + { + name: "xmlns:xdr", + value: + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + }, + { + name: "xmlns:a", + value: "http://schemas.openxmlformats.org/drawingml/2006/main", + }, + { + name: "xmlns:r", + value: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + }, + ]); + }); + + it("declares one image and one chart relationship, in order, each with its own real target path", () => { + expect(result.drawingRelsRoot).toEqual( + el( + "Relationships", + { + xmlns: "http://schemas.openxmlformats.org/package/2006/relationships", + }, + [ + el("Relationship", { + Id: "rId1", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + Target: "../media/image1.png", + }), + el("Relationship", { + Id: "rId2", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart", + Target: "../charts/chart1.xml", + }), + ], + ), + ); + }); + + it("writes the image bytes verbatim under xl/media/image1.png, and reports png as a used format", () => { + expect(result.extraParts["xl/media/image1.png"]).toEqual({ + kind: "binary", + base64: "aGVsbG8=", + }); + expect(result.usedImageFormats).toEqual(new Set(["png"])); + }); + + it("names the chart part xl/charts/chart1.xml and lists it in chartPartNames", () => { + expect(result.chartPartNames).toEqual(["xl/charts/chart1.xml"]); + expect(result.extraParts["xl/charts/chart1.xml"]).toBeDefined(); + }); + + it("builds the chart XML declaration and c:chartSpace root with its own three namespace declarations", () => { + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + expect(chartPart.nodes[0]).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + { name: "standalone", value: "yes" }, + ], + }); + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + expect(root.tag).toBe("c:chartSpace"); + expect(root.attributes).toEqual([ + { + name: "xmlns:c", + value: "http://schemas.openxmlformats.org/drawingml/2006/chart", + }, + { + name: "xmlns:a", + value: "http://schemas.openxmlformats.org/drawingml/2006/main", + }, + { + name: "xmlns:r", + value: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + }, + ]); + }); + + it("builds one c:ser per column, with its own idx/order, tx, and a real sheet-qualified cache range for both cat and val", () => { + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + const chart = root.children.find( + (n) => n.type === "element" && n.tag === "c:chart", + ); + if (chart?.type !== "element") { + throw new Error("expected c:chart"); + } + const plotArea = chart.children.find( + (n) => n.type === "element" && n.tag === "c:plotArea", + ); + if (plotArea?.type !== "element") { + throw new Error("expected c:plotArea"); + } + const barChart = plotArea.children.find( + (n) => n.type === "element" && n.tag === "c:barChart", + ); + if (barChart?.type !== "element") { + throw new Error("expected c:barChart"); + } + const ser = barChart.children.find( + (n) => n.type === "element" && n.tag === "c:ser", + ); + expect(ser).toEqual( + el("c:ser", {}, [ + el("c:idx", { val: "0" }), + el("c:order", { val: "0" }), + el("c:tx", {}, [el("c:v", {}, [txt("Sales")])]), + el("c:cat", {}, [ + el("c:strRef", {}, [ + el("c:f", {}, [txt("Data!$A$2:$A$2")]), + el("c:strCache", {}, [ + el("c:ptCount", { val: "1" }), + el("c:pt", { idx: "0" }, [el("c:v", {}, [txt("Q1")])]), + ]), + ]), + ]), + el("c:val", {}, [ + el("c:numRef", {}, [ + el("c:f", {}, [txt("Data!$B$2:$B$2")]), + el("c:numCache", {}, [ + el("c:ptCount", { val: "1" }), + el("c:pt", { idx: "0" }, [el("c:v", {}, [txt("100")])]), + ]), + ]), + ]), + ]), + ); + }); + + it("builds c:barChart/c:catAx/c:valAx with fixed axis ids, bar direction, and grouping", () => { + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + const chart = root.children.find( + (n) => n.type === "element" && n.tag === "c:chart", + ); + if (chart?.type !== "element") { + throw new Error("expected c:chart"); + } + const plotArea = chart.children.find( + (n) => n.type === "element" && n.tag === "c:plotArea", + ); + if (plotArea?.type !== "element") { + throw new Error("expected c:plotArea"); + } + expect(plotArea.children[0]).toEqual(el("c:layout")); + const barChart = plotArea.children.find( + (n) => n.type === "element" && n.tag === "c:barChart", + ); + if (barChart?.type !== "element") { + throw new Error("expected c:barChart"); + } + expect(barChart.children[0]).toEqual(el("c:barDir", { val: "col" })); + expect(barChart.children[1]).toEqual( + el("c:grouping", { val: "clustered" }), + ); + expect(barChart.children[barChart.children.length - 2]).toEqual( + el("c:axId", { val: "111111111" }), + ); + expect(barChart.children[barChart.children.length - 1]).toEqual( + el("c:axId", { val: "222222222" }), + ); + expect( + plotArea.children.find( + (n) => n.type === "element" && n.tag === "c:catAx", + ), + ).toEqual( + el("c:catAx", {}, [ + el("c:axId", { val: "111111111" }), + el("c:scaling", {}, [el("c:orientation", { val: "minMax" })]), + el("c:delete", { val: "0" }), + el("c:axPos", { val: "b" }), + el("c:crossAx", { val: "222222222" }), + ]), + ); + expect( + plotArea.children.find( + (n) => n.type === "element" && n.tag === "c:valAx", + ), + ).toEqual( + el("c:valAx", {}, [ + el("c:axId", { val: "222222222" }), + el("c:scaling", {}, [el("c:orientation", { val: "minMax" })]), + el("c:delete", { val: "0" }), + el("c:axPos", { val: "l" }), + el("c:crossAx", { val: "111111111" }), + ]), + ); + }); +}); + +describe("buildSheetDrawing: series/category range arithmetic against a second, non-trivial category count", () => { + it("closes the cache range at categories.length + 1, and derives each column's own letters from index + 1", () => { + const document: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Data", + cells: [ + { + row: 0, + column: 1, + value: { kind: "string", value: "A" }, + displayText: "A", + }, + { + row: 0, + column: 2, + value: { kind: "string", value: "B" }, + displayText: "B", + }, + { + row: 1, + column: 0, + value: { kind: "string", value: "Cat1" }, + displayText: "Cat1", + }, + { + row: 2, + column: 0, + value: { kind: "string", value: "Cat2" }, + displayText: "Cat2", + }, + { + row: 1, + column: 1, + value: { kind: "string", value: "1" }, + displayText: "1", + }, + { + row: 2, + column: 1, + value: { kind: "string", value: "2" }, + displayText: "2", + }, + { + row: 1, + column: 2, + value: { kind: "string", value: "3" }, + displayText: "3", + }, + { + row: 2, + column: 2, + value: { kind: "string", value: "4" }, + displayText: "4", + }, + ], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + }, + ], + }; + const result = buildSheetDrawing( + sheet({ + embeddedObjects: [chartObject({ document })], + }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + const findAll = (tag: string, node: typeof root): (typeof root)[] => { + const out: (typeof root)[] = []; + const walk = (n: typeof root) => { + if (n.tag === tag) { + out.push(n); + } + for (const child of n.children) { + if (child.type === "element") { + walk(child); + } + } + }; + walk(node); + return out; + }; + const fRanges = findAll("c:f", root).map((n) => { + const first = n.children[0]; + return first?.type === "text" ? first.value : undefined; + }); + // Two categories -> the cache range closes at row 3 (2 + 1), not row 1 (2 - 1); the second column's own letters are "C" (index 1 + 1), not "A" (index 1 - 1). + expect(fRanges).toEqual([ + "Data!$A$2:$A$3", + "Data!$B$2:$B$3", + "Data!$A$2:$A$3", + "Data!$C$2:$C$3", + ]); + }); +}); + +describe("buildSheetDrawing: object-id and relationship-id counters advance forward, not backward", () => { + it("assigns rId1/rId2 and cNvPr id 2/3 to two images in document order", () => { + const result = buildSheetDrawing( + sheet({ + images: [pngImage({ anchorColumn: 0 }), pngImage({ anchorColumn: 1 })], + }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + const ids = result.drawingRelsRoot.children.map((n) => + n.type === "element" + ? n.attributes.find((a) => a.name === "Id")?.value + : undefined, + ); + expect(ids).toEqual(["rId1", "rId2"]); + const secondAnchor = result.drawingRoot.children[1]; + if (secondAnchor?.type !== "element") { + throw new Error("expected an element"); + } + const pic = secondAnchor.children.find( + (n) => n.type === "element" && n.tag === "xdr:pic", + ); + if (pic?.type !== "element") { + throw new Error("expected xdr:pic"); + } + const nvPicPr = pic.children.find( + (n) => n.type === "element" && n.tag === "xdr:nvPicPr", + ); + if (nvPicPr?.type !== "element") { + throw new Error("expected xdr:nvPicPr"); + } + const cNvPr = nvPicPr.children[0]; + expect(cNvPr?.type === "element" && cNvPr.attributes).toContainEqual({ + name: "id", + value: "3", + }); + }); + + it("keeps media/chart numbering advancing across TWO separate buildSheetDrawing calls sharing one counters instance", () => { + const counters = newDrawingCounters(); + const first = buildSheetDrawing( + sheet({ name: "Sheet1", images: [pngImage()] }), + counters, + ); + const second = buildSheetDrawing( + sheet({ name: "Sheet2", images: [pngImage()] }), + counters, + ); + expect(first?.extraParts["xl/media/image1.png"]).toBeDefined(); + expect(second?.extraParts["xl/media/image2.png"]).toBeDefined(); + + const chartCounters = newDrawingCounters(); + const firstChart = buildSheetDrawing( + sheet({ name: "Sheet1", embeddedObjects: [chartObject()] }), + chartCounters, + ); + const secondChart = buildSheetDrawing( + sheet({ name: "Sheet2", embeddedObjects: [chartObject()] }), + chartCounters, + ); + expect(firstChart?.chartPartNames).toEqual(["xl/charts/chart1.xml"]); + expect(secondChart?.chartPartNames).toEqual(["xl/charts/chart2.xml"]); + }); +}); + +describe("buildSheetDrawing: error paths", () => { + it("throws for an svg image, naming the reason no raster blip exists", () => { + expect(() => + buildSheetDrawing( + sheet({ images: [pngImage({ format: "svg" })] }), + newDrawingCounters(), + ), + ).toThrow(/svg/); + }); + + it("throws for a non-chart embedded object, naming its actual objectKind", () => { + expect(() => + buildSheetDrawing( + sheet({ + embeddedObjects: [chartObject({ objectKind: "oleObject" as never })], + }), + newDrawingCounters(), + ), + ).toThrow(/oleObject/); + }); + + it("throws for a chart embedded object missing any one of its four anchor fields", () => { + expect(() => + buildSheetDrawing( + sheet({ + embeddedObjects: [chartObject({ anchorRow: undefined })], + }), + newDrawingCounters(), + ), + ).toThrow(/anchorRow/); + }); + + it("throws for a chart embedded object whose document is not a spreadsheet ContentDocument", () => { + expect(() => + buildSheetDrawing( + sheet({ + embeddedObjects: [ + chartObject({ + document: { kind: "wordprocessing", metadata: {}, sections: [] }, + }), + ], + }), + newDrawingCounters(), + ), + ).toThrow(/wordprocessing/); + }); + + it("throws for a chart embedded object whose spreadsheet document carries no sheet at all", () => { + expect(() => + buildSheetDrawing( + sheet({ + embeddedObjects: [ + chartObject({ + document: { kind: "spreadsheet", metadata: {}, sheets: [] }, + }), + ], + }), + newDrawingCounters(), + ), + ).toThrow(/exactly one sheet/); + }); +}); + +describe("CT_DRAWING/CT_CHART content-type constants", () => { + it("names the real ECMA-376 drawing and chart content types build.ts registers", () => { + expect(CT_DRAWING).toBe( + "application/vnd.openxmlformats-officedocument.drawing+xml", + ); + expect(CT_CHART).toBe( + "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", + ); + }); +}); From ea6730ef5badbcb304baed0b925505cde9f00bb9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:49:24 +0100 Subject: [PATCH 20/63] test(ooxml.js): fix drawings-write test coverage attribution Moved the shared picture+chart buildSheetDrawing() call from the describe body into beforeEach. Stryker's per-test coverage instrumentation attributes a line's execution to whichever test is running when that line executes, and a describe body runs during test collection, before any it() has started -- a call made there is invisible to that attribution, so Stryker silently ran some other, less precise test against these mutants instead of this file's own assertions. Verified directly: an L62 StringLiteral mutant on "xdr:col" showed Survived in a real scoped run despite the exact assertion in this file catching it when the same mutation was applied by hand and run locally, until the call moved into beforeEach. --- .../src/typed/xlsx/drawings-write.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts index 985833254d..10a746fdb6 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { ContentDocument, ContentEmbeddedObject, @@ -116,13 +116,18 @@ describe("buildSheetDrawing: undefined for a sheet with neither images nor embed }); describe("buildSheetDrawing: one image and one chart, every element and attribute exactly", () => { - const result = buildSheetDrawing( - sheet({ images: [pngImage()], embeddedObjects: [chartObject()] }), - newDrawingCounters(), - ); - if (result === undefined) { - throw new Error("expected a SheetDrawingWrite"); - } + // Computed fresh inside beforeEach, not once at describe-body level: Stryker's own per-test coverage instrumentation attributes a line's execution to whichever test is "currently running" at the moment it executes, and a describe body runs during test COLLECTION, before any it() has started -- a call made there is invisible to that attribution, so Stryker silently falls back to running some OTHER, less precise test against a mutant on this line instead of this file's own (confirmed directly: an L62 mutant survived under a real scoped run despite this exact assertion catching it when applied by hand, until this call moved into beforeEach). + let result: NonNullable>; + beforeEach(() => { + const built = buildSheetDrawing( + sheet({ images: [pngImage()], embeddedObjects: [chartObject()] }), + newDrawingCounters(), + ); + if (built === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + result = built; + }); it("builds the picture anchor with its own xdr:from/xdr:ext/xdr:pic/xdr:clientData shape", () => { const picAnchor = el("xdr:oneCellAnchor", {}, [ From 430e4a41b8d47b9177887f02f72cce44a099587f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:52:52 +0100 Subject: [PATCH 21/63] test(ooxml.js): close drawings-write's remaining chart-counter and sparse-cell gaps Adds a two-chart test proving the object-id counter genuinely advances (2 then 3) for charts, not just for the two-image case already covered, and a sparse-chart-cells test proving a missing series-name/value cell reads back through chartSeriesFromDocument's own cellAt fallback as an empty string, matching the empty label a missing point already gets on the read side. --- .../src/typed/xlsx/drawings-write.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts index 10a746fdb6..04eb901c39 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts @@ -412,6 +412,78 @@ describe("buildSheetDrawing: one image and one chart, every element and attribut }); }); +describe("buildSheetDrawing: chartSeriesFromDocument's own sparse-cell fallback", () => { + it("reads a missing cell (one chartCells never materialised, e.g. an absent point) back as an empty string, not undefined", () => { + const document: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Data", + // No (0,1) series-name cell at all, and no (1,1) value cell -- both genuinely absent from the sparse array, the same shape chartCells leaves for a missing point. (2,1) forces maxColumn to 1 so a series column genuinely exists to read the missing (0,1)/(1,1) cells back through. + cells: [ + { + row: 1, + column: 0, + value: { kind: "string", value: "Q1" }, + displayText: "Q1", + }, + { + row: 2, + column: 1, + value: { kind: "string", value: "42" }, + displayText: "42", + }, + ], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + }, + ], + }; + const result = buildSheetDrawing( + sheet({ embeddedObjects: [chartObject({ document })] }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + const findFirst = ( + tag: string, + node: typeof root, + ): typeof root | undefined => { + if (node.tag === tag) { + return node; + } + for (const child of node.children) { + if (child.type === "element") { + const found = findFirst(tag, child); + if (found !== undefined) { + return found; + } + } + } + return undefined; + }; + const tx = findFirst("c:tx", root); + const seriesNameValue = tx?.children[0]; + if (seriesNameValue?.type !== "element") { + throw new Error("expected c:v"); + } + const seriesNameText = seriesNameValue.children[0]; + expect(seriesNameText?.type === "text" && seriesNameText.value).toBe(""); + }); +}); + describe("buildSheetDrawing: series/category range arithmetic against a second, non-trivial category count", () => { it("closes the cache range at categories.length + 1, and derives each column's own letters from index + 1", () => { const document: ContentDocument = { @@ -563,6 +635,39 @@ describe("buildSheetDrawing: object-id and relationship-id counters advance forw }); }); + it("assigns cNvPr id 2 then 3 to two charts in document order -- the object-id counter advances for charts too, not just images", () => { + const result = buildSheetDrawing( + sheet({ + embeddedObjects: [chartObject(), chartObject({ anchorColumn: 5 })], + }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + const secondAnchor = result.drawingRoot.children[1]; + if (secondAnchor?.type !== "element") { + throw new Error("expected an element"); + } + const frame = secondAnchor.children.find( + (n) => n.type === "element" && n.tag === "xdr:graphicFrame", + ); + if (frame?.type !== "element") { + throw new Error("expected xdr:graphicFrame"); + } + const nvPr = frame.children.find( + (n) => n.type === "element" && n.tag === "xdr:nvGraphicFramePr", + ); + if (nvPr?.type !== "element") { + throw new Error("expected xdr:nvGraphicFramePr"); + } + const cNvPr = nvPr.children[0]; + expect(cNvPr?.type === "element" && cNvPr.attributes).toContainEqual({ + name: "id", + value: "3", + }); + }); + it("keeps media/chart numbering advancing across TWO separate buildSheetDrawing calls sharing one counters instance", () => { const counters = newDrawingCounters(); const first = buildSheetDrawing( From 241f8e648e2090a5f2a3c22fdeb7d63d56fb374d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 06:00:56 +0100 Subject: [PATCH 22/63] test(ooxml.js): add conditional-format.ts's own direct unit suite This module's only prior coverage came from two real-producer fixtures (cellIs, colorScale) and a round-trip suite covering every rule family's own value shape, neither of which exercised the module's own exact XML vocabulary directly: an attribute name mutation on the write side and the matching read-side lookup can cancel each other out in a round trip, and toEqual-based value checks cannot distinguish a key genuinely absent from one assigned undefined. Covers, on the read side: the wrapper-level sqref gate, priority/ stopIfTrue/source residue capture, every cfvo type token including formula/percentile (previously entirely uncovered), the colorScale cfvo/color count-mismatch rejection, dataBar/iconSet's own true-default showValue convention and reverse flag, every dxf residue passthrough branch (font/fill/numFmt/alignment/border/ protection, including a font or fill kept whole when no colour was captured from it), cellIs formula2 restricted to between/ notBetween, top10's rank<=0 rejection and percent/bottom presence, aboveAverage's own true-default and stdDev<=0 rejection, and the colorScale/iconSet type discriminants. Covers, on the write side: formula/formula2 element count, top10/ aboveAverage/dataBar/iconSet's own attribute presence (each written only when explicitly set, never restating a default), the colorScale element's cfvo-before-color ordering, and buildConditionalFormattingElements' own range-based grouping and gap-filling priority assignment (an unpriorised rule never reuses an already-claimed explicit priority). --- .../src/typed/xlsx/conditional-format.test.ts | 758 ++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts diff --git a/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts b/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts new file mode 100644 index 0000000000..2f3f2c106d --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts @@ -0,0 +1,758 @@ +import { describe, expect, it } from "vitest"; +import type { ContentSheetConditionalFormat } from "document-schema.js"; +import { el, txt } from "../../xml/fragment"; +import { childrenWithTag } from "../util"; +import { + DxfTable, + buildConditionalFormattingElements, + readConditionalFormats, +} from "./conditional-format"; + +function hasOwn(obj: object, key: string): boolean { + return Object.hasOwn(obj, key); +} + +// A worksheet carrying exactly one wrapper with exactly one child, so every test below can build just the cfRule's own attributes/children and get back formats[0]/residueElements[0] directly. +function worksheetWithRule( + sqref: string, + cfRule: ReturnType, + dxfs: ReturnType[] = [], +): { + formats: ContentSheetConditionalFormat[]; + residueElements: ReturnType[]; +} { + const worksheet = el("worksheet", {}, [ + el("conditionalFormatting", { sqref }, [cfRule]), + ]); + return readConditionalFormats(worksheet, dxfs); +} + +describe("readConditionalFormats: the wrapper's own sqref gates every rule inside it", () => { + it("quarantines every cfRule as residue when the wrapper's own sqref parses to no range at all", () => { + const { formats, residueElements } = worksheetWithRule( + "not a ref", + el("cfRule", { type: "containsBlanks", dxfId: "0", priority: "1" }), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + +describe("readCommonFields: priority and stopIfTrue", () => { + it("states no priority for a non-integer priority attribute", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { + type: "containsBlanks", + priority: "not-a-number", + }), + ); + expect(hasOwn(formats[0] ?? {}, "priority")).toBe(false); + }); + + it("states stopIfTrue: true only for an explicit true value, and omits the key entirely otherwise", () => { + const { formats: withStop } = worksheetWithRule( + "A1", + el("cfRule", { + type: "containsBlanks", + priority: "1", + stopIfTrue: "1", + }), + ); + expect(withStop[0]?.stopIfTrue).toBe(true); + const { formats: withoutStop } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1" }), + ); + expect(hasOwn(withoutStop[0] ?? {}, "stopIfTrue")).toBe(false); + }); + + it("captures a genuinely unrecognised cfRule attribute as source residue, and states no source when every attribute is a managed one", () => { + const { formats: withResidue } = worksheetWithRule( + "A1", + el("cfRule", { + type: "containsBlanks", + priority: "1", + "x14ac:extraAttr": "value", + }), + ); + expect(withResidue[0]?.source).toBeDefined(); + const { formats: withoutResidue } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1" }), + ); + expect(hasOwn(withoutResidue[0] ?? {}, "source")).toBe(false); + }); +}); + +describe("readCfvo: exact type-token membership", () => { + function cfvoType(type: string): string | undefined { + const { formats } = worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, [ + el("cfvo", { type, val: "0" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF0000FF" }), + ]), + ]), + ); + const format = formats[0]; + return format?.type === "colorScale" + ? format.stops[0]?.value.type + : undefined; + } + + it('recognises "num"', () => { + expect(cfvoType("num")).toBe("num"); + }); + + it('recognises "formula"', () => { + expect(cfvoType("formula")).toBe("formula"); + }); + + it('recognises "percentile"', () => { + expect(cfvoType("percentile")).toBe("percentile"); + }); + + it("rejects an unrecognised type token, dropping the whole colorScale rule to residue", () => { + const { formats, residueElements } = worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, [ + el("cfvo", { type: "bogus", val: "0" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF0000FF" }), + ]), + ]), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + +describe("readColorScaleStops: min/max cfvo/color pair count mismatch", () => { + it("rejects a colorScale whose cfvo/color counts genuinely mismatch, dropping the rule to residue", () => { + const { formats, residueElements } = worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, [ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + ]), + ]), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + +describe("readDataBar: showValue's own default-is-true convention", () => { + function dataBarShowValue(showValue?: string): boolean | undefined { + const attrs: Record = {}; + if (showValue !== undefined) { + attrs.showValue = showValue; + } + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "dataBar", priority: "1" }, [ + el("dataBar", attrs, [ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + ]), + ]), + ); + const format = formats[0]; + return format?.type === "dataBar" ? format.showValue : undefined; + } + + it("states no showValue key at all when the attribute is absent (the true default)", () => { + expect(hasOwn({ v: dataBarShowValue(undefined) }, "v")).toBe(true); + expect(dataBarShowValue(undefined)).toBeUndefined(); + }); + + it("states showValue: false only for an explicit false value", () => { + expect(dataBarShowValue("0")).toBe(false); + }); + + it("states no showValue at all for an explicit true value (matching the default, nothing to record)", () => { + expect(dataBarShowValue("1")).toBeUndefined(); + }); +}); + +describe("readIconSet: reverse, showValue, and the empty-thresholds rejection", () => { + it("rejects an iconSet with no cfvo thresholds at all, dropping the rule to residue", () => { + const { formats, residueElements } = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [el("iconSet", {})]), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("states reverse: true only for an explicit true value", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [ + el("iconSet", { reverse: "1" }, [ + el("cfvo", { type: "percent", val: "33" }), + ]), + ]), + ); + const format = formats[0]; + expect(format?.type === "iconSet" ? format.reverse : undefined).toBe(true); + }); + + it("states showValue: false only for an explicit false value, and nothing for an explicit true", () => { + const falseCase = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [ + el("iconSet", { showValue: "0" }, [ + el("cfvo", { type: "percent", val: "33" }), + ]), + ]), + ).formats[0]; + expect( + falseCase?.type === "iconSet" ? falseCase.showValue : undefined, + ).toBe(false); + const trueCase = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [ + el("iconSet", { showValue: "1" }, [ + el("cfvo", { type: "percent", val: "33" }), + ]), + ]), + ).formats[0]; + expect( + trueCase?.type === "iconSet" ? trueCase.showValue : undefined, + ).toBeUndefined(); + }); +}); + +describe("styleFromDxf/dxfResidueChildren: residue passthrough for font/fill/numFmt/alignment/border/protection", () => { + function styleOf(dxf: ReturnType) { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1", dxfId: "0" }), + [dxf], + ); + return formats[0]?.type === "containsBlanks" ? formats[0].style : undefined; + } + + it("states no style at all for a dxf carrying neither a resolvable colour nor any residue", () => { + expect(styleOf(el("dxf", {}, []))).toBeUndefined(); + }); + + it("keeps a font's other children (e.g. b/i toggles) as residue alongside a captured textColor", () => { + const style = styleOf( + el("dxf", {}, [ + el("font", {}, [el("b"), el("color", { rgb: "FFFF0000" })]), + ]), + ); + expect(style?.textColor).toEqual({ r: 1, g: 0, b: 0 }); + expect(style?.source?.xml).toContain(" { + const style = styleOf(el("dxf", {}, [el("font", {}, [el("b")])])); + expect(style?.textColor).toBeUndefined(); + expect(style?.source?.xml).toContain(" { + const style = styleOf( + el("dxf", {}, [el("numFmt", { numFmtId: "1", formatCode: "0.00" })]), + ); + expect(style?.source?.xml).toContain("numFmt"); + }); + + it("keeps other patternFill children and other fill children alongside a captured background", () => { + const style = styleOf( + el("dxf", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "solid" }, [ + el("fgColor", { rgb: "FF00FF00" }), + el("bgColor", { rgb: "FFFF0000" }), + ]), + ]), + ]), + ); + expect(style?.background).toEqual({ r: 1, g: 0, b: 0 }); + expect(style?.source?.xml).toContain("fgColor"); + }); + + it("keeps a whole fill element as residue when it carries no bgColor at all (no background captured)", () => { + const style = styleOf( + el("dxf", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "solid" }, [ + el("fgColor", { rgb: "FF00FF00" }), + ]), + ]), + ]), + ); + expect(style?.background).toBeUndefined(); + expect(style?.source?.xml).toContain("fgColor"); + }); + + it("keeps alignment/border/protection residue elements verbatim, in document order", () => { + const style = styleOf( + el("dxf", {}, [ + el("alignment", { horizontal: "center" }), + el("border", {}, [el("left", { style: "thin" })]), + el("protection", { locked: "0" }), + ]), + ); + expect(style?.source?.xml).toBe( + '', + ); + }); + + it("resolves style from an out-of-range dxfId as no style at all, rather than throwing", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1", dxfId: "99" }), + [], + ); + expect(hasOwn(formats[0] ?? {}, "style")).toBe(false); + }); +}); + +describe("readCfRule: cellIs formula2 only for between/notBetween", () => { + it("carries formula2 for a between operator", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator: "between", priority: "1" }, [ + el("formula", {}, [txt("1")]), + el("formula", {}, [txt("10")]), + ]), + ); + const format = formats[0]; + expect(format?.type === "cellIs" ? format.formula2 : undefined).toBe("10"); + }); + + it("omits formula2 entirely for a non-between/notBetween operator, even when a second exists", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator: "greaterThan", priority: "1" }, [ + el("formula", {}, [txt("1")]), + el("formula", {}, [txt("10")]), + ]), + ); + expect(hasOwn(formats[0] ?? {}, "formula2")).toBe(false); + }); +}); + +describe("readCfRule: top10's rank boundary", () => { + it("rejects rank 0 and negative rank, dropping the rule to residue", () => { + for (const rank of ["0", "-1"]) { + const { formats, residueElements } = worksheetWithRule( + "A1", + el("cfRule", { type: "top10", rank, priority: "1" }), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + } + }); + + it("accepts rank 1 (the boundary itself) and states percent/bottom only when explicitly true", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { + type: "top10", + rank: "1", + percent: "1", + bottom: "1", + priority: "1", + }), + ); + const format = formats[0]; + expect(format?.type === "top10" ? format.rank : undefined).toBe(1); + expect(format?.type === "top10" ? format.percent : undefined).toBe(true); + expect(format?.type === "top10" ? format.bottom : undefined).toBe(true); + }); + + it("omits percent/bottom entirely when neither attribute is set", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "top10", rank: "5", priority: "1" }), + ); + expect(hasOwn(formats[0] ?? {}, "percent")).toBe(false); + expect(hasOwn(formats[0] ?? {}, "bottom")).toBe(false); + }); +}); + +describe("readCfRule: aboveAverage's own true-default and stdDev boundary", () => { + it("states aboveAverage: false only for an explicit false value, and nothing for an absent or true value", () => { + const explicit = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", aboveAverage: "0", priority: "1" }), + ).formats[0]; + expect( + explicit?.type === "aboveAverage" ? explicit.aboveAverage : undefined, + ).toBe(false); + const absent = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", priority: "1" }), + ).formats[0]; + expect(hasOwn(absent ?? {}, "aboveAverage")).toBe(false); + }); + + it("states equalAverage: true only for an explicit true value", () => { + const format = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", equalAverage: "1", priority: "1" }), + ).formats[0]; + expect( + format?.type === "aboveAverage" ? format.equalAverage : undefined, + ).toBe(true); + }); + + it("rejects stdDev 0, keeping the rule but omitting the stdDev key", () => { + const format = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", stdDev: "0", priority: "1" }), + ).formats[0]; + expect(hasOwn(format ?? {}, "stdDev")).toBe(false); + }); + + it("accepts stdDev 1 (the boundary itself)", () => { + const format = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", stdDev: "1", priority: "1" }), + ).formats[0]; + expect(format?.type === "aboveAverage" ? format.stdDev : undefined).toBe(1); + }); +}); + +describe("readCfRule: colorScale/iconSet type discrimination", () => { + it('reads type "colorScale" as the colorScale kind, not falling through to residue', () => { + const { formats } = worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, [ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF0000FF" }), + ]), + ]), + ); + expect(formats[0]?.type).toBe("colorScale"); + }); + + it('reads type "iconSet" as the iconSet kind, not falling through to residue', () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [ + el("iconSet", {}, [el("cfvo", { type: "percent", val: "33" })]), + ]), + ); + expect(formats[0]?.type).toBe("iconSet"); + }); +}); + +// --- the write side --------------------------------------------------------------------------------------------- + +function buildOneRule(format: ContentSheetConditionalFormat): { + conditionalFormatting: ReturnType; + dxfTable: DxfTable; +} { + const dxfTable = new DxfTable(); + const [conditionalFormatting] = buildConditionalFormattingElements( + [format], + dxfTable, + ); + if (conditionalFormatting === undefined) { + throw new Error("expected one conditionalFormatting element"); + } + return { conditionalFormatting, dxfTable }; +} + +function firstCfRule(conditionalFormatting: ReturnType) { + const rule = childrenWithTag(conditionalFormatting, "cfRule")[0]; + if (rule === undefined) { + throw new Error("expected a cfRule"); + } + return rule; +} + +describe("buildCfRuleElement: cellIs formula/formula2 elements", () => { + it("writes exactly one for a formula1-only rule", () => { + const { conditionalFormatting } = buildOneRule({ + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + operator: "greaterThan", + formula1: "5", + }); + const rule = firstCfRule(conditionalFormatting); + expect(childrenWithTag(rule, "formula")).toHaveLength(1); + }); + + it("writes two elements, in order, for a formula1+formula2 rule", () => { + const { conditionalFormatting } = buildOneRule({ + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + operator: "between", + formula1: "1", + formula2: "10", + }); + const rule = firstCfRule(conditionalFormatting); + const formulas = childrenWithTag(rule, "formula").map((f) => { + const t = f.children[0]; + return t?.type === "text" ? t.value : undefined; + }); + expect(formulas).toEqual(["1", "10"]); + }); +}); + +describe("buildCfRuleElement: top10's percent/bottom attribute presence", () => { + it("writes bottom='1' only when bottom is true, and omits it entirely otherwise", () => { + const withBottom = firstCfRule( + buildOneRule({ + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 5, + bottom: true, + }).conditionalFormatting, + ); + expect(childrenWithTag).toBeDefined(); + const bottomAttr = withBottom.attributes.find((a) => a.name === "bottom"); + expect(bottomAttr?.value).toBe("true"); + + const withoutBottom = firstCfRule( + buildOneRule({ + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 5, + }).conditionalFormatting, + ); + expect(withoutBottom.attributes.some((a) => a.name === "bottom")).toBe( + false, + ); + }); + + it("writes percent='1' only when percent is true", () => { + const withPercent = firstCfRule( + buildOneRule({ + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 5, + percent: true, + }).conditionalFormatting, + ); + expect( + withPercent.attributes.find((a) => a.name === "percent")?.value, + ).toBe("true"); + }); +}); + +describe("buildCfRuleElement: aboveAverage's own three independent flags", () => { + it("writes aboveAverage='0' only when aboveAverage is explicitly false", () => { + const rule = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + aboveAverage: false, + }).conditionalFormatting, + ); + expect(rule.attributes.find((a) => a.name === "aboveAverage")?.value).toBe( + "false", + ); + }); + + it("writes equalAverage='1' only when equalAverage is explicitly true", () => { + const rule = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + equalAverage: true, + }).conditionalFormatting, + ); + expect(rule.attributes.find((a) => a.name === "equalAverage")?.value).toBe( + "true", + ); + }); + + it("writes stdDev only when it is genuinely present", () => { + const rule = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + stdDev: 2, + }).conditionalFormatting, + ); + expect(rule.attributes.find((a) => a.name === "stdDev")?.value).toBe("2"); + }); +}); + +describe("buildCfRuleElement: colorScale/dataBar/iconSet element shape", () => { + it("writes one with every cfvo before every color, in stop order", () => { + const rule = firstCfRule( + buildOneRule({ + type: "colorScale", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + stops: [ + { value: { type: "min" }, color: { r: 1, g: 0, b: 0 } }, + { value: { type: "max" }, color: { r: 0, g: 0, b: 1 } }, + ], + }).conditionalFormatting, + ); + const colorScale = childrenWithTag(rule, "colorScale")[0]; + if (colorScale === undefined) { + throw new Error("expected colorScale"); + } + const tags = colorScale.children + .filter((c) => c.type === "element") + .map((c) => c.tag); + expect(tags).toEqual(["cfvo", "cfvo", "color", "color"]); + const colors = childrenWithTag(colorScale, "color").map( + (c) => c.attributes.find((a) => a.name === "rgb")?.value, + ); + expect(colors).toEqual(["FFff0000", "FF0000ff"]); + }); + + it("writes dataBar's showValue on the element itself, not the ", () => { + const rule = firstCfRule( + buildOneRule({ + type: "dataBar", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + min: { type: "min" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + showValue: false, + }).conditionalFormatting, + ); + expect(rule.attributes.some((a) => a.name === "showValue")).toBe(false); + const dataBar = childrenWithTag(rule, "dataBar")[0]; + expect(dataBar?.attributes.find((a) => a.name === "showValue")?.value).toBe( + "false", + ); + }); + + it("writes iconSet's iconSet attribute only for a non-default iconSetType", () => { + const defaultType = firstCfRule( + buildOneRule({ + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + iconSetType: "3TrafficLights1", + thresholds: [{ type: "percent", value: "33" }], + }).conditionalFormatting, + ); + const defaultIconSet = childrenWithTag(defaultType, "iconSet")[0]; + expect(defaultIconSet?.attributes.some((a) => a.name === "iconSet")).toBe( + false, + ); + + const customType = firstCfRule( + buildOneRule({ + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + iconSetType: "3Arrows", + thresholds: [{ type: "percent", value: "33" }], + }).conditionalFormatting, + ); + const customIconSet = childrenWithTag(customType, "iconSet")[0]; + expect( + customIconSet?.attributes.find((a) => a.name === "iconSet")?.value, + ).toBe("3Arrows"); + }); + + it("writes iconSet's reverse and showValue only when explicitly set", () => { + const rule = firstCfRule( + buildOneRule({ + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + iconSetType: "3TrafficLights1", + thresholds: [{ type: "percent", value: "33" }], + reverse: true, + showValue: false, + }).conditionalFormatting, + ); + const iconSet = childrenWithTag(rule, "iconSet")[0]; + expect(iconSet?.attributes.find((a) => a.name === "reverse")?.value).toBe( + "true", + ); + expect(iconSet?.attributes.find((a) => a.name === "showValue")?.value).toBe( + "false", + ); + }); +}); + +describe("buildConditionalFormattingElements: range grouping and priority assignment", () => { + it("groups two rules sharing the identical range set into one conditionalFormatting wrapper", () => { + const range = { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }; + const elements = buildConditionalFormattingElements( + [ + { type: "containsBlanks", ranges: [range] }, + { type: "containsErrors", ranges: [range] }, + ], + new DxfTable(), + ); + expect(elements).toHaveLength(1); + expect(childrenWithTag(elements[0] ?? el("x"), "cfRule")).toHaveLength(2); + }); + + it("splits two rules with genuinely different range sets into two separate wrappers", () => { + const elements = buildConditionalFormattingElements( + [ + { + type: "containsBlanks", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + { + type: "containsErrors", + ranges: [{ startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 }], + }, + ], + new DxfTable(), + ); + expect(elements).toHaveLength(2); + }); + + it("assigns explicit priorities verbatim, and fills the gap for an unpriorised rule rather than colliding with it", () => { + const elements = buildConditionalFormattingElements( + [ + { + type: "containsBlanks", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + priority: 1, + }, + { + type: "containsErrors", + ranges: [{ startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 }], + }, + ], + new DxfTable(), + ); + const priorities = elements.flatMap((wrapper) => + childrenWithTag(wrapper, "cfRule").map( + (rule) => rule.attributes.find((a) => a.name === "priority")?.value, + ), + ); + // The unpriorised rule must NOT reuse "1" (already explicitly claimed) -- it gets the next free integer, "2". + expect( + [...priorities].sort((a, b) => (a ?? "").localeCompare(b ?? "")), + ).toEqual(["1", "2"]); + }); + + it("assigns sequential priorities to two unpriorised rules sharing one range, in document order", () => { + const range = { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }; + const elements = buildConditionalFormattingElements( + [ + { type: "containsBlanks", ranges: [range] }, + { type: "containsErrors", ranges: [range] }, + ], + new DxfTable(), + ); + const priorities = childrenWithTag(elements[0] ?? el("x"), "cfRule").map( + (rule) => rule.attributes.find((a) => a.name === "priority")?.value, + ); + expect(priorities).toEqual(["1", "2"]); + }); +}); From 2ad1b6e7b94de3f636a885eb02df9e5932d48582 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 06:07:58 +0100 Subject: [PATCH 23/63] test(ooxml.js): close conditional-format's operator, boundary, and residue gaps Covers every isSheetRuleOperator member distinctly (not just between/greaterThan), cellIs formula2 for notBetween alongside between, the absent-timePeriod rejection, the colorScale cfvo/ color count boundary at exactly 2 and exactly 3 stops on both sides, residualAttributesFor's own expectedTag gate (an unmanaged cfRule attribute genuinely restored on write), rangeSetKey's field separator (two ranges that would collide under naive concatenation without it), and a full round trip of every dxf residue kind at once (font+color, fill+patternFill+bgColor, numFmt, alignment, border, protection) through DxfTable.intern. Adds the missing "omitted entirely" half of several write-side attribute-presence tests (top10 percent, aboveAverage/equalAverage/ stdDev, dataBar showValue, iconSet reverse/showValue) that only asserted the explicit-true/false case, never that the attribute is genuinely absent -- not merely unasserted -- when nothing was set. --- .../src/typed/xlsx/conditional-format.test.ts | 312 +++++++++++++++++- 1 file changed, 298 insertions(+), 14 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts b/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts index 2f3f2c106d..27e7e36fe4 100644 --- a/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts @@ -85,6 +85,70 @@ describe("readCommonFields: priority and stopIfTrue", () => { }); }); +describe("isSheetRuleOperator: every accepted member, distinctly", () => { + function operatorOf(operator: string): string | undefined { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator, priority: "1" }, [ + el("formula", {}, [txt("1")]), + ]), + ); + const format = formats[0]; + return format?.type === "cellIs" ? format.operator : undefined; + } + + for (const operator of [ + "between", + "notBetween", + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + ]) { + it(`accepts "${operator}"`, () => { + expect(operatorOf(operator)).toBe(operator); + }); + } + + it("rejects an unrecognised operator token, dropping the rule to residue", () => { + const { formats, residueElements } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator: "bogus", priority: "1" }, [ + el("formula", {}, [txt("1")]), + ]), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + +describe("readCfRule: cellIs formula2 for notBetween too, not just between", () => { + it("carries formula2 for a notBetween operator", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator: "notBetween", priority: "1" }, [ + el("formula", {}, [txt("1")]), + el("formula", {}, [txt("10")]), + ]), + ); + const format = formats[0]; + expect(format?.type === "cellIs" ? format.formula2 : undefined).toBe("10"); + }); +}); + +describe("isTimePeriod: rejects an absent timePeriod attribute, dropping the rule to residue", () => { + it("drops a timePeriod rule with no timePeriod attribute at all", () => { + const { formats, residueElements } = worksheetWithRule( + "A1", + el("cfRule", { type: "timePeriod", priority: "1" }), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + describe("readCfvo: exact type-token membership", () => { function cfvoType(type: string): string | undefined { const { formats } = worksheetWithRule( @@ -133,18 +197,68 @@ describe("readCfvo: exact type-token membership", () => { }); }); -describe("readColorScaleStops: min/max cfvo/color pair count mismatch", () => { +function colorScaleFormats(cfvoAndColor: ReturnType[]) { + return worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, cfvoAndColor), + ]), + ); +} + +describe("readColorScaleStops: the cfvo/color count boundary (2..3 stops, matched counts)", () => { it("rejects a colorScale whose cfvo/color counts genuinely mismatch, dropping the rule to residue", () => { - const { formats, residueElements } = worksheetWithRule( - "A1:B2", - el("cfRule", { type: "colorScale", priority: "1" }, [ - el("colorScale", {}, [ - el("cfvo", { type: "min" }), - el("cfvo", { type: "max" }), - el("color", { rgb: "FFFF0000" }), - ]), - ]), - ); + const { formats, residueElements } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + ]); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("rejects a single-stop colorScale (below the 2-stop minimum)", () => { + const { formats, residueElements } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("color", { rgb: "FFFF0000" }), + ]); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("accepts exactly 2 stops (the minimum boundary itself)", () => { + const { formats } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF0000FF" }), + ]); + expect(formats[0]?.type).toBe("colorScale"); + }); + + it("accepts exactly 3 stops (the maximum boundary itself)", () => { + const { formats } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("cfvo", { type: "percentile", val: "50" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF00FF00" }), + el("color", { rgb: "FF0000FF" }), + ]); + expect(formats[0]?.type).toBe("colorScale"); + }); + + it("rejects a 4-stop colorScale (above the 3-stop maximum), even though the counts still match", () => { + const { formats, residueElements } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("cfvo", { type: "percentile", val: "25" }), + el("cfvo", { type: "percentile", val: "75" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF00FF00" }), + el("color", { rgb: "FF00FFFF" }), + el("color", { rgb: "FF0000FF" }), + ]); expect(formats).toEqual([]); expect(residueElements).toHaveLength(1); }); @@ -311,6 +425,71 @@ describe("styleFromDxf/dxfResidueChildren: residue passthrough for font/fill/num expect(style?.source?.xml).toBe( '', ); + expect(hasOwn(style ?? {}, "textColor")).toBe(false); + expect(hasOwn(style ?? {}, "background")).toBe(false); + }); + + it("round-trips a dxf carrying every residue kind at once (font+color, fill+patternFill+bgColor, numFmt, alignment, border, protection) back through DxfTable.intern", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1", dxfId: "0" }), + [ + el("dxf", {}, [ + el("font", {}, [el("b"), el("color", { rgb: "FFFF0000" })]), + el("numFmt", { numFmtId: "1", formatCode: "0.00" }), + el("fill", {}, [ + el("patternFill", { patternType: "solid" }, [ + el("fgColor", { rgb: "FF00FF00" }), + el("bgColor", { rgb: "FF0000FF" }), + ]), + ]), + el("alignment", { horizontal: "center" }), + el("border", {}, [el("left", { style: "thin" })]), + el("protection", { locked: "0" }), + ]), + ], + ); + const style = + formats[0]?.type === "containsBlanks" ? formats[0].style : undefined; + if (style === undefined) { + throw new Error("expected a style"); + } + const dxfTable = new DxfTable(); + dxfTable.intern(style); + const rebuilt = dxfTable.dxfElements()[0]; + if (rebuilt === undefined) { + throw new Error("expected a rebuilt dxf element"); + } + const tags = rebuilt.children + .filter((c) => c.type === "element") + .map((c) => c.tag); + expect(tags).toEqual([ + "font", + "numFmt", + "fill", + "alignment", + "border", + "protection", + ]); + const font = childrenWithTag(rebuilt, "font")[0]; + expect(childrenWithTag(font ?? el("x"), "b")).toHaveLength(1); + expect( + childrenWithTag(font ?? el("x"), "color")[0]?.attributes.find( + (a) => a.name === "rgb", + )?.value, + ).toBe("FFff0000"); + const fill = childrenWithTag(rebuilt, "fill")[0]; + const patternFill = childrenWithTag(fill ?? el("x"), "patternFill")[0]; + expect( + childrenWithTag(patternFill ?? el("x"), "fgColor")[0]?.attributes.find( + (a) => a.name === "rgb", + )?.value, + ).toBe("FF00FF00"); + expect( + childrenWithTag(patternFill ?? el("x"), "bgColor")[0]?.attributes.find( + (a) => a.name === "rgb", + )?.value, + ).toBe("FF0000ff"); }); it("resolves style from an out-of-range dxfId as no style at all, rather than throwing", () => { @@ -511,6 +690,44 @@ describe("buildCfRuleElement: cellIs formula/formula2 elements", () => { }); }); +describe("buildCfRuleElement: residualAttributesFor's own expectedTag gate", () => { + it("restores an unmanaged residual attribute (a real one this schema does not model) back onto the built cfRule", () => { + const rule = firstCfRule( + buildOneRule({ + type: "containsBlanks", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + source: { + format: "xlsx", + xml: '', + }, + }).conditionalFormatting, + ); + expect(rule.attributes.find((a) => a.name === "pivot")?.value).toBe("1"); + }); +}); + +describe("rangeSetKey: distinguishes ranges by the separator between fields, not just concatenation", () => { + it("groups a single 10:0-1:1 range separately from two adjacent 1:0-1:1/0:1-1:1 ranges, even though naive concatenation without a separator would collide", () => { + const elements = buildConditionalFormattingElements( + [ + { + type: "containsBlanks", + ranges: [{ startRow: 10, startColumn: 0, endRow: 1, endColumn: 1 }], + }, + { + type: "containsErrors", + ranges: [ + { startRow: 1, startColumn: 0, endRow: 1, endColumn: 1 }, + { startRow: 0, startColumn: 1, endRow: 1, endColumn: 1 }, + ], + }, + ], + new DxfTable(), + ); + expect(elements).toHaveLength(2); + }); +}); + describe("buildCfRuleElement: top10's percent/bottom attribute presence", () => { it("writes bottom='1' only when bottom is true, and omits it entirely otherwise", () => { const withBottom = firstCfRule( @@ -537,7 +754,7 @@ describe("buildCfRuleElement: top10's percent/bottom attribute presence", () => ); }); - it("writes percent='1' only when percent is true", () => { + it("writes percent='true' only when percent is true, and omits it entirely otherwise", () => { const withPercent = firstCfRule( buildOneRule({ type: "top10", @@ -549,6 +766,16 @@ describe("buildCfRuleElement: top10's percent/bottom attribute presence", () => expect( withPercent.attributes.find((a) => a.name === "percent")?.value, ).toBe("true"); + const withoutPercent = firstCfRule( + buildOneRule({ + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 5, + }).conditionalFormatting, + ); + expect(withoutPercent.attributes.some((a) => a.name === "percent")).toBe( + false, + ); }); }); @@ -564,9 +791,18 @@ describe("buildCfRuleElement: aboveAverage's own three independent flags", () => expect(rule.attributes.find((a) => a.name === "aboveAverage")?.value).toBe( "false", ); + const defaultRule = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }).conditionalFormatting, + ); + expect(defaultRule.attributes.some((a) => a.name === "aboveAverage")).toBe( + false, + ); }); - it("writes equalAverage='1' only when equalAverage is explicitly true", () => { + it("writes equalAverage='true' only when equalAverage is explicitly true, and omits it otherwise", () => { const rule = firstCfRule( buildOneRule({ type: "aboveAverage", @@ -577,9 +813,18 @@ describe("buildCfRuleElement: aboveAverage's own three independent flags", () => expect(rule.attributes.find((a) => a.name === "equalAverage")?.value).toBe( "true", ); + const withoutEqualAverage = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }).conditionalFormatting, + ); + expect( + withoutEqualAverage.attributes.some((a) => a.name === "equalAverage"), + ).toBe(false); }); - it("writes stdDev only when it is genuinely present", () => { + it("writes stdDev only when it is genuinely present, never a phantom stdDev attribute", () => { const rule = firstCfRule( buildOneRule({ type: "aboveAverage", @@ -588,6 +833,15 @@ describe("buildCfRuleElement: aboveAverage's own three independent flags", () => }).conditionalFormatting, ); expect(rule.attributes.find((a) => a.name === "stdDev")?.value).toBe("2"); + const withoutStdDev = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }).conditionalFormatting, + ); + expect(withoutStdDev.attributes.some((a) => a.name === "stdDev")).toBe( + false, + ); }); }); @@ -633,6 +887,19 @@ describe("buildCfRuleElement: colorScale/dataBar/iconSet element shape", () => { expect(dataBar?.attributes.find((a) => a.name === "showValue")?.value).toBe( "false", ); + const withoutShowValue = firstCfRule( + buildOneRule({ + type: "dataBar", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + min: { type: "min" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + }).conditionalFormatting, + ); + const defaultDataBar = childrenWithTag(withoutShowValue, "dataBar")[0]; + expect(defaultDataBar?.attributes.some((a) => a.name === "showValue")).toBe( + false, + ); }); it("writes iconSet's iconSet attribute only for a non-default iconSetType", () => { @@ -681,6 +948,23 @@ describe("buildCfRuleElement: colorScale/dataBar/iconSet element shape", () => { expect(iconSet?.attributes.find((a) => a.name === "showValue")?.value).toBe( "false", ); + const withoutFlags = childrenWithTag( + firstCfRule( + buildOneRule({ + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + iconSetType: "3TrafficLights1", + thresholds: [{ type: "percent", value: "33" }], + }).conditionalFormatting, + ), + "iconSet", + )[0]; + expect(withoutFlags?.attributes.some((a) => a.name === "reverse")).toBe( + false, + ); + expect(withoutFlags?.attributes.some((a) => a.name === "showValue")).toBe( + false, + ); }); }); From 0b60dfc9c01fae9efaafb3a2c58b5382c9113d18 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 06:18:47 +0100 Subject: [PATCH 24/63] test(ooxml.js): cover pptx read's slide-size fallback, alignment, and underline/strike tokens Adds a minimal, layout/master-free slide package builder for isolating a single shape's own paragraph/run properties, and uses it to cover: the widescreen-default fallback when p:sldSz carries no cx (previously untested against a real, differently-sized sldSz, so the default and a genuine explicit size were indistinguishable), every algn token (l/ctr/r/just/justLow, plus an unrecognised token falling through to no alignment), and the exact none/noStrike tokens for underline and strikethrough alongside their positive and absent-attribute cases. --- packages/ooxml.js/src/typed/pptx/read.test.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/packages/ooxml.js/src/typed/pptx/read.test.ts b/packages/ooxml.js/src/typed/pptx/read.test.ts index 8756c00f43..88d0ca77b2 100644 --- a/packages/ooxml.js/src/typed/pptx/read.test.ts +++ b/packages/ooxml.js/src/typed/pptx/read.test.ts @@ -2083,3 +2083,143 @@ describe("readPptxContent: paragraph outline levels", () => { ]); }); }); + +// A single-slide deck with no layout/master/theme at all -- readSlide tolerates a slide whose own relationships name no slideLayout, simply resolving no cascade/geometry inheritance, so these minimal packages isolate one shape's own paragraph/run/table-cell properties without needing the full cascade chain buildFixturePackage sets up. +function minimalSlidePackage(shapes: ReturnType[]): Package { + const slide = el("p:sld", {}, [ + el("p:cSld", {}, [el("p:spTree", {}, shapes)]), + ]); + const presentation = el("p:presentation", {}, [ + el("p:sldIdLst", {}, [el("p:sldId", { id: "256", "r:id": "rIdSlide1" })]), + el("p:sldSz", { cx: "9144000", cy: "6858000" }), + ]); + const presentationRels = rels([ + { id: "rIdSlide1", type: SLIDE_REL, target: "slides/slide1.xml" }, + ]); + return { + parts: { + "ppt/presentation.xml": { kind: "xml", nodes: [presentation] }, + "ppt/_rels/presentation.xml.rels": { + kind: "xml", + nodes: [presentationRels], + }, + "ppt/slides/slide1.xml": { kind: "xml", nodes: [slide] }, + "ppt/slides/_rels/slide1.xml.rels": { + kind: "xml", + nodes: [rels([])], + }, + }, + }; +} + +function firstShapeParagraph( + shapes: ReturnType[], +): ContentParagraph { + const doc = readPptxContent(minimalSlidePackage(shapes)); + return asParagraph(doc.slides[0]?.shapes[0]?.blocks[0]); +} + +function textShape(paragraph: ReturnType): ReturnType { + return el("p:sp", {}, [ + el("p:nvSpPr", {}, [ + el("p:cNvPr", { id: "2", name: "Shape 1" }), + el("p:cNvSpPr"), + el("p:nvPr"), + ]), + // An explicit xfrm, not inherited placeholder geometry: this minimal package has no layout/master chain for resolveShapeFrame to inherit from, so a shape with no own frame at all resolves to no frame and is dropped from the slide entirely. + el("p:spPr", {}, [ + el("a:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { cx: "914400", cy: "914400" }), + ]), + ]), + el("p:txBody", {}, [paragraph]), + ]); +} + +describe("readPptxContent: slide size falls back to the widescreen default when cx/cy is missing", () => { + it("reads the widescreen default (960x540pt), not the real sldSz value, when p:sldSz carries no cx", () => { + const pkg = minimalSlidePackage([ + textShape(el("a:p", {}, [el("a:r", {}, [el("a:t", {}, [txt("x")])])])), + ]); + // Overwrite the presentation part with one whose sldSz has no cx, after construction, to isolate exactly this one field -- a real cx of 9144000 EMU (720pt) would be observably different from the 960pt default this missing-cx case must fall back to. + const presentation = el("p:presentation", {}, [ + el("p:sldIdLst", {}, [el("p:sldId", { id: "256", "r:id": "rIdSlide1" })]), + el("p:sldSz", { cy: "6858000" }), + ]); + pkg.parts["ppt/presentation.xml"] = { kind: "xml", nodes: [presentation] }; + const result = readPptxContent(pkg); + expect(result.slides[0]?.size).toEqual({ widthPt: 960, heightPt: 540 }); + }); +}); + +describe("readPptxContent: paragraph alignment, every token distinctly", () => { + function alignmentOf(algn: string): string | undefined { + const para = firstShapeParagraph([ + textShape( + el("a:p", {}, [ + el("a:pPr", { algn }), + el("a:r", {}, [el("a:t", {}, [txt("x")])]), + ]), + ), + ]); + return para.alignment; + } + + it('reads algn="l" as "left"', () => { + expect(alignmentOf("l")).toBe("left"); + }); + + it('reads algn="ctr" as "center"', () => { + expect(alignmentOf("ctr")).toBe("center"); + }); + + it('reads algn="r" as "right"', () => { + expect(alignmentOf("r")).toBe("right"); + }); + + it('reads algn="just" as "justify"', () => { + expect(alignmentOf("just")).toBe("justify"); + }); + + it('reads algn="justLow" as "justify" too', () => { + expect(alignmentOf("justLow")).toBe("justify"); + }); + + it("reads no alignment at all for an unrecognised token", () => { + expect(alignmentOf("dist")).toBeUndefined(); + }); +}); + +describe("readPptxContent: run underline/strikethrough exact val tokens", () => { + function runProps(rPrAttrs: Record) { + const para = firstShapeParagraph([ + textShape( + el("a:p", {}, [ + el("a:r", {}, [el("a:rPr", rPrAttrs), el("a:t", {}, [txt("x")])]), + ]), + ), + ]); + return para.runs[0]; + } + + it('reads u="none" as underline: false, not true', () => { + expect(runProps({ u: "none" })?.underline).toBe(false); + }); + + it("reads no u attribute at all as underline: undefined", () => { + expect(runProps({})?.underline).toBeUndefined(); + }); + + it('reads u="sng" as underline: true', () => { + expect(runProps({ u: "sng" })?.underline).toBe(true); + }); + + it('reads strike="noStrike" as strike: false, not true', () => { + expect(runProps({ strike: "noStrike" })?.strike).toBe(false); + }); + + it("reads no strike attribute at all as strike: undefined", () => { + expect(runProps({})?.strike).toBeUndefined(); + }); +}); From 31838ab03b4d9d62691152a5535376789b74e292 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 06:50:40 +0100 Subject: [PATCH 25/63] docs(ooxml.js): raise the mutation break threshold to the re-measured floor Re-measures the package-wide score after closing the mutation gaps in content.ts, styles.ts, drawings-write.ts (now a genuine 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, up from the original 64.55% baseline the threshold of 63 reflected. States plainly which modules remain the real next targets (xlsx/build.ts, docx/write.ts, docx/read.ts), so the number reads as a measured floor rather than a ceiling. --- packages/ooxml.js/stryker.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index 10fa317f4e..d0560cd5a9 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // First CI-measured baseline: 64.55% of 6823 valid mutants, timeout share 0.4% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. - breakThreshold: 63, + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. The package's three largest modules (xlsx/build.ts, docx/write.ts, docx/read.ts) remain well short of 100% and are the next real targets for closing this gap further; this threshold reflects the genuinely measured floor today, not a ceiling to stop at. + breakThreshold: 83, }); From c8191e588c1163e6f73a7600ef1821ca260ed94b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 07:56:28 +0100 Subject: [PATCH 26/63] test(ooxml.js): cover build.ts's exact package-scaffolding output Adds direct assertions for the XML declaration prolog, every [Content_Types].xml Override across a document exercising comments, images, charts, and tables, the fixed _rels/.rels and xl/_rels/workbook.xml.rels relationships, xl/workbook.xml's sheetId and r:id numbering, and xl/sharedStrings.xml's count/uniqueCount and xml:space attribute. Closes the Print_Titles derivation gap where repeatRows and repeatColumns were always set together, so mutating the || between them to && never changed the observable output; adds cases with each set alone and with neither set. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 414 ++++++++++++++++++ 1 file changed, 414 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 4915a1c8f1..a9511966e7 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -1957,3 +1957,417 @@ describe("buildXlsxPackageFromContent: the definitions option (Table objects) an expect(Object.keys(pkg.parts)).not.toContain("xl/tables/table1.xml"); }); }); + +// --- exact scaffolding: the XML declaration, [Content_Types].xml, package/workbook relationships ----------------- + +describe("buildXlsxPackageFromContent: every XML part carries the same declaration prolog", () => { + it('declares version="1.0" encoding="UTF-8" standalone="yes" on the [Content_Types].xml part', () => { + const part = buildXlsxPackageFromContent(singleSheetDocument([])).parts[ + "[Content_Types].xml" + ]; + if (part?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const declaration = part.nodes[0]; + if (declaration?.type !== "declaration") { + throw new Error("expected a declaration node first"); + } + const attrOf = (name: string): string | undefined => + declaration.attributes.find((a) => a.name === name)?.value; + expect(attrOf("version")).toBe("1.0"); + expect(attrOf("encoding")).toBe("UTF-8"); + expect(attrOf("standalone")).toBe("yes"); + }); +}); + +describe("buildXlsxPackageFromContent: [Content_Types].xml carries every part's exact Override, for a document exercising every content kind", () => { + function fullDocument(): ContentDocument { + const chart = chartEmbeddedObject(); + return { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + comment: { text: "note" }, + }, + ], + columns: [], + rows: [], + images: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + embeddedObjects: [chart], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + { + name: "Sheet2", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }; + } + + it("writes the fixed workbook/styles/sharedStrings overrides, one worksheet override per sheet, and the media/comments/drawing/chart/table overrides for the parts a fuller document actually carries", () => { + const pkg = buildXlsxPackageFromContent(fullDocument(), { + definitions: tableDefinitions(), + }); + const contentTypes = rootElement(pkg.parts["[Content_Types].xml"]); + if (contentTypes === undefined) { + throw new Error("expected [Content_Types].xml to have a root element"); + } + const defaults = childrenWithTag(contentTypes, "Default").map((el) => ({ + extension: attr(el, "Extension"), + contentType: attr(el, "ContentType"), + })); + expect(defaults).toContainEqual({ + extension: "rels", + contentType: "application/vnd.openxmlformats-package.relationships+xml", + }); + expect(defaults).toContainEqual({ + extension: "xml", + contentType: "application/xml", + }); + expect(defaults).toContainEqual({ + extension: "png", + contentType: "image/png", + }); + + const overrides = childrenWithTag(contentTypes, "Override").map((el) => ({ + partName: attr(el, "PartName"), + contentType: attr(el, "ContentType"), + })); + expect(overrides).toContainEqual({ + partName: "/xl/workbook.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", + }); + expect(overrides).toContainEqual({ + partName: "/xl/styles.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml", + }); + expect(overrides).toContainEqual({ + partName: "/xl/sharedStrings.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml", + }); + // One worksheet override per sheet, not one fewer or one more. + expect(overrides).toContainEqual({ + partName: "/xl/worksheets/sheet1.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", + }); + expect(overrides).toContainEqual({ + partName: "/xl/worksheets/sheet2.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", + }); + expect( + overrides.filter((o) => o.partName?.startsWith("/xl/worksheets/sheet")), + ).toHaveLength(2); + // Only sheet 1 carries a comment, a drawing, and a table -- indices must not leak onto sheet 2. + expect(overrides).toContainEqual({ + partName: "/xl/threadedComments/threadedComment1.xml", + contentType: "application/vnd.ms-excel.threadedcomments+xml", + }); + expect(overrides).not.toContainEqual( + expect.objectContaining({ + partName: "/xl/threadedComments/threadedComment2.xml", + }), + ); + expect(overrides).toContainEqual({ + partName: "/xl/drawings/drawing1.xml", + contentType: "application/vnd.openxmlformats-officedocument.drawing+xml", + }); + expect(overrides).not.toContainEqual( + expect.objectContaining({ partName: "/xl/drawings/drawing2.xml" }), + ); + expect(overrides).toContainEqual({ + partName: "/xl/charts/chart1.xml", + contentType: + "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", + }); + expect(overrides).toContainEqual({ + partName: "/xl/tables/table1.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml", + }); + expect(overrides).toContainEqual({ + partName: "/docProps/core.xml", + contentType: "application/vnd.openxmlformats-package.core-properties+xml", + }); + expect(overrides).toContainEqual({ + partName: "/docProps/app.xml", + contentType: + "application/vnd.openxmlformats-officedocument.extended-properties+xml", + }); + }); + + it("declares no jpeg/gif media default when only a png is actually used", () => { + const pkg = buildXlsxPackageFromContent(fullDocument()); + const contentTypes = rootElement(pkg.parts["[Content_Types].xml"]); + if (contentTypes === undefined) { + throw new Error("expected [Content_Types].xml to have a root element"); + } + const extensions = childrenWithTag(contentTypes, "Default").map((el) => + attr(el, "Extension"), + ); + expect(extensions).not.toContain("jpeg"); + expect(extensions).not.toContain("gif"); + }); +}); + +describe("buildXlsxPackageFromContent: _rels/.rels carries exactly the three fixed package relationships", () => { + it("writes rId1/rId2/rId3 pointing at the workbook, core properties, and extended properties, in that order", () => { + const pkg = buildXlsxPackageFromContent(singleSheetDocument([])); + const rels = rootElement(pkg.parts["_rels/.rels"]); + if (rels === undefined) { + throw new Error("expected _rels/.rels to have a root element"); + } + const relationships = childrenWithTag(rels, "Relationship").map((el) => ({ + id: attr(el, "Id"), + type: attr(el, "Type"), + target: attr(el, "Target"), + })); + expect(relationships).toEqual([ + { + id: "rId1", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", + target: "xl/workbook.xml", + }, + { + id: "rId2", + type: "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + target: "docProps/core.xml", + }, + { + id: "rId3", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", + target: "docProps/app.xml", + }, + ]); + }); +}); + +describe("buildXlsxPackageFromContent: xl/_rels/workbook.xml.rels numbers worksheet relationships before styles/sharedStrings, exactly one id past the sheet count", () => { + it("writes one worksheet relationship per sheet (rId1..rIdN), then styles at rId(N+1) and sharedStrings at rId(N+2), for a 2-sheet workbook", () => { + const pkg = buildXlsxPackageFromContent(DOCUMENT); + const rels = rootElement(pkg.parts["xl/_rels/workbook.xml.rels"]); + if (rels === undefined) { + throw new Error( + "expected xl/_rels/workbook.xml.rels to have a root element", + ); + } + const relationships = childrenWithTag(rels, "Relationship").map((el) => ({ + id: attr(el, "Id"), + type: attr(el, "Type"), + target: attr(el, "Target"), + })); + expect(relationships).toEqual([ + { + id: "rId1", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + target: "worksheets/sheet1.xml", + }, + { + id: "rId2", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + target: "worksheets/sheet2.xml", + }, + { + id: "rId3", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + target: "styles.xml", + }, + { + id: "rId4", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings", + target: "sharedStrings.xml", + }, + ]); + }); + + it("writes exactly one worksheet relationship, at rId1, for a single-sheet workbook -- proving the loop runs sheetCount times, not one more or fewer", () => { + const pkg = buildXlsxPackageFromContent(singleSheetDocument([])); + const rels = rootElement(pkg.parts["xl/_rels/workbook.xml.rels"]); + if (rels === undefined) { + throw new Error( + "expected xl/_rels/workbook.xml.rels to have a root element", + ); + } + const worksheetRels = childrenWithTag(rels, "Relationship").filter( + (el) => + attr(el, "Type") === + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + ); + expect(worksheetRels).toHaveLength(1); + expect(attr(worksheetRels[0], "Id")).toBe("rId1"); + }); +}); + +describe("buildXlsxPackageFromContent: xl/workbook.xml sheet elements carry the correct sheetId and r:id per index", () => { + it("numbers sheetId from 1 and r:id via worksheetRelId, matching the sheet's own position, for a 2-sheet workbook", () => { + const pkg = buildXlsxPackageFromContent(DOCUMENT); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + expect(attr(workbook, "xmlns:r")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + ); + const sheetsEl = requireChild(workbook, "sheets"); + const sheetElements = elementsOf(sheetsEl, "sheet").map((el) => ({ + name: attributeOf(el, "name"), + sheetId: attributeOf(el, "sheetId"), + rId: attributeOf(el, "r:id"), + })); + expect(sheetElements).toEqual([ + { name: "Data", sheetId: "1", rId: "rId1" }, + { name: "Summary", sheetId: "2", rId: "rId2" }, + ]); + }); +}); + +describe("buildXlsxPackageFromContent: derives _xlnm.Print_Titles from EITHER repeatRows or repeatColumns alone, not only when both are present", () => { + function documentWithRepeat( + repeat: Partial< + Pick + >, + ): ContentDocument { + return { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { ...DEFAULT_PRINT_SETTINGS, ...repeat }, + }, + ], + }; + } + + it("derives Print_Titles from repeatRows alone, with no repeatColumns set", () => { + const pkg = buildXlsxPackageFromContent( + documentWithRepeat({ repeatRows: { start: 0, end: 1 } }), + ); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + const definedNames = requireChild(workbook, "definedNames"); + const printTitles = elementsOf(definedNames, "definedName").find( + (el) => attributeOf(el, "name") === "_xlnm.Print_Titles", + ); + expect(printTitles).toBeDefined(); + }); + + it("derives Print_Titles from repeatColumns alone, with no repeatRows set", () => { + const pkg = buildXlsxPackageFromContent( + documentWithRepeat({ repeatColumns: { start: 0, end: 1 } }), + ); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + const definedNames = requireChild(workbook, "definedNames"); + const printTitles = elementsOf(definedNames, "definedName").find( + (el) => attributeOf(el, "name") === "_xlnm.Print_Titles", + ); + expect(printTitles).toBeDefined(); + }); + + it("derives no Print_Titles at all when neither repeatRows nor repeatColumns is set", () => { + const pkg = buildXlsxPackageFromContent(documentWithRepeat({})); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + expect(childrenWithTag(workbook, "definedNames")).toHaveLength(0); + }); + + it("does not duplicate Print_Titles when the names array already carries it verbatim for that sheet", () => { + const wide = documentWithRepeat({ repeatRows: { start: 0, end: 1 } }); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + wide.names = [ + { + name: "_xlnm.Print_Titles", + refersTo: "Sheet1!$1:$1", + scopeSheetIndex: 0, + }, + ]; + const pkg = buildXlsxPackageFromContent(wide); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + const definedNames = requireChild(workbook, "definedNames"); + const printTitlesEntries = elementsOf(definedNames, "definedName").filter( + (el) => attributeOf(el, "name") === "_xlnm.Print_Titles", + ); + expect(printTitlesEntries).toHaveLength(1); + expect(textContent(printTitlesEntries[0]!)).toBe("Sheet1!$1:$1"); + }); +}); + +describe("buildXlsxPackageFromContent: xl/sharedStrings.xml carries the exact count/uniqueCount and per-entry xml:space", () => { + it('writes count and uniqueCount equal to the number of distinct strings, and xml:space="preserve" on every ', () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "Alpha" }, + displayText: "Alpha", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "Beta" }, + displayText: "Beta", + }, + ]), + ); + const sharedStrings = rootElement(pkg.parts["xl/sharedStrings.xml"]); + if (sharedStrings === undefined) { + throw new Error("expected xl/sharedStrings.xml to have a root element"); + } + expect(attr(sharedStrings, "count")).toBe("2"); + expect(attr(sharedStrings, "uniqueCount")).toBe("2"); + const tElements = childrenWithTag(sharedStrings, "si").map( + (si) => childrenWithTag(si, "t")[0], + ); + for (const t of tElements) { + expect(t === undefined ? undefined : attr(t, "xml:space")).toBe( + "preserve", + ); + } + expect(textContent(childrenWithTag(sharedStrings, "si")[0]!)).toBe("Alpha"); + }); +}); From 2a01e63c252069eb5f20e0b4645082bbba06943c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 07:58:30 +0100 Subject: [PATCH 27/63] test(ooxml.js): cover build.ts's styles-part and docProps output exactly Asserts the exact fixed scaffolding buildStylesPart writes for a document needing no number formats: the single default font, the two reserved fills, the one reserved border, and the default cellStyleXfs/cellXfs/cellStyles entries, none apply*-flagged. Adds a font carrying bold, italic, strike, and underline together, proving each toggle writes its own element independently; a border with only its top edge set, proving the per-edge branch runs independently for each of the four edges rather than uniformly; a cell with verticalAlignment 'top', the one branch neither 'middle' nor the default omission exercises; and pattern fills with only a foreground or only a background colour. Asserts every docProps/core.xml and docProps/app.xml field, including subject, modifiedIso, and creator, and the case where metadata carries none of them and keywords is an empty array. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index a9511966e7..737a0c7ee5 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -812,6 +812,334 @@ describe("buildXlsxPackageFromContent: a workbook needing no number formats writ ), ).toEqual([undefined]); }); + + it("writes the exact fixed scaffolding: one default font, the two reserved fills, the one reserved border, and a single default cellStyleXfs/cellXfs/cellStyles entry, none of them apply*-flagged", () => { + const styles = styleSheetOf(pkg); + + const fontsEl = requireChild(styles, "fonts"); + expect(attributeOf(fontsEl, "count")).toBe("1"); + const fonts = elementsOf(fontsEl, "font"); + expect(fonts).toHaveLength(1); + const defaultFont = fonts[0]; + if (defaultFont === undefined) { + throw new Error("expected a default "); + } + expect(attributeOf(requireChild(defaultFont, "sz"), "val")).toBe("11"); + expect(attributeOf(requireChild(defaultFont, "name"), "val")).toBe( + "Calibri", + ); + expect(elementsOf(defaultFont, "color")).toHaveLength(0); + expect(elementsOf(defaultFont, "b")).toHaveLength(0); + expect(elementsOf(defaultFont, "i")).toHaveLength(0); + expect(elementsOf(defaultFont, "strike")).toHaveLength(0); + expect(elementsOf(defaultFont, "u")).toHaveLength(0); + + const fillsEl = requireChild(styles, "fills"); + expect(attributeOf(fillsEl, "count")).toBe("2"); + const fills = elementsOf(fillsEl, "fill"); + expect( + fills.map((fill) => + attributeOf(requireChild(fill, "patternFill"), "patternType"), + ), + ).toEqual(["none", "gray125"]); + + const bordersEl = requireChild(styles, "borders"); + expect(attributeOf(bordersEl, "count")).toBe("1"); + const borders = elementsOf(bordersEl, "border"); + expect(borders).toHaveLength(1); + const reserved = borders[0]; + if (reserved === undefined) { + throw new Error("expected the reserved "); + } + expect(reserved.tag).toBe("border"); + for (const edge of ["left", "right", "top", "bottom", "diagonal"]) { + const edgeEl = requireChild(reserved, edge); + expect(attributeOf(edgeEl, "style")).toBeUndefined(); + expect(elementsOf(edgeEl, "color")).toHaveLength(0); + } + + const cellStyleXfsEl = requireChild(styles, "cellStyleXfs"); + expect(attributeOf(cellStyleXfsEl, "count")).toBe("1"); + const cellStyleXf = elementsOf(cellStyleXfsEl, "xf")[0]; + if (cellStyleXf === undefined) { + throw new Error("expected a inside "); + } + expect(attributeOf(cellStyleXf, "numFmtId")).toBe("0"); + expect(attributeOf(cellStyleXf, "fontId")).toBe("0"); + expect(attributeOf(cellStyleXf, "fillId")).toBe("0"); + expect(attributeOf(cellStyleXf, "borderId")).toBe("0"); + + const cellXfs = requireChild(styles, "cellXfs"); + const xf = elementsOf(cellXfs, "xf")[0]; + if (xf === undefined) { + throw new Error("expected the default "); + } + expect(attributeOf(xf, "fontId")).toBe("0"); + expect(attributeOf(xf, "fillId")).toBe("0"); + expect(attributeOf(xf, "borderId")).toBe("0"); + expect(attributeOf(xf, "xfId")).toBe("0"); + for (const flag of [ + "applyFont", + "applyFill", + "applyBorder", + "applyAlignment", + ]) { + expect(xf.attributes.map((a) => a.name)).not.toContain(flag); + } + + const cellStylesEl = requireChild(styles, "cellStyles"); + expect(attributeOf(cellStylesEl, "count")).toBe("1"); + const cellStyle = elementsOf(cellStylesEl, "cellStyle")[0]; + if (cellStyle === undefined) { + throw new Error("expected a "); + } + expect(attributeOf(cellStyle, "name")).toBe("Normal"); + expect(attributeOf(cellStyle, "xfId")).toBe("0"); + expect(attributeOf(cellStyle, "builtinId")).toBe("0"); + + expect(childrenWithTag(styles, "dxfs")).toHaveLength(0); + expect(styles.tag).toBe("styleSheet"); + expect(attr(styles, "xmlns")).toBe( + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + ); + }); + + it("writes numFmts with the exact declared numFmtId/formatCode and count, for a document needing a custom format", () => { + const withCustomFormat = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "boolean", value: true }, + displayText: "TRUE", + }, + ]), + ); + const styles = styleSheetOf(withCustomFormat); + const numFmts = requireChild(styles, "numFmts"); + expect(attributeOf(numFmts, "count")).toBe("1"); + const declared = elementsOf(numFmts, "numFmt"); + expect(declared).toHaveLength(1); + expect(attributeOf(declared[0]!, "numFmtId")).toBe("164"); + }); +}); + +describe("buildXlsxPackageFromContent: xl/styles.xml carries every font toggle, per-edge border mixing, and a one-sided pattern fill exactly", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + // A font using EVERY toggle at once, to prove each one writes its own element independently of the others. + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + font: { bold: true, italic: true, strike: true, underline: true }, + }, + // A border carrying only its top edge, so left/right/bottom must fall back to the bare, style-less branch while top alone carries real data. + { + row: 1, + column: 0, + value: { kind: "string", value: "y" }, + displayText: "y", + borders: { top: { color: { r: 0, g: 1, b: 0 }, widthPt: 1.5 } }, + }, + // A cell whose alignment.vertical is 'top', the one branch neither 'middle' nor the default omission exercises. + { + row: 2, + column: 0, + value: { kind: "string", value: "z" }, + displayText: "z", + alignment: "left", + verticalAlignment: "top", + }, + // A pattern fill with only its foreground colour set. + { + row: 3, + column: 0, + value: { kind: "string", value: "fg" }, + displayText: "fg", + background: { + kind: "pattern", + patternType: "lightGray", + foregroundColor: { r: 1, g: 0, b: 1 }, + }, + }, + // A pattern fill with only its background colour set. + { + row: 4, + column: 0, + value: { kind: "string", value: "bg" }, + displayText: "bg", + background: { + kind: "pattern", + patternType: "lightGray", + backgroundColor: { r: 0, g: 1, b: 1 }, + }, + }, + ]), + ); + const styles = styleSheetOf(pkg); + + it("writes bold/italic/strike/underline as four independent elements on the same ", () => { + const font = elementsOf(requireChild(styles, "fonts"), "font")[1]; + if (font === undefined) { + throw new Error("expected the all-toggles at index 1"); + } + expect(elementsOf(font, "b")).toHaveLength(1); + expect(elementsOf(font, "i")).toHaveLength(1); + expect(elementsOf(font, "strike")).toHaveLength(1); + const underline = elementsOf(font, "u")[0]; + expect(underline).toBeDefined(); + expect(attributeOf(underline!, "val")).toBe("single"); + }); + + it("writes only the top edge with real style/colour data, leaving left/right/bottom bare and the diagonal always empty", () => { + const border = elementsOf(requireChild(styles, "borders"), "border")[1]; + if (border === undefined) { + throw new Error("expected the top-only at index 1"); + } + expect(border.tag).toBe("border"); + const top = requireChild(border, "top"); + expect(attributeOf(top, "style")).toBe("medium"); + expect(attributeOf(requireChild(top, "color"), "rgb")).toBe("FF00ff00"); + for (const edge of ["left", "right", "bottom"]) { + const edgeEl = requireChild(border, edge); + expect(attributeOf(edgeEl, "style")).toBeUndefined(); + expect(elementsOf(edgeEl, "color")).toHaveLength(0); + } + expect(elementsOf(requireChild(border, "diagonal"), "color")).toHaveLength( + 0, + ); + }); + + it("writes verticalAlignment 'top' as alignment vertical=\"top\", distinct from 'middle' and the default omission", () => { + const cellXfs = requireChild(styles, "cellXfs"); + const topStyleIndex = attributeOf(writtenCell(pkg, "A3"), "s"); + const xf = elementsOf(cellXfs, "xf")[Number(topStyleIndex)]; + if (xf === undefined) { + throw new Error("expected an for the top-aligned cell"); + } + const alignment = requireChild(xf, "alignment"); + expect(attributeOf(alignment, "vertical")).toBe("top"); + }); + + it("writes a foreground-only pattern fill with fgColor and no bgColor", () => { + const fills = elementsOf(requireChild(styles, "fills"), "fill"); + const fgOnly = fills.find((fill) => { + const patternFill = childElement(fill, "patternFill"); + return ( + patternFill !== undefined && + attributeOf(patternFill, "patternType") === "lightGray" && + elementsOf(patternFill, "fgColor").length > 0 && + elementsOf(patternFill, "bgColor").length === 0 + ); + }); + expect(fgOnly).toBeDefined(); + const patternFill = requireChild(fgOnly!, "patternFill"); + expect(attributeOf(requireChild(patternFill, "fgColor"), "rgb")).toBe( + "FFff00ff", + ); + }); + + it("writes a background-only pattern fill with bgColor and no fgColor", () => { + const fills = elementsOf(requireChild(styles, "fills"), "fill"); + const bgOnly = fills.find((fill) => { + const patternFill = childElement(fill, "patternFill"); + return ( + patternFill !== undefined && + attributeOf(patternFill, "patternType") === "lightGray" && + elementsOf(patternFill, "bgColor").length > 0 && + elementsOf(patternFill, "fgColor").length === 0 + ); + }); + expect(bgOnly).toBeDefined(); + const patternFill = requireChild(bgOnly!, "patternFill"); + expect(attributeOf(requireChild(patternFill, "bgColor"), "rgb")).toBe( + "FF00ffff", + ); + }); +}); + +describe("buildXlsxPackageFromContent: docProps/core.xml and docProps/app.xml carry every metadata field", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: { + title: "T", + author: "A", + subject: "S", + keywords: ["k1", "k2"], + creator: "C", + createdIso: "2026-01-01T00:00:00Z", + modifiedIso: "2026-02-02T00:00:00Z", + }, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + + it("writes every core-properties field, including subject and modified date, into docProps/core.xml with the correct namespaces", () => { + const core = rootElement(pkg.parts["docProps/core.xml"]); + if (core === undefined) { + throw new Error("expected docProps/core.xml to have a root element"); + } + expect(core.tag).toBe("cp:coreProperties"); + expect(attr(core, "xmlns:cp")).toBe( + "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + ); + expect(attr(core, "xmlns:dc")).toBe("http://purl.org/dc/elements/1.1/"); + expect(attr(core, "xmlns:dcterms")).toBe("http://purl.org/dc/terms/"); + expect(attr(core, "xmlns:xsi")).toBe( + "http://www.w3.org/2001/XMLSchema-instance", + ); + expect(textContent(requireChild(core, "dc:subject"))).toBe("S"); + const modified = requireChild(core, "dcterms:modified"); + expect(attr(modified, "xsi:type")).toBe("dcterms:W3CDTF"); + expect(textContent(modified)).toBe("2026-02-02T00:00:00Z"); + }); + + it("writes the creator into docProps/app.xml's ", () => { + const app = rootElement(pkg.parts["docProps/app.xml"]); + if (app === undefined) { + throw new Error("expected docProps/app.xml to have a root element"); + } + expect(app.tag).toBe("Properties"); + expect(textContent(requireChild(app, "Application"))).toBe("C"); + }); + + it("writes no dc:subject, no cp:keywords, and no at all when those fields are absent, keywords is an empty array", () => { + const bare = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: { keywords: [] }, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + const core = rootElement(bare.parts["docProps/core.xml"]); + if (core === undefined) { + throw new Error("expected docProps/core.xml to have a root element"); + } + expect(childrenWithTag(core, "dc:subject")).toHaveLength(0); + expect(childrenWithTag(core, "cp:keywords")).toHaveLength(0); + const app = rootElement(bare.parts["docProps/app.xml"]); + if (app === undefined) { + throw new Error("expected docProps/app.xml to have a root element"); + } + expect(childrenWithTag(app, "Application")).toHaveLength(0); + }); }); describe('buildXlsxPackageFromContent: a formula cell with a cached STRING result writes t="str" literally, never shared-string-indexed', () => { From bc9fa5cce39c19ffacc0c183af0a14d1b2120928 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:00:55 +0100 Subject: [PATCH 28/63] test(ooxml.js): cover build.ts's dimension, cols, row/cell assembly, and merge output exactly Adds cases where a sheet's dimension is extended solely by its columns array or solely by its rows array (no cells at all), and a case where a column/row entry reaches past the last cell, proving computeDimension takes the genuine max across all three sources rather than letting one silently dominate. Asserts buildColsElement writes hidden with no width attribute, and width/customWidth with no hidden attribute, as two independent column declarations rather than always pairing the two. Proves buildSheetDataElement sorts both rows and, within a row, cells into ascending order regardless of input order, and that a row with no matching ContentSheetRow entry carries only its own r attribute. Proves buildMergeCellsElement treats colSpan and rowSpan as independent merge triggers, and writes no at all when every cell's span is 1 or absent. Covers buildCellElement's decoration ternary for alignment-only cells, the exact / child order and missing t attribute for a formula's numeric result, and the no-formula case writing no at all; renderString's formula-result branch for an unparseable temporal value; and buildSheetPrElement's fitToPage reflecting whether the sheet actually declares fitToPages. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 737a0c7ee5..a0e735d08a 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -2699,3 +2699,392 @@ describe("buildXlsxPackageFromContent: xl/sharedStrings.xml carries the exact co expect(textContent(childrenWithTag(sharedStrings, "si")[0]!)).toBe("Alpha"); }); }); + +// --- computeDimension, buildColsElement, cell/row assembly --------------------------------------------------------- + +describe("computeDimension: each of cells, columns, and rows independently extends the dimension, never overwriting a larger extent with a smaller one", () => { + function sheetOf( + overrides: Partial>, + ): ContentDocument { + return { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + ...overrides, + }, + ], + }; + } + + function dimensionRefOf(pkg: Package): string | undefined { + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + return attr(requireChild(worksheet, "dimension"), "ref"); + } + + it("extends the dimension from columns alone, with no cells or rows, down to row 1 only", () => { + const pkg = buildXlsxPackageFromContent( + sheetOf({ columns: [{ index: 4 }] }), + ); + expect(dimensionRefOf(pkg)).toBe("A1:E1"); + }); + + it("extends the dimension from rows alone, with no cells or columns, out to column A only", () => { + const pkg = buildXlsxPackageFromContent(sheetOf({ rows: [{ index: 4 }] })); + expect(dimensionRefOf(pkg)).toBe("A1:A5"); + }); + + it("takes the larger of cells' and rows'/columns' own extents, not the smaller -- a column/row entry past the last cell still widens the dimension", () => { + const pkg = buildXlsxPackageFromContent( + sheetOf({ + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + ], + columns: [{ index: 9 }], + rows: [{ index: 9 }], + }), + ); + expect(dimensionRefOf(pkg)).toBe("A1:J10"); + }); +}); + +describe("buildColsElement: width and hidden are independent, either can be written alone", () => { + it("writes a hidden column with no width attribute at all, when only `hidden` is declared", () => { + const hiddenOnly = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [{ index: 0, hidden: true }], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + const worksheet = rootElement(hiddenOnly.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const col = requireChild(requireChild(worksheet, "cols"), "col"); + expect(attr(col, "hidden")).toBe("true"); + expect(attr(col, "width")).toBeUndefined(); + expect(attr(col, "customWidth")).toBeUndefined(); + expect(attr(col, "min")).toBe("1"); + expect(attr(col, "max")).toBe("1"); + }); + + it("writes a visible column with width/customWidth and no hidden attribute at all, when only `widthPt` is declared", () => { + const widthOnly = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [{ index: 2, widthPt: 80 }], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + const worksheet = rootElement(widthOnly.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const col = requireChild(requireChild(worksheet, "cols"), "col"); + expect(attr(col, "customWidth")).toBe("true"); + expect(attr(col, "hidden")).toBeUndefined(); + expect(attr(col, "min")).toBe("3"); + expect(attr(col, "max")).toBe("3"); + }); +}); + +describe("buildSheetDataElement: rows and cells are written in ascending order regardless of input order, and a row with no ContentSheetRow entry carries only its own r attribute", () => { + it("writes rows in ascending row-index order and, within a row, cells in ascending column order, even when supplied in reverse", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 5, + column: 2, + value: { kind: "string", value: "e" }, + displayText: "e", + }, + { + row: 2, + column: 0, + value: { kind: "string", value: "b" }, + displayText: "b", + }, + { + row: 2, + column: 3, + value: { kind: "string", value: "d" }, + displayText: "d", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "a" }, + displayText: "a", + }, + ]), + ); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const sheetData = requireChild(worksheet, "sheetData"); + const rows = elementsOf(sheetData, "row"); + expect(rows.map((row) => attr(row, "r"))).toEqual(["1", "3", "6"]); + const middleRow = rows[1]; + if (middleRow === undefined) { + throw new Error("expected the row at index 1 (row 3)"); + } + expect(elementsOf(middleRow, "c").map((cell) => attr(cell, "r"))).toEqual([ + "A3", + "D3", + ]); + }); + + it("writes a row's own r attribute alone, with no ht/customHeight/hidden, when the sheet declares no matching ContentSheetRow", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 3, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + ]), + ); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const row = requireChild(requireChild(worksheet, "sheetData"), "row"); + expect(attr(row, "r")).toBe("4"); + expect(attr(row, "ht")).toBeUndefined(); + expect(attr(row, "customHeight")).toBeUndefined(); + expect(attr(row, "hidden")).toBeUndefined(); + }); +}); + +describe("buildMergeCellsElement: colSpan and rowSpan trigger a merge independently of each other", () => { + function pkgWith(cells: ContentSheet["cells"]): Package { + return buildXlsxPackageFromContent(singleSheetDocument(cells)); + } + + it("treats colSpan alone (rowSpan defaulting to 1) as a merge", () => { + const pkg = pkgWith([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + colSpan: 3, + }, + ]); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const mergeCells = requireChild(worksheet, "mergeCells"); + expect(attr(mergeCells, "count")).toBe("1"); + const mergeCell = requireChild(mergeCells, "mergeCell"); + expect(attr(mergeCell, "ref")).toBe("A1:C1"); + }); + + it("treats rowSpan alone (colSpan defaulting to 1) as a merge", () => { + const pkg = pkgWith([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + rowSpan: 3, + }, + ]); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const mergeCell = requireChild( + requireChild(worksheet, "mergeCells"), + "mergeCell", + ); + expect(attr(mergeCell, "ref")).toBe("A1:A3"); + }); + + it("writes no element at all when every cell's colSpan/rowSpan is exactly 1 or absent", () => { + const pkg = pkgWith([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + colSpan: 1, + rowSpan: 1, + }, + ]); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(worksheet, "mergeCells")).toHaveLength(0); + }); +}); + +describe("buildCellElement: the decoration/format branches that decide styleIndex, and the exact t/f/v children written", () => { + it("writes a cell carrying alignment alone (no font/background/borders/verticalAlignment) as decorated, not left at the default style index", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "left" }, + displayText: "left", + alignment: "left", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "plain" }, + displayText: "plain", + }, + ]), + ); + const leftIndex = attr(writtenCell(pkg, "A1"), "s"); + const plainIndex = attr(writtenCell(pkg, "B1"), "s"); + expect(leftIndex).not.toBe(plainIndex); + expect(plainIndex).toBe("0"); + }); + + it("writes both and for a formula cell, in that order, and no t attribute for its numeric cached result", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "number", value: 5 }, + formula: "2+3", + displayText: "5", + }, + ]), + ); + const cell = writtenCell(pkg, "A1"); + expect( + cell.children.map((c) => (c.type === "element" ? c.tag : c.type)), + ).toEqual(["f", "v"]); + expect(textContent(requireChild(cell, "f"))).toBe("2+3"); + expect(attr(cell, "t")).toBeUndefined(); + }); + + it("writes no element at all for a cell with no formula", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "number", value: 5 }, + displayText: "5", + }, + ]), + ); + expect(childrenWithTag(writtenCell(pkg, "A1"), "f")).toHaveLength(0); + }); +}); + +describe("renderString/renderTemporal: the formula-result and undefined-serial branches", () => { + it('writes a formula\'s own cached STRING result inline as t="str", never shared-string-indexed, even for a repeated value', () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "same" }, + formula: '"same"', + displayText: "same", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "same" }, + displayText: "same", + }, + ]), + ); + expect(attr(writtenCell(pkg, "A1"), "t")).toBe("str"); + expect(attr(writtenCell(pkg, "B1"), "t")).toBe("s"); + // Only the literal cell interned into sharedStrings -- the formula's own cached text did not. + const sharedStrings = rootElement(pkg.parts["xl/sharedStrings.xml"]); + if (sharedStrings === undefined) { + throw new Error("expected xl/sharedStrings.xml to have a root element"); + } + expect(childrenWithTag(sharedStrings, "si")).toHaveLength(1); + }); + + it("degrades an unparseable date to text via renderString's OWN formula-result branch, writing t=\"str\" when the temporal value is itself a formula's cached result", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "date", value: "not-a-real-date" }, + formula: "TODAY()", + displayText: "not-a-real-date", + }, + ]), + ); + const cell = writtenCell(pkg, "A1"); + expect(attr(cell, "t")).toBe("str"); + expect(textContent(requireChild(cell, "v"))).toBe("not-a-real-date"); + }); +}); + +describe("buildSheetPrElement: fitToPage reflects whether fitToPages is actually present", () => { + it('writes pageSetUpPr fitToPage="true" when the sheet declares fitToPages', () => { + const pkg = buildXlsxPackageFromContent(SUMMARY_ONLY_DOCUMENT()); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const sheetPr = requireChild(worksheet, "sheetPr"); + expect(attr(requireChild(sheetPr, "pageSetUpPr"), "fitToPage")).toBe( + "true", + ); + }); + + it('writes pageSetUpPr fitToPage="false" when the sheet declares no fitToPages', () => { + const pkg = buildXlsxPackageFromContent(singleSheetDocument([])); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const sheetPr = requireChild(worksheet, "sheetPr"); + expect(attr(requireChild(sheetPr, "pageSetUpPr"), "fitToPage")).toBe( + "false", + ); + }); +}); + +function SUMMARY_ONLY_DOCUMENT(): ContentDocument { + return { kind: "spreadsheet", metadata: {}, sheets: [SUMMARY_SHEET] }; +} From e0726be1fe8de78ed540f13fdaef64c789457f3e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:02:56 +0100 Subject: [PATCH 29/63] test(ooxml.js): cover build.ts's page margins, page setup, and manual breaks exactly Asserts ptToInches's genuine points-to-inches conversion for both the common 72pt case and non-72pt margins, and the fixed 0.3in header/footer margin. Covers buildPageSetupElement's paperSize/paperWidth-paperHeight branch for a standard versus a custom page size, the landscape and portrait orientation branches, the default scale/fitToWidth/ fitToHeight when neither scalePercent nor fitToPages is declared, and their declared values when present. Covers buildBreaksElements writing row breaks and column breaks independently of each other, with the exact id/min/max/man attributes, and writing neither element when manualBreaks is undefined or both its arrays are empty. Asserts buildWorksheetPart writes cols/mergeCells/drawing/ tableParts together for a sheet carrying every optional feature and none of them for a plain sheet, plus the worksheet root's own xmlns/xmlns:r and buildWorksheetRelsPart's Relationships root. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index a0e735d08a..7ac36fa812 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -3088,3 +3088,303 @@ describe("buildSheetPrElement: fitToPage reflects whether fitToPages is actually function SUMMARY_ONLY_DOCUMENT(): ContentDocument { return { kind: "spreadsheet", metadata: {}, sheets: [SUMMARY_SHEET] }; } + +// --- print settings: margins, page setup, and manual breaks -------------------------------------------------------- + +describe("buildPageMarginsElement/ptToInches: writes the genuine points-to-inches conversion, not a fabricated one", () => { + it("converts 72pt margins to exactly 1 inch on every side, and the fixed 0.5in header/footer margin", () => { + const pkg = buildXlsxPackageFromContent(singleSheetDocument([])); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const margins = requireChild(worksheet, "pageMargins"); + expect(attr(margins, "left")).toBe("1"); + expect(attr(margins, "right")).toBe("1"); + expect(attr(margins, "top")).toBe("1"); + expect(attr(margins, "bottom")).toBe("1"); + expect(attr(margins, "header")).toBe("0.3"); + expect(attr(margins, "footer")).toBe("0.3"); + }); + + it("converts non-72pt margins proportionally, not with a fixed or fabricated ratio", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + margins: { topPt: 36, rightPt: 18, bottomPt: 144, leftPt: 9 }, + }, + }, + ], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const margins = requireChild(worksheet, "pageMargins"); + expect(attr(margins, "top")).toBe("0.5"); + expect(attr(margins, "right")).toBe("0.25"); + expect(attr(margins, "bottom")).toBe("2"); + expect(attr(margins, "left")).toBe("0.125"); + }); +}); + +describe("buildPageSetupElement: paperSize vs paperWidth/paperHeight, orientation, and scale/fitToWidth/fitToHeight defaults", () => { + function pageSetupOf(pageSize: { + widthPt: number; + heightPt: number; + }): XmlElement { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { ...DEFAULT_PRINT_SETTINGS, pageSize }, + }, + ], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + return requireChild(worksheet, "pageSetup"); + } + + it('writes paperSize (the recognised code), no paperWidth/paperHeight, and orientation="portrait" for a standard, taller-than-wide page', () => { + const pageSetup = pageSetupOf({ widthPt: 612, heightPt: 792 }); // US Letter + expect(attr(pageSetup, "paperSize")).toBe("1"); + expect(attr(pageSetup, "paperWidth")).toBeUndefined(); + expect(attr(pageSetup, "paperHeight")).toBeUndefined(); + expect(attr(pageSetup, "orientation")).toBe("portrait"); + }); + + it('writes paperWidth/paperHeight, no paperSize, and orientation="landscape" for a custom, wider-than-tall page', () => { + const pageSetup = pageSetupOf({ widthPt: 500, heightPt: 300 }); + expect(attr(pageSetup, "paperSize")).toBeUndefined(); + expect(attr(pageSetup, "paperWidth")).toBeDefined(); + expect(attr(pageSetup, "paperHeight")).toBeDefined(); + expect(attr(pageSetup, "orientation")).toBe("landscape"); + }); + + it('writes scale="100", fitToWidth="1", fitToHeight="1" as the genuine defaults when neither scalePercent nor fitToPages is declared', () => { + const pageSetup = pageSetupOf({ widthPt: 612, heightPt: 792 }); + expect(attr(pageSetup, "scale")).toBe("100"); + expect(attr(pageSetup, "fitToWidth")).toBe("1"); + expect(attr(pageSetup, "fitToHeight")).toBe("1"); + }); + + it("writes the declared scalePercent and fitToPages verbatim when they are present, not the defaults", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + scalePercent: 80, + fitToPages: { width: 2, height: 5 }, + }, + }, + ], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const pageSetup = requireChild(worksheet, "pageSetup"); + expect(attr(pageSetup, "scale")).toBe("80"); + expect(attr(pageSetup, "fitToWidth")).toBe("2"); + expect(attr(pageSetup, "fitToHeight")).toBe("5"); + }); +}); + +describe("buildBreaksElements: manual row and column breaks are written independently of each other", () => { + function pkgWithBreaks(manualBreaks: { + rows: readonly number[]; + columns: readonly number[]; + }): Package { + return buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { ...DEFAULT_PRINT_SETTINGS, manualBreaks }, + }, + ], + }); + } + + it("writes rowBreaks with the exact id/min/max/man attributes and count/manualBreakCount, no colBreaks at all, for row breaks alone", () => { + const pkg = pkgWithBreaks({ rows: [3, 7], columns: [] }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(worksheet, "colBreaks")).toHaveLength(0); + const rowBreaks = requireChild(worksheet, "rowBreaks"); + expect(attr(rowBreaks, "count")).toBe("2"); + expect(attr(rowBreaks, "manualBreakCount")).toBe("2"); + const brks = elementsOf(rowBreaks, "brk"); + expect(brks.map((brk) => attributeOf(brk, "id"))).toEqual(["3", "7"]); + const first = brks[0]; + if (first === undefined) { + throw new Error("expected the first "); + } + expect(attributeOf(first, "min")).toBe("0"); + expect(attributeOf(first, "max")).toBe("16383"); + expect(attributeOf(first, "man")).toBe("1"); + }); + + it("writes colBreaks with the exact id/min/max/man attributes and count/manualBreakCount, no rowBreaks at all, for column breaks alone", () => { + const pkg = pkgWithBreaks({ rows: [], columns: [2] }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(worksheet, "rowBreaks")).toHaveLength(0); + const colBreaks = requireChild(worksheet, "colBreaks"); + expect(attr(colBreaks, "count")).toBe("1"); + expect(attr(colBreaks, "manualBreakCount")).toBe("1"); + const brk = elementsOf(colBreaks, "brk")[0]; + if (brk === undefined) { + throw new Error("expected a "); + } + expect(attributeOf(brk, "id")).toBe("2"); + expect(attributeOf(brk, "min")).toBe("0"); + expect(attributeOf(brk, "max")).toBe("1048575"); + expect(attributeOf(brk, "man")).toBe("1"); + }); + + it("writes neither rowBreaks nor colBreaks when manualBreaks is undefined, and neither when both arrays are empty", () => { + const noBreaks = rootElement( + buildXlsxPackageFromContent(singleSheetDocument([])).parts[ + "xl/worksheets/sheet1.xml" + ], + ); + if (noBreaks === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(noBreaks, "rowBreaks")).toHaveLength(0); + expect(childrenWithTag(noBreaks, "colBreaks")).toHaveLength(0); + + const emptyBreaks = rootElement( + pkgWithBreaks({ rows: [], columns: [] }).parts[ + "xl/worksheets/sheet1.xml" + ], + ); + if (emptyBreaks === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(emptyBreaks, "rowBreaks")).toHaveLength(0); + expect(childrenWithTag(emptyBreaks, "colBreaks")).toHaveLength(0); + }); +}); + +describe("buildWorksheetPart: element presence for cols, mergeCells, drawing, and tableParts, and buildWorksheetRelsPart's own root", () => { + it("writes cols, mergeCells, drawing, and tableParts all together, and no more than one of each, for a sheet carrying every optional feature", () => { + const pkg = buildXlsxPackageFromContent( + { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + colSpan: 2, + }, + ], + columns: [{ index: 0, widthPt: 50 }], + rows: [], + images: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 1, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }, + { definitions: tableDefinitions() }, + ); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(worksheet.tag).toBe("worksheet"); + expect(attr(worksheet, "xmlns")).toBe( + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + ); + expect(attr(worksheet, "xmlns:r")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + ); + expect(childrenWithTag(worksheet, "cols")).toHaveLength(1); + expect(childrenWithTag(worksheet, "mergeCells")).toHaveLength(1); + const drawing = requireChild(worksheet, "drawing"); + expect(attr(drawing, "r:id")).toBeDefined(); + const tableParts = requireChild(worksheet, "tableParts"); + expect(attr(tableParts, "count")).toBe("1"); + expect(attr(requireChild(tableParts, "tablePart"), "r:id")).toBeDefined(); + + const rels = rootElement(pkg.parts["xl/worksheets/_rels/sheet1.xml.rels"]); + if (rels === undefined) { + throw new Error( + "expected the worksheet rels part to have a root element", + ); + } + expect(rels.tag).toBe("Relationships"); + expect(attr(rels, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/relationships", + ); + }); + + it("writes no cols, mergeCells, drawing, or tableParts at all for a plain sheet with none of those features", () => { + const worksheet = rootElement( + buildXlsxPackageFromContent(singleSheetDocument([])).parts[ + "xl/worksheets/sheet1.xml" + ], + ); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(worksheet, "cols")).toHaveLength(0); + expect(childrenWithTag(worksheet, "mergeCells")).toHaveLength(0); + expect(childrenWithTag(worksheet, "drawing")).toHaveLength(0); + expect(childrenWithTag(worksheet, "tableParts")).toHaveLength(0); + }); +}); From 14dfb9a949f6285a255e5f13a1afeaf1b043b9d7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:26:07 +0100 Subject: [PATCH 30/63] test(ooxml.js): cover build.ts's per-sheet table filtering and relationship numbering exactly Proves a table definitions entry attaches tableParts and its own xl/tables/tableN.xml only to the sheet it names, and that an unrelated second sheet gets neither the table nor its own rels part at all, closing the gap where the 'table.sheet !== sheet.name' skip was never exercised by a genuinely non-matching sheet. Asserts worksheet relationship ids are assigned sequentially (rId1/rId2/rId3) across comments, drawing, and table relationships on the same sheet, in that order. Proves usedImageFormats collects every distinct format a sheet's images actually use (png and jpeg together), not just the first, and declares no Default extension for a format never used. Adds a negative assertion that a plain document with neither a chart nor a table writes no /xl/charts/ or /xl/tables/ Override at all, closing the gap where an initial-empty-array mutant seeding a bogus part name went undetected by toContainEqual-only assertions. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 7ac36fa812..12cc5f218d 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -3388,3 +3388,176 @@ describe("buildWorksheetPart: element presence for cols, mergeCells, drawing, an expect(childrenWithTag(worksheet, "tableParts")).toHaveLength(0); }); }); + +// --- entry point: per-sheet table filtering, sequential relationship ids, and multi-format image usage ------------ + +describe("buildXlsxPackageFromContent: a table definitions entry attaches only to its own named sheet, never to any other", () => { + it("writes tableParts and xl/tables/table1.xml for the sheet the table names, and neither for a second, unrelated sheet", () => { + const pkg = buildXlsxPackageFromContent( + { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + { + name: "Other", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }, + { definitions: tableDefinitions() }, + ); + const sheet1 = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + const sheet2 = rootElement(pkg.parts["xl/worksheets/sheet2.xml"]); + if (sheet1 === undefined || sheet2 === undefined) { + throw new Error("expected both worksheet root elements"); + } + expect(childrenWithTag(sheet1, "tableParts")).toHaveLength(1); + expect(childrenWithTag(sheet2, "tableParts")).toHaveLength(0); + expect(Object.keys(pkg.parts)).not.toContain( + "xl/worksheets/_rels/sheet2.xml.rels", + ); + }); +}); + +describe("buildXlsxPackageFromContent: worksheet relationships are numbered sequentially across comments, drawing, and tables on the same sheet", () => { + it("assigns rId1/rId2/rId3 in the order comments, drawing, and table relationships are added, with no gap or repeat", () => { + const pkg = buildXlsxPackageFromContent( + { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + comment: { text: "note" }, + }, + ], + columns: [], + rows: [], + images: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }, + { definitions: tableDefinitions() }, + ); + const rels = rootElement(pkg.parts["xl/worksheets/_rels/sheet1.xml.rels"]); + if (rels === undefined) { + throw new Error( + "expected the worksheet rels part to have a root element", + ); + } + const relationships = childrenWithTag(rels, "Relationship"); + expect(relationships.map((el) => attr(el, "Id"))).toEqual([ + "rId1", + "rId2", + "rId3", + ]); + const types = relationships.map((el) => attr(el, "Type")); + expect(types[0]).toContain("threadedComment"); + expect(types[1]).toContain("/drawing"); + expect(types[2]).toContain("/table"); + }); +}); + +describe("buildXlsxPackageFromContent: usedImageFormats collects every distinct image format actually used, and only those", () => { + it("declares a Default entry for both png and jpeg when a sheet carries one image of each, and none for gif", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + { + kind: "image", + format: "jpeg", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 1, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + const contentTypes = rootElement(pkg.parts["[Content_Types].xml"]); + if (contentTypes === undefined) { + throw new Error("expected [Content_Types].xml to have a root element"); + } + const extensions = childrenWithTag(contentTypes, "Default").map((el) => + attr(el, "Extension"), + ); + expect(extensions).toContain("png"); + expect(extensions).toContain("jpeg"); + expect(extensions).not.toContain("gif"); + expect(Object.keys(pkg.parts)).toContain("xl/media/image1.png"); + expect(Object.keys(pkg.parts)).toContain("xl/media/image2.jpeg"); + }); +}); + +describe("buildXlsxPackageFromContent: [Content_Types].xml carries no chart/table overrides at all for a document with neither", () => { + it("writes no /xl/charts/ or /xl/tables/ Override, and no chart/table Default extensions, for a plain document", () => { + const pkg = buildXlsxPackageFromContent(DOCUMENT); + const contentTypes = rootElement(pkg.parts["[Content_Types].xml"]); + if (contentTypes === undefined) { + throw new Error("expected [Content_Types].xml to have a root element"); + } + const overrides = childrenWithTag(contentTypes, "Override").map((el) => + attr(el, "PartName"), + ); + expect(overrides.some((name) => name?.startsWith("/xl/charts/"))).toBe( + false, + ); + expect(overrides.some((name) => name?.startsWith("/xl/tables/"))).toBe( + false, + ); + }); +}); From ed7cca9b4a8a77106a107097848ded84da9eb131 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:05:16 +0100 Subject: [PATCH 31/63] test(ooxml.js): assert the derived Print_Area definedName's own text content The existing "derives the reserved print names" test checked the definedName's name and localSheetId but never its own text, so a mutant dropping the range text entirely went unnoticed. --- packages/ooxml.js/src/typed/xlsx/build.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 12cc5f218d..ad2661f248 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -2273,6 +2273,7 @@ describe("buildXlsxPackageFromContent: the definitions option (Table objects) an } expect(attr(printArea, "name")).toBe("_xlnm.Print_Area"); expect(attr(printArea, "localSheetId")).toBe("0"); + expect(textContent(printArea)).toBe("Sheet1!$A$1:$B$10"); }); it("writes no container and no xl/tables part at all when no definitions are supplied and the document carries no names", () => { From 646213c3ee8bd190d51d615db0bf2a012d843566 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:25:10 +0100 Subject: [PATCH 32/63] docs(ooxml.js): record build.ts's re-measured mutation score and a Stryker reporting anomaly xlsx/build.ts now measures 69.3-69.5% of its own valid mutants under Stryker's scoped mutation run, up from 32.83% before the structural coverage added in this session's earlier commits, confirmed reproducible across three independent runs including one at concurrency 1. Documents a genuine tool-measurement anomaly found while verifying that improvement: several mutants Stryker's own reporter marks survived were directly disproven as equivalent by manually applying the exact mutation and running the identical vitest configuration Stryker's own runner uses, which fails the relevant tests every time. The package breakThreshold is left unchanged, since raising it needs a fresh full-package measurement once docx/write.ts and docx/read.ts, the package's two remaining large modules, have also been closed. --- packages/ooxml.js/stryker.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index d0560cd5a9..b052b53eb1 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,6 +2,8 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. The package's three largest modules (xlsx/build.ts, docx/write.ts, docx/read.ts) remain well short of 100% and are the next real targets for closing this gap further; this threshold reflects the genuinely measured floor today, not a ceiling to stop at. + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. The package's other two largest modules, docx/write.ts and docx/read.ts, remain essentially untouched and are still well short of 100%, so this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor once those two modules have also been closed. + // + // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. breakThreshold: 83, }); From 745cc145bb32e4f218938c2be7dea8f7987b8e1c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:29:19 +0100 Subject: [PATCH 33/63] test(ooxml.js): fix build.test.ts's own type errors under Node typecheck Narrow worksheetRels[0] with an explicit undefined check before passing it to attr(), instead of an unsound index access typed as XmlElement | undefined, and drop the readonly modifier from pkgWithBreaks's manualBreaks parameter to match ContentSheetPrintSettingsSchema's own mutable array type, which readonly arrays cannot satisfy. --- packages/ooxml.js/src/typed/xlsx/build.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index ad2661f248..aa84d479da 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -2551,7 +2551,11 @@ describe("buildXlsxPackageFromContent: xl/_rels/workbook.xml.rels numbers worksh "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", ); expect(worksheetRels).toHaveLength(1); - expect(attr(worksheetRels[0], "Id")).toBe("rId1"); + const [worksheetRel] = worksheetRels; + if (worksheetRel === undefined) { + throw new Error("expected exactly one worksheet relationship"); + } + expect(attr(worksheetRel, "Id")).toBe("rId1"); }); }); @@ -3219,8 +3223,8 @@ describe("buildPageSetupElement: paperSize vs paperWidth/paperHeight, orientatio describe("buildBreaksElements: manual row and column breaks are written independently of each other", () => { function pkgWithBreaks(manualBreaks: { - rows: readonly number[]; - columns: readonly number[]; + rows: number[]; + columns: number[]; }): Package { return buildXlsxPackageFromContent({ kind: "spreadsheet", From bba66d40ac5f29d40aa4e3dfc1c879261da613e3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 09:00:44 +0100 Subject: [PATCH 34/63] test(ooxml.js): cover docx write.ts's table, link, and scaffolding XML exactly Adds direct assertions for buildTable's exact tblPr/tblGrid/gridSpan/vMerge/trHeight XML rather than only a round-trippable one, for wrapInternalLinks resolving overlapping extents by sorted startRun (not constructs array order) and wrapping independent non-overlapping extents without a false overlap block, for buildCommentsPart/buildNotesPart minting an id past the highest explicit numeric id while leaving a non-numeric id untouched, for footnotes.xml/endnotes.xml's exact separator/continuationSeparator boilerplate and conditional w:type, and for the root tag/namespace of Content_Types, package rels, document rels, core properties, and extended properties, plus jpeg/gif media Default entries and an empty keywords list being omitted rather than written empty. --- .../ooxml.js/src/typed/docx/write.test.ts | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index 38cb142dbe..250c32a72d 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -445,6 +445,10 @@ describe("buildDocxPackageFromContent: fixed package-scaffolding parts", () => { sections: [emptyBodySection()], }); const root = rootElement(written.parts["_rels/.rels"]); + expect(root?.tag).toBe("Relationships"); + expect(root === undefined ? undefined : attr(root, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/relationships", + ); const rels = root === undefined ? [] : childrenWithTag(root, "Relationship"); expect( @@ -477,6 +481,10 @@ describe("buildDocxPackageFromContent: fixed package-scaffolding parts", () => { sections: [emptyBodySection()], }); const root = rootElement(written.parts["[Content_Types].xml"]); + expect(root?.tag).toBe("Types"); + expect(root === undefined ? undefined : attr(root, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/content-types", + ); const defaults = root === undefined ? [] : childrenWithTag(root, "Default"); expect( defaults.map((entry) => ({ @@ -513,6 +521,100 @@ describe("buildDocxPackageFromContent: fixed package-scaffolding parts", () => { ); }); + it("declares a Default entry for every media format actually used, jpeg and gif included, never one that was not", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { + kind: "image", + format: "jpeg", + base64: "AAAA", + widthPt: 10, + heightPt: 10, + }, + { + kind: "image", + format: "gif", + base64: "BBBB", + widthPt: 10, + heightPt: 10, + }, + ], + }, + ], + }); + const root = rootElement(written.parts["[Content_Types].xml"]); + const defaultFor = (extension: string): string | undefined => { + const found = ( + root === undefined ? [] : childrenWithTag(root, "Default") + ).find((entry) => attr(entry, "Extension") === extension); + return found === undefined ? undefined : attr(found, "ContentType"); + }; + expect(defaultFor("jpeg")).toBe("image/jpeg"); + expect(defaultFor("gif")).toBe("image/gif"); + expect(defaultFor("png")).toBeUndefined(); + }); + + it("writes word/_rels/document.xml.rels with the Relationships root tag and namespace", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { + kind: "paragraph", + runs: [{ text: "link", hyperlink: "https://example.com" }], + }, + ], + }, + ], + }); + const root = rootElement(written.parts["word/_rels/document.xml.rels"]); + expect(root?.tag).toBe("Relationships"); + expect(root === undefined ? undefined : attr(root, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/relationships", + ); + }); + + it("writes docProps/core.xml and docProps/app.xml's exact XML, including an empty keywords list and a modifiedIso date", () => { + const written = buildDocxPackageFromContent({ + sections: [emptyBodySection()], + metadata: { + title: "T", + author: "A", + subject: "S", + keywords: [], + createdIso: "2026-01-01T00:00:00Z", + modifiedIso: "2026-02-02T00:00:00Z", + creator: "ooxml.js", + }, + }); + const core = rootElement(written.parts["docProps/core.xml"]); + expect(core?.tag).toBe("cp:coreProperties"); + expect(core === undefined ? undefined : attr(core, "xmlns:cp")).toBe( + "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + ); + // An empty keywords array is not undefined, but the writer's own length>0 guard means a present-but-empty list is treated the same as an absent one: no cp:keywords element at all, not an empty one. + expect(childrenWithTag(core!, "cp:keywords")).toHaveLength(0); + expect(childrenWithTag(core!, "dcterms:modified")[0]).toEqual( + el("dcterms:modified", { "xsi:type": "dcterms:W3CDTF" }, [ + txt("2026-02-02T00:00:00Z"), + ]), + ); + const app = rootElement(written.parts["docProps/app.xml"]); + expect(app?.tag).toBe("Properties"); + expect(app === undefined ? undefined : attr(app, "xmlns")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties", + ); + expect(childrenWithTag(app!, "Application")[0]).toEqual( + el("Application", {}, [txt("ooxml.js")]), + ); + }); + it("writes styles.xml's fixed docDefaults and Normal/DefaultParagraphFont scaffolding for a document with no named styles", () => { const written = buildDocxPackageFromContent({ sections: [emptyBodySection()], @@ -764,6 +866,114 @@ describe("buildDocxPackageFromContent: content round trip", () => { expect(written.rows[0]?.cells[0]?.colSpan).toBe(2); }); + it("writes a table's exact tblPr, tblGrid, gridSpan, vMerge, and trHeight XML, not just a round-trippable one", () => { + // A round trip through readTable can mask a writer defect the reader happens to tolerate (a wrong tag name it still recognises, a swapped constant it still parses back the same way), so this asserts the actual written XML shape directly rather than only the read-back content. + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { + kind: "table" as const, + columnWidthsPt: [100, 50], + rows: [ + { + heightPt: 30, + cells: [ + { + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "top" }] }, + ], + colSpan: 2, + }, + ], + }, + { + cells: [ + { + blocks: [ + { + kind: "paragraph" as const, + runs: [{ text: "left" }], + }, + ], + rowSpan: 2, + }, + { + blocks: [ + { + kind: "paragraph" as const, + runs: [{ text: "right1" }], + }, + ], + }, + ], + }, + { + cells: [ + { blocks: [] }, + { + blocks: [ + { + kind: "paragraph" as const, + runs: [{ text: "right2" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }); + const document = rootElement(written.parts["word/document.xml"]); + const table = elementsWithTag( + document === undefined ? [] : [document], + "w:tbl", + )[0]; + if (table === undefined) { + throw new Error("expected a w:tbl element"); + } + expect(table.children[0]).toEqual( + el("w:tblPr", {}, [el("w:tblW", { "w:w": "0", "w:type": "auto" })]), + ); + expect(table.children[1]).toEqual( + el("w:tblGrid", {}, [ + el("w:gridCol", { "w:w": "2000" }), + el("w:gridCol", { "w:w": "1000" }), + ]), + ); + const rows = childrenWithTag(table, "w:tr"); + expect(rows).toHaveLength(3); + expect(childrenWithTag(rows[0]!, "w:trPr")[0]).toEqual( + el("w:trPr", {}, [el("w:trHeight", { "w:val": "600" })]), + ); + const row0Cells = childrenWithTag(rows[0]!, "w:tc"); + expect(row0Cells).toHaveLength(1); + expect(childrenWithTag(row0Cells[0]!, "w:tcPr")[0]).toEqual( + el("w:tcPr", {}, [el("w:gridSpan", { "w:val": "2" })]), + ); + const row1Cells = childrenWithTag(rows[1]!, "w:tc"); + expect(row1Cells).toHaveLength(2); + expect(childrenWithTag(row1Cells[0]!, "w:tcPr")[0]).toEqual( + el("w:tcPr", {}, [el("w:vMerge", { "w:val": "restart" })]), + ); + expect(childrenWithTag(row1Cells[1]!, "w:tcPr")).toHaveLength(0); + const row2Cells = childrenWithTag(rows[2]!, "w:tc"); + expect(row2Cells).toHaveLength(2); + expect(childrenWithTag(row2Cells[0]!, "w:tcPr")[0]).toEqual( + el("w:tcPr", {}, [el("w:vMerge", {})]), + ); + // The vMerge continuation cell carries no blocks of its own, but ECMA-376 still requires a trailing block-level element, so it still gets the empty paragraph every genuinely empty cell gets. + expect(row2Cells[0]!.children.filter((c) => c.type === "element")).toEqual([ + el("w:tcPr", {}, [el("w:vMerge", {})]), + el("w:p"), + ]); + }); + it("round-trips a genuine two-colour pattern fill instead of dropping it (ExaDev/documents.js#951)", () => { const table = el("w:tbl", {}, [ el("w:tblGrid", {}, [el("w:gridCol", { "w:w": "2880" })]), @@ -1597,6 +1807,113 @@ describe("buildDocxPackageFromContent: construct round trip", () => { ).toBe("abc"); }); + it("resolves two overlapping internal links by the earliest-starting extent, regardless of the constructs array's own order", () => { + // The winner is decided by sorting the extents by startRun (ties broken by the LONGER extent first), never by the order they happen to appear in `constructs` -- this paragraph lists the later-starting, shorter link FIRST specifically to prove the sort, not the array order, decides the winner. + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { + kind: "paragraph", + runs: [{ text: "a" }, { text: "b" }, { text: "c" }], + constructs: [ + { + descriptor: { + kind: "link", + target: { kind: "internal", anchor: "later-shorter" }, + }, + startRun: 1, + endRun: 3, + }, + { + descriptor: { + kind: "link", + target: { kind: "internal", anchor: "earlier-longer" }, + }, + startRun: 0, + endRun: 2, + }, + ], + }, + ], + }, + ], + }); + const document = rootElement(written.parts["word/document.xml"]); + const hyperlinks = elementsWithTag( + document === undefined ? [] : [document], + "w:hyperlink", + ); + expect(hyperlinks).toHaveLength(1); + expect( + hyperlinks[0]?.type === "element" + ? hyperlinks[0].attributes.find((a) => a.name === "w:anchor")?.value + : undefined, + ).toBe("earlier-longer"); + }); + + it("wraps two non-overlapping internal links independently, without one's own wrap falsely blocking the other", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { + kind: "paragraph", + runs: [ + { text: "a" }, + { text: "b" }, + { text: "between" }, + { text: "c" }, + { text: "d" }, + ], + constructs: [ + { + descriptor: { + kind: "link", + target: { kind: "internal", anchor: "first-pair" }, + }, + startRun: 0, + endRun: 2, + }, + { + descriptor: { + kind: "link", + target: { kind: "internal", anchor: "second-pair" }, + }, + startRun: 3, + endRun: 5, + }, + ], + }, + ], + }, + ], + }); + const document = rootElement(written.parts["word/document.xml"]); + const hyperlinks = elementsWithTag( + document === undefined ? [] : [document], + "w:hyperlink", + ); + expect(hyperlinks).toHaveLength(2); + expect( + hyperlinks.map( + (h) => h.attributes.find((a) => a.name === "w:anchor")?.value, + ), + ).toEqual(["first-pair", "second-pair"]); + expect( + hyperlinks.map( + (h) => + h.children.filter( + (child) => child.type === "element" && child.tag === "w:r", + ).length, + ), + ).toEqual([2, 2]); + }); + it("refuses a run-level extent whose range does not name real runs, rather than writing markers at a made-up position", () => { const faulty = { sections: [ @@ -1858,6 +2175,65 @@ describe("buildDocxPackageFromContent: styles, numbering, comments, footnotes, e expect(after.sections).toEqual(before.sections); }); + it("mints a comment id past the highest explicit numeric id, leaving a non-numeric id untouched", () => { + // A hand-built DocxContent, not a round trip: readDocxContent always carries every comment's own real w:id, so the minting path (comment.id undefined) is only ever exercised by content built by hand. + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [], + }, + ], + comments: [ + { id: "5", author: "A", text: "first" }, + { text: "second" }, + { id: "abc", text: "third" }, + ], + }); + const root = rootElement(written.parts["word/comments.xml"]); + const comments = childrenWithTag(root!, "w:comment"); + expect(comments.map((c) => attr(c, "w:id"))).toEqual(["5", "6", "abc"]); + }); + + it("writes the exact footnotes.xml/endnotes.xml boilerplate, and mints a footnote id past the highest explicit numeric id", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [], + }, + ], + footnotes: [ + { id: "2", text: "a" }, + { text: "b" }, + { id: "9", type: "custom", text: "c" }, + ], + }); + const root = rootElement(written.parts["word/footnotes.xml"]); + if (root === undefined) { + throw new Error("expected a word/footnotes.xml root element"); + } + expect(root.children[0]).toEqual( + el("w:footnote", { "w:type": "separator", "w:id": "-1" }, [ + el("w:p", {}, [el("w:r", {}, [el("w:separator")])]), + ]), + ); + expect(root.children[1]).toEqual( + el("w:footnote", { "w:type": "continuationSeparator", "w:id": "0" }, [ + el("w:p", {}, [el("w:r", {}, [el("w:continuationSeparator")])]), + ]), + ); + const notes = childrenWithTag(root, "w:footnote").slice(2); + // Explicit ids are 2 and 9, so the minted id for the id-less middle note is 10, not one past 2 -- every explicit id counts toward the floor, regardless of array position. + expect(notes.map((n) => attr(n, "w:id"))).toEqual(["2", "10", "9"]); + // w:type is written only when the source recorded one other than the ordinary "normal" implied by its absence. + expect(notes[0]?.attributes.some((a) => a.name === "w:type")).toBe(false); + expect(notes[1]?.attributes.some((a) => a.name === "w:type")).toBe(false); + expect(attr(notes[2]!, "w:type")).toBe("custom"); + }); + it("round-trips a footnote and an endnote reference mark and body through their own parts", () => { const paragraph = el("w:p", {}, [ el("w:r", {}, [el("w:t", {}, [txt("see")])]), From aace335eb7517d9f6395541a67c3dc284737c3e2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 09:12:54 +0100 Subject: [PATCH 35/63] docs(ooxml.js): record docx write.ts's re-measured mutation score write.ts alone now measures 74.53% of its own 1107 valid mutants, up from a genuinely re-measured baseline of 66.67% taken in the same session immediately beforehand, after adding direct structural coverage for its table XML, internal-link resolution, comment/footnote id minting, and package-scaffolding output. The package's overall break threshold is left unchanged: docx/read.ts remains essentially untouched, and raising the threshold needs a fresh full-package run rather than a local single-file scope. --- packages/ooxml.js/stryker.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index b052b53eb1..821c21920e 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,7 +2,7 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. The package's other two largest modules, docx/write.ts and docx/read.ts, remain essentially untouched and are still well short of 100%, so this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor once those two modules have also been closed. + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since had direct structural coverage added too (exact table/tblGrid/vMerge XML, wrapInternalLinks' sorted-extent resolution, comment/footnote id minting, and package-scaffolding root tags/namespaces); scoped-mutating it alone now measures 74.53% of its own 1107 valid mutants, up from a genuinely re-measured baseline of 66.67% taken in the same session immediately beforehand (both runs against the identical source, differing only in the new tests). docx/read.ts remains essentially untouched and is still well short of 100%, so this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor once that module has also been closed. // // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. breakThreshold: 83, From 9757cfa85f74b3206f44cb9b99d98b508ce59d98 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 09:45:50 +0100 Subject: [PATCH 36/63] test(ooxml.js): cover docx read.ts's page geometry, toggles, and field/border fallbacks --- packages/ooxml.js/src/typed/docx/read.test.ts | 699 ++++++++++++++++++ 1 file changed, 699 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/read.test.ts b/packages/ooxml.js/src/typed/docx/read.test.ts index b9a6507515..dd39bd4209 100644 --- a/packages/ooxml.js/src/typed/docx/read.test.ts +++ b/packages/ooxml.js/src/typed/docx/read.test.ts @@ -9,6 +9,7 @@ import type { ContentParagraph, ContentTable, } from "document-schema.js"; +import { rgbHexToColor } from "document-schema.js"; import { el, txt } from "../../xml/fragment"; import { bytesToBase64 } from "../../util/base64"; import { zipPackage } from "../../zip"; @@ -18,6 +19,7 @@ import { minimalPptxBytes, minimalXlsxBytes, } from "../../test-support/embedded"; +import { eighthPointsToPt } from "../shared/units"; import { attr, childrenWithTag, elementsWithTag, rootElement } from "../util"; import { readDocxContent } from "./read"; import { buildDocxPackageFromContent } from "./write"; @@ -2724,3 +2726,700 @@ describe("readDocxContent: header/footer structure", () => { ]); }); }); + +// A single-section, sectPr-only body: readSections closes the one implicit section entirely from that sectPr, with no paragraphs at all, so readPageSize/readMargins' own fallback branches are exercised in isolation from every other section-level concern. +function sectionOnlyPackage(sectPr: XmlElement): Package { + const body = el("w:body", {}, [sectPr]); + return { + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + "word/_rels/document.xml.rels": { kind: "xml", nodes: [rels([])] }, + }, + }; +} + +describe("readDocxContent: page size and margin fallbacks", () => { + it("falls back to the Letter default page size when w:pgSz is entirely absent", () => { + const doc = readDocxContent(sectionOnlyPackage(el("w:sectPr", {}, []))); + expect(doc.sections[0]?.pageSize).toEqual({ widthPt: 612, heightPt: 792 }); + }); + + it("falls back to the Letter default page size when w:pgSz carries only @w:w or only @w:h", () => { + const widthOnly = readDocxContent( + sectionOnlyPackage( + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "11906" })]), + ), + ); + expect(widthOnly.sections[0]?.pageSize).toEqual({ + widthPt: 612, + heightPt: 792, + }); + const heightOnly = readDocxContent( + sectionOnlyPackage( + el("w:sectPr", {}, [el("w:pgSz", { "w:h": "16838" })]), + ), + ); + expect(heightOnly.sections[0]?.pageSize).toEqual({ + widthPt: 612, + heightPt: 792, + }); + }); + + it("falls back to the default 1in margins entirely when the section carries no w:pgMar at all", () => { + const doc = readDocxContent(sectionOnlyPackage(el("w:sectPr", {}, []))); + expect(doc.sections[0]?.margins).toEqual({ + topPt: 72, + rightPt: 72, + bottomPt: 72, + leftPt: 72, + }); + }); + + it("fills in only the edges w:pgMar omits, converting whichever edges it does spell", () => { + const doc = readDocxContent( + sectionOnlyPackage( + el("w:sectPr", {}, [ + el("w:pgMar", { "w:top": "2880", "w:left": "720" }), + ]), + ), + ); + expect(doc.sections[0]?.margins).toEqual({ + topPt: 144, + rightPt: 72, + bottomPt: 72, + leftPt: 36, + }); + }); + + it("recognises every w:sectPr/w:type value, not just continuous", () => { + for (const val of ["nextPage", "evenPage", "oddPage"] as const) { + const doc = readDocxContent( + sectionOnlyPackage( + el("w:sectPr", {}, [el("w:type", { "w:val": val })]), + ), + ); + expect(doc.sections[0]?.breakType).toBe(val); + } + }); + + it("leaves breakType absent for an unrecognised w:type value", () => { + const doc = readDocxContent( + sectionOnlyPackage( + el("w:sectPr", {}, [el("w:type", { "w:val": "nonsense" })]), + ), + ); + expect(doc.sections[0]?.breakType).toBeUndefined(); + }); +}); + +describe("readDocxContent: w:pageBreakBefore toggle values", () => { + function pageBreakBeforeDoc(val: string | undefined) { + const pPrChildren = + val === undefined + ? [el("w:pageBreakBefore")] + : [el("w:pageBreakBefore", { "w:val": val })]; + const paragraph = el("w:p", {}, [ + el("w:pPr", {}, pPrChildren), + textRun("text"), + ]); + return readDocxContent(paragraphPackage(paragraph)); + } + + it("treats w:val of 0, false, or off as explicitly disabling the page break", () => { + for (const val of ["0", "false", "off"]) { + expect(pageBreakBeforeDoc(val).sections[0]?.blocks[0]?.kind).toBe( + "paragraph", + ); + } + }); + + it("treats any other w:val as enabling the page break, same as an absent @w:val", () => { + expect(pageBreakBeforeDoc("1").sections[0]?.blocks[0]?.kind).toBe( + "pageBreak", + ); + expect(pageBreakBeforeDoc(undefined).sections[0]?.blocks[0]?.kind).toBe( + "pageBreak", + ); + }); +}); + +describe("readDocxContent: list membership edge cases", () => { + it("leaves list undefined when w:numPr carries no w:numId", () => { + const paragraph = el("w:p", {}, [ + el("w:pPr", {}, [el("w:numPr", {}, [el("w:ilvl", { "w:val": "0" })])]), + textRun("no numId"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(firstParagraph(doc).list).toBeUndefined(); + }); + + it("defaults level to 0 when w:numPr carries a w:numId but no w:ilvl", () => { + const paragraph = el("w:p", {}, [ + el("w:pPr", {}, [el("w:numPr", {}, [el("w:numId", { "w:val": "5" })])]), + textRun("top-level item"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(firstParagraph(doc).list).toEqual({ numId: "5", level: 0 }); + }); +}); + +describe("readDocxContent: findRunPageBreakOffset's own accounting for w:tab/w:br/w:cr/w:delText", () => { + it("counts a preceding w:tab as one character when locating a mid-run page break", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:t", { "xml:space": "preserve" }, [txt("a")]), + el("w:tab"), + el("w:br", { "w:type": "page" }), + el("w:t", { "xml:space": "preserve" }, [txt("after")]), + ]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const blocks = doc.sections[0]?.blocks ?? []; + expect(asParagraph(blocks[0]).runs.map((r) => r.text)).toEqual(["a\t"]); + expect(blocks[1]?.kind).toBe("pageBreak"); + expect(asParagraph(blocks[2]).runs.map((r) => r.text)).toEqual(["after"]); + }); + + it("counts a preceding non-page w:br and a preceding w:cr as one character each when locating a mid-run page break", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:t", { "xml:space": "preserve" }, [txt("a")]), + el("w:br"), + el("w:cr"), + el("w:br", { "w:type": "page" }), + el("w:t", { "xml:space": "preserve" }, [txt("after")]), + ]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const blocks = doc.sections[0]?.blocks ?? []; + expect(asParagraph(blocks[0]).runs.map((r) => r.text)).toEqual(["a\n\n"]); + expect(blocks[1]?.kind).toBe("pageBreak"); + expect(asParagraph(blocks[2]).runs.map((r) => r.text)).toEqual(["after"]); + }); + + it("counts a preceding w:delText's own length when locating a mid-run page break inside a wholly deleted paragraph", () => { + const paragraph = el("w:del", { "w:id": "9" }, [ + el("w:p", {}, [ + el("w:r", {}, [ + el("w:delText", { "xml:space": "preserve" }, [txt("gone")]), + el("w:br", { "w:type": "page" }), + el("w:delText", { "xml:space": "preserve" }, [txt("more")]), + ]), + ]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const blocks = doc.sections[0]?.blocks ?? []; + expect(asParagraph(blocks[1]).runs.map((r) => r.text)).toEqual(["gone"]); + expect(blocks[2]?.kind).toBe("pageBreak"); + expect(asParagraph(blocks[3]).runs.map((r) => r.text)).toEqual(["more"]); + }); +}); + +describe("readDocxContent: readObjectEmbeddedObject malformed geometry", () => { + it("skips a w:object whose w:dyaOrig is not numeric, rather than emitting a NaN-sized frame", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:object", { "w:dxaOrig": "1920", "w:dyaOrig": "not-a-number" }, [ + el("o:OLEObject", { Type: "Embed", "r:id": "rIdOle" }), + ]), + ]), + ]); + const doc = readDocxContent( + paragraphPackage(paragraph, { + "word/embeddings/oleObject1.xlsx": { + kind: "binary", + base64: bytesToBase64(minimalXlsxBytes()), + }, + }), + ); + // No dxaOrig/dyaOrig pair passes the finiteness check, so the object contributes no block at all. + expect(doc.sections[0]?.blocks).toHaveLength(1); + }); +}); + +describe("readDocxContent: lifted media inside a w:object's own children, and deletion-scoped lifting", () => { + it("recurses into a w:object's own children to lift a nested w:drawing, anchored at the object's own run position", () => { + const paragraph = el("w:p", {}, [ + textRun("before "), + el("w:r", {}, [ + el("w:object", { "w:dxaOrig": "1920", "w:dyaOrig": "1200" }, [ + drawingElement("wp:inline", "rIdNestedPreview", "Nested preview"), + el("o:OLEObject", { Type: "Embed", "r:id": "rIdMissingOle" }), + ]), + ]), + ]); + const pkg = paragraphPackage(paragraph, { + "word/media/nestedPreview.png": { + kind: "binary", + base64: TINY_PNG_BASE64, + }, + }); + pkg.parts["word/_rels/document.xml.rels"] = { + kind: "xml", + nodes: [ + rels([ + { + id: "rIdNestedPreview", + type: IMAGE_REL, + target: "media/nestedPreview.png", + }, + ]), + ], + }; + const doc = readDocxContent(pkg); + // The object's own OLE payload never resolves (rIdMissingOle has no relationship), so only the nested drawing surfaces as a lifted image, anchored to where the run before it ends. + expect(doc.sections[0]?.blocks).toHaveLength(2); + const image = asImage(doc.sections[0]?.blocks[1]); + expect(image.altText).toBe("Nested preview"); + expect(image.anchorRunIndex).toBe(0); + expect(image.anchorOffset).toBe("before ".length); + }); + + it("excludes a drawing nested inside a mid-paragraph w:del when the paragraph itself is not wholly deleted", () => { + const paragraph = el("w:p", {}, [ + textRun("kept "), + el("w:del", { "w:id": "3" }, [ + el("w:r", {}, [ + drawingElement("wp:inline", "rIdDeletedImg", "Deleted"), + ]), + ]), + ]); + const pkg = paragraphPackage(paragraph, { + "word/media/deleted.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + }); + pkg.parts["word/_rels/document.xml.rels"] = { + kind: "xml", + nodes: [ + rels([ + { id: "rIdDeletedImg", type: IMAGE_REL, target: "media/deleted.png" }, + ]), + ], + }; + const doc = readDocxContent(pkg); + expect(doc.sections[0]?.blocks).toHaveLength(1); + expect( + asParagraph(doc.sections[0]?.blocks[0]).runs.map((r) => r.text), + ).toEqual(["kept "]); + }); + + it("includes a drawing nested inside a mid-paragraph w:del when the whole paragraph is itself a tracked deletion", () => { + const paragraph = el("w:del", { "w:id": "4" }, [ + el("w:p", {}, [ + el("w:del", { "w:id": "5" }, [ + el("w:r", {}, [drawingElement("wp:inline", "rIdKeptImg", "Kept")]), + ]), + ]), + ]); + const pkg = paragraphPackage(paragraph, { + "word/media/kept.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + }); + pkg.parts["word/_rels/document.xml.rels"] = { + kind: "xml", + nodes: [ + rels([{ id: "rIdKeptImg", type: IMAGE_REL, target: "media/kept.png" }]), + ], + }; + const doc = readDocxContent(pkg); + const image = asImage( + doc.sections[0]?.blocks.find((b) => b.kind === "image"), + ); + expect(image.altText).toBe("Kept"); + }); +}); + +describe("readDocxContent: field block-scope boundary checks", () => { + it("encodes a field as a run extent, not a block marker, when text follows its end within the same paragraph", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "begin" })]), + el("w:r", {}, [ + el("w:instrText", { "xml:space": "preserve" }, [txt(" PAGE ")]), + ]), + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "separate" })]), + textRun("1"), + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "end" })]), + textRun(" of 10"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const para = firstParagraph(doc); + expect(para.constructs).toEqual([ + { + descriptor: { kind: "field", instruction: " PAGE " }, + startRun: 0, + endRun: 1, + }, + ]); + expect(para.runs.map((r) => r.text)).toEqual(["1", " of 10"]); + }); + + it("encodes a w:fldSimple as a run extent, not a block marker, when other content shares its paragraph", () => { + const paragraph = el("w:p", {}, [ + textRun("See "), + el("w:fldSimple", { "w:instr": " PAGE " }, [textRun("1")]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const para = firstParagraph(doc); + expect(para.constructs).toEqual([ + { + descriptor: { kind: "field", instruction: " PAGE " }, + startRun: 1, + endRun: 2, + }, + ]); + }); +}); + +describe("readDocxContent: a complex field spanning multiple paragraphs (the TOC shape)", () => { + it("brackets a field whose begin is one paragraph's only content and whose end is a later paragraph's only content", () => { + const beginPara = el("w:p", {}, [ + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "begin" })]), + ]); + const codePara = el("w:p", {}, [ + el("w:r", {}, [ + el("w:instrText", { "xml:space": "preserve" }, [txt(" TOC ")]), + ]), + ]); + const separatePara = el("w:p", {}, [ + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "separate" })]), + ]); + const resultPara = el("w:p", {}, [textRun("Chapter 1 ... 1")]); + const endPara = el("w:p", {}, [ + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "end" })]), + ]); + const body = el("w:body", {}, [ + beginPara, + codePara, + separatePara, + resultPara, + endPara, + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ]); + const doc = readDocxContent({ + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + }, + }); + const blocks = doc.sections[0]?.blocks ?? []; + expect(asConstructStart(blocks[0]).descriptor).toEqual({ + kind: "field", + instruction: " TOC ", + }); + // Every paragraph between begin and end -- including the begin/code/separate/end paragraphs' own, mostly-empty, paragraph blocks -- stays inside the marker pair; only the result paragraph carries real text. + const resultParagraph = blocks.find( + (block) => + block.kind === "paragraph" && block.runs[0]?.text === "Chapter 1 ... 1", + ); + expect(resultParagraph).toBeDefined(); + expect(blocks[blocks.length - 1]?.kind).toBe("constructEnd"); + }); +}); + +describe("readDocxContent: cell border w:start/w:end aliases, default width, and empty-borders collapse", () => { + function tableWithCellBorders(tcBorders: XmlElement): ContentTable { + const table = el("w:tbl", {}, [ + el("w:tblGrid", {}, [el("w:gridCol", { "w:w": "1440" })]), + el("w:tr", {}, [ + el("w:tc", {}, [ + el("w:tcPr", {}, [tcBorders]), + el("w:p", {}, [textRun("cell")]), + ]), + ]), + ]); + return asTable( + readDocxContent(paragraphPackage(table)).sections[0]?.blocks[0], + ); + } + + it("falls back to w:start/w:end when w:left/w:right are absent, and defaults a missing @w:sz to half a point", () => { + const table = tableWithCellBorders( + el("w:tcBorders", {}, [ + el("w:start", { "w:val": "single", "w:color": "112233" }), + el("w:end", { "w:val": "single", "w:color": "445566" }), + ]), + ); + expect(table.rows[0]?.cells[0]?.borders?.left).toEqual({ + color: rgbHexToColor("112233"), + widthPt: eighthPointsToPt(4), + style: "solid", + }); + expect(table.rows[0]?.cells[0]?.borders?.right).toEqual({ + color: rgbHexToColor("445566"), + widthPt: eighthPointsToPt(4), + style: "solid", + }); + }); + + it("prefers w:left/w:right over the w:start/w:end aliases when both are spelled", () => { + const table = tableWithCellBorders( + el("w:tcBorders", {}, [ + el("w:left", { "w:val": "single", "w:color": "AAAAAA" }), + el("w:start", { "w:val": "single", "w:color": "BBBBBB" }), + ]), + ); + expect(table.rows[0]?.cells[0]?.borders?.left?.color).toEqual( + rgbHexToColor("AAAAAA"), + ); + }); + + it("collapses to no borders at all when every edge is nil or none", () => { + const table = tableWithCellBorders( + el("w:tcBorders", {}, [ + el("w:top", { "w:val": "nil" }), + el("w:bottom", { "w:val": "none" }), + ]), + ); + expect(table.rows[0]?.cells[0]?.borders).toBeUndefined(); + }); + + it("reads a right-only border edge with an explicit @w:sz", () => { + const table = tableWithCellBorders( + el("w:tcBorders", {}, [ + el("w:right", { "w:val": "single", "w:sz": "16", "w:color": "010203" }), + ]), + ); + expect(table.rows[0]?.cells[0]?.borders).toEqual({ + right: { + color: rgbHexToColor("010203"), + widthPt: eighthPointsToPt(16), + style: "solid", + }, + }); + }); +}); + +describe("readDocxContent: table span and row-height edge cases", () => { + it("leaves colSpan and rowSpan undefined for an ordinary, unmerged cell", () => { + const doc = readDocxContent(buildFixturePackage()); + const table = asTable(doc.sections[0]?.blocks[19]); + expect(table.rows[1]?.cells[1]?.colSpan).toBeUndefined(); + expect(table.rows[1]?.cells[1]?.rowSpan).toBeUndefined(); + }); + + it("leaves a row's own heightPt undefined when it carries no w:trPr at all, and when w:trPr carries no w:trHeight", () => { + const noTrPr = el("w:tbl", {}, [ + el("w:tblGrid", {}, [el("w:gridCol", { "w:w": "1440" })]), + el("w:tr", {}, [el("w:tc", {}, [el("w:p", {}, [textRun("a")])])]), + ]); + const noTrHeight = el("w:tbl", {}, [ + el("w:tblGrid", {}, [el("w:gridCol", { "w:w": "1440" })]), + el("w:tr", {}, [ + el("w:trPr", {}, []), + el("w:tc", {}, [el("w:p", {}, [textRun("b")])]), + ]), + ]); + expect( + asTable(readDocxContent(paragraphPackage(noTrPr)).sections[0]?.blocks[0]) + .rows[0]?.heightPt, + ).toBeUndefined(); + expect( + asTable( + readDocxContent(paragraphPackage(noTrHeight)).sections[0]?.blocks[0], + ).rows[0]?.heightPt, + ).toBeUndefined(); + }); +}); + +describe("readDocxContent: block-level bookmarks, duplicate ids, and out-of-order halves", () => { + function flowDoc(children: XmlElement[]) { + const body = el("w:body", {}, [ + ...children, + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ]); + return readDocxContent({ + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + }, + }); + } + + it("brackets a whole block-level bookmark with a name, and drops one with no @w:name at all", () => { + const doc = flowDoc([ + el("w:bookmarkStart", { "w:id": "1", "w:name": "Target" }), + el("w:p", {}, [textRun("Bookmarked paragraph")]), + el("w:bookmarkEnd", { "w:id": "1" }), + el("w:bookmarkStart", { "w:id": "2" }), + el("w:p", {}, [textRun("Unnamed bookmark paragraph")]), + el("w:bookmarkEnd", { "w:id": "2" }), + ]); + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "Target", + }); + expect(doc.sections[0]?.blocks[2]?.kind).toBe("constructEnd"); + expect(asParagraph(doc.sections[0]?.blocks[3]).runs[0]?.text).toBe( + "Unnamed bookmark paragraph", + ); + expect(doc.sections[0]?.blocks).toHaveLength(4); + }); + + it("drops a range marker pair with a duplicate id (two starts sharing one id)", () => { + const doc = flowDoc([ + el("w:bookmarkStart", { "w:id": "1", "w:name": "First" }), + el("w:bookmarkStart", { "w:id": "1", "w:name": "Duplicate" }), + el("w:p", {}, [textRun("Ambiguous")]), + el("w:bookmarkEnd", { "w:id": "1" }), + ]); + expect( + doc.sections[0]?.blocks.every((b) => b.kind !== "constructStart"), + ).toBe(true); + }); + + it("drops a comment range whose end sits before its start in document order", () => { + const doc = flowDoc([ + el("w:p", {}, [textRun("Before")]), + el("w:commentRangeEnd", { "w:id": "7" }), + el("w:p", {}, [textRun("Between")]), + el("w:commentRangeStart", { "w:id": "7" }), + el("w:p", {}, [textRun("After")]), + ]); + expect( + doc.sections[0]?.blocks.every((b) => b.kind !== "constructStart"), + ).toBe(true); + }); +}); + +describe("readDocxContent: paragraph-scoped bookmark markers", () => { + it("brackets a whole paragraph in a bookmark marker pair when both halves sit inside it but outside its own runs", () => { + const paragraph = el("w:p", {}, [ + el("w:bookmarkStart", { "w:id": "3", "w:name": "WholeParaBookmark" }), + textRun("Bookmarked text"), + el("w:bookmarkEnd", { "w:id": "3" }), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "WholeParaBookmark", + }); + expect(asParagraph(doc.sections[0]?.blocks[1]).runs[0]?.text).toBe( + "Bookmarked text", + ); + expect(doc.sections[0]?.blocks[2]?.kind).toBe("constructEnd"); + }); +}); + +describe("readDocxContent: sections fallback for a document with no w:sectPr anywhere", () => { + it("still produces one empty default section for a body with no content and no w:sectPr at all", () => { + const body = el("w:body", {}, []); + const doc = readDocxContent({ + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + }, + }); + expect(doc.sections).toHaveLength(1); + expect(doc.sections[0]?.blocks).toEqual([]); + expect(doc.sections[0]?.pageSize).toEqual({ widthPt: 612, heightPt: 792 }); + }); +}); + +describe("readDocxContent: comment/footnote optional id, author, and type fields", () => { + it("carries a comment's own id and author when both are present, and omits id/author when absent", () => { + const pkg = buildFixturePackage(); + pkg.parts["word/comments.xml"] = { + kind: "xml", + nodes: [ + el("w:comments", {}, [ + el("w:comment", { "w:id": "9", "w:author": "Reviewer" }, [ + el("w:p", {}, [textRun("with id")]), + ]), + el("w:comment", {}, [el("w:p", {}, [textRun("no id or author")])]), + ]), + ], + }; + const doc = readDocxContent(pkg); + expect(doc.comments[0]).toEqual({ + id: "9", + author: "Reviewer", + text: "with id", + }); + expect(doc.comments[1]).toEqual({ text: "no id or author" }); + }); + + it("carries a footnote's own id, and its own w:type when present", () => { + const pkg = buildFixturePackage(); + pkg.parts["word/footnotes.xml"] = { + kind: "xml", + nodes: [ + el("w:footnotes", {}, [ + el("w:footnote", { "w:id": "4", "w:type": "continuationNotice" }, [ + el("w:p", {}, [textRun("typed note")]), + ]), + ]), + ], + }; + const doc = readDocxContent(pkg); + expect(doc.footnotes[0]).toEqual({ + id: "4", + type: "continuationNotice", + text: "typed note", + }); + }); +}); + +describe("readDocxContent: header/footer reference edge cases", () => { + it("skips a header reference whose @w:type is unrecognised, and one whose r:id does not resolve to a relationship", () => { + const HEADER_REFERENCE_REL = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"; + const body = el("w:body", {}, [ + el("w:p", {}, [ + el("w:pPr", {}, [ + el("w:sectPr", {}, [ + el("w:headerReference", { "w:type": "bogus", "r:id": "rIdA" }), + el("w:headerReference", { + "w:type": "default", + "r:id": "rIdMissing", + }), + el("w:pgSz", { "w:w": "12240", "w:h": "15840" }), + ]), + ]), + ]), + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ]); + const pkg: Package = { + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + "word/_rels/document.xml.rels": { + kind: "xml", + nodes: [ + rels([ + { id: "rIdA", type: HEADER_REFERENCE_REL, target: "header1.xml" }, + ]), + ], + }, + }, + }; + const doc = readDocxContent(pkg); + expect(doc.sectionHeaderFooters[0]?.header).toBeUndefined(); + }); +}); + +describe("readDocxContent: word/document.xml missing w:body", () => { + it("throws with the part path named in the message", () => { + const pkg: Package = { + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [])], + }, + }, + }; + expect(() => readDocxContent(pkg)).toThrow( + "readDocxContent: word/document.xml has no w:body element", + ); + }); +}); From 1bb2650fc241721c687c7b108386064d26b6f9c6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 10:00:24 +0100 Subject: [PATCH 37/63] test(ooxml.js): cover docx read.ts's split-run reindexing and border-absent edge cases --- packages/ooxml.js/src/typed/docx/read.test.ts | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/read.test.ts b/packages/ooxml.js/src/typed/docx/read.test.ts index dd39bd4209..ec69f8b2d5 100644 --- a/packages/ooxml.js/src/typed/docx/read.test.ts +++ b/packages/ooxml.js/src/typed/docx/read.test.ts @@ -3339,12 +3339,13 @@ describe("readDocxContent: comment/footnote optional id, author, and type fields ], }; const doc = readDocxContent(pkg); - expect(doc.comments[0]).toEqual({ + expect(doc.comments[0]).toStrictEqual({ id: "9", author: "Reviewer", text: "with id", }); - expect(doc.comments[1]).toEqual({ text: "no id or author" }); + // toStrictEqual (not toEqual) so that a mutant which sets id/author to an explicit undefined, rather than leaving the key genuinely absent, is caught rather than treated as equivalent. + expect(doc.comments[1]).toStrictEqual({ text: "no id or author" }); }); it("carries a footnote's own id, and its own w:type when present", () => { @@ -3356,15 +3357,17 @@ describe("readDocxContent: comment/footnote optional id, author, and type fields el("w:footnote", { "w:id": "4", "w:type": "continuationNotice" }, [ el("w:p", {}, [textRun("typed note")]), ]), + el("w:footnote", {}, [el("w:p", {}, [textRun("no id or type")])]), ]), ], }; const doc = readDocxContent(pkg); - expect(doc.footnotes[0]).toEqual({ + expect(doc.footnotes[0]).toStrictEqual({ id: "4", type: "continuationNotice", text: "typed note", }); + expect(doc.footnotes[1]).toStrictEqual({ text: "no id or type" }); }); }); @@ -3423,3 +3426,51 @@ describe("readDocxContent: word/document.xml missing w:body", () => { ); }); }); + +describe("readDocxContent: construct re-indexing after a page-break split with a non-zero offset", () => { + it("re-indexes a construct after the split run correctly when the split run's own after-half is empty", () => { + const paragraph = el("w:p", {}, [ + textRun("lead run"), + el("w:r", {}, [ + el("w:t", { "xml:space": "preserve" }, [txt("before")]), + el("w:br", { "w:type": "page" }), + ]), + el("w:bookmarkStart", { "w:id": "9", "w:name": "afterEmpty" }), + textRun("bookmarked"), + el("w:bookmarkEnd", { "w:id": "9" }), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const blocks = doc.sections[0]?.blocks ?? []; + const after = asParagraph(blocks[2]); + expect(after.runs.map((r) => r.text)).toEqual(["bookmarked"]); + expect(after.constructs).toEqual([ + { + descriptor: { + kind: "anchor", + anchorType: "bookmark", + name: "afterEmpty", + }, + startRun: 0, + endRun: 1, + }, + ]); + }); +}); + +describe("readDocxContent: cell/paragraph borders with the surrounding property element present but no border element at all", () => { + it("leaves a cell's own borders undefined when w:tcPr is present but carries no w:tcBorders", () => { + const table = el("w:tbl", {}, [ + el("w:tblGrid", {}, [el("w:gridCol", { "w:w": "1440" })]), + el("w:tr", {}, [ + el("w:tc", {}, [ + el("w:tcPr", {}, [el("w:shd", { "w:fill": "00FF00" })]), + el("w:p", {}, [textRun("cell")]), + ]), + ]), + ]); + const doc = readDocxContent(paragraphPackage(table)); + expect( + asTable(doc.sections[0]?.blocks[0]).rows[0]?.cells[0]?.borders, + ).toBeUndefined(); + }); +}); From 01d100edadbff684bf75c2f3ae0e7773894e689a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 10:11:21 +0100 Subject: [PATCH 38/63] docs(ooxml.js): record docx read.ts's re-measured mutation score --- packages/ooxml.js/stryker.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index 821c21920e..e0b7d2a238 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,7 +2,7 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since had direct structural coverage added too (exact table/tblGrid/vMerge XML, wrapInternalLinks' sorted-extent resolution, comment/footnote id minting, and package-scaffolding root tags/namespaces); scoped-mutating it alone now measures 74.53% of its own 1107 valid mutants, up from a genuinely re-measured baseline of 66.67% taken in the same session immediately beforehand (both runs against the identical source, differing only in the new tests). docx/read.ts remains essentially untouched and is still well short of 100%, so this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor once that module has also been closed. + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since had direct structural coverage added too (exact table/tblGrid/vMerge XML, wrapInternalLinks' sorted-extent resolution, comment/footnote id minting, and package-scaffolding root tags/namespaces); scoped-mutating it alone now measures 74.53% of its own 1107 valid mutants, up from a genuinely re-measured baseline of 66.67% taken in the same session immediately beforehand (both runs against the identical source, differing only in the new tests). docx/read.ts has since had direct structural coverage added too (page-size/margin fallback edges, w:pageBreakBefore toggle values, findRunPageBreakOffset's own tab/br/cr/delText accounting, nested-object drawing lifting, field block-scope boundaries including the multi-paragraph TOC shape, cell-border w:start/w:end aliasing, block-level bookmark pairing and malformed-order/duplicate-id rejection, the empty-body sections fallback, and comment/footnote optional-field presence); scoped-mutating it alone now measures 86.64% of its own 1899 valid mutants, up from a genuinely re-measured baseline of 75.87% taken in the same campaign immediately beforehand (both runs against the identical source, differing only in the new tests). A residual, well-understood floor remains in docx/read.ts -- mostly UpdateOperator mutants on discovery-order counters (`order++`) that only an exact multi-construct ordering assertion would distinguish, and a handful of `!== -1` index guards in isBlockScopedField/isBlockScopedSimpleField that are behaviourally unobservable except when a paragraph has zero content-bearing children AND the field's own begin/end sit outside the paragraph's direct children simultaneously, a combination with no realistic real-world markup shape -- so this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. // // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. breakThreshold: 83, From 4ef7c940b028671d9fef2ac9e87432dc55f3ea90 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 14:49:50 +0100 Subject: [PATCH 39/63] refactor(ooxml.js): drop docx read.ts's provably-redundant guards readDrawingImage and readObjectEmbeddedObject no longer pre-check their geometry attributes for undefined before the Number() conversion: Number(undefined) is NaN, so the existing non-finite check already returns the same undefined for a missing wp:extent attribute, a missing wp:extent element, and a missing w:dxaOrig/w:dyaOrig alike. splitParagraphAtPageBreak indexes paragraph.runs[pageBreak.runIndex] with a non-null assertion instead of an undefined fallback: the event's runIndex is the index the run walk assigned the break's own run as it pushed it, and the runs array is append-only from that point to this assembly, so the index always names a real run. --- packages/ooxml.js/src/typed/docx/read.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/read.ts b/packages/ooxml.js/src/typed/docx/read.ts index 0eaae7e594..b371ae28b2 100644 --- a/packages/ooxml.js/src/typed/docx/read.ts +++ b/packages/ooxml.js/src/typed/docx/read.ts @@ -344,12 +344,9 @@ function readDrawingImage( const extent = childrenWithTag(container, "wp:extent")[0]; const cx = extent === undefined ? undefined : attr(extent, "cx"); const cy = extent === undefined ? undefined : attr(extent, "cy"); - if (cx === undefined || cy === undefined) { - return undefined; - } const widthPt = emuToPt(Number(cx)); const heightPt = emuToPt(Number(cy)); - // Malformed geometry (a non-numeric EMU value) degrades to no image, the same tier readObjectEmbeddedObject applies below and every other numeric attribute reader here degrades on: a NaN widthPt would emit a block no geometry schema accepts, poisoning the whole section for downstream validators. + // Malformed geometry (a non-numeric or absent EMU value -- Number(undefined) is NaN, so a missing attribute or a missing wp:extent both land here) degrades to no image, the same tier readObjectEmbeddedObject applies below and every other numeric attribute reader here degrades on: a NaN widthPt would emit a block no geometry schema accepts, poisoning the whole section for downstream validators. if (!Number.isFinite(widthPt) || !Number.isFinite(heightPt)) { return undefined; } @@ -396,12 +393,9 @@ function readObjectEmbeddedObject( ): ContentEmbeddedObjectBlock | undefined { const dxaOrig = attr(object, "w:dxaOrig"); const dyaOrig = attr(object, "w:dyaOrig"); - if (dxaOrig === undefined || dyaOrig === undefined) { - return undefined; - } const widthPt = twipsToPt(Number(dxaOrig)); const heightPt = twipsToPt(Number(dyaOrig)); - // Malformed geometry (a non-numeric ST_TwipsMeasure) degrades to no block, the same tier readDrawingImage above applies and readOutlineLevel's malformed @lvl is the family's own example of: a NaN widthPt would emit a block no geometry schema accepts, poisoning the whole section for downstream validators. Checked before any relationship resolution, so a doomed object never decodes its payload. + // Malformed geometry (a non-numeric or absent ST_TwipsMeasure -- Number(undefined) is NaN, so a missing attribute lands here too) degrades to no block, the same tier readDrawingImage above applies and readOutlineLevel's malformed @lvl is the family's own example of: a NaN widthPt would emit a block no geometry schema accepts, poisoning the whole section for downstream validators. Checked before any relationship resolution, so a doomed object never decodes its payload. if (!Number.isFinite(widthPt) || !Number.isFinite(heightPt)) { return undefined; } @@ -949,10 +943,8 @@ function splitParagraphAtPageBreak( paragraph: ContentParagraph, pageBreak: ParagraphPageBreakEvent, ): ContentBlock[] { - const splitRun = paragraph.runs[pageBreak.runIndex]; - if (splitRun === undefined) { - return [paragraph]; - } + // The event's runIndex is the index the run walk assigned the break's own run as it pushed it, and the runs array is append-only from that point to this assembly, so the index always names a real run -- no undefined fallback exists to take. + const splitRun = paragraph.runs[pageBreak.runIndex]!; const { before: beforeHalf, after: afterHalf } = splitRunAtOffset( splitRun, pageBreak.charIndex, From f161967cd32bf651cabcccd64bc1c37decf21332 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 14:50:03 +0100 Subject: [PATCH 40/63] test(ooxml.js): cover docx read.ts's construct tie-breaks, header joins, and anchor offsets Discovery-order tie-breaks between constructs sharing one extent range (two paragraph-scoped bookmarks, tracked change vs bookmark pair, content control vs tracked change, block bookmark vs content control, point bookmarks after a wide bookmark close, body-level bookmark treated as content), section-split extent re-indexing into second-section coordinates, table grid-column merge arithmetic, range-marker malformed-pair rejection, per-edge margin fallbacks, page-break split offset accounting across rPr/tab/first-break-wins, drawing geometry degradation and alt-text/float-position edges, lifted-element deletion and alternate-content guards, run-walk anchor and link guards, block-scoped field qualification against proofErr/bookmark siblings, complex-field instruction accumulation stopping at the separate, theme resolution order, header-part joins and extension filtering, and lifted-image anchor offsets across tab, break, carriage return, and deleted-text children. --- packages/ooxml.js/src/typed/docx/read.test.ts | 1141 +++++++++++++++++ 1 file changed, 1141 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/read.test.ts b/packages/ooxml.js/src/typed/docx/read.test.ts index ec69f8b2d5..05fe43bf33 100644 --- a/packages/ooxml.js/src/typed/docx/read.test.ts +++ b/packages/ooxml.js/src/typed/docx/read.test.ts @@ -3474,3 +3474,1144 @@ describe("readDocxContent: cell/paragraph borders with the surrounding property ).toBeUndefined(); }); }); + +describe("readDocxContent: per-edge margin fallbacks for top and left", () => { + function sectPrDoc(sectPr: XmlElement): Package { + const body = el("w:body", {}, [ + el("w:p", {}, [textRun("Margins")]), + sectPr, + ]); + return { + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + }, + }; + } + + it("defaults the top and left margins to one inch when w:pgMar omits them while spelling right and bottom", () => { + const doc = readDocxContent( + sectPrDoc( + el("w:sectPr", {}, [ + el("w:pgSz", { "w:w": "12240", "w:h": "15840" }), + el("w:pgMar", { "w:right": "720", "w:bottom": "1440" }), + ]), + ), + ); + expect(doc.sections[0]?.margins).toEqual({ + topPt: 72, + rightPt: 36, + bottomPt: 72, + leftPt: 72, + }); + }); +}); + +describe("readDocxContent: page-break split offset accounting", () => { + it("does not count a run-properties child toward the split offset of a mid-run page break", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:rPr", {}, [el("w:b", {})]), + el("w:t", { "xml:space": "preserve" }, [txt("abcd")]), + el("w:br", { "w:type": "page" }), + el("w:t", { "xml:space": "preserve" }, [txt("ef")]), + ]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const blocks = doc.sections[0]?.blocks ?? []; + expect(asParagraph(blocks[0]).runs.map((r) => r.text)).toEqual(["abcd"]); + expect(blocks[1]?.kind).toBe("pageBreak"); + expect(asParagraph(blocks[2]).runs.map((r) => r.text)).toEqual(["ef"]); + }); + + it("splits at the paragraph's first page-type break when a later run carries one too", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:t", { "xml:space": "preserve" }, [txt("one")]), + el("w:br", { "w:type": "page" }), + ]), + el("w:r", {}, [ + el("w:t", { "xml:space": "preserve" }, [txt("two")]), + el("w:br", { "w:type": "page" }), + ]), + textRun("three"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const blocks = doc.sections[0]?.blocks ?? []; + // Only the FIRST break splits the paragraph; the second stays a literal newline inside the after-half's own run text. + expect(asParagraph(blocks[0]).runs.map((r) => r.text)).toEqual(["one"]); + expect(blocks[1]?.kind).toBe("pageBreak"); + expect(asParagraph(blocks[2]).runs.map((r) => r.text)).toEqual([ + "two\n", + "three", + ]); + }); + + it("leaves a split paragraph's lifted image unanchored rather than naming a pre-split run index", () => { + const paragraph = el("w:p", {}, [ + textRun("before"), + el("w:r", {}, [el("w:br", { "w:type": "page" })]), + el("w:r", {}, [drawingElement("wp:inline", "rIdImg", "Split alt text")]), + ]); + const parts: Package["parts"] = { + "word/_rels/document.xml.rels": { + kind: "xml", + nodes: [ + rels([{ id: "rIdImg", type: IMAGE_REL, target: "media/image1.png" }]), + ], + }, + "word/media/image1.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + }; + const doc = readDocxContent(paragraphPackage(paragraph, parts)); + const blocks = doc.sections[0]?.blocks ?? []; + const image = asImage(blocks[3]); + expect(image.altText).toBe("Split alt text"); + expect(image.anchorRunIndex).toBeUndefined(); + expect(image.anchorOffset).toBeUndefined(); + }); +}); + +describe("readDocxContent: drawing geometry, alt text, and float-position edges", () => { + function imageParts(): Package["parts"] { + return { + "word/_rels/document.xml.rels": { + kind: "xml", + nodes: [ + rels([{ id: "rIdImg", type: IMAGE_REL, target: "media/image1.png" }]), + ], + }, + "word/media/image1.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + }; + } + + function positionAxes(): XmlElement[] { + return [ + el("wp:positionH", { relativeFrom: "column" }, [ + el("wp:posOffset", {}, [txt("914400")]), + ]), + el("wp:positionV", { relativeFrom: "paragraph" }, [ + el("wp:posOffset", {}, [txt("457200")]), + ]), + ]; + } + + it("degrades a drawing whose wp:extent omits @w:cy to no image block", () => { + const drawing = el("w:drawing", {}, [ + el("wp:inline", {}, [ + el("wp:extent", { cx: "914400" }), + el("wp:docPr", { id: "1", name: "Picture 1" }), + el("a:graphic", {}, [ + el("a:graphicData", { uri: PICTURE_GRAPHIC_URI }, [ + el("pic:pic", {}, [ + el("pic:blipFill", {}, [el("a:blip", { "r:embed": "rIdImg" })]), + ]), + ]), + ]), + ]), + ]); + const doc = readDocxContent( + paragraphPackage(el("w:p", {}, [el("w:r", {}, [drawing])]), imageParts()), + ); + expect(doc.sections[0]?.blocks).toHaveLength(1); + }); + + it("falls back to wp:docPr/@w:title for alt text when @w:descr is absent", () => { + const drawing = el("w:drawing", {}, [ + el("wp:inline", {}, [ + el("wp:extent", { cx: "914400", cy: "457200" }), + el("wp:docPr", { id: "1", name: "Picture 1", title: "The Title" }), + el("a:graphic", {}, [ + el("a:graphicData", { uri: PICTURE_GRAPHIC_URI }, [ + el("pic:pic", {}, [ + el("pic:blipFill", {}, [el("a:blip", { "r:embed": "rIdImg" })]), + ]), + ]), + ]), + ]), + ]); + const doc = readDocxContent( + paragraphPackage(el("w:p", {}, [el("w:r", {}, [drawing])]), imageParts()), + ); + expect(asImage(doc.sections[0]?.blocks[1]).altText).toBe("The Title"); + }); + + it("keeps an inline image unpositioned even when its markup carries wp:positionH/wp:positionV", () => { + const inline = el("wp:inline", {}, [ + el("wp:extent", { cx: "914400", cy: "457200" }), + el("wp:docPr", { id: "1", name: "Picture 1", descr: "Inline alt" }), + ...positionAxes(), + el("a:graphic", {}, [ + el("a:graphicData", { uri: PICTURE_GRAPHIC_URI }, [ + el("pic:pic", {}, [ + el("pic:blipFill", {}, [el("a:blip", { "r:embed": "rIdImg" })]), + ]), + ]), + ]), + ]); + const doc = readDocxContent( + paragraphPackage( + el("w:p", {}, [el("w:r", {}, [el("w:drawing", {}, [inline])])]), + imageParts(), + ), + ); + expect(asImage(doc.sections[0]?.blocks[1]).floatPosition).toBeUndefined(); + }); + + it("omits the floatPosition field entirely for an anchored image with no position elements", () => { + const anchor = el("wp:anchor", {}, [ + el("wp:extent", { cx: "914400", cy: "457200" }), + el("wp:docPr", { id: "1", name: "Picture 1", descr: "Anchored alt" }), + el("a:graphic", {}, [ + el("a:graphicData", { uri: PICTURE_GRAPHIC_URI }, [ + el("pic:pic", {}, [ + el("pic:blipFill", {}, [el("a:blip", { "r:embed": "rIdImg" })]), + ]), + ]), + ]), + ]); + const doc = readDocxContent( + paragraphPackage( + el("w:p", {}, [el("w:r", {}, [el("w:drawing", {}, [anchor])])]), + imageParts(), + ), + ); + const image = asImage(doc.sections[0]?.blocks[1]); + expect("floatPosition" in image).toBe(false); + }); + + it("degrades a w:object missing @w:dxaOrig to no embedded block", () => { + const pkg = oleObjectFixturePackage( + { target: "embeddings/oleObject1.xlsx" }, + [], + ); + const relsPart = pkg.parts["word/_rels/document.xml.rels"]; + if (relsPart?.kind !== "xml") { + throw new Error("expected document rels"); + } + const objectRun = el("w:r", {}, [ + el("w:object", { "w:dyaOrig": "1200" }, [ + el("o:OLEObject", { "r:id": "rIdOle" }), + ]), + ]); + pkg.parts["word/embeddings/oleObject1.xlsx"] = { + kind: "binary", + base64: bytesToBase64(minimalXlsxBytes()), + }; + const body = el("w:body", {}, [ + el("w:p", {}, [objectRun, textRun("after")]), + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ]); + pkg.parts["word/document.xml"] = { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }; + const doc = readDocxContent(pkg); + expect(doc.sections[0]?.blocks).toHaveLength(1); + // The object's own run still emits (empty text -- a w:object contributes no run text), but no embedded block follows it. + expect( + asParagraph(doc.sections[0]?.blocks[0]).runs.map((r) => r.text), + ).toEqual(["", "after"]); + }); +}); + +describe("readDocxContent: lifted-element collection guards", () => { + function liftingParts(): Package["parts"] { + return { + "word/_rels/document.xml.rels": { + kind: "xml", + nodes: [ + rels([ + { id: "rIdImg", type: IMAGE_REL, target: "media/image1.png" }, + { id: "rIdImg2", type: IMAGE_REL, target: "media/image2.png" }, + ]), + ], + }, + "word/media/image1.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + "word/media/image2.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + }; + } + + function nestedDrawingElement(rId: string, altText: string): XmlElement { + return el("w:drawing", {}, [ + el("wp:inline", {}, [ + el("wp:extent", { cx: "914400", cy: "457200" }), + el("wp:docPr", { id: "1", name: "Picture 1", descr: altText }), + el("a:graphic", {}, [ + el("a:graphicData", { uri: PICTURE_GRAPHIC_URI }, [ + el("pic:pic", {}, [ + el("pic:blipFill", {}, [el("a:blip", { "r:embed": rId })]), + ]), + ]), + ]), + ]), + ]); + } + + it("does not lift a drawing out of a tracked deletion that is not carried", () => { + const paragraph = el("w:p", {}, [ + el( + "w:del", + { "w:id": "1", "w:author": "Ed", "w:date": "2024-01-01T00:00:00Z" }, + [el("w:r", {}, [nestedDrawingElement("rIdImg", "deleted alt")])], + ), + textRun("kept"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph, liftingParts())); + expect(doc.sections[0]?.blocks).toHaveLength(1); + expect( + asParagraph(doc.sections[0]?.blocks[0]).runs.map((r) => r.text), + ).toEqual(["kept"]); + }); + + it("does not lift a drawing out of a tracked move-from that is not carried", () => { + const paragraph = el("w:p", {}, [ + el( + "w:moveFrom", + { "w:id": "1", "w:author": "Ed", "w:date": "2024-01-01T00:00:00Z" }, + [el("w:r", {}, [nestedDrawingElement("rIdImg", "moved alt")])], + ), + textRun("kept"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph, liftingParts())); + expect(doc.sections[0]?.blocks).toHaveLength(1); + expect( + asParagraph(doc.sections[0]?.blocks[0]).runs.map((r) => r.text), + ).toEqual(["kept"]); + }); + + it("lifts a drawing once even when its graphic data carries an alternate-content nested drawing", () => { + const outer = el("w:drawing", {}, [ + el("wp:inline", {}, [ + el("wp:extent", { cx: "914400", cy: "457200" }), + el("wp:docPr", { id: "1", name: "Picture 1", descr: "outer alt" }), + el("a:graphic", {}, [ + el("a:graphicData", { uri: PICTURE_GRAPHIC_URI }, [ + el("mc:AlternateContent", {}, [ + el("mc:Choice", {}, [ + nestedDrawingElement("rIdImg2", "nested alt"), + ]), + ]), + el("pic:pic", {}, [ + el("pic:blipFill", {}, [el("a:blip", { "r:embed": "rIdImg" })]), + ]), + ]), + ]), + ]), + ]); + const doc = readDocxContent( + paragraphPackage(el("w:p", {}, [el("w:r", {}, [outer])]), liftingParts()), + ); + const blocks = doc.sections[0]?.blocks ?? []; + expect(blocks).toHaveLength(2); + expect(asImage(blocks[1]).altText).toBe("outer alt"); + }); +}); + +describe("readDocxContent: run-walk anchor and link guard edges", () => { + it("ignores a w:id attribute on a run child that is not a reference mark", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:t", { "xml:space": "preserve" }, [txt("tab run")]), + el("w:tab", { "w:id": "7" }), + ]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(firstParagraph(doc).constructs).toBeUndefined(); + }); + + it("records no link extent for a hyperlink with neither a resolvable target nor an anchor", () => { + const paragraph = el("w:p", {}, [ + el("w:hyperlink", {}, [textRun("orphan link text")]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(firstParagraph(doc).constructs).toBeUndefined(); + }); + + it("records no link extent for an anchored hyperlink that emitted no runs", () => { + const paragraph = el("w:p", {}, [ + textRun("before"), + el("w:hyperlink", { "w:anchor": "missing" }, [el("w:ins", {}, [])]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(firstParagraph(doc).constructs).toBeUndefined(); + }); + + it("ignores a permission start beside a comment range start rather than pairing it as the range's end half", () => { + const paragraph = el("w:p", {}, [ + el("w:commentRangeStart", { "w:id": "4" }), + textRun("annotated"), + el("w:permStart", { "w:id": "4" }), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(firstParagraph(doc).constructs).toBeUndefined(); + expect( + doc.sections[0]?.blocks.every((b) => b.kind !== "constructStart"), + ).toBe(true); + }); + + it("leaves a plain run without its own hyperlink field", () => { + const doc = readDocxContent( + paragraphPackage(el("w:p", {}, [textRun("plain")])), + ); + expect(Object.hasOwn(firstParagraph(doc).runs[0]!, "hyperlink")).toBe( + false, + ); + }); +}); + +describe("readDocxContent: block-scoped field qualification against non-content siblings", () => { + function complexFieldRuns(instruction: string): XmlElement[] { + return [ + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "begin" })]), + el("w:r", {}, [ + el("w:instrText", { "xml:space": "preserve" }, [txt(instruction)]), + ]), + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "end" })]), + ]; + } + + it("keeps a whole-paragraph field off the paragraph's constructs when a proofErr precedes it", () => { + const paragraph = el("w:p", {}, [ + el("w:proofErr", { "w:type": "spellStart" }), + ...complexFieldRuns(" X "), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + // The begin run is the first CONTENT child (a proofErr is not content), so the field is block-scoped: a construct marker pair, never a second run-level encoding. + expect(asParagraph(doc.sections[0]?.blocks[1]).constructs).toBeUndefined(); + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "field", + instruction: " X ", + }); + expect(doc.sections[0]?.blocks[2]?.kind).toBe("constructEnd"); + }); + + it("keeps a whole-paragraph field off the paragraph's constructs when a proofErr follows it", () => { + const paragraph = el("w:p", {}, [ + ...complexFieldRuns(" Y "), + el("w:proofErr", { "w:type": "spellEnd" }), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(asParagraph(doc.sections[0]?.blocks[1]).constructs).toBeUndefined(); + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "field", + instruction: " Y ", + }); + }); + + it("keeps a bookmark-flanked simple field off the paragraph's constructs while bracketing the paragraph", () => { + const paragraph = el("w:p", {}, [ + el("w:bookmarkStart", { "w:id": "11", "w:name": "Flank" }), + el("w:fldSimple", { "w:instr": " DATE " }, [textRun("1 Jan")]), + el("w:bookmarkEnd", { "w:id": "11" }), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(asParagraph(doc.sections[0]?.blocks[2]).constructs).toBeUndefined(); + // The bookmark's halves sit outside the field (the paragraph's only content child), so the bookmark opens first and the field nests inside it. + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "Flank", + }); + expect(asConstructStart(doc.sections[0]?.blocks[1]).descriptor).toEqual({ + kind: "field", + instruction: " DATE ", + }); + expect(asParagraph(doc.sections[0]?.blocks[2]).runs[0]?.text).toBe("1 Jan"); + expect(doc.sections[0]?.blocks[3]?.kind).toBe("constructEnd"); + expect(doc.sections[0]?.blocks[4]?.kind).toBe("constructEnd"); + }); + + it("emits a simple field followed by text as a run extent, not a block construct", () => { + const paragraph = el("w:p", {}, [ + el("w:fldSimple", { "w:instr": " DATE " }, [textRun("1 Jan")]), + textRun(" trailing"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect( + doc.sections[0]?.blocks.every((b) => b.kind !== "constructStart"), + ).toBe(true); + expect(firstParagraph(doc).constructs).toEqual([ + { + descriptor: { kind: "field", instruction: " DATE " }, + startRun: 0, + endRun: 1, + }, + ]); + }); + + it("reads a mid-paragraph simple field with no @w:instr as an empty instruction", () => { + const paragraph = el("w:p", {}, [ + textRun("lead"), + el("w:fldSimple", {}, [textRun("cached")]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(firstParagraph(doc).constructs).toEqual([ + { + descriptor: { kind: "field", instruction: "" }, + startRun: 1, + endRun: 2, + }, + ]); + }); + + it("brackets a whole-paragraph simple field with no @w:instr as an empty-instruction construct", () => { + const paragraph = el("w:p", {}, [ + el("w:fldSimple", {}, [textRun("cached")]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "field", + instruction: "", + }); + }); +}); + +describe("readDocxContent: complex-field instruction accumulation", () => { + it("stops the instruction at the field's separate, ignoring instrText in the result half", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "begin" })]), + el("w:r", {}, [ + el("w:instrText", { "xml:space": "preserve" }, [txt(" A ")]), + ]), + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "separate" })]), + el("w:r", {}, [ + el("w:instrText", { "xml:space": "preserve" }, [txt(" B ")]), + ]), + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "end" })]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "field", + instruction: " A ", + }); + }); + + it("keeps instruction text nested directly inside a hyperlink out of a whole-paragraph field's instruction", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "begin" })]), + el("w:hyperlink", {}, [ + el("w:instrText", { "xml:space": "preserve" }, [txt(" POLLUTE ")]), + ]), + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "end" })]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "field", + instruction: "", + }); + }); +}); + +describe("readDocxContent: discovery-order tie-breaks between constructs sharing one extent range", () => { + // Several constructs can bracket the identical block range (two bookmarks around one paragraph, a bookmark around a content control, a content control around a tracked paragraph). Their emission order at the shared boundary is the source's own discovery order, carried by the walk's order counter -- these tests pin that order exactly, because a marker pair emitted in the wrong order decodes to the wrong nesting. + function flowDoc(children: XmlElement[]): ReturnType { + const body = el("w:body", {}, [ + ...children, + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ]); + return readDocxContent({ + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + }, + }); + } + + it("opens two paragraph-scoped bookmarks over one paragraph in source order", () => { + const paragraph = el("w:p", {}, [ + el("w:bookmarkStart", { "w:id": "1", "w:name": "First" }), + el("w:bookmarkStart", { "w:id": "2", "w:name": "Second" }), + textRun("Bookmarked"), + el("w:bookmarkEnd", { "w:id": "1" }), + el("w:bookmarkEnd", { "w:id": "2" }), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const blocks = doc.sections[0]?.blocks ?? []; + expect(asConstructStart(blocks[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "First", + }); + expect(asConstructStart(blocks[1]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "Second", + }); + expect(asParagraph(blocks[2]).runs[0]?.text).toBe("Bookmarked"); + expect(blocks[3]?.kind).toBe("constructEnd"); + expect(blocks[4]?.kind).toBe("constructEnd"); + }); + + it("opens a whole-paragraph tracked change before the paragraph's own bookmark pair", () => { + const paragraph = el("w:p", {}, [ + el("w:bookmarkStart", { "w:id": "3", "w:name": "Marked" }), + el( + "w:ins", + { "w:id": "9", "w:author": "Ed", "w:date": "2024-01-01T00:00:00Z" }, + [textRun("Inserted")], + ), + el("w:bookmarkEnd", { "w:id": "3" }), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const blocks = doc.sections[0]?.blocks ?? []; + expect(asConstructStart(blocks[0]).descriptor).toEqual({ + kind: "provenance", + change: "insertion", + author: "Ed", + dateIso: "2024-01-01T00:00:00Z", + }); + expect(asConstructStart(blocks[1]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "Marked", + }); + expect(asParagraph(blocks[2]).runs[0]?.text).toBe("Inserted"); + expect(blocks[3]?.kind).toBe("constructEnd"); + expect(blocks[4]?.kind).toBe("constructEnd"); + }); + + it("opens a content control before the tracked-change extent of the paragraph it wraps", () => { + const doc = readDocxContent( + paragraphPackage( + el("w:sdt", {}, [ + el("w:sdtContent", {}, [ + el("w:p", {}, [ + el( + "w:ins", + { + "w:id": "9", + "w:author": "Ed", + "w:date": "2024-01-01T00:00:00Z", + }, + [textRun("Drafted")], + ), + ]), + ]), + ]), + ), + ); + const blocks = doc.sections[0]?.blocks ?? []; + expect(asConstructStart(blocks[0]).descriptor).toEqual({ + kind: "contentControl", + controlType: "richText", + }); + expect(asConstructStart(blocks[1]).descriptor).toEqual({ + kind: "provenance", + change: "insertion", + author: "Ed", + dateIso: "2024-01-01T00:00:00Z", + }); + expect(asParagraph(blocks[2]).runs[0]?.text).toBe("Drafted"); + expect(blocks[3]?.kind).toBe("constructEnd"); + expect(blocks[4]?.kind).toBe("constructEnd"); + }); + + it("opens a block-level bookmark before a content control sharing its extent", () => { + const doc = flowDoc([ + el("w:bookmarkStart", { "w:id": "7", "w:name": "Wrapped" }), + el("w:sdt", {}, [ + el("w:sdtContent", {}, [el("w:p", {}, [textRun("Controlled")])]), + ]), + el("w:bookmarkEnd", { "w:id": "7" }), + ]); + const blocks = doc.sections[0]?.blocks ?? []; + expect(blocks).toHaveLength(5); + expect(asConstructStart(blocks[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "Wrapped", + }); + expect(asConstructStart(blocks[1]).descriptor).toEqual({ + kind: "contentControl", + controlType: "richText", + }); + expect(asParagraph(blocks[2]).runs[0]?.text).toBe("Controlled"); + expect(blocks[3]?.kind).toBe("constructEnd"); + expect(blocks[4]?.kind).toBe("constructEnd"); + }); + + it("opens point bookmarks sharing one block position in source order after an intervening wide bookmark close", () => { + const doc = flowDoc([ + el("w:bookmarkStart", { "w:id": "8", "w:name": "Wide" }), + el("w:p", {}, [textRun("One")]), + el("w:bookmarkStart", { "w:id": "9", "w:name": "FirstPoint" }), + el("w:bookmarkEnd", { "w:id": "9" }), + el("w:bookmarkEnd", { "w:id": "8" }), + el("w:bookmarkStart", { "w:id": "10", "w:name": "SecondPoint" }), + el("w:bookmarkEnd", { "w:id": "10" }), + ]); + const blocks = doc.sections[0]?.blocks ?? []; + expect(blocks).toHaveLength(7); + expect(asConstructStart(blocks[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "Wide", + }); + expect(asParagraph(blocks[1]).runs[0]?.text).toBe("One"); + expect(blocks[2]?.kind).toBe("constructEnd"); + expect(asConstructStart(blocks[3]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "FirstPoint", + }); + expect(blocks[4]?.kind).toBe("constructEnd"); + expect(asConstructStart(blocks[5]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "SecondPoint", + }); + expect(blocks[6]?.kind).toBe("constructEnd"); + }); + + it("treats a body-level bookmark as content, never as a section break", () => { + const doc = flowDoc([ + el("w:bookmarkStart", { "w:id": "12", "w:name": "Only" }), + el("w:p", {}, [textRun("Solo")]), + el("w:bookmarkEnd", { "w:id": "12" }), + ]); + expect(doc.sections).toHaveLength(1); + }); +}); + +describe("readDocxContent: section-split extent re-indexing and final-section handling", () => { + function twoSectionDoc( + trailingBodySectPr: boolean, + ): ReturnType { + const bodyChildren: XmlElement[] = [ + el("w:p", {}, [textRun("First section")]), + el("w:p", {}, [ + el("w:pPr", {}, [ + el("w:sectPr", {}, [ + el("w:pgSz", { "w:w": "12240", "w:h": "15840" }), + ]), + ]), + textRun("Break paragraph"), + ]), + el("w:bookmarkStart", { "w:id": "20", "w:name": "SecondHalf" }), + el("w:p", {}, [textRun("Second section")]), + el("w:bookmarkEnd", { "w:id": "20" }), + ]; + if (trailingBodySectPr) { + bodyChildren.push( + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ); + } + const body = el("w:body", {}, bodyChildren); + return readDocxContent({ + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + }, + }); + } + + it("re-indexes a construct into the second section's own coordinates and keeps it out of the first", () => { + const doc = twoSectionDoc(true); + expect(doc.sections).toHaveLength(2); + const first = doc.sections[0]?.blocks ?? []; + const second = doc.sections[1]?.blocks ?? []; + expect(first.every((b) => b.kind !== "constructStart")).toBe(true); + expect( + first.map((b) => (b.kind === "paragraph" ? b.runs[0]?.text : b.kind)), + ).toEqual(["First section", "Break paragraph"]); + expect(asConstructStart(second[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "SecondHalf", + }); + expect(asParagraph(second[1]).runs[0]?.text).toBe("Second section"); + expect(second[2]?.kind).toBe("constructEnd"); + }); + + it("keeps the trailing section after a mid-document break even with no body-level sectPr", () => { + const doc = twoSectionDoc(false); + expect(doc.sections).toHaveLength(2); + expect(asParagraph(doc.sections[1]?.blocks[1]).runs[0]?.text).toBe( + "Second section", + ); + expect(doc.sectionHeaderFooters).toStrictEqual([{}, {}]); + }); + + it("leaves breakType absent as a key on a section whose sectPr spells no w:type", () => { + const doc = twoSectionDoc(false); + expect(Object.hasOwn(doc.sections[1]!, "breakType")).toBe(false); + }); +}); + +describe("readDocxContent: table column and merge arithmetic", () => { + function vMergeTable( + topRestart: XmlElement, + bottomContinue: XmlElement, + ): XmlElement { + return el("w:tbl", {}, [ + el("w:tblGrid", {}, [ + el("w:gridCol", { "w:w": "1440" }), + el("w:gridCol", { "w:w": "2880" }), + ]), + el("w:tr", {}, [ + el("w:tc", {}, [el("w:p", {}, [textRun("left top")])]), + topRestart, + ]), + el("w:tr", {}, [ + el("w:tc", {}, [el("w:p", {}, [textRun("left bottom")])]), + bottomContinue, + ]), + ]); + } + + it("derives a vertical merge's rowSpan on the second column from grid-column indices with plain unspanned cells", () => { + const restart = el("w:tc", {}, [ + el("w:tcPr", {}, [el("w:vMerge", { "w:val": "restart" })]), + el("w:p", {}, [textRun("merged")]), + ]); + const continuation = el("w:tc", {}, [ + el("w:tcPr", {}, [el("w:vMerge")]), + el("w:p", {}, [textRun("hidden")]), + ]); + const doc = readDocxContent( + paragraphPackage(vMergeTable(restart, continuation)), + ); + const rows = asTable(doc.sections[0]?.blocks[0]).rows; + expect(rows[0]?.cells[1]?.rowSpan).toBe(2); + expect(rows[1]?.cells[1]).toStrictEqual({ blocks: [] }); + }); + + it("reads a grid column with no @w:w as zero width", () => { + const table = el("w:tbl", {}, [ + el("w:tblGrid", {}, [ + el("w:gridCol", { "w:w": "1440" }), + el("w:gridCol", {}), + ]), + el("w:tr", {}, [ + el("w:tc", {}, [el("w:p", {}, [textRun("a")])]), + el("w:tc", {}, [el("w:p", {}, [textRun("b")])]), + ]), + ]); + const doc = readDocxContent(paragraphPackage(table)); + expect(asTable(doc.sections[0]?.blocks[0]).columnWidthsPt).toEqual([72, 0]); + }); +}); + +describe("readDocxContent: range-marker pairing and tracked-paragraph qualification", () => { + it("drops a bookmark pair whose id has two end halves", () => { + const body = el("w:body", {}, [ + el("w:bookmarkStart", { "w:id": "1", "w:name": "Ambiguous" }), + el("w:p", {}, [textRun("Bracketed")]), + el("w:bookmarkEnd", { "w:id": "1" }), + el("w:bookmarkEnd", { "w:id": "1" }), + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ]); + const doc = readDocxContent({ + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + }, + }); + expect( + doc.sections[0]?.blocks.every((b) => b.kind !== "constructStart"), + ).toBe(true); + }); + + it("emits no provenance extent for a paragraph mixing tracked and untracked children", () => { + const paragraph = el("w:p", {}, [ + el( + "w:ins", + { "w:id": "1", "w:author": "Ed", "w:date": "2024-01-01T00:00:00Z" }, + [textRun("tracked")], + ), + textRun("untracked"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect( + doc.sections[0]?.blocks.every((b) => b.kind !== "constructStart"), + ).toBe(true); + expect( + asParagraph(doc.sections[0]?.blocks[0]).runs.map((r) => r.text), + ).toEqual(["tracked", "untracked"]); + }); + + it("emits a provenance extent for a paragraph whose every content child is the same insertion", () => { + const paragraph = el("w:p", {}, [ + el( + "w:ins", + { "w:id": "1", "w:author": "Ann", "w:date": "2024-01-01T00:00:00Z" }, + [textRun("first")], + ), + el( + "w:ins", + { "w:id": "2", "w:author": "Ann", "w:date": "2024-01-01T00:00:00Z" }, + [textRun("second")], + ), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "provenance", + change: "insertion", + author: "Ann", + dateIso: "2024-01-01T00:00:00Z", + }); + expect( + asParagraph(doc.sections[0]?.blocks[1]).runs.map((r) => r.text), + ).toEqual(["first", "second"]); + expect(doc.sections[0]?.blocks[2]?.kind).toBe("constructEnd"); + }); +}); + +describe("readDocxContent: paragraph-scoped comment markers and empty-paragraph bookmark edges", () => { + it("brackets a whole paragraph in a comment marker pair when both halves sit inside it but outside its runs", () => { + const paragraph = el("w:p", {}, [ + el("w:commentRangeStart", { "w:id": "31" }), + textRun("Annotated"), + el("w:commentRangeEnd", { "w:id": "31" }), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(asConstructStart(doc.sections[0]?.blocks[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "comment", + name: "31", + }); + expect(asParagraph(doc.sections[0]?.blocks[1]).runs[0]?.text).toBe( + "Annotated", + ); + expect(doc.sections[0]?.blocks[2]?.kind).toBe("constructEnd"); + }); + + it("wraps an otherwise-empty paragraph in its own bookmark pair rather than pinning it to a point", () => { + const paragraph = el("w:p", {}, [ + el("w:bookmarkStart", { "w:id": "41", "w:name": "Empty" }), + el("w:bookmarkEnd", { "w:id": "41" }), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const blocks = doc.sections[0]?.blocks ?? []; + expect(blocks).toHaveLength(3); + expect(asConstructStart(blocks[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "Empty", + }); + expect(asParagraph(blocks[1]).runs).toEqual([]); + expect(blocks[2]?.kind).toBe("constructEnd"); + }); +}); + +describe("readDocxContent: theme resolution order and part-level joins", () => { + it("resolves the theme from the relationship whose type ends in /theme even when an earlier relationship targets an existing xml part", () => { + const styles = el("w:styles", {}, [ + el("w:docDefaults", {}, [ + el("w:rPrDefault", {}, [ + el("w:rPr", {}, [el("w:rFonts", { "w:asciiTheme": "minorHAnsi" })]), + ]), + ]), + ]); + const theme = el("a:theme", {}, [ + el("a:themeElements", {}, [ + el("a:fontScheme", {}, [ + el("a:minorFont", {}, [el("a:latin", { typeface: "Minor Font" })]), + ]), + ]), + ]); + const body = el("w:body", {}, [ + el("w:p", {}, [textRun("themed")]), + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ]); + const doc = readDocxContent({ + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + "word/_rels/document.xml.rels": { + kind: "xml", + nodes: [ + rels([ + { + id: "rIdStyles", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + target: "styles.xml", + }, + { id: "rIdTheme", type: THEME_REL, target: "theme/theme1.xml" }, + ]), + ], + }, + "word/styles.xml": { kind: "xml", nodes: [styles] }, + "word/theme/theme1.xml": { kind: "xml", nodes: [theme] }, + }, + }); + expect(firstParagraph(doc).runs[0]?.fontFamily).toBe("Minor Font"); + }); + + it("joins a comment's several text runs with no separator", () => { + const pkg = buildFixturePackage(); + pkg.parts["word/comments.xml"] = { + kind: "xml", + nodes: [ + el("w:comments", {}, [ + el("w:comment", { "w:id": "5", "w:author": "Ann" }, [ + el("w:p", {}, [ + el("w:r", {}, [el("w:t", {}, [txt("first ")])]), + el("w:r", {}, [el("w:t", {}, [txt("second")])]), + ]), + ]), + ]), + ], + }; + const doc = readDocxContent(pkg); + expect(doc.comments[0]?.text).toBe("first second"); + }); + + it("joins a footnote's several text runs with no separator", () => { + const pkg = buildFixturePackage(); + pkg.parts["word/footnotes.xml"] = { + kind: "xml", + nodes: [ + el("w:footnotes", {}, [ + el("w:footnote", { "w:id": "3" }, [ + el("w:p", {}, [ + el("w:r", {}, [el("w:t", {}, [txt("note ")])]), + el("w:r", {}, [el("w:t", {}, [txt("tail")])]), + ]), + ]), + ]), + ], + }; + const doc = readDocxContent(pkg); + expect(doc.footnotes[0]?.text).toBe("note tail"); + }); + + it("skips a header-shaped part whose path does not end in .xml", () => { + const pkg = buildFixturePackage(); + pkg.parts["word/header1"] = { + kind: "xml", + nodes: [el("w:hdr", {}, [el("w:p", {}, [textRun("Extensionless")])])], + }; + const doc = readDocxContent(pkg); + expect( + doc.headerFooterParts.every((part) => part.path !== "word/header1"), + ).toBe(true); + }); + + it("drops a header part's run-level tracked deletion rather than carrying its content", () => { + const pkg = buildFixturePackage(); + pkg.parts["word/header2.xml"] = { + kind: "xml", + nodes: [ + el("w:hdr", {}, [ + el("w:p", {}, [ + textRun("Kept"), + el( + "w:del", + { + "w:id": "1", + "w:author": "Ed", + "w:date": "2024-01-01T00:00:00Z", + }, + [el("w:r", {}, [el("w:delText", {}, [txt("Deleted")])])], + ), + ]), + ]), + ], + }; + const doc = readDocxContent(pkg); + const header = doc.headerFooterParts.find( + (part) => part.path === "word/header2.xml", + ); + expect( + header?.blocks.map((b) => + b.kind === "paragraph" ? b.runs.map((r) => r.text) : b.kind, + ), + ).toEqual([["Kept"]]); + }); +}); + +describe("readDocxContent: lifted-image anchor offsets across tab, break, and deleted-text children", () => { + function offsetParts(): Package["parts"] { + return { + "word/_rels/document.xml.rels": { + kind: "xml", + nodes: [ + rels([{ id: "rIdImg", type: IMAGE_REL, target: "media/image1.png" }]), + ], + }, + "word/media/image1.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + }; + } + + function anchorOf( + doc: ReturnType, + ): ContentImageBlock { + return asImage(doc.sections[0]?.blocks[1]); + } + + it("counts a tab before an image in the same run as one character of offset", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:t", { "xml:space": "preserve" }, [txt("ab")]), + el("w:tab"), + drawingElement("wp:inline", "rIdImg", "tab alt"), + ]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph, offsetParts())); + expect(anchorOf(doc).anchorRunIndex).toBe(0); + expect(anchorOf(doc).anchorOffset).toBe(3); + }); + + it("counts a line break before an image in the same run as one character of offset", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:t", { "xml:space": "preserve" }, [txt("ab")]), + el("w:br"), + drawingElement("wp:inline", "rIdImg", "br alt"), + ]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph, offsetParts())); + expect(anchorOf(doc).anchorRunIndex).toBe(0); + expect(anchorOf(doc).anchorOffset).toBe(3); + }); + + it("counts a carriage return before an image in the same run as one character of offset", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:t", { "xml:space": "preserve" }, [txt("ab")]), + el("w:cr"), + drawingElement("wp:inline", "rIdImg", "cr alt"), + ]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph, offsetParts())); + expect(anchorOf(doc).anchorRunIndex).toBe(0); + expect(anchorOf(doc).anchorOffset).toBe(3); + }); + + it("counts deleted text before an image inside a carried deletion", () => { + const deleted = el( + "w:del", + { "w:id": "1", "w:author": "Ed", "w:date": "2024-01-01T00:00:00Z" }, + [ + el("w:p", {}, [ + el("w:r", {}, [ + el("w:delText", { "xml:space": "preserve" }, [txt("xy")]), + drawingElement("wp:inline", "rIdImg", "deleted alt"), + ]), + ]), + ], + ); + const doc = readDocxContent(paragraphPackage(deleted, offsetParts())); + // The flow-level w:del brackets its paragraph with provenance markers, so the image is located rather than assumed at a fixed index. + const image = (doc.sections[0]?.blocks ?? []).find( + (b) => b.kind === "image", + ); + expect(image).toBeDefined(); + if (image?.kind !== "image") { + throw new Error("expected an image block"); + } + expect(image.anchorRunIndex).toBe(0); + expect(image.anchorOffset).toBe(2); + }); +}); From 83fe09aaf52ddc794721265237def7eb9e8084c2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 15:26:00 +0100 Subject: [PATCH 41/63] refactor(ooxml.js): remove docx read.ts's behaviourally-dead guard expressions readToggle's val === undefined disjunct: the !== conjunction that follows already evaluates true for undefined, so absent @w:val means on without its own branch. isBlockScopedField and isBlockScopedSimpleField drop their indexOf !== -1 guards: the run walk only reaches a begin or fldSimple through containers that are all content-bearing, so a paragraph with a field event always has a content-bearing direct child and an unfound element simply fails the equality that follows. readCellBorders and readParagraphBorders drop their absent-parent early returns: readCellBorderEdge already accepts XmlElement | undefined, and the trailing empty-borders check returns the same undefined. The vMerge continuation lookup indexes directly, since indexing with indexOf's -1 miss already yields undefined. The marker-half name conditions collapse to reading @w:name only at the two bookmark-start sites (passing it into recordRangeMarkerHalf), and the trailing marker shortcut mirrors constructs.ts's own reasoning that position >= 0 already exceeds a -1 lastContentIndex. A gridCol's absent @w:w falls back to the numeric 0 rather than the string "0" (Number() maps both to the same zero width). --- packages/ooxml.js/src/typed/docx/read.ts | 99 ++++++++++++------------ 1 file changed, 48 insertions(+), 51 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/read.ts b/packages/ooxml.js/src/typed/docx/read.ts index b371ae28b2..1b56b2c08d 100644 --- a/packages/ooxml.js/src/typed/docx/read.ts +++ b/packages/ooxml.js/src/typed/docx/read.ts @@ -222,7 +222,8 @@ function readToggle(el: XmlElement | undefined): boolean { return false; } const val = attr(el, "w:val"); - return val === undefined || (val !== "0" && val !== "false" && val !== "off"); + // An absent @w:val needs no explicit disjunct: undefined !== "0" (and "false"/"off") are all true, so the conjunction alone already spells "absent means on". + return val !== "0" && val !== "false" && val !== "off"; } function hasPageBreakBefore(paragraph: XmlElement): boolean { @@ -624,24 +625,22 @@ function readParagraphRuns( formControl: ContentControlDescriptor | undefined; }[] = []; + // `name` is passed in rather than read here because only a bookmark's start half carries one: every other call site passes undefined, so the "which halves have a name" knowledge stays at the call sites instead of as a condition the body re-derives. const recordRangeMarkerHalf = ( node: XmlElement, family: RangeMarkerFamily, start: boolean, + name: string | undefined, ): void => { const id = attr(node, "w:id"); if (id === undefined) { return; } - const name = attr(node, "w:name"); events.halves.push({ element: node, family, id, - name: - start && family === "bookmark" && name !== undefined - ? decodeEntities(name) - : undefined, + name, kind: start ? "start" : "end", runPosition: runs.length, }); @@ -653,16 +652,21 @@ function readParagraphRuns( if (child.type !== "element") { continue; } - const anchorType = + if ( + child.tag !== "w:footnoteReference" && + child.tag !== "w:endnoteReference" && + child.tag !== "w:commentReference" + ) { + continue; + } + const anchorType: "footnote" | "endnote" | "comment" = child.tag === "w:footnoteReference" ? "footnote" : child.tag === "w:endnoteReference" ? "endnote" - : child.tag === "w:commentReference" - ? "comment" - : undefined; - const id = anchorType === undefined ? undefined : attr(child, "w:id"); - if (anchorType !== undefined && id !== undefined) { + : "comment"; + const id = attr(child, "w:id"); + if (id !== undefined) { events.pointAnchors.push({ descriptor: { kind: "anchor", anchorType, name: id }, runPosition: runs.length - 1, @@ -692,7 +696,8 @@ function readParagraphRuns( }); } else if (type === "separate") { fieldState = "result"; - } else if (type === "end") { + } else { + // fieldCharType returns exactly begin/separate/end/undefined, and undefined returned early above, so this else IS the end case. fieldState = "none"; const open = openFields.pop(); if (open !== undefined) { @@ -775,11 +780,16 @@ function readParagraphRuns( if (sdtContent !== undefined) { walk(sdtContent.children, hyperlinkTarget); } - } else if ( - node.tag === "w:bookmarkStart" || - node.tag === "w:bookmarkEnd" - ) { - recordRangeMarkerHalf(node, "bookmark", node.tag === "w:bookmarkStart"); + } else if (node.tag === "w:bookmarkStart") { + const name = attr(node, "w:name"); + recordRangeMarkerHalf( + node, + "bookmark", + true, + name === undefined ? undefined : decodeEntities(name), + ); + } else if (node.tag === "w:bookmarkEnd") { + recordRangeMarkerHalf(node, "bookmark", false, undefined); } else if ( node.tag === "w:commentRangeStart" || node.tag === "w:commentRangeEnd" @@ -788,6 +798,7 @@ function readParagraphRuns( node, "comment", node.tag === "w:commentRangeStart", + undefined, ); } } @@ -797,31 +808,24 @@ function readParagraphRuns( return runs; } -// A complex field is block-scoped exactly when its begin run is the paragraph's first content-bearing child and its end run the last -- the whole-paragraph shape scanParagraphFields brackets as a marker pair, which this assembly must therefore not also encode as a run extent. A begin or end nested inside a container (w:hyperlink, w:ins) is never a direct child, so it cannot be block-scoped -- and scanParagraphFields, which walks only direct children, never saw it either: the two paths partition the occurrences between them by construction. +// A complex field is block-scoped exactly when its begin run is the paragraph's first content-bearing child and its end run the last -- the whole-paragraph shape scanParagraphFields brackets as a marker pair, which this assembly must therefore not also encode as a run extent. A begin or end nested inside a container (w:hyperlink, w:ins) is never a direct child, so it cannot be block-scoped -- and scanParagraphFields, which walks only direct children, never saw it either: the two paths partition the occurrences between them by construction. No "found among direct children" guard is needed on the indexOf lookups: the walk that produced this event only reaches a begin through containers that are all themselves content-bearing (w:hyperlink, w:fldSimple, w:ins, w:sdt), so a paragraph with a field event always has a content-bearing direct child and firstContentIndex/lastContentIndex are never -1 here -- an unfound element indexes at -1 and simply fails the equality that follows. function isBlockScopedField( event: RunFieldEvent, index: ParagraphContentIndex, ): boolean { const begin = index.elements.indexOf(event.beginElement); const end = index.elements.indexOf(event.endElement); - return ( - begin !== -1 && - end !== -1 && - begin === index.firstContentIndex && - end === index.lastContentIndex - ); + return begin === index.firstContentIndex && end === index.lastContentIndex; } -// A w:fldSimple is block-scoped when it is its paragraph's only content-bearing child -- scanParagraphFields' own test for the simple spelling. +// A w:fldSimple is block-scoped when it is its paragraph's only content-bearing child -- scanParagraphFields' own test for the simple spelling. The same no-indexOf-guard reasoning as isBlockScopedField applies: a fldSimple the walk saw is either a direct content-bearing child itself or nested inside one. function isBlockScopedSimpleField( event: RunSimpleFieldEvent, index: ParagraphContentIndex, ): boolean { const position = index.elements.indexOf(event.element); return ( - position !== -1 && - index.firstContentIndex === position && - index.lastContentIndex === position + index.firstContentIndex === position && index.lastContentIndex === position ); } @@ -1065,15 +1069,12 @@ function readCellBorderEdge( }; } -// w:left/w:right also accept the RTL-neutral w:start/w:end aliases, mirroring resolveParagraphProperties' own w:ind/@w:left-vs-@w:start handling in styles.ts. Returns undefined (rather than an all-undefined object) when the cell declares no w:tcBorders at all, or declares one with every edge nil/none -- distinguishing "no border information present" from "borders explicitly present but empty" isn't meaningful here, so both collapse to the same absent result. +// w:left/w:right also accept the RTL-neutral w:start/w:end aliases, mirroring resolveParagraphProperties' own w:ind/@w:left-vs-@w:start handling in styles.ts. Returns undefined (rather than an all-undefined object) when the cell declares no w:tcBorders at all, or declares one with every edge nil/none -- distinguishing "no border information present" from "borders explicitly present but empty" isn't meaningful here, so both collapse to the same absent result (readCellBorderEdge already takes XmlElement | undefined, so an absent w:tcBorders needs no early return of its own: every edge reads undefined and the empty-borders check below returns the same undefined). function readCellBorders( tcPr: XmlElement | undefined, ): ContentCellBorders | undefined { const tcBorders = tcPr === undefined ? undefined : childrenWithTag(tcPr, "w:tcBorders")[0]; - if (tcBorders === undefined) { - return undefined; - } const borders: ContentCellBorders = {}; const left = readCellBorderEdge(tcBorders, "w:left") ?? @@ -1104,9 +1105,7 @@ function readParagraphBorders( ): ContentParagraphBorders | undefined { const pBdr = pPr === undefined ? undefined : childrenWithTag(pPr, "w:pBdr")[0]; - if (pBdr === undefined) { - return undefined; - } + // The same absent-parent reasoning as readCellBorders: an absent w:pBdr needs no early return, every edge reads undefined, and the empty-borders check returns the same undefined. const borders: ContentParagraphBorders = {}; const left = readCellBorderEdge(pBdr, "w:left"); const right = readCellBorderEdge(pBdr, "w:right"); @@ -1182,7 +1181,7 @@ function readTable( tblGrid === undefined ? [] : childrenWithTag(tblGrid, "w:gridCol").map((col) => - twipsToPt(Number(attr(col, "w:w") ?? "0")), + twipsToPt(Number(attr(col, "w:w") ?? 0)), ); const trs = childrenWithTag(tbl, "w:tr"); @@ -1211,8 +1210,8 @@ function readTable( let rowSpan = 1; for (let r = rowIndex + 1; r < rawRows.length; r++) { const matchIndex = rowColumnIndices[r]!.indexOf(colIndex); - const matchCell = - matchIndex === -1 ? undefined : rawRows[r]![matchIndex]; + // Indexing with indexOf's -1 miss already yields undefined, so no ternary is needed -- matchCell is RawCell | undefined either way. + const matchCell = rawRows[r]![matchIndex]; if (!matchCell?.isVMergeContinuation) { break; } @@ -1379,17 +1378,16 @@ function recordParagraphRangeMarkers( element.tag === "w:commentRangeStart"; const leading = index.firstContentIndex === -1 || position < index.firstContentIndex; - const trailing = - index.lastContentIndex === -1 || position > index.lastContentIndex; + // No "lastContentIndex === -1 ||" shortcut here, mirroring constructs.ts's own isBlockScopedHalf reasoning: position is always >= 0 at this point, so position > -1 is already true whenever lastContentIndex is -1 and the right-hand side covers the empty-paragraph case unaided. + const trailing = position > index.lastContentIndex; if (start) { - const name = attr(element, "w:name"); + // Only a bookmark's start half carries a @w:name; a comment extent is named by its own w:id, so the name is read for the bookmark tag rather than re-derived from the family. + const name = + element.tag === "w:bookmarkStart" ? attr(element, "w:name") : undefined; state.rangeMarkerEvents.push({ family, id, - name: - family === "bookmark" && name !== undefined - ? decodeEntities(name) - : undefined, + name: name === undefined ? undefined : decodeEntities(name), kind: "start", index: leading ? paragraphIndex : endIndex, qualified: leading || trailing, @@ -1584,15 +1582,14 @@ function collectFlowNodes( } if (node.tag === "w:bookmarkStart" || node.tag === "w:commentRangeStart") { const id = attr(node, "w:id"); - const name = attr(node, "w:name"); + // Only a bookmark start carries a @w:name; a comment extent is named by its own w:id (mirroring recordParagraphRangeMarkers above). + const name = + node.tag === "w:bookmarkStart" ? attr(node, "w:name") : undefined; if (id !== undefined) { state.rangeMarkerEvents.push({ family: node.tag === "w:bookmarkStart" ? "bookmark" : "comment", id, - name: - node.tag === "w:bookmarkStart" && name !== undefined - ? decodeEntities(name) - : undefined, + name: name === undefined ? undefined : decodeEntities(name), kind: "start", index: state.blocks.length, qualified: true, From 70b9f912856878941c2d0be5b0aaa219df49db07 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 15:26:16 +0100 Subject: [PATCH 42/63] test(ooxml.js): cover docx read.ts's run-walk guards, tracked-change carry, and crossing extents A permission start sharing a comment range's id must not become the range's second end half (which would drop the comment's run extent); a footnote reference with no @w:id records no point anchor; a run-properties child adds nothing to a lifted image's anchor offset. A nested run-level deletion is carried inside a flow-level w:del but dropped inside a flow-level w:ins. An alternate-content block with only a Choice unwraps to the Choice's content, and a stray body-level proofErr contributes nothing rather than cutting a section. A bookmark crossing a section break is dropped wholesale without swallowing the bookmark wholly inside the later section. --- packages/ooxml.js/src/typed/docx/read.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/read.test.ts b/packages/ooxml.js/src/typed/docx/read.test.ts index 05fe43bf33..5dbbfb7934 100644 --- a/packages/ooxml.js/src/typed/docx/read.test.ts +++ b/packages/ooxml.js/src/typed/docx/read.test.ts @@ -4615,3 +4615,185 @@ describe("readDocxContent: lifted-image anchor offsets across tab, break, and de expect(image.anchorOffset).toBe(2); }); }); + +describe("readDocxContent: run-walk guard edges for marker halves, reference ids, and anchor offsets", () => { + function imageParts(): Package["parts"] { + return { + "word/_rels/document.xml.rels": { + kind: "xml", + nodes: [ + rels([{ id: "rIdImg", type: IMAGE_REL, target: "media/image1.png" }]), + ], + }, + "word/media/image1.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + }; + } + + it("keeps a mid-paragraph comment range's run extent when a permission start shares its id", () => { + const paragraph = el("w:p", {}, [ + textRun("lead"), + el("w:commentRangeStart", { "w:id": "7" }), + el("w:permStart", { "w:id": "7" }), + textRun("annotated"), + el("w:commentRangeEnd", { "w:id": "7" }), + textRun("tail"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(firstParagraph(doc).constructs).toEqual([ + { + descriptor: { kind: "anchor", anchorType: "comment", name: "7" }, + startRun: 1, + endRun: 2, + }, + ]); + }); + + it("records no point anchor for a footnote reference carrying no @w:id", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [el("w:footnoteReference", {})]), + textRun("after"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + expect(firstParagraph(doc).constructs).toBeUndefined(); + }); + + it("does not count a run-properties child toward a lifted image's anchor offset", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:rPr", {}, [el("w:b", {})]), + el("w:t", { "xml:space": "preserve" }, [txt("ab")]), + drawingElement("wp:inline", "rIdImg", "rPr alt"), + ]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph, imageParts())); + const image = asImage(doc.sections[0]?.blocks[1]); + expect(image.anchorRunIndex).toBe(0); + expect(image.anchorOffset).toBe(2); + }); +}); + +describe("readDocxContent: flow-level tracked-change carry and stray body children", () => { + const ED = { + "w:id": "1", + "w:author": "Ed", + "w:date": "2024-01-01T00:00:00Z", + }; + + function flowDoc(children: XmlElement[]): ReturnType { + const body = el("w:body", {}, [ + ...children, + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ]); + return readDocxContent({ + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + }, + }); + } + + it("carries a nested run-level deletion inside a flow-level w:del", () => { + const doc = flowDoc([ + el("w:del", { ...ED, "w:id": "1" }, [ + el("w:p", {}, [ + textRun("kept"), + el("w:del", { ...ED, "w:id": "2" }, [ + el("w:r", {}, [el("w:delText", {}, [txt("gone")])]), + ]), + ]), + ]), + ]); + expect( + asParagraph(doc.sections[0]?.blocks[1]).runs.map((r) => r.text), + ).toEqual(["kept", "gone"]); + }); + + it("drops a nested run-level deletion inside a flow-level w:ins", () => { + const doc = flowDoc([ + el("w:ins", { ...ED, "w:id": "3" }, [ + el("w:p", {}, [ + textRun("kept"), + el("w:del", { ...ED, "w:id": "4" }, [ + el("w:r", {}, [el("w:delText", {}, [txt("gone")])]), + ]), + ]), + ]), + ]); + expect( + asParagraph(doc.sections[0]?.blocks[1]).runs.map((r) => r.text), + ).toEqual(["kept"]); + }); + + it("unwraps an alternate-content block whose only branch is a Choice", () => { + const doc = flowDoc([ + el("mc:AlternateContent", {}, [ + el("mc:Choice", { Requires: "wps" }, [ + el("w:p", {}, [textRun("chosen")]), + ]), + ]), + ]); + expect(asParagraph(doc.sections[0]?.blocks[0]).runs[0]?.text).toBe( + "chosen", + ); + }); + + it("treats a stray body-level proofErr as content-less, never as a section break", () => { + const doc = flowDoc([ + el("w:p", {}, [textRun("One")]), + el("w:proofErr", { "w:type": "spellStart" }), + el("w:p", {}, [textRun("Two")]), + ]); + expect(doc.sections).toHaveLength(1); + expect( + (doc.sections[0]?.blocks ?? []).map((b) => + b.kind === "paragraph" ? b.runs[0]?.text : b.kind, + ), + ).toEqual(["One", "Two"]); + }); +}); + +describe("readDocxContent: section-crossing construct extents", () => { + it("drops a bookmark crossing a section break without eating the bookmark wholly inside the later section", () => { + const body = el("w:body", {}, [ + el("w:bookmarkStart", { "w:id": "1", "w:name": "Wide" }), + el("w:p", {}, [ + el("w:pPr", {}, [ + el("w:sectPr", {}, [ + el("w:pgSz", { "w:w": "12240", "w:h": "15840" }), + ]), + ]), + textRun("first"), + ]), + el("w:bookmarkStart", { "w:id": "2", "w:name": "Inner" }), + el("w:p", {}, [textRun("second")]), + el("w:bookmarkEnd", { "w:id": "1" }), + el("w:p", {}, [textRun("third")]), + el("w:bookmarkEnd", { "w:id": "2" }), + el("w:sectPr", {}, [el("w:pgSz", { "w:w": "12240", "w:h": "15840" })]), + ]); + const doc = readDocxContent({ + parts: { + "word/document.xml": { + kind: "xml", + nodes: [el("w:document", {}, [body])], + }, + }, + }); + expect(doc.sections).toHaveLength(2); + // The wide bookmark's start must not leak into the first section either: its extent crosses the break, so no marker at all. + expect( + (doc.sections[0]?.blocks ?? []).every((b) => b.kind !== "constructStart"), + ).toBe(true); + const second = doc.sections[1]?.blocks ?? []; + expect(asConstructStart(second[0]).descriptor).toEqual({ + kind: "anchor", + anchorType: "bookmark", + name: "Inner", + }); + expect(asParagraph(second[1]).runs[0]?.text).toBe("second"); + expect(asParagraph(second[2]).runs[0]?.text).toBe("third"); + expect(second[3]?.kind).toBe("constructEnd"); + }); +}); From 97f2ae259e33c1067bbc174a7667d75c93323051 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 15:39:00 +0100 Subject: [PATCH 43/63] refactor(ooxml.js): read docx marker halves' @w:name unconditionally The bookmark-start-only ternary before each attr(element, "w:name") read guarded a distinction the pairing never observes: resolveRangeMarkerExtents reads a start half's name only for the bookmark family, naming a comment extent by its own w:id instead, so a @w:name on a comment range start is recorded and never consumed either way. --- packages/ooxml.js/src/typed/docx/read.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/read.ts b/packages/ooxml.js/src/typed/docx/read.ts index 1b56b2c08d..562d2e49ea 100644 --- a/packages/ooxml.js/src/typed/docx/read.ts +++ b/packages/ooxml.js/src/typed/docx/read.ts @@ -1381,9 +1381,8 @@ function recordParagraphRangeMarkers( // No "lastContentIndex === -1 ||" shortcut here, mirroring constructs.ts's own isBlockScopedHalf reasoning: position is always >= 0 at this point, so position > -1 is already true whenever lastContentIndex is -1 and the right-hand side covers the empty-paragraph case unaided. const trailing = position > index.lastContentIndex; if (start) { - // Only a bookmark's start half carries a @w:name; a comment extent is named by its own w:id, so the name is read for the bookmark tag rather than re-derived from the family. - const name = - element.tag === "w:bookmarkStart" ? attr(element, "w:name") : undefined; + // Only a bookmark's start half carries a @w:name in real markup, and the pairing below reads a name only for bookmarks (a comment extent is named by its own w:id), so reading @w:name unconditionally is safe even for a comment start carrying one. + const name = attr(element, "w:name"); state.rangeMarkerEvents.push({ family, id, @@ -1582,9 +1581,8 @@ function collectFlowNodes( } if (node.tag === "w:bookmarkStart" || node.tag === "w:commentRangeStart") { const id = attr(node, "w:id"); - // Only a bookmark start carries a @w:name; a comment extent is named by its own w:id (mirroring recordParagraphRangeMarkers above). - const name = - node.tag === "w:bookmarkStart" ? attr(node, "w:name") : undefined; + // Only a bookmark start carries a @w:name in real markup, and the pairing reads a name only for bookmarks (a comment extent is named by its own w:id), so the unconditional read is safe even for a comment start carrying one. + const name = attr(node, "w:name"); if (id !== undefined) { state.rangeMarkerEvents.push({ family: node.tag === "w:bookmarkStart" ? "bookmark" : "comment", From 8605bc76064b3bc0589f37201113a195f0cf6951 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 15:48:43 +0100 Subject: [PATCH 44/63] docs(ooxml.js): record docx read.ts's protocol-verified 99.40% mutation floor --- packages/ooxml.js/stryker.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index e0b7d2a238..7aa5e2295a 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,7 +2,7 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since had direct structural coverage added too (exact table/tblGrid/vMerge XML, wrapInternalLinks' sorted-extent resolution, comment/footnote id minting, and package-scaffolding root tags/namespaces); scoped-mutating it alone now measures 74.53% of its own 1107 valid mutants, up from a genuinely re-measured baseline of 66.67% taken in the same session immediately beforehand (both runs against the identical source, differing only in the new tests). docx/read.ts has since had direct structural coverage added too (page-size/margin fallback edges, w:pageBreakBefore toggle values, findRunPageBreakOffset's own tab/br/cr/delText accounting, nested-object drawing lifting, field block-scope boundaries including the multi-paragraph TOC shape, cell-border w:start/w:end aliasing, block-level bookmark pairing and malformed-order/duplicate-id rejection, the empty-body sections fallback, and comment/footnote optional-field presence); scoped-mutating it alone now measures 86.64% of its own 1899 valid mutants, up from a genuinely re-measured baseline of 75.87% taken in the same campaign immediately beforehand (both runs against the identical source, differing only in the new tests). A residual, well-understood floor remains in docx/read.ts -- mostly UpdateOperator mutants on discovery-order counters (`order++`) that only an exact multi-construct ordering assertion would distinguish, and a handful of `!== -1` index guards in isBlockScopedField/isBlockScopedSimpleField that are behaviourally unobservable except when a paragraph has zero content-bearing children AND the field's own begin/end sit outside the paragraph's direct children simultaneously, a combination with no realistic real-world markup shape -- so this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since had direct structural coverage added too (exact table/tblGrid/vMerge XML, wrapInternalLinks' sorted-extent resolution, comment/footnote id minting, and package-scaffolding root tags/namespaces); scoped-mutating it alone now measures 74.53% of its own 1107 valid mutants, up from a genuinely re-measured baseline of 66.67% taken in the same session immediately beforehand (both runs against the identical source, differing only in the new tests). docx/read.ts has since been closed to a measured, protocol-verified floor: two rounds of direct structural coverage (page geometry and toggle fallbacks, page-break split offset accounting, drawing alt-text/float-position edges, lifted-element deletion guards, discovery-order tie-break fixtures for constructs sharing one extent range, section-split extent re-indexing, theme resolution order, header-part joins, and image anchor offsets across tab/br/cr/delText children) plus removal of the guard expressions that were behaviourally dead by construction took its scoped score 75.87% -> 86.64% -> 96.12% -> 99.16% -> 99.40% of its own 1648 valid mutants, with every step measured by a fresh scoped run. Its five remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing every time): the vMerge grid-column accumulator's `col += gridSpan` -> `-=` (column indices are only ever compared for equality between rows via indexOf, and a uniform sign flip preserves which cells align); `position < firstContentIndex` -> `<=` and `position > lastContentIndex` -> `>=` in recordParagraphRangeMarkers (a range-marker half is never itself a content-bearing child, so it can never sit at exactly the first or last content index); and the two block-scoped field `order: state.order++` -> `order--` sites in scanParagraphFields (post-decrement evaluates to the same used value as post-increment, only shifting later sites' counters, and no construct discovered after a block-scoped field can share that field's extent range since every same-range partner -- whole-paragraph tracked change, paragraph-scoped bookmark pair, enclosing sdt or flow-level change, bracketing body-level bookmark -- is discovered before it). So this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. // // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. breakThreshold: 83, From d983d25fd9a0de247fdc7434836a914082ea6d37 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 16:16:28 +0100 Subject: [PATCH 45/63] test(ooxml.js): assert docx write.ts's optional part emission and link wrap precedence exactly The styles/numbering/comments/footnotes/endnotes relationships carry their exact targets with no external target mode, and each part's [Content_Types].xml Override its exact path and content type; a document with none of them carries none of the parts, relationships, or overrides. Header and footer part overrides are numbered by emission index and typed by kind, and a style id referenced only by a header part's blocks still lands in styles.xml. A page break before a leading image materialises as one break paragraph carrying the drawing, and a trailing page break keeps its own empty break paragraph. Lifted images return to their paragraph's trailing empty runs in order (two empty runs and two images, so a walk that stops after the first still shows), never into a text-bearing or hyperlink-wrapped run, and bookmark markers interleaving the runs do not misalign the reuse. Of two internal links sharing a start run, the longer extent wins the wrap regardless of the constructs array's own order, and a link whose runs already carry an external hyperlink stays plain rather than nesting hyperlinks. --- .../ooxml.js/src/typed/docx/write.test.ts | 571 ++++++++++++++++++ 1 file changed, 571 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index 250c32a72d..a2791759c4 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -2512,3 +2512,574 @@ describe("buildDocxPackageFromContent: styles, numbering, comments, footnotes, e expect(header2Rels).toBeDefined(); }); }); + +describe("buildDocxPackageFromContent: optional part emission, relationships, and content-type overrides", () => { + const OFFICE_REL_NS = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + function relsOf( + written: Package, + ): { type: string; target: string; targetMode: string | undefined }[] { + const relsRoot = rootElement(written.parts["word/_rels/document.xml.rels"]); + if (relsRoot === undefined) { + throw new Error("expected document rels part"); + } + return elementsWithTag([relsRoot], "Relationship").map((rel) => ({ + type: attr(rel, "Type") ?? "", + target: attr(rel, "Target") ?? "", + targetMode: attr(rel, "TargetMode"), + })); + } + + function overridesOf( + written: Package, + ): { partName: string; contentType: string }[] { + const typesRoot = rootElement(written.parts["[Content_Types].xml"]); + if (typesRoot === undefined) { + throw new Error("expected content types part"); + } + return elementsWithTag([typesRoot], "Override").map((override) => ({ + partName: attr(override, "PartName") ?? "", + contentType: attr(override, "ContentType") ?? "", + })); + } + + function fullyLoadedSource(): Package { + const listParagraph = el("w:p", {}, [ + el("w:pPr", {}, [ + el("w:numPr", {}, [ + el("w:ilvl", { "w:val": "0" }), + el("w:numId", { "w:val": "1" }), + ]), + ]), + el("w:r", {}, [el("w:t", {}, [txt("bulleted")])]), + ]); + return docxPackage([listParagraph], { + "word/numbering.xml": { + kind: "xml", + nodes: [ + el("w:numbering", {}, [ + el("w:abstractNum", { "w:abstractNumId": "0" }, [ + el("w:lvl", { "w:ilvl": "0" }, [ + el("w:start", { "w:val": "1" }), + el("w:numFmt", { "w:val": "bullet" }), + el("w:lvlText", { "w:val": "•" }), + ]), + ]), + el("w:num", { "w:numId": "1" }, [ + el("w:abstractNumId", { "w:val": "0" }), + ]), + ]), + ], + }, + "word/comments.xml": { + kind: "xml", + nodes: [ + el("w:comments", {}, [ + el("w:comment", { "w:id": "1", "w:author": "A Reviewer" }, [ + el("w:p", {}, [el("w:r", {}, [el("w:t", {}, [txt("a note")])])]), + ]), + ]), + ], + }, + "word/footnotes.xml": { + kind: "xml", + nodes: [ + el("w:footnotes", {}, [ + el("w:footnote", { "w:id": "1" }, [ + el("w:p", {}, [el("w:r", {}, [el("w:t", {}, [txt("fn body")])])]), + ]), + ]), + ], + }, + "word/endnotes.xml": { + kind: "xml", + nodes: [ + el("w:endnotes", {}, [ + el("w:endnote", { "w:id": "1" }, [ + el("w:p", {}, [el("w:r", {}, [el("w:t", {}, [txt("en body")])])]), + ]), + ]), + ], + }, + }); + } + + it("writes internal relationships for styles, numbering, comments, footnotes, and endnotes exactly, with no external target mode", () => { + const { written } = fullRoundTrip(fullyLoadedSource()); + const internal = relsOf(written).filter( + (rel) => rel.type !== `${OFFICE_REL_NS}/hyperlink`, + ); + expect(internal).toEqual([ + { + type: `${OFFICE_REL_NS}/styles`, + target: "styles.xml", + targetMode: undefined, + }, + { + type: `${OFFICE_REL_NS}/numbering`, + target: "numbering.xml", + targetMode: undefined, + }, + { + type: `${OFFICE_REL_NS}/comments`, + target: "comments.xml", + targetMode: undefined, + }, + { + type: `${OFFICE_REL_NS}/footnotes`, + target: "footnotes.xml", + targetMode: undefined, + }, + { + type: `${OFFICE_REL_NS}/endnotes`, + target: "endnotes.xml", + targetMode: undefined, + }, + ]); + }); + + it("declares each optional part's Override with its exact part path and content type", () => { + const { written } = fullRoundTrip(fullyLoadedSource()); + const overridePairs = overridesOf(written).map((o) => [ + o.partName, + o.contentType, + ]); + expect(overridePairs).toEqual( + expect.arrayContaining([ + [ + "/word/document.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", + ], + [ + "/word/styles.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml", + ], + [ + "/word/numbering.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml", + ], + [ + "/word/comments.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", + ], + [ + "/word/footnotes.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", + ], + [ + "/word/endnotes.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml", + ], + ]), + ); + }); + + it("omits the comments, footnotes, and endnotes parts, relationships, and overrides when there are none", () => { + const { written } = fullRoundTrip(docxPackage([para("plain")])); + expect(written.parts["word/comments.xml"]).toBeUndefined(); + expect(written.parts["word/footnotes.xml"]).toBeUndefined(); + expect(written.parts["word/endnotes.xml"]).toBeUndefined(); + const relTypes = relsOf(written).map((rel) => rel.type); + expect(relTypes).not.toContain(`${OFFICE_REL_NS}/comments`); + expect(relTypes).not.toContain(`${OFFICE_REL_NS}/footnotes`); + expect(relTypes).not.toContain(`${OFFICE_REL_NS}/endnotes`); + const overrideNames = overridesOf(written).map((o) => o.partName); + expect(overrideNames).not.toContain("/word/comments.xml"); + expect(overrideNames).not.toContain("/word/footnotes.xml"); + expect(overrideNames).not.toContain("/word/endnotes.xml"); + }); + + it("numbers each header and footer part override by its own emission index and kind", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text: "body" }] }], + }, + ], + headerFooterParts: [ + { + path: "word/header1.xml", + kind: "header", + blocks: [{ kind: "paragraph", runs: [{ text: "running head" }] }], + }, + { + path: "word/footer1.xml", + kind: "footer", + blocks: [{ kind: "paragraph", runs: [{ text: "running foot" }] }], + }, + ], + }); + expect(overridesOf(written)).toEqual( + expect.arrayContaining([ + { + partName: "/word/header1.xml", + contentType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml", + }, + { + partName: "/word/footer2.xml", + contentType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml", + }, + ]), + ); + }); + + it("collects a style id referenced only by a header part's blocks into styles.xml", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text: "body" }] }], + }, + ], + headerFooterParts: [ + { + path: "word/header1.xml", + kind: "header", + blocks: [ + { + kind: "paragraph", + styleId: "Heading1", + runs: [{ text: "styled head" }], + }, + ], + }, + ], + }); + const stylesRoot = rootElement(written.parts["word/styles.xml"]); + if (stylesRoot === undefined) { + throw new Error("expected styles part"); + } + const styleIds = elementsWithTag([stylesRoot], "w:style").map( + (style) => attr(style, "w:styleId") ?? "", + ); + expect(styleIds).toContain("Heading1"); + }); + + it("writes one empty letter-sized section for content carrying no sections at all", () => { + const written = buildDocxPackageFromContent({ sections: [] }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + if (documentRoot === undefined) { + throw new Error("expected document part"); + } + const body = childrenWithTag(documentRoot, "w:body")[0]; + if (body === undefined) { + throw new Error("expected body"); + } + expect(elementsWithTag([body], "w:p")).toEqual([]); + const sectPr = childrenWithTag(body, "w:sectPr")[0]; + expect(sectPr).toBeDefined(); + const pgSz = + sectPr === undefined ? undefined : childrenWithTag(sectPr, "w:pgSz")[0]; + expect(pgSz === undefined ? undefined : attr(pgSz, "w:w")).toBe("12240"); + expect(pgSz === undefined ? undefined : attr(pgSz, "w:h")).toBe("15840"); + const pgMar = + sectPr === undefined ? undefined : childrenWithTag(sectPr, "w:pgMar")[0]; + expect(pgMar === undefined ? undefined : attr(pgMar, "w:top")).toBe("1440"); + }); +}); + +describe("buildDocxPackageFromContent: page-break materialisation and lifted-image placement", () => { + function bodyOf(written: Package): XmlElement { + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + if (body === undefined) { + throw new Error("expected body"); + } + return body; + } + + function elementChildren(node: XmlElement): XmlElement[] { + return node.children.filter( + (child): child is XmlElement => child.type === "element", + ); + } + + function image(): ContentBlock { + return { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + }; + } + + it("materialises a pending page break before a leading image as one break paragraph carrying the drawing", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [{ kind: "pageBreak" }, image()], + }, + ], + }); + const body = bodyOf(written); + const paragraphs = elementChildren(body).filter( + (child) => child.tag === "w:p", + ); + expect(paragraphs).toHaveLength(1); + const pPr = childrenWithTag(paragraphs[0]!, "w:pPr")[0]; + expect( + pPr === undefined ? [] : childrenWithTag(pPr, "w:pageBreakBefore"), + ).toHaveLength(1); + const runs = childrenWithTag(paragraphs[0]!, "w:r"); + expect(runs).toHaveLength(1); + expect( + runs[0] === undefined ? [] : elementsWithTag([runs[0]], "w:drawing"), + ).toHaveLength(1); + }); + + it("keeps a page break at the very end of the flow as a trailing break paragraph", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "paragraph", runs: [{ text: "last text" }] }, + { kind: "pageBreak" }, + ], + }, + ], + }); + const paragraphs = elementChildren(bodyOf(written)).filter( + (child) => child.tag === "w:p", + ); + expect(paragraphs).toHaveLength(2); + const pPr = childrenWithTag(paragraphs[1]!, "w:pPr")[0]; + expect( + pPr === undefined ? [] : childrenWithTag(pPr, "w:pageBreakBefore"), + ).toHaveLength(1); + expect(childrenWithTag(paragraphs[1]!, "w:r")).toEqual([]); + }); + + it("places a leading image in a paragraph of its own", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [image()] }], + }); + const paragraphs = elementChildren(bodyOf(written)).filter( + (child) => child.tag === "w:p", + ); + expect(paragraphs).toHaveLength(1); + expect(childrenWithTag(paragraphs[0]!, "w:r")).toHaveLength(1); + expect(elementsWithTag([paragraphs[0]!], "w:drawing")).toHaveLength(1); + }); + + it("returns lifted images to their paragraph's trailing empty runs in order, rather than fresh ones", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "head" }, { text: "" }, { text: "" }], + }, + image(), + image(), + ], + }, + ], + }); + const paragraphs = elementChildren(bodyOf(written)).filter( + (child) => child.tag === "w:p", + ); + expect(paragraphs).toHaveLength(1); + const runs = childrenWithTag(paragraphs[0]!, "w:r"); + // Both drawings ride inside the two trailing empty runs themselves, so no fourth run appears. + expect(runs).toHaveLength(3); + expect(elementsWithTag([runs[1]!], "w:drawing")).toHaveLength(1); + expect(elementsWithTag([runs[2]!], "w:drawing")).toHaveLength(1); + }); + + it("does not reuse a trailing run that carries text for a lifted image", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "paragraph", runs: [{ text: "full text" }] }, + image(), + ], + }, + ], + }); + const paragraphs = elementChildren(bodyOf(written)).filter( + (child) => child.tag === "w:p", + ); + const runs = childrenWithTag(paragraphs[0]!, "w:r"); + expect(runs).toHaveLength(2); + expect(elementsWithTag([runs[0]!], "w:t")).toHaveLength(1); + expect(elementsWithTag([runs[1]!], "w:drawing")).toHaveLength(1); + }); + + it("does not reuse a trailing empty run that sits inside an external hyperlink", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [ + { text: "lead" }, + { text: "", hyperlink: "https://example.com/target" }, + ], + }, + image(), + ], + }, + ], + }); + const paragraphs = elementChildren(bodyOf(written)).filter( + (child) => child.tag === "w:p", + ); + const hyperlinks = childrenWithTag(paragraphs[0]!, "w:hyperlink"); + expect(hyperlinks).toHaveLength(1); + // The drawing must not land inside the hyperlink's own wrapped run. + expect(elementsWithTag([hyperlinks[0]!], "w:drawing")).toHaveLength(0); + const runs = childrenWithTag(paragraphs[0]!, "w:r"); + expect(elementsWithTag([runs[runs.length - 1]!], "w:drawing")).toHaveLength( + 1, + ); + }); + + it("keeps trailing-run reuse aligned when bookmark markers interleave the runs", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "a" }, { text: "" }], + constructs: [ + { + descriptor: { + kind: "anchor", + anchorType: "bookmark", + name: "Mark", + }, + startRun: 0, + endRun: 2, + }, + ], + }, + image(), + ], + }, + ], + }); + const paragraphs = elementChildren(bodyOf(written)).filter( + (child) => child.tag === "w:p", + ); + const runs = childrenWithTag(paragraphs[0]!, "w:r"); + expect(runs).toHaveLength(2); + expect(elementsWithTag([runs[1]!], "w:drawing")).toHaveLength(1); + }); +}); + +describe("buildDocxPackageFromContent: internal link wrap precedence and guards", () => { + function internalHyperlinks( + written: Package, + ): { anchor: string | undefined; runCount: number }[] { + const documentRoot = rootElement(written.parts["word/document.xml"]); + return elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:hyperlink", + ) + .filter((hyperlink) => + hyperlink.attributes.some((a) => a.name === "w:anchor"), + ) + .map((hyperlink) => ({ + anchor: hyperlink.attributes.find((a) => a.name === "w:anchor")?.value, + runCount: hyperlink.children.filter( + (child) => child.type === "element" && child.tag === "w:r", + ).length, + })); + } + + it("resolves two internal links sharing one start run in favour of the longer extent", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "a" }, { text: "b" }, { text: "c" }], + constructs: [ + { + descriptor: { + kind: "link", + target: { kind: "internal", anchor: "short" }, + }, + startRun: 0, + endRun: 2, + }, + { + descriptor: { + kind: "link", + target: { kind: "internal", anchor: "long" }, + }, + startRun: 0, + endRun: 3, + }, + ], + }, + ], + }, + ], + }); + expect(internalHyperlinks(written)).toEqual([ + { anchor: "long", runCount: 3 }, + ]); + }); + + it("leaves an internal link plain when its own runs already carry an external hyperlink", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [ + { text: "outside" }, + { text: "linked", hyperlink: "https://example.com/target" }, + ], + constructs: [ + { + descriptor: { + kind: "link", + target: { kind: "internal", anchor: "internal" }, + }, + startRun: 0, + endRun: 2, + }, + ], + }, + ], + }, + ], + }); + // No w:anchor wrapper at all: the slice carries the external hyperlink's own w:hyperlink element, so the internal wrap is refused. + expect(internalHyperlinks(written)).toEqual([]); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const external = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:hyperlink", + ).filter((hyperlink) => + hyperlink.attributes.some((a) => a.name === "r:id"), + ); + expect(external).toHaveLength(1); + }); +}); From 8b0f7dc4ab85122bca3f3c6aed4833ac95fbef08 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 16:16:38 +0100 Subject: [PATCH 46/63] refactor(ooxml.js): drop docx write.ts's links-empty early return The wrap loop below handles an empty internal-link list identically (zero iterations, the output array still the input), so the guard was a second spelling of the same fact. --- packages/ooxml.js/src/typed/docx/write.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/write.ts b/packages/ooxml.js/src/typed/docx/write.ts index 24795c8254..d2f8fc3fde 100644 --- a/packages/ooxml.js/src/typed/docx/write.ts +++ b/packages/ooxml.js/src/typed/docx/write.ts @@ -716,9 +716,7 @@ function wrapInternalLinks( positions: RunPositions, links: readonly InternalLinkExtent[], ): XmlElement[] { - if (links.length === 0) { - return elements; - } + // No links-length early return: the loop below handles an empty list identically (zero iterations, out still the input array), so the guard would only be a second spelling of the same fact. let out = elements; const wrapped: { first: number; last: number }[] = []; for (const link of [...links].sort( From 6a288ca9992b9cf00e7bffa7465826285d9b7108 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 16:20:41 +0100 Subject: [PATCH 47/63] test(ooxml.js): assert docx write.ts's run content, styles, notes, and control XML exactly A run's tab and newline characters split into their own w:tab and w:br elements between space-preserving w:t pieces, and an empty-text run is one empty space-preserving w:t. styles.xml carries its fixed Normal/DefaultParagraphFont scaffolding plus exactly one entry per referenced id sorted, with Normal itself never doubling up, each naming itself and basedOn Normal. The comments part's root and per-comment paragraph are asserted exactly, with a second minted id landing past the highest explicit one when several are carried; the endnotes part keeps its own root tag, Word's separator boilerplate ids, and a note's non-normal w:type. A drop-down control's list is w:dropDownList with its options as w:listItem pairs, an unchecked check-box writes w14:val 0, and a plainText control keeps its own w:text element rather than the richText fallback. --- .../ooxml.js/src/typed/docx/write.test.ts | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index a2791759c4..f701cb5fc8 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -3083,3 +3083,277 @@ describe("buildDocxPackageFromContent: internal link wrap precedence and guards" expect(external).toHaveLength(1); }); }); + +describe("buildDocxPackageFromContent: run content, styles part, note parts, and control property XML", () => { + function bodyParagraph(written: Package): XmlElement { + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + const paragraph = + body === undefined + ? undefined + : body.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + if (paragraph === undefined) { + throw new Error("expected a body paragraph"); + } + return paragraph; + } + + function firstRun(paragraph: XmlElement): XmlElement { + const run = childrenWithTag(paragraph, "w:r")[0]; + if (run === undefined) { + throw new Error("expected a run"); + } + return run; + } + + it("splits a run's text into w:t, w:tab, and w:br children exactly, each w:t preserving space", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [{ kind: "paragraph", runs: [{ text: "a\tb\nc" }] }], + }, + ], + }); + const run = firstRun(bodyParagraph(written)); + const summary = run.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => ({ + tag: child.tag, + space: attr(child, "xml:space"), + text: child.children + .map((grandChild) => + grandChild.type === "text" ? grandChild.value : "", + ) + .join(""), + })); + expect(summary).toEqual([ + { tag: "w:t", space: "preserve", text: "a" }, + { tag: "w:tab", space: undefined, text: "" }, + { tag: "w:t", space: "preserve", text: "b" }, + { tag: "w:br", space: undefined, text: "" }, + { tag: "w:t", space: "preserve", text: "c" }, + ]); + }); + + it("writes an empty-text run as one empty space-preserving w:t", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [{ kind: "paragraph", runs: [{ text: "" }] }], + }, + ], + }); + const run = firstRun(bodyParagraph(written)); + const textElement = childrenWithTag(run, "w:t")[0]; + expect(textElement).toBeDefined(); + expect( + textElement === undefined ? undefined : attr(textElement, "xml:space"), + ).toBe("preserve"); + expect(textElement?.children).toEqual([]); + }); + + it("writes one styles.xml w:style entry per referenced id, excluding Normal itself, with the basedOn scaffolding", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "paragraph", styleId: "Normal", runs: [{ text: "plain" }] }, + { + kind: "paragraph", + styleId: "Heading1", + runs: [{ text: "head" }], + }, + ], + }, + ], + }); + const stylesRoot = rootElement(written.parts["word/styles.xml"]); + if (stylesRoot === undefined) { + throw new Error("expected styles part"); + } + expect(stylesRoot.tag).toBe("w:styles"); + expect(attr(stylesRoot, "xmlns:w")).toBe( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + ); + const styles = elementsWithTag([stylesRoot], "w:style"); + // The fixed Normal/DefaultParagraphFont scaffolding plus exactly one referenced entry -- referencing Normal itself adds nothing. + expect(styles.map((style) => attr(style, "w:styleId"))).toEqual([ + "Normal", + "DefaultParagraphFont", + "Heading1", + ]); + const heading = styles[2]!; + expect(attr(heading, "w:type")).toBe("paragraph"); + expect( + elementsWithTag([heading], "w:name").map((n) => attr(n, "w:val")), + ).toEqual(["Heading1"]); + expect( + elementsWithTag([heading], "w:basedOn").map((n) => attr(n, "w:val")), + ).toEqual(["Normal"]); + }); + + it("writes the comments part's root, one comment per entry, and mints a second id past the highest explicit one", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [{ kind: "paragraph", runs: [{ text: "body" }] }], + }, + ], + comments: [ + { id: "5", author: "First", text: "explicit five" }, + { id: "9", author: "Second", text: "explicit nine" }, + { author: "Third", text: "minted" }, + { author: "Fourth", text: "minted again" }, + ], + }); + const commentsRoot = rootElement(written.parts["word/comments.xml"]); + if (commentsRoot === undefined) { + throw new Error("expected comments part"); + } + expect(commentsRoot.tag).toBe("w:comments"); + expect(attr(commentsRoot, "xmlns:w")).toBe( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + ); + const entries = elementsWithTag([commentsRoot], "w:comment"); + expect(entries.map((entry) => attr(entry, "w:id"))).toEqual([ + "5", + "9", + "10", + "11", + ]); + expect( + entries.map((entry) => + elementsWithTag([entry], "w:t").map((t) => + t.children.map((c) => (c.type === "text" ? c.value : "")).join(""), + ), + ), + ).toEqual([ + ["explicit five"], + ["explicit nine"], + ["minted"], + ["minted again"], + ]); + }); + + it("writes the endnotes part under its own root tag, keeping a note's non-normal type attribute", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [{ kind: "paragraph", runs: [{ text: "body" }] }], + }, + ], + endnotes: [ + { id: "2", type: "continuationNotice", text: "continues" }, + { text: "minted endnote" }, + ], + }); + const notesRoot = rootElement(written.parts["word/endnotes.xml"]); + if (notesRoot === undefined) { + throw new Error("expected endnotes part"); + } + expect(notesRoot.tag).toBe("w:endnotes"); + expect(attr(notesRoot, "xmlns:w")).toBe( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + ); + const entries = elementsWithTag([notesRoot], "w:endnote"); + // Word's own separator/continuationSeparator boilerplate carries ids -1 and 0 ahead of the real notes. + expect(entries.map((entry) => attr(entry, "w:id"))).toEqual([ + "-1", + "0", + "2", + "3", + ]); + expect(attr(entries[2]!, "w:type")).toBe("continuationNotice"); + expect(attr(entries[3]!, "w:type")).toBeUndefined(); + }); + + it("spells a drop-down control's list w:dropDownList and a check-box's unchecked state w14:val 0", () => { + const control = (descriptor: ConstructDescriptor): ContentBlock[] => [ + { kind: "constructStart", descriptor }, + { kind: "paragraph", runs: [{ text: "inside" }] }, + { kind: "constructEnd" }, + ]; + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...control({ + kind: "contentControl", + controlType: "dropDown", + options: ["first choice", "second choice"], + }), + ...control({ + kind: "contentControl", + controlType: "checkbox", + checked: false, + }), + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const sdtPrs = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:sdtPr", + ); + expect(sdtPrs).toHaveLength(2); + const dropDown = childrenWithTag(sdtPrs[0]!, "w:dropDownList")[0]; + expect(dropDown).toBeDefined(); + expect( + dropDown === undefined + ? [] + : elementsWithTag([dropDown], "w:listItem").map((item) => [ + attr(item, "w:displayText"), + attr(item, "w:value"), + ]), + ).toEqual([ + ["first choice", "first choice"], + ["second choice", "second choice"], + ]); + const checked = elementsWithTag([sdtPrs[1]!], "w14:checked")[0]; + expect(checked === undefined ? undefined : attr(checked, "w14:val")).toBe( + "0", + ); + }); + + it("keeps a plainText control's own w:text element rather than the richText fallback", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "plainText" }, + }, + { kind: "paragraph", runs: [{ text: "typed" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const sdtPrs = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:sdtPr", + ); + expect(sdtPrs).toHaveLength(1); + expect( + sdtPrs[0]!.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toContain("w:text"); + }); +}); From 933f665bf82abfd1e05da1e31b7198f3411c8530 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 16:31:35 +0100 Subject: [PATCH 48/63] docs(ooxml.js): record docx write.ts's re-measured 83.95% mutation score --- packages/ooxml.js/stryker.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index 7aa5e2295a..4456e88695 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,7 +2,7 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since had direct structural coverage added too (exact table/tblGrid/vMerge XML, wrapInternalLinks' sorted-extent resolution, comment/footnote id minting, and package-scaffolding root tags/namespaces); scoped-mutating it alone now measures 74.53% of its own 1107 valid mutants, up from a genuinely re-measured baseline of 66.67% taken in the same session immediately beforehand (both runs against the identical source, differing only in the new tests). docx/read.ts has since been closed to a measured, protocol-verified floor: two rounds of direct structural coverage (page geometry and toggle fallbacks, page-break split offset accounting, drawing alt-text/float-position edges, lifted-element deletion guards, discovery-order tie-break fixtures for constructs sharing one extent range, section-split extent re-indexing, theme resolution order, header-part joins, and image anchor offsets across tab/br/cr/delText children) plus removal of the guard expressions that were behaviourally dead by construction took its scoped score 75.87% -> 86.64% -> 96.12% -> 99.16% -> 99.40% of its own 1648 valid mutants, with every step measured by a fresh scoped run. Its five remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing every time): the vMerge grid-column accumulator's `col += gridSpan` -> `-=` (column indices are only ever compared for equality between rows via indexOf, and a uniform sign flip preserves which cells align); `position < firstContentIndex` -> `<=` and `position > lastContentIndex` -> `>=` in recordParagraphRangeMarkers (a range-marker half is never itself a content-bearing child, so it can never sit at exactly the first or last content index); and the two block-scoped field `order: state.order++` -> `order--` sites in scanParagraphFields (post-decrement evaluates to the same used value as post-increment, only shifting later sites' counters, and no construct discovered after a block-scoped field can share that field's extent range since every same-range partner -- whole-paragraph tracked change, paragraph-scoped bookmark pair, enclosing sdt or flow-level change, bracketing body-level bookmark -- is discovered before it). So this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since had three further rounds of direct structural coverage added (optional part emission: exact internal relationships and [Content_Types].xml overrides for styles/numbering/comments/footnotes/endnotes, their absence when empty, header/footer part override naming and typing, and style ids collected from header-part blocks; flow assembly: pending page-break materialisation before a leading image and at flow end, lifted-image return to trailing empty runs in order with text-bearing and hyperlink-wrapped runs excluded, and internal-link wrap precedence -- the longer extent wins a shared start regardless of the constructs array's own order, and a slice carrying an external hyperlink stays plain; and exact run-content/styles/notes/control XML: tab and newline splitting, the styles part's Normal-filtered entries, comments/endnotes id minting past the highest of several explicit ids with Word's separator boilerplate, drop-down lists, and the unchecked check-box spelling); scoped-mutating it alone now measures 83.95% of its own 1744 valid mutants, up from 74.44% measured immediately beforehand in the same session against the identical source (the earlier-recorded 74.53% was a different run of the same code -- the TypeScript-checker phase's own classification nondeterminism moves the valid-mutant count between runs, as the anomaly note below records). docx/read.ts has since been closed to a measured, protocol-verified floor: two rounds of direct structural coverage (page geometry and toggle fallbacks, page-break split offset accounting, drawing alt-text/float-position edges, lifted-element deletion guards, discovery-order tie-break fixtures for constructs sharing one extent range, section-split extent re-indexing, theme resolution order, header-part joins, and image anchor offsets across tab/br/cr/delText children) plus removal of the guard expressions that were behaviourally dead by construction took its scoped score 75.87% -> 86.64% -> 96.12% -> 99.16% -> 99.40% of its own 1648 valid mutants, with every step measured by a fresh scoped run. Its five remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing every time): the vMerge grid-column accumulator's `col += gridSpan` -> `-=` (column indices are only ever compared for equality between rows via indexOf, and a uniform sign flip preserves which cells align); `position < firstContentIndex` -> `<=` and `position > lastContentIndex` -> `>=` in recordParagraphRangeMarkers (a range-marker half is never itself a content-bearing child, so it can never sit at exactly the first or last content index); and the two block-scoped field `order: state.order++` -> `order--` sites in scanParagraphFields (post-decrement evaluates to the same used value as post-increment, only shifting later sites' counters, and no construct discovered after a block-scoped field can share that field's extent range since every same-range partner -- whole-paragraph tracked change, paragraph-scoped bookmark pair, enclosing sdt or flow-level change, bracketing body-level bookmark -- is discovered before it). So this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. // // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. breakThreshold: 83, From be3d2188701b3cbf64f4cddbeeb78b7cce88b36f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:02:05 +0100 Subject: [PATCH 49/63] refactor(ooxml.js): drop docx write.ts's stale index-space link-overlap guard The wrapped-index-range overlap check in wrapInternalLinks could only ever fire on links whose slices are genuinely disjoint: a link crossing or contained in an earlier wrap has its start or end run inside that wrap's w:hyperlink element, so the first/last lookup skips it before the overlap test is reached, and a containing extent always sorts before the one it contains (ascending start, longer extent first at a shared start). The only shapes left for the index-space check are adjacent links whose stale pre-wrap indices still intersect, whose descriptor was therefore lost even though the writer could have written it; adjacent links now each wrap. --- packages/ooxml.js/src/typed/docx/write.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/write.ts b/packages/ooxml.js/src/typed/docx/write.ts index d2f8fc3fde..ac945d64d7 100644 --- a/packages/ooxml.js/src/typed/docx/write.ts +++ b/packages/ooxml.js/src/typed/docx/write.ts @@ -711,6 +711,8 @@ interface InternalLinkExtent { } // Wraps each internal link extent's runs in one w:hyperlink/@w:anchor element -- the inverse of the reader's internal-hyperlink walk. A link whose slice cannot be wrapped -- it crosses another link's (WordprocessingML has no nested w:hyperlink, and Word itself cannot produce the shape), or it covers a run carrying an external hyperlink of its own (already a w:hyperlink) -- writes its runs plain and loses only the descriptor, the content-preserving policy every unwritable construct kind here follows. +// +// No wrap-overlap bookkeeping is needed for the internal-link wraps themselves, and an earlier version that tracked wrapped index ranges was removed: nesting is already impossible by construction here. A link crossing or contained in an earlier link's wrap has its start or end run sitting inside that wrap's w:hyperlink element, so `first`/`last` never both resolve and the guard below skips it; a link CONTAINING an earlier one cannot be processed after it, because the sort order (ascending start, then longer extent first at a shared start) always reaches the containing extent first. Index-range overlap tracking in addition to that could only ever fire on links whose slices are genuinely disjoint (adjacent links, whose stale pre-wrap indices still intersect), losing a descriptor the writer could have written. function wrapInternalLinks( elements: XmlElement[], positions: RunPositions, @@ -718,7 +720,6 @@ function wrapInternalLinks( ): XmlElement[] { // No links-length early return: the loop below handles an empty list identically (zero iterations, out still the input array), so the guard would only be a second spelling of the same fact. let out = elements; - const wrapped: { first: number; last: number }[] = []; for (const link of [...links].sort( (a, b) => a.startRun - b.startRun || b.endRun - a.endRun, )) { @@ -737,17 +738,13 @@ function wrapInternalLinks( continue; } const slice = out.slice(first, last + 1); - // No nesting a hyperlink inside a hyperlink -- neither another internal link's wrap (tracked in `wrapped`) nor a run's own external-target wrapper element. - const overlapsWrapped = wrapped.some( - (range) => first <= range.last && range.first <= last, - ); + // No nesting a hyperlink inside a hyperlink -- the only shape that can still reach this test is a slice carrying a run's own external-target wrapper element, since internal-link overlap is unreachable by the sort-and-lookup argument above. const carriesHyperlink = slice.some( (element) => element.tag === "w:hyperlink", ); - if (overlapsWrapped || carriesHyperlink) { + if (carriesHyperlink) { continue; } - wrapped.push({ first, last }); out = [ ...out.slice(0, first), el("w:hyperlink", { "w:anchor": encodeXmlText(link.anchor) }, slice), From cfc6bd7557e84a4941fa61c74f8fd566feed4dcb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:02:23 +0100 Subject: [PATCH 50/63] test(ooxml.js): cover docx write.ts's boundary shapes, media, and exact part XML Direct structural coverage for the writer's remaining untested behaviour: internal-link boundary shapes (adjacent links both wrapping, a crossing or zero-width or start-past-the-runs extent staying plain, a single-run wrap); raster media naming and sharing (each format's own extension and content-type Default, one file and relationship per repeated payload, the svg refusal) and embedded-object emission (the shared OLE payload part with Word's activation attributes, sequential numbering of distinct payloads, the drawing and serialiser-less presentation refusals, the injected serialiser port); exact run and paragraph property XML (toggle on/off spellings, underline single/none, rtl/bidi both directions, first-line/hanging/zero indents, line-spacing auto rule); run-level construct exactness (increasing bookmark ids, field character order at both boundary and point shapes, instrText space preservation, note-reference injection and its no-run refusal, comment range halves carrying the comments.xml id); table grid and vertical-merge arithmetic (anchor restart, continuation, the fresh anchor after an exhausted merge, side-by-side merges, the column a colSpan anchor skips past, four-edge borders, row heights, grid columns); tracked-change paragraph marks (the empty pPr for a properties-less paragraph, id minting per element, the Unknown author fallback, moveFrom/formatChange spellings, delText); content-control property edges (alias/tag/lock, the index docPartObj gallery, comboBox, date fullDate presence, gallery residue restoration and its non-XML refusal, the mint-condition gate); flow assembly (a break cleared by the table before the next paragraph, object placement into the break paragraph, a preceding paragraph, or a fresh one, table-cell style collection, block-scoped comment ranges, field characters inside an sdt-wrapped paragraph, the no-paragraph field fallback, section-break attachment around trailing tables and into existing or minted pPr); and part-scaffolding exactness (the standalone declaration, document and header/footer roots with their namespace sets and relationship types, a header-only image's part-local relationships and content type, comment authors and bodies, footnote id minting, and the W3CDTF timestamp type). --- .../ooxml.js/src/typed/docx/write.test.ts | 2199 ++++++++++++++++- 1 file changed, 2198 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index f701cb5fc8..25e662a4e6 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -2706,7 +2706,7 @@ describe("buildDocxPackageFromContent: optional part emission, relationships, an blocks: [{ kind: "paragraph", runs: [{ text: "running head" }] }], }, { - path: "word/footer1.xml", + path: "word/footer2.xml", kind: "footer", blocks: [{ kind: "paragraph", runs: [{ text: "running foot" }] }], }, @@ -3357,3 +3357,2200 @@ describe("buildDocxPackageFromContent: run content, styles part, note parts, and ).toContain("w:text"); }); }); + +describe("buildDocxPackageFromContent: internal link wrap boundary shapes", () => { + function paragraphWithLinks( + runs: { text: string; hyperlink?: string }[], + constructs: { + anchor: string; + startRun: number; + endRun: number; + }[], + ): Package { + return buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs, + constructs: constructs.map((extent) => ({ + descriptor: { + kind: "link" as const, + target: { kind: "internal" as const, anchor: extent.anchor }, + }, + startRun: extent.startRun, + endRun: extent.endRun, + })), + }, + ], + }, + ], + }); + } + + function anchorHyperlinks( + written: Package, + ): { anchor: string; runs: string }[] { + const documentRoot = rootElement(written.parts["word/document.xml"]); + return elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:hyperlink", + ) + .filter((hyperlink) => + hyperlink.attributes.some((a) => a.name === "w:anchor"), + ) + .map((hyperlink) => ({ + anchor: + hyperlink.attributes.find((a) => a.name === "w:anchor")?.value ?? "", + runs: hyperlink.children + .filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:r", + ) + .map((run) => + run.children + .filter( + (c): c is XmlElement => c.type === "element" && c.tag === "w:t", + ) + .map((t) => + t.children + .map((x) => (x.type === "text" ? x.value : "")) + .join(""), + ) + .join(""), + ) + .join(","), + })); + } + + const runs4 = [{ text: "a" }, { text: "b" }, { text: "c" }, { text: "d" }]; + + it("wraps each of two adjacent internal links, losing neither descriptor", () => { + const written = paragraphWithLinks(runs4, [ + { anchor: "one", startRun: 0, endRun: 2 }, + { anchor: "two", startRun: 2, endRun: 4 }, + ]); + expect(anchorHyperlinks(written)).toEqual([ + { anchor: "one", runs: "a,b" }, + { anchor: "two", runs: "c,d" }, + ]); + }); + + it("leaves a crossing internal link plain while the earlier-starting extent wraps", () => { + const written = paragraphWithLinks(runs4, [ + { anchor: "one", startRun: 0, endRun: 2 }, + { anchor: "crosses", startRun: 1, endRun: 3 }, + ]); + expect(anchorHyperlinks(written)).toEqual([{ anchor: "one", runs: "a,b" }]); + }); + + it("writes a zero-width internal link plain, both at a run boundary and at the paragraph's start", () => { + // A zero-width extent covers no run at all: at startRun 0 its end's position lookup (endRun - 1) names no run, and at startRun 1 its end resolves to the run BEFORE its own start, so either way there is no slice to wrap and the descriptor alone is lost. + const atStart = paragraphWithLinks( + [{ text: "a" }, { text: "b" }], + [{ anchor: "zero-at-start", startRun: 0, endRun: 0 }], + ); + expect(anchorHyperlinks(atStart)).toEqual([]); + const betweenRuns = paragraphWithLinks( + [{ text: "a" }, { text: "b" }], + [{ anchor: "zero-between", startRun: 1, endRun: 1 }], + ); + expect(anchorHyperlinks(betweenRuns)).toEqual([]); + }); + + it("writes an internal link whose start names no run plain rather than wrapping the tail", () => { + // startRun === endRun === runs.length passes the extent contract (only endRun > runs.length is beyond it), so the start's lookup finds no element and the link is dropped rather than wrapping a tail slice it never named. + const written = paragraphWithLinks( + [{ text: "a" }, { text: "b" }], + [{ anchor: "past-the-end", startRun: 2, endRun: 2 }], + ); + expect(anchorHyperlinks(written)).toEqual([]); + }); + + it("wraps an internal link covering exactly one run", () => { + const written = paragraphWithLinks( + [{ text: "a" }, { text: "b" }], + [{ anchor: "solo", startRun: 1, endRun: 2 }], + ); + expect(anchorHyperlinks(written)).toEqual([{ anchor: "solo", runs: "b" }]); + }); +}); + +describe("buildDocxPackageFromContent: media and embedded-object emission", () => { + function imageBlock( + format: "png" | "jpeg" | "gif", + base64: string, + ): Extract { + return { kind: "image", format, base64, widthPt: 72, heightPt: 36 }; + } + + function documentRels(written: Package): XmlElement[] { + const relsRoot = rootElement(written.parts["word/_rels/document.xml.rels"]); + if (relsRoot === undefined) { + throw new Error("expected document rels part"); + } + return elementsWithTag([relsRoot], "Relationship"); + } + + function contentTypesDefaults( + written: Package, + ): { extension: string; contentType: string }[] { + const typesRoot = rootElement(written.parts["[Content_Types].xml"]); + if (typesRoot === undefined) { + throw new Error("expected content types part"); + } + return elementsWithTag([typesRoot], "Default").map((entry) => ({ + extension: attr(entry, "Extension") ?? "", + contentType: attr(entry, "ContentType") ?? "", + })); + } + + function blipEmbeds(written: Package): string[] { + const documentRoot = rootElement(written.parts["word/document.xml"]); + return elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "a:blip", + ).map((blip) => attr(blip, "r:embed") ?? ""); + } + + it("names each raster format's media file with its own extension and declares the matching content-type Default", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + imageBlock("jpeg", "jpegbytes"), + imageBlock("gif", "gifbytes"), + ], + }, + ], + }); + expect(Object.keys(written.parts)).toEqual( + expect.arrayContaining([ + "word/media/image1.jpeg", + "word/media/image2.gif", + ]), + ); + const defaults = contentTypesDefaults(written); + expect(defaults).toContainEqual({ + extension: "jpeg", + contentType: "image/jpeg", + }); + expect(defaults).toContainEqual({ + extension: "gif", + contentType: "image/gif", + }); + expect(defaults.filter((entry) => entry.extension === "png")).toEqual([]); + const imageRels = documentRels(written).filter( + (rel) => attr(rel, "Type") === IMAGE_REL, + ); + expect(imageRels.map((rel) => attr(rel, "Target"))).toEqual([ + "media/image1.jpeg", + "media/image2.gif", + ]); + }); + + it("shares one media file and one relationship across an image repeated in the document", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + imageBlock("png", TINY_PNG_BASE64), + imageBlock("png", TINY_PNG_BASE64), + ], + }, + ], + }); + const mediaFiles = Object.keys(written.parts).filter((name) => + name.startsWith("word/media/"), + ); + expect(mediaFiles).toEqual(["word/media/image1.png"]); + expect(new Set(blipEmbeds(written)).size).toBe(1); + expect( + documentRels(written).filter((rel) => attr(rel, "Type") === IMAGE_REL), + ).toHaveLength(1); + }); + + it("refuses an svg image block rather than writing a blip no Word can render", () => { + expect(() => + buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + imageBlock("png", TINY_PNG_BASE64), + { ...imageBlock("png", TINY_PNG_BASE64), format: "svg" }, + ], + }, + ], + }), + ).toThrow( + /an image block in svg format has no OOXML blip this writer can produce/, + ); + }); + + const embeddedWordprocessing = (): Extract< + ContentBlock, + { kind: "embeddedObject" } + >["document"] => ({ + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text: "nested" }] }], + }, + ], + }); + + function embeddedObjectBlock( + document: ReturnType, + widthPt = 100, + ): Extract { + return { + kind: "embeddedObject", + objectKind: "wordprocessing", + document, + frame: { widthPt, heightPt: 60 }, + }; + } + + function oleObjects(written: Package): XmlElement[] { + const documentRoot = rootElement(written.parts["word/document.xml"]); + return elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "o:OLEObject", + ); + } + + it("serialises an embedded wordprocessing document into one shared payload part with Word's own activation attributes", () => { + const nested = embeddedWordprocessing(); + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [embeddedObjectBlock(nested), embeddedObjectBlock(nested)], + }, + ], + }); + const embeddingFiles = Object.keys(written.parts).filter((name) => + name.startsWith("word/embeddings/"), + ); + expect(embeddingFiles).toEqual(["word/embeddings/oleObject1.docx"]); + const objects = oleObjects(written); + expect(objects).toHaveLength(2); + for (const object of objects) { + expect(attr(object, "Type")).toBe("Embed"); + expect(attr(object, "ProgID")).toBe("Word.Document.12"); + expect(attr(object, "DrawAspect")).toBe("Content"); + expect(attr(object, "r:id")).toBe( + objects[0] === undefined ? "" : attr(objects[0], "r:id"), + ); + } + const objectElements = elementsWithTag( + rootElement(written.parts["word/document.xml"]) === undefined + ? [] + : [rootElement(written.parts["word/document.xml"])!], + "w:object", + ); + expect(objectElements.map((element) => attr(element, "w:dxaOrig"))).toEqual( + ["2000", "2000"], + ); + expect(objectElements.map((element) => attr(element, "w:dyaOrig"))).toEqual( + ["1200", "1200"], + ); + const typesRoot = rootElement(written.parts["[Content_Types].xml"]); + const overrides = + typesRoot === undefined + ? [] + : elementsWithTag([typesRoot], "Override").map((o) => [ + attr(o, "PartName") ?? "", + attr(o, "ContentType") ?? "", + ]); + expect(overrides).toContainEqual([ + "/word/embeddings/oleObject1.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ]); + }); + + it("numbers distinct embedded payloads sequentially", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + embeddedObjectBlock(embeddedWordprocessing(), 100), + embeddedObjectBlock( + { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { + topPt: 72, + rightPt: 72, + bottomPt: 72, + leftPt: 72, + }, + blocks: [{ kind: "paragraph", runs: [{ text: "other" }] }], + }, + ], + }, + 120, + ), + ], + }, + ], + }); + const embeddingFiles = Object.keys(written.parts).filter((name) => + name.startsWith("word/embeddings/"), + ); + expect(embeddingFiles).toEqual([ + "word/embeddings/oleObject1.docx", + "word/embeddings/oleObject2.docx", + ]); + }); + + it("refuses an embedded drawing document with the exact reason", () => { + expect(() => + buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "embeddedObject", + objectKind: "drawing", + document: { kind: "drawing", metadata: {}, pages: [] }, + frame: { widthPt: 100, heightPt: 60 }, + }, + ], + }, + ], + }), + ).toThrow( + /an embedded object carrying a drawing document has no OOXML OLE payload this writer can produce/, + ); + }); + + it("refuses an embedded presentation with no injected serialiser, and serialises it through the port when one is injected", () => { + const presentation = { + kind: "presentation", + metadata: {}, + slides: [], + } as const; + const block = { + kind: "embeddedObject", + objectKind: "presentation", + document: presentation, + frame: { widthPt: 100, heightPt: 60 }, + } as const; + expect(() => + buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [block] }], + }), + ).toThrow( + /an embedded object carrying a presentation document has no serialiser/, + ); + const written = buildDocxPackageFromContent( + { sections: [{ ...emptyBodySection(), blocks: [block] }] }, + { + serialiseEmbeddedPresentation: () => + new Uint8Array([1, 2, 3, 4]).slice(), + }, + ); + expect(Object.keys(written.parts)).toContain( + "word/embeddings/oleObject1.pptx", + ); + const object = oleObjects(written)[0]; + expect(object === undefined ? "" : attr(object, "ProgID")).toBe( + "PowerPoint.Show.12", + ); + }); +}); + +describe("buildDocxPackageFromContent: run and paragraph property XML exactness", () => { + function singleParagraph(written: Package): XmlElement { + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + const paragraph = + body === undefined + ? undefined + : body.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + if (paragraph === undefined) { + throw new Error("expected a body paragraph"); + } + return paragraph; + } + + function runProperties( + written: Package, + ): { tag: string; val: string | undefined }[] { + const paragraph = singleParagraph(written); + const run = childrenWithTag(paragraph, "w:r")[0]; + if (run === undefined) { + throw new Error("expected a run"); + } + const rPr = childrenWithTag(run, "w:rPr")[0]; + if (rPr === undefined) { + throw new Error("expected run properties"); + } + return rPr.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => ({ tag: child.tag, val: attr(child, "w:val") })); + } + + function paragraphProperties( + written: Package, + ): { tag: string; attrs: Record }[] { + const paragraph = singleParagraph(written); + const pPr = childrenWithTag(paragraph, "w:pPr")[0]; + if (pPr === undefined) { + throw new Error("expected paragraph properties"); + } + return pPr.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => ({ + tag: child.tag, + attrs: Object.fromEntries( + child.attributes.map((attribute) => [ + attribute.name, + attribute.value, + ]), + ), + })); + } + + it("spells every resolved run toggle with its own on/off w:val, including the explicit off spellings", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [ + { + text: "styled", + bold: true, + italic: false, + strike: true, + underline: true, + direction: "rtl", + }, + ], + }, + ], + }, + ], + }); + expect(runProperties(written)).toEqual([ + { tag: "w:b", val: "1" }, + { tag: "w:i", val: "0" }, + { tag: "w:strike", val: "1" }, + { tag: "w:u", val: "single" }, + { tag: "w:rtl", val: "1" }, + ]); + }); + + it("spells a resolved left-to-right run's direction with the rtl off spelling and an absent underline as none", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "plain", underline: false, direction: "ltr" }], + }, + ], + }, + ], + }); + expect(runProperties(written)).toEqual([ + { tag: "w:u", val: "none" }, + { tag: "w:rtl", val: "0" }, + ]); + }); + + it("writes a positive first-line indent as w:firstLine, a negative one as the signed inverse w:hanging, and zero as firstLine zero", () => { + const build = (indentFirstLinePt: number) => + buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "indented" }], + indentFirstLinePt, + }, + ], + }, + ], + }); + const positive = paragraphProperties(build(24)); + expect(positive.find((child) => child.tag === "w:ind")?.attrs).toEqual({ + "w:firstLine": "480", + }); + const negative = paragraphProperties(build(-24)); + expect(negative.find((child) => child.tag === "w:ind")?.attrs).toEqual({ + "w:hanging": "480", + }); + const zero = paragraphProperties(build(0)); + expect(zero.find((child) => child.tag === "w:ind")?.attrs).toEqual({ + "w:firstLine": "0", + }); + }); + + it("spells a paragraph's line spacing with the auto rule and its direction with the bidi on/off spelling", () => { + const rtl = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: " rtl" }], + lineSpacing: 1.5, + direction: "rtl", + }, + ], + }, + ], + }); + expect(paragraphProperties(rtl)).toEqual([ + { tag: "w:bidi", attrs: { "w:val": "1" } }, + { tag: "w:spacing", attrs: { "w:line": "360", "w:lineRule": "auto" } }, + ]); + const ltr = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "ltr" }], + lineSpacing: 2, + direction: "ltr", + }, + ], + }, + ], + }); + expect(paragraphProperties(ltr)).toEqual([ + { tag: "w:bidi", attrs: { "w:val": "0" } }, + { tag: "w:spacing", attrs: { "w:line": "480", "w:lineRule": "auto" } }, + ]); + }); +}); + +describe("buildDocxPackageFromContent: run-level construct marker XML exactness", () => { + function bodyParagraph(written: Package): XmlElement { + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + const paragraph = + body === undefined + ? undefined + : body.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + if (paragraph === undefined) { + throw new Error("expected a body paragraph"); + } + return paragraph; + } + + function childTags(paragraph: XmlElement): string[] { + return paragraph.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag); + } + + it("mints increasing bookmark ids across two point bookmarks in one paragraph", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "marked" }], + constructs: [ + { + descriptor: { + kind: "anchor", + anchorType: "bookmark", + name: "one", + }, + startRun: 0, + endRun: 0, + }, + { + descriptor: { + kind: "anchor", + anchorType: "bookmark", + name: "two", + }, + startRun: 0, + endRun: 0, + }, + ], + }, + ], + }, + ], + }); + const paragraph = bodyParagraph(written); + expect(childTags(paragraph)).toEqual([ + "w:bookmarkStart", + "w:bookmarkEnd", + "w:bookmarkStart", + "w:bookmarkEnd", + "w:r", + ]); + const ids = paragraph.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => attr(child, "w:id")) + .filter((id): id is string => id !== undefined); + expect(ids).toEqual(["1", "1", "2", "2"]); + const names = paragraph.children + .filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:bookmarkStart", + ) + .map((child) => attr(child, "w:name")); + expect(names).toEqual(["one", "two"]); + // A point bookmark mutates nothing about the run it sits at: the run keeps exactly its own text child. + const run = childrenWithTag(paragraph, "w:r")[0]!; + expect( + run.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toEqual(["w:t"]); + }); + + it("writes a run-level field's characters with the begin/instruction/separate group at its opening boundary and the typed end at its closing boundary", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "result" }, { text: "after" }], + constructs: [ + { + descriptor: { kind: "field", instruction: " PAGE " }, + startRun: 0, + endRun: 1, + }, + ], + }, + ], + }, + ], + }); + const paragraph = bodyParagraph(written); + expect(childTags(paragraph)).toEqual([ + "w:r", + "w:r", + "w:r", + "w:r", + "w:r", + "w:r", + ]); + const fldChars = elementsWithTag([paragraph], "w:fldChar").map((run) => + attr(run, "w:fldCharType"), + ); + expect(fldChars).toEqual(["begin", "separate", "end"]); + const instr = elementsWithTag([paragraph], "w:instrText")[0]!; + expect(attr(instr, "xml:space")).toBe("preserve"); + expect( + instr.children + .map((child) => (child.type === "text" ? child.value : "")) + .join(""), + ).toBe(" PAGE "); + }); + + it("writes a point field's four characters as one adjacent group before the run at its own position", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "first" }, { text: "second" }], + constructs: [ + { + descriptor: { kind: "field", instruction: "x" }, + startRun: 1, + endRun: 1, + }, + ], + }, + ], + }, + ], + }); + const paragraph = bodyParagraph(written); + // The four characters all precede the second run, begin to end in order. + const described = paragraph.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => { + if (child.tag !== "w:r") { + return child.tag; + } + const fldChar = childrenWithTag(child, "w:fldChar")[0]; + if (fldChar !== undefined) { + return `fld:${attr(fldChar, "w:fldCharType")}`; + } + if (childrenWithTag(child, "w:instrText").length > 0) { + return "instr"; + } + return "run"; + }); + expect(described).toEqual([ + "run", + "fld:begin", + "instr", + "fld:separate", + "fld:end", + "run", + ]); + }); + + it("injects a note reference mark into the run at its own index and refuses one that names no run", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "before" }, { text: "" }], + constructs: [ + { + descriptor: { + kind: "anchor", + anchorType: "footnote", + name: "3", + }, + startRun: 1, + endRun: 1, + }, + ], + }, + ], + }, + ], + }); + const paragraph = bodyParagraph(written); + const secondRun = childrenWithTag(paragraph, "w:r")[1]!; + expect( + secondRun.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toEqual(["w:t", "w:footnoteReference"]); + const reference = childrenWithTag(secondRun, "w:footnoteReference")[0]!; + expect(attr(reference, "w:id")).toBe("3"); + expect(() => + buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "only" }], + constructs: [ + { + descriptor: { + kind: "anchor", + anchorType: "comment", + name: "9", + }, + startRun: 1, + endRun: 1, + }, + ], + }, + ], + }, + ], + }), + ).toThrow( + /a comment reference at run index 1 of a paragraph does not name a real run/, + ); + }); + + it("writes a comment extent's halves with the comments.xml id verbatim", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [{ text: "a" }, { text: "b" }], + constructs: [ + { + descriptor: { + kind: "anchor", + anchorType: "comment", + name: "4", + }, + startRun: 0, + endRun: 2, + }, + ], + }, + ], + }, + ], + }); + const paragraph = bodyParagraph(written); + expect(childTags(paragraph)).toEqual([ + "w:commentRangeStart", + "w:r", + "w:r", + "w:commentRangeEnd", + ]); + expect( + attr(childrenWithTag(paragraph, "w:commentRangeStart")[0]!, "w:id"), + ).toBe("4"); + expect( + attr(childrenWithTag(paragraph, "w:commentRangeEnd")[0]!, "w:id"), + ).toBe("4"); + }); +}); + +describe("buildDocxPackageFromContent: table grid and vertical-merge arithmetic", () => { + interface TableFixtureCell { + blocks: ContentBlock[]; + rowSpan?: number; + colSpan?: number; + borders?: Extract< + ContentBlock, + { kind: "table" } + >["rows"][number]["cells"][number]["borders"]; + } + + interface TableFixtureRow { + cells: TableFixtureCell[]; + heightPt?: number; + } + + function tableOf( + rows: TableFixtureRow[], + columnWidthsPt: number[] = [100, 100], + ): Extract { + return { kind: "table", rows, columnWidthsPt }; + } + + function writtenTable(written: Package): XmlElement { + const documentRoot = rootElement(written.parts["word/document.xml"]); + const table = + documentRoot === undefined + ? undefined + : elementsWithTag([documentRoot], "w:tbl")[0]; + if (table === undefined) { + throw new Error("expected a table"); + } + return table; + } + + function cellShapes( + written: Package, + ): { span: string | undefined; merge: string | undefined }[][] { + const rows = elementsWithTag([writtenTable(written)], "w:tr"); + return rows.map((row) => + elementsWithTag([row], "w:tc").map((cell) => { + const tcPr = childrenWithTag(cell, "w:tcPr")[0]; + if (tcPr === undefined) { + return { span: undefined, merge: undefined }; + } + const gridSpan = childrenWithTag(tcPr, "w:gridSpan")[0]; + const vMerge = childrenWithTag(tcPr, "w:vMerge")[0]; + return { + span: gridSpan === undefined ? undefined : attr(gridSpan, "w:val"), + merge: + vMerge === undefined + ? undefined + : (attr(vMerge, "w:val") ?? "(bare)"), + }; + }), + ); + } + + it("restarts a vertical merge's anchor, continues its covered rows, and treats the row after the merge as a fresh anchor", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + tableOf([ + { cells: [{ blocks: [], rowSpan: 2 }, { blocks: [] }] }, + { cells: [{ blocks: [] }, { blocks: [] }] }, + { cells: [{ blocks: [] }, { blocks: [] }] }, + ]), + ], + }, + ], + }); + expect(cellShapes(written)).toEqual([ + [ + { merge: "restart", span: undefined }, + { merge: undefined, span: undefined }, + ], + [ + { merge: "(bare)", span: undefined }, + { merge: undefined, span: undefined }, + ], + [ + { merge: undefined, span: undefined }, + { merge: undefined, span: undefined }, + ], + ]); + }); + + it("continues both columns of two side-by-side vertical merges at their own grid columns", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + tableOf([ + { + cells: [ + { blocks: [], rowSpan: 2 }, + { blocks: [], rowSpan: 2 }, + ], + }, + { cells: [{ blocks: [] }, { blocks: [] }] }, + ]), + ], + }, + ], + }); + expect(cellShapes(written)).toEqual([ + [ + { merge: "restart", span: undefined }, + { merge: "restart", span: undefined }, + ], + [ + { merge: "(bare)", span: undefined }, + { merge: "(bare)", span: undefined }, + ], + ]); + }); + + it("continues a merge at the grid column a leading colSpan anchor skips past", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + tableOf( + [ + { + cells: [ + { blocks: [], colSpan: 2, rowSpan: 2 }, + { blocks: [], rowSpan: 2 }, + ], + }, + { cells: [{ blocks: [] }, { blocks: [] }] }, + ], + [100, 100, 100], + ), + ], + }, + ], + }); + expect(cellShapes(written)).toEqual([ + [ + { merge: "restart", span: "2" }, + { merge: "restart", span: undefined }, + ], + [ + { merge: "(bare)", span: "2" }, + { merge: "(bare)", span: undefined }, + ], + ]); + }); + + it("writes each of a cell's four border edges under its own side tag", () => { + const border = (style?: "solid" | "dashed" | "dotted" | "double") => ({ + style, + widthPt: 1, + color: { red: 17, green: 34, blue: 51 }, + }); + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + tableOf([ + { + cells: [ + { + blocks: [], + borders: { + top: border(), + left: border("dashed"), + bottom: border("dotted"), + right: border("double"), + }, + }, + ], + }, + ]), + ], + }, + ], + }); + const edges = elementsWithTag([writtenTable(written)], "w:tcBorders"); + expect(edges).toHaveLength(1); + expect( + edges[0]!.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => [child.tag, attr(child, "w:val"), attr(child, "w:sz")]), + ).toEqual([ + ["w:top", "single", "8"], + ["w:left", "dashed", "8"], + ["w:bottom", "dotted", "8"], + ["w:right", "double", "8"], + ]); + }); + + it("writes a row's own height in the trPr/trHeight spelling and the grid's column widths in twips", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + tableOf([{ cells: [{ blocks: [] }], heightPt: 30 }], [72, 144]), + ], + }, + ], + }); + const table = writtenTable(written); + const row = elementsWithTag([table], "w:tr")[0]!; + const trPr = childrenWithTag(row, "w:trPr")[0]!; + expect(attr(childrenWithTag(trPr, "w:trHeight")[0]!, "w:val")).toBe("600"); + const grid = childrenWithTag(table, "w:tblGrid")[0]!; + expect( + elementsWithTag([grid], "w:gridCol").map((col) => attr(col, "w:w")), + ).toEqual(["1440", "2880"]); + }); +}); + +describe("buildDocxPackageFromContent: tracked-change paragraph marks and ids", () => { + function bodyParagraphs(written: Package): XmlElement[] { + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + if (body === undefined) { + throw new Error("expected body"); + } + return body.children.filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + } + + const insertion = { + kind: "provenance" as const, + change: "insertion" as const, + author: "Editor", + dateIso: "2026-09-16T10:00:00Z", + }; + + it("marks both the paragraph mark and the runs of every paragraph in a tracked-change extent, minting one id per element", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "constructStart", descriptor: insertion }, + { kind: "paragraph", runs: [{ text: "first" }] }, + { kind: "paragraph", runs: [{ text: "second" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const paragraphs = bodyParagraphs(written); + expect(paragraphs).toHaveLength(2); + const ids: string[] = []; + for (const paragraph of paragraphs) { + const pPr = childrenWithTag(paragraph, "w:pPr")[0]!; + const rPr = childrenWithTag(pPr, "w:rPr")[0]!; + const markChange = elementsWithTag([rPr], "w:ins")[0]!; + expect(attr(markChange, "w:author")).toBe("Editor"); + expect(attr(markChange, "w:date")).toBe("2026-09-16T10:00:00Z"); + ids.push(attr(markChange, "w:id") ?? ""); + const runWrapper = childrenWithTag(paragraph, "w:ins")[0]!; + expect(attr(runWrapper, "w:author")).toBe("Editor"); + ids.push(attr(runWrapper, "w:id") ?? ""); + } + expect(ids).toEqual(["1", "2", "3", "4"]); + }); + + it("gives a tracked paragraph with no other properties an empty pPr carrying only the change", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "constructStart", descriptor: insertion }, + { kind: "paragraph", runs: [{ text: "bare" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const paragraph = bodyParagraphs(written)[0]!; + const pPr = childrenWithTag(paragraph, "w:pPr")[0]!; + expect( + pPr.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toEqual(["w:rPr"]); + }); + + it("falls back to the unknown author spelling when a change carries none, and omits w:date when there is no date", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { + ...insertion, + author: undefined, + dateIso: undefined, + }, + }, + { kind: "paragraph", runs: [{ text: "anon" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const paragraph = bodyParagraphs(written)[0]!; + const wrapper = childrenWithTag(paragraph, "w:ins")[0]!; + expect(attr(wrapper, "w:author")).toBe("Unknown"); + expect( + wrapper.attributes.find((attribute) => attribute.name === "w:date"), + ).toBeUndefined(); + }); + + it("writes a moveFrom change under its own tag and leaves a formatChange extent's content unwrapped", () => { + const moveWritten = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { ...insertion, change: "moveFrom" }, + }, + { kind: "paragraph", runs: [{ text: "moved" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const moveParagraph = bodyParagraphs(moveWritten)[0]!; + expect(childrenWithTag(moveParagraph, "w:moveFrom")).toHaveLength(1); + + const formatWritten = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { ...insertion, change: "formatChange" }, + }, + { + kind: "paragraph", + styleId: "Styled", + runs: [{ text: "reformatted" }], + }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const formatParagraph = bodyParagraphs(formatWritten)[0]!; + expect(childrenWithTag(formatParagraph, "w:ins")).toHaveLength(0); + expect(childrenWithTag(formatParagraph, "w:del")).toHaveLength(0); + const pPr = childrenWithTag(formatParagraph, "w:pPr")[0]!; + expect( + elementsWithTag([pPr], "w:pStyle").map((style) => attr(style, "w:val")), + ).toEqual(["Styled"]); + expect(childrenWithTag(pPr, "w:rPr")).toHaveLength(0); + }); + + it("deletes a tracked deletion's runs through the delText spelling", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { ...insertion, change: "deletion" }, + }, + { kind: "paragraph", runs: [{ text: "gone" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const paragraph = bodyParagraphs(written)[0]!; + const wrapper = childrenWithTag(paragraph, "w:del")[0]!; + expect( + elementsWithTag([wrapper], "w:delText").map((t) => + t.children + .map((child) => (child.type === "text" ? child.value : "")) + .join(""), + ), + ).toEqual(["gone"]); + }); +}); + +describe("buildDocxPackageFromContent: content-control property edges", () => { + function sdtProperties(written: Package): XmlElement[] { + const documentRoot = rootElement(written.parts["word/document.xml"]); + return elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:sdtPr", + ); + } + + function controlBlocks(descriptor: ConstructDescriptor): ContentBlock[] { + return [ + { kind: "constructStart", descriptor }, + { kind: "paragraph", runs: [{ text: "inside" }] }, + { kind: "constructEnd" }, + ]; + } + + it("writes the alias, tag, and each lock spelling under their own elements", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...controlBlocks({ + kind: "contentControl", + controlType: "richText", + alias: "Named", + tag: "tagged", + lock: "both", + }), + ], + }, + ], + }); + const sdtPr = sdtProperties(written)[0]!; + expect( + sdtPr.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => [child.tag, attr(child, "w:val")]), + ).toEqual([ + ["w:alias", "Named"], + ["w:tag", "tagged"], + ["w:lock", "sdtContentLocked"], + ["w:richText", undefined], + ]); + }); + + it("spells an index control as the table-of-contents docPartObj gallery with its unique marker", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...controlBlocks({ kind: "contentControl", controlType: "index" }), + ], + }, + ], + }); + const sdtPr = sdtProperties(written)[0]!; + const docPartObj = childrenWithTag(sdtPr, "w:docPartObj")[0]!; + expect( + elementsWithTag([docPartObj], "w:docPartGallery").map((gallery) => + attr(gallery, "w:val"), + ), + ).toEqual(["Table of Contents"]); + expect(childrenWithTag(docPartObj, "w:docPartUnique")).toHaveLength(1); + }); + + it("spells a combo box under its own tag with the same list items a drop-down carries", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...controlBlocks({ + kind: "contentControl", + controlType: "comboBox", + options: ["alpha", "beta"], + }), + ], + }, + ], + }); + const sdtPr = sdtProperties(written)[0]!; + const comboBox = childrenWithTag(sdtPr, "w:comboBox")[0]!; + expect(comboBox).toBeDefined(); + expect( + elementsWithTag([comboBox], "w:listItem").map((item) => [ + attr(item, "w:displayText"), + attr(item, "w:value"), + ]), + ).toEqual([ + ["alpha", "alpha"], + ["beta", "beta"], + ]); + }); + + it("writes a date control's full date when carried and no attribute when absent", () => { + const withDate = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...controlBlocks({ + kind: "contentControl", + controlType: "date", + value: "2026-09-16T09:00:00Z", + }), + ], + }, + ], + }); + const dateElement = childrenWithTag( + sdtProperties(withDate)[0]!, + "w:date", + )[0]!; + expect(attr(dateElement, "w:fullDate")).toBe("2026-09-16T09:00:00Z"); + const withoutDate = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...controlBlocks({ kind: "contentControl", controlType: "date" }), + ], + }, + ], + }); + const bareDate = childrenWithTag( + sdtProperties(withoutDate)[0]!, + "w:date", + )[0]!; + expect(bareDate.attributes).toEqual([]); + }); + + it("re-emits a degraded gallery's docPartObj residue in place of the richText element and refuses residue that is not XML", () => { + const galleryResidue = ``; + const restored = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...controlBlocks({ + kind: "contentControl", + controlType: "richText", + source: { format: "docx", xml: galleryResidue }, + }), + ], + }, + ], + }); + const sdtPr = sdtProperties(restored)[0]!; + const docPartObj = childrenWithTag(sdtPr, "w:docPartObj")[0]!; + expect( + elementsWithTag([docPartObj], "w:docPartGallery").map((gallery) => + attr(gallery, "w:val"), + ), + ).toEqual(["Table of Contents"]); + + expect(() => + buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...controlBlocks({ + kind: "contentControl", + controlType: "richText", + source: { format: "docx", xml: "not xml <" }, + }), + ], + }, + ], + }), + ).toThrow(/carries docx residue that does not parse as XML: not xml { + const build = (xml: string) => + buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...controlBlocks({ + kind: "contentControl", + controlType: "richText", + source: { format: "docx", xml }, + }), + ], + }, + ], + }); + // Two top-level nodes: not the single element a restoration requires. + const twoNodes = build(""); + expect( + sdtProperties(twoNodes)[0]! + .children.filter( + (child): child is XmlElement => child.type === "element", + ) + .map((child) => child.tag), + ).toEqual(["w:richText"]); + // One element of the wrong tag: same fallback. + const wrongTag = build(""); + expect( + sdtProperties(wrongTag)[0]! + .children.filter( + (child): child is XmlElement => child.type === "element", + ) + .map((child) => child.tag), + ).toEqual(["w:richText"]); + }); + + it("ignores docx residue on a control that is not richText, since the gate is the mint condition", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + ...controlBlocks({ + kind: "contentControl", + controlType: "plainText", + source: { + format: "docx", + xml: '', + }, + }), + ], + }, + ], + }); + expect( + sdtProperties(written)[0]! + .children.filter( + (child): child is XmlElement => child.type === "element", + ) + .map((child) => child.tag), + ).toEqual(["w:text"]); + }); +}); + +describe("buildDocxPackageFromContent: flow assembly and section breaks", () => { + function bodyElements(written: Package): { tag: string; summary: string }[] { + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + if (body === undefined) { + throw new Error("expected body"); + } + return body.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => { + if (child.tag === "w:p") { + const pPr = childrenWithTag(child, "w:pPr")[0]; + const breakBefore = + pPr !== undefined && + childrenWithTag(pPr, "w:pageBreakBefore").length > 0; + const hasSectPr = + pPr !== undefined && childrenWithTag(pPr, "w:sectPr").length > 0; + const runCount = childrenWithTag(child, "w:r").length; + const objectCount = elementsWithTag([child], "w:object").length; + const drawingCount = elementsWithTag([child], "w:drawing").length; + return { + tag: "w:p", + summary: `p${breakBefore ? "+break" : ""}${hasSectPr ? "+sectPr" : ""}:r${runCount}:o${objectCount}:d${drawingCount}`, + }; + } + return { tag: child.tag, summary: "" }; + }); + } + + it("clears the pending page break once the table before the next paragraph has carried it", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "pageBreak" }, + { + kind: "table", + rows: [{ cells: [{ blocks: [] }] }], + columnWidthsPt: [100], + }, + { kind: "paragraph", runs: [{ text: "after" }] }, + ], + }, + ], + }); + expect(bodyElements(written)).toEqual([ + { tag: "w:p", summary: "p+break:r0:o0:d0" }, + { tag: "w:tbl", summary: "" }, + { tag: "w:p", summary: "p:r1:o0:d0" }, + { tag: "w:sectPr", summary: "" }, + ]); + }); + + it("materialises a pending break before an embedded object, then places the object inside that break paragraph", () => { + const nested = { + kind: "wordprocessing", + metadata: {}, + sections: [], + } as const; + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "pageBreak" }, + { + kind: "embeddedObject", + objectKind: "wordprocessing", + document: nested, + frame: { widthPt: 100, heightPt: 60 }, + }, + ], + }, + ], + }); + expect(bodyElements(written)).toEqual([ + { tag: "w:p", summary: "p+break:r1:o1:d0" }, + { tag: "w:sectPr", summary: "" }, + ]); + }); + + it("appends an embedded object with no trailing empty run to its preceding paragraph, and one with no paragraph at all to a fresh paragraph", () => { + const nested = (text: string) => + ({ + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text }] }], + }, + ], + }) as const; + const object = (text: string) => + ({ + kind: "embeddedObject", + objectKind: "wordprocessing", + document: nested(text), + frame: { widthPt: 100, heightPt: 60 }, + }) as const; + const attached = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "paragraph", runs: [{ text: "host" }] }, + object("first"), + ], + }, + ], + }); + expect(bodyElements(attached)).toEqual([ + { tag: "w:p", summary: "p:r2:o1:d0" }, + { tag: "w:sectPr", summary: "" }, + ]); + const fresh = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [object("second")] }], + }); + expect(bodyElements(fresh)).toEqual([ + { tag: "w:p", summary: "p:r1:o1:d0" }, + { tag: "w:sectPr", summary: "" }, + ]); + }); + + it("collects a paragraph styleId referenced only inside a table cell into the styles part", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "paragraph", + styleId: "CellStyle", + runs: [{ text: "cell" }], + }, + ], + }, + ], + }, + ], + columnWidthsPt: [100], + }, + ], + }, + ], + }); + const stylesRoot = rootElement(written.parts["word/styles.xml"]); + expect( + stylesRoot === undefined + ? [] + : elementsWithTag([stylesRoot], "w:style").map((style) => + attr(style, "w:styleId"), + ), + ).toEqual(["Normal", "DefaultParagraphFont", "CellStyle"]); + }); + + it("writes a block-scoped comment extent as the same-id range pair around its paragraphs", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { kind: "anchor", anchorType: "comment", name: "7" }, + }, + { kind: "paragraph", runs: [{ text: "commented" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined ? [] : childrenWithTag(documentRoot, "w:body"); + const bodyChildren = + body[0] === undefined + ? [] + : body[0].children.filter( + (child): child is XmlElement => child.type === "element", + ); + expect(bodyChildren.map((child) => child.tag)).toEqual([ + "w:commentRangeStart", + "w:p", + "w:commentRangeEnd", + "w:sectPr", + ]); + expect(attr(bodyChildren[0]!, "w:id")).toBe("7"); + const rangeEnd = bodyChildren.find( + (child) => child.tag === "w:commentRangeEnd", + )!; + expect(attr(rangeEnd, "w:id")).toBe("7"); + }); + + it("places a field's characters inside the sdt-wrapped paragraph its extent contains", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { kind: "field", instruction: "f" }, + }, + { + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "richText" }, + }, + { kind: "paragraph", runs: [{ text: "inner" }] }, + { kind: "constructEnd" }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + const sdt = + body === undefined ? undefined : childrenWithTag(body, "w:sdt")[0]; + if (sdt === undefined) { + throw new Error("expected an sdt"); + } + const paragraph = elementsWithTag([sdt], "w:p")[0]!; + const described = paragraph.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => { + if (child.tag !== "w:r") { + return child.tag; + } + const fldChar = childrenWithTag(child, "w:fldChar")[0]; + if (fldChar !== undefined) { + return `fld:${attr(fldChar, "w:fldCharType")}`; + } + if (childrenWithTag(child, "w:instrText").length > 0) { + return "instr"; + } + return "run"; + }); + expect(described).toEqual([ + "fld:begin", + "instr", + "fld:separate", + "run", + "fld:end", + ]); + }); + + it("wraps a field extent with no paragraph in its own minted opening and closing paragraphs", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { kind: "field", instruction: "empty" }, + }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const described = bodyElements(written); + expect(described).toEqual([ + { tag: "w:p", summary: "p:r3:o0:d0" }, + { tag: "w:p", summary: "p:r1:o0:d0" }, + { tag: "w:sectPr", summary: "" }, + ]); + }); + + it("keeps a section break's fldChar-free paragraph choice away from a trailing table's cell paragraphs", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { kind: "paragraph", runs: [{ text: "last of section one" }] }, + { + kind: "table", + rows: [{ cells: [{ blocks: [] }] }], + columnWidthsPt: [100], + }, + ], + }, + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text: "section two" }] }], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + if (body === undefined) { + throw new Error("expected body"); + } + // The first section's sectPr rides the paragraph before the table, never a cell's paragraph inside it; the final section's sectPr is a direct body child. + const table = childrenWithTag(body, "w:tbl")[0]!; + expect(elementsWithTag([table], "w:sectPr")).toEqual([]); + const paragraphs = body.children.filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + const firstParagraphPPr = childrenWithTag(paragraphs[0]!, "w:pPr")[0]!; + expect(childrenWithTag(firstParagraphPPr, "w:sectPr")).toHaveLength(1); + const directSectPr = childrenWithTag(body, "w:sectPr"); + expect(directSectPr).toHaveLength(1); + }); + + it("appends the section break into a closing paragraph's existing properties and mints a pPr for one carrying none", () => { + const styledSection = ( + paragraphBlock: ContentBlock, + ): ReturnType extends never + ? never + : Package => { + return buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [paragraphBlock], + }, + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text: "tail" }] }], + }, + ], + }); + }; + const styled = styledSection({ + kind: "paragraph", + styleId: "Tail", + runs: [{ text: "closing" }], + }); + const styledRoot = rootElement(styled.parts["word/document.xml"]); + const styledBody = + styledRoot === undefined + ? undefined + : childrenWithTag(styledRoot, "w:body")[0]; + const styledParagraph = + styledBody === undefined + ? undefined + : styledBody.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + if (styledParagraph === undefined) { + throw new Error("expected a paragraph"); + } + const styledPPr = childrenWithTag(styledParagraph, "w:pPr")[0]!; + expect( + styledPPr.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toEqual(["w:pStyle", "w:sectPr"]); + + const plain = styledSection({ + kind: "paragraph", + runs: [{ text: "closing" }], + }); + const plainRoot = rootElement(plain.parts["word/document.xml"]); + const plainBody = + plainRoot === undefined + ? undefined + : childrenWithTag(plainRoot, "w:body")[0]; + const plainParagraph = + plainBody === undefined + ? undefined + : plainBody.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + if (plainParagraph === undefined) { + throw new Error("expected a paragraph"); + } + const plainPPr = childrenWithTag(plainParagraph, "w:pPr")[0]!; + expect( + plainPPr.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toEqual(["w:sectPr"]); + }); + + it("gives a mid-document section with no paragraph of its own an empty paragraph carrying the break", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { + kind: "table", + rows: [{ cells: [{ blocks: [] }] }], + columnWidthsPt: [100], + }, + ], + }, + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text: "after" }] }], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + if (body === undefined) { + throw new Error("expected body"); + } + const children = body.children.filter( + (child): child is XmlElement => child.type === "element", + ); + expect(children.map((child) => child.tag)).toEqual([ + "w:tbl", + "w:p", + "w:p", + "w:sectPr", + ]); + const empty = children[1]!; + expect(childrenWithTag(empty, "w:r")).toHaveLength(0); + const pPr = childrenWithTag(empty, "w:pPr")[0]!; + expect(childrenWithTag(pPr, "w:sectPr")).toHaveLength(1); + }); +}); + +describe("buildDocxPackageFromContent: part scaffolding XML exactness", () => { + function partNodes(written: Package, name: string): XmlNode[] { + const part = written.parts[name]; + if (part?.kind !== "xml") { + throw new Error(`expected xml part ${name}`); + } + return part.nodes; + } + + function declarationOf( + written: Package, + name: string, + ): { name: string; value: string }[] { + const declaration = partNodes(written, name)[0]; + if (declaration?.type !== "declaration") { + throw new Error(`expected a declaration in ${name}`); + } + return declaration.attributes; + } + + it("leads every xml part with the standalone UTF-8 declaration", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [{ kind: "paragraph", runs: [{ text: "x" }] }], + }, + ], + }); + expect(declarationOf(written, "word/document.xml")).toEqual([ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + { name: "standalone", value: "yes" }, + ]); + expect(declarationOf(written, "word/styles.xml")).toEqual([ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + { name: "standalone", value: "yes" }, + ]); + }); + + it("declares the document root's namespace set and ignorable extensions exactly, with the body as its one child", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + }); + const root = rootElement(written.parts["word/document.xml"]); + if (root === undefined) { + throw new Error("expected document root"); + } + expect(root.tag).toBe("w:document"); + expect(attr(root, "mc:Ignorable")).toBe("w14 w15"); + expect(attr(root, "xmlns:w")).toBe( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + ); + expect(attr(root, "xmlns:r")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + ); + expect(attr(root, "xmlns:w14")).toBe( + "http://schemas.microsoft.com/office/word/2010/wordml", + ); + expect( + root.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toEqual(["w:body"]); + }); + + it("writes each header and footer part under its own root tag and namespace set, and registers its document relationship", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + headerFooterParts: [ + { + path: "word/header1.xml", + kind: "header", + blocks: [{ kind: "paragraph", runs: [{ text: "head" }] }], + }, + { + path: "word/footer2.xml", + kind: "footer", + blocks: [{ kind: "paragraph", runs: [{ text: "foot" }] }], + }, + ], + }); + const headerRoot = rootElement(written.parts["word/header1.xml"]); + const footerRoot = rootElement(written.parts["word/footer2.xml"]); + if (headerRoot === undefined || footerRoot === undefined) { + throw new Error("expected header and footer parts"); + } + expect(headerRoot.tag).toBe("w:hdr"); + expect(footerRoot.tag).toBe("w:ftr"); + for (const partRoot of [headerRoot, footerRoot]) { + expect(attr(partRoot, "xmlns:w")).toBe( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + ); + expect(attr(partRoot, "xmlns:pic")).toBe( + "http://schemas.openxmlformats.org/drawingml/2006/picture", + ); + expect(attr(partRoot, "mc:Ignorable")).toBe("w14 w15"); + } + const relsRoot = rootElement(written.parts["word/_rels/document.xml.rels"]); + const headerFooterRels = + relsRoot === undefined + ? [] + : elementsWithTag([relsRoot], "Relationship").map((rel) => ({ + type: attr(rel, "Type") ?? "", + target: attr(rel, "Target") ?? "", + })); + expect(headerFooterRels).toContainEqual({ + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header", + target: "header1.xml", + }); + expect(headerFooterRels).toContainEqual({ + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer", + target: "footer2.xml", + }); + }); + + it("emits a header-only image's media file, part-local relationships, and content-type default", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + headerFooterParts: [ + { + path: "word/header1.xml", + kind: "header", + blocks: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 72, + heightPt: 36, + }, + ], + }, + ], + }); + expect(Object.keys(written.parts)).toContain("word/media/image1.png"); + expect(Object.keys(written.parts)).toContain("word/_rels/header1.xml.rels"); + const headerRels = rootElement( + written.parts["word/_rels/header1.xml.rels"], + ); + const imageRels = + headerRels === undefined + ? [] + : elementsWithTag([headerRels], "Relationship").filter( + (rel) => attr(rel, "Type") === IMAGE_REL, + ); + expect(imageRels).toHaveLength(1); + expect(attr(imageRels[0]!, "Target")).toBe("media/image1.png"); + const typesRoot = rootElement(written.parts["[Content_Types].xml"]); + expect( + typesRoot === undefined + ? [] + : elementsWithTag([typesRoot], "Default").map((entry) => [ + attr(entry, "Extension") ?? "", + attr(entry, "ContentType") ?? "", + ]), + ).toContainEqual(["png", "image/png"]); + }); + + it("writes a comment's author attribute and body paragraph under the comments root", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + comments: [{ id: "1", author: "Reviewer", text: "body text" }], + }); + const commentsRoot = rootElement(written.parts["word/comments.xml"]); + if (commentsRoot === undefined) { + throw new Error("expected comments part"); + } + const comment = elementsWithTag([commentsRoot], "w:comment")[0]!; + expect(attr(comment, "w:author")).toBe("Reviewer"); + const paragraph = childrenWithTag(comment, "w:p")[0]!; + const run = childrenWithTag(paragraph, "w:r")[0]!; + expect( + elementsWithTag([run], "w:t").map((t) => + t.children + .map((child) => (child.type === "text" ? child.value : "")) + .join(""), + ), + ).toEqual(["body text"]); + }); + + it("writes the footnotes part under its own root, minting successive ids past nothing when none is explicit", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + footnotes: [{ text: "first minted" }, { text: "second minted" }], + }); + const notesRoot = rootElement(written.parts["word/footnotes.xml"]); + if (notesRoot === undefined) { + throw new Error("expected footnotes part"); + } + expect(notesRoot.tag).toBe("w:footnotes"); + const entries = elementsWithTag([notesRoot], "w:footnote"); + expect(entries.map((entry) => attr(entry, "w:id"))).toEqual([ + "-1", + "0", + "1", + "2", + ]); + expect(entries.map((entry) => attr(entry, "w:type"))).toEqual([ + "separator", + "continuationSeparator", + undefined, + undefined, + ]); + }); + + it("writes the created and modified timestamps with the W3CDTF type attribute", () => { + const written = buildDocxPackageFromContent({ + metadata: { + createdIso: "2026-09-16T08:00:00Z", + modifiedIso: "2026-09-16T09:00:00Z", + }, + sections: [{ ...emptyBodySection(), blocks: [] }], + }); + const coreRoot = rootElement(written.parts["docProps/core.xml"]); + if (coreRoot === undefined) { + throw new Error("expected core properties part"); + } + const created = elementsWithTag([coreRoot], "dcterms:created")[0]!; + expect(attr(created, "xsi:type")).toBe("dcterms:W3CDTF"); + const modified = elementsWithTag([coreRoot], "dcterms:modified")[0]!; + expect(attr(modified, "xsi:type")).toBe("dcterms:W3CDTF"); + }); +}); From df8e926f40ae9efba7cba9ef5fda2460b4820446 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:10:37 +0100 Subject: [PATCH 51/63] test(ooxml.js): assert xlsx build's workbook, dimension, merges, and scaffolding XML exactly Sequential numbering across the workbook's sheet elements, worksheet relationships, and worksheet parts; the reserved print-area and print-titles definedNames derived with their sheet-local scope ids; the shared-strings part's matching count/uniqueCount and space-preserving text; the fixed package relationships and content-type defaults; sheetData's ascending row/column grouping of out-of-order cells and a height-only row; the dimension derived from whichever of cells, columns, and rows reaches furthest (and A1 for an empty sheet); each merge's ref from its own spans; a hidden column with no width written as hidden alone; and the styles part's element counts alongside the exact cellStyleXfs and Normal cellStyle scaffolding. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 432 ++++++++++++++++++ 1 file changed, 432 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index aa84d479da..ad2a57b38d 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -3566,3 +3566,435 @@ describe("buildXlsxPackageFromContent: [Content_Types].xml carries no chart/tabl ); }); }); + +describe("buildXlsxPackageFromContent: workbook, relationship, and shared-string part exactness", () => { + function documentOfSheets(sheets: ContentSheet[]): ContentDocument { + return { kind: "spreadsheet", metadata: {}, sheets }; + } + + it("numbers each sheet element, its worksheet relationship, and its worksheet part sequentially", () => { + const pkg = buildXlsxPackageFromContent( + documentOfSheets([emptySheetFixture("Alpha"), emptySheetFixture("Beta")]), + ); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected workbook root"); + } + const sheetsElement = requireChild(workbook, "sheets"); + expect( + elementsOf(sheetsElement, "sheet").map((sheet) => [ + attributeOf(sheet, "name"), + attributeOf(sheet, "sheetId"), + attributeOf(sheet, "r:id"), + ]), + ).toEqual([ + ["Alpha", "1", "rId1"], + ["Beta", "2", "rId2"], + ]); + expect(attr(workbook, "xmlns")).toBe( + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + ); + const relsRoot = rootElement(pkg.parts["xl/_rels/workbook.xml.rels"]); + if (relsRoot === undefined) { + throw new Error("expected workbook rels root"); + } + expect( + elementsOf(relsRoot, "Relationship").map((rel) => [ + attributeOf(rel, "Id"), + attributeOf(rel, "Type"), + attributeOf(rel, "Target"), + ]), + ).toEqual([ + [ + "rId1", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + "worksheets/sheet1.xml", + ], + [ + "rId2", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + "worksheets/sheet2.xml", + ], + [ + "rId3", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + "styles.xml", + ], + [ + "rId4", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings", + "sharedStrings.xml", + ], + ]); + expect(Object.keys(pkg.parts)).toEqual( + expect.arrayContaining([ + "xl/worksheets/sheet1.xml", + "xl/worksheets/sheet2.xml", + ]), + ); + }); + + it("omits the definedNames element entirely when no sheet derives a name and the document carries none", () => { + const pkg = buildXlsxPackageFromContent( + documentOfSheets([emptySheetFixture("Only")]), + ); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected workbook root"); + } + expect(childrenWithTag(workbook, "definedNames")).toHaveLength(0); + }); + + it("derives the reserved print-area and print-titles definedNames with their sheet-local scope ids", () => { + const sheet: ContentSheet = { + ...emptySheetFixture("Printed"), + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + printRange: { startRow: 0, startColumn: 0, endRow: 4, endColumn: 1 }, + repeatRows: { start: 0, end: 1 }, + }, + }; + const pkg = buildXlsxPackageFromContent(documentOfSheets([sheet])); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected workbook root"); + } + const definedNames = requireChild(workbook, "definedNames"); + expect( + elementsOf(definedNames, "definedName").map((name) => [ + attributeOf(name, "name"), + attributeOf(name, "localSheetId"), + textContent(name), + ]), + ).toEqual([ + ["_xlnm.Print_Area", "0", "Printed!$A$1:$B$5"], + ["_xlnm.Print_Titles", "0", "Printed!$1:$2"], + ]); + }); + + it("writes the shared-strings part with matching count and uniqueCount and space-preserving text elements", () => { + const pkg = buildXlsxPackageFromContent( + documentOfSheets([ + { + ...emptySheetFixture("Sheet1"), + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "repeat me" }, + displayText: "repeat me", + }, + { + row: 1, + column: 0, + value: { kind: "string", value: "repeat me" }, + displayText: "repeat me", + }, + ], + }, + ]), + ); + const sharedStrings = rootElement(pkg.parts["xl/sharedStrings.xml"]); + if (sharedStrings === undefined) { + throw new Error("expected shared strings root"); + } + expect(attr(sharedStrings, "count")).toBe("1"); + expect(attr(sharedStrings, "uniqueCount")).toBe("1"); + const entries = elementsOf(sharedStrings, "si"); + expect(entries).toHaveLength(1); + const text = requireChild(entries[0]!, "t"); + expect(attr(text, "xml:space")).toBe("preserve"); + expect(textContent(text)).toBe("repeat me"); + }); + + it("declares the fixed package rels and content-type defaults with their exact types", () => { + const pkg = buildXlsxPackageFromContent( + documentOfSheets([emptySheetFixture("S")]), + ); + const packageRels = rootElement(pkg.parts["_rels/.rels"]); + if (packageRels === undefined) { + throw new Error("expected package rels root"); + } + expect( + elementsOf(packageRels, "Relationship").map((rel) => [ + attributeOf(rel, "Id"), + attributeOf(rel, "Type"), + attributeOf(rel, "Target"), + ]), + ).toEqual([ + [ + "rId1", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", + "xl/workbook.xml", + ], + [ + "rId2", + "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + "docProps/core.xml", + ], + [ + "rId3", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", + "docProps/app.xml", + ], + ]); + const types = rootElement(pkg.parts["[Content_Types].xml"]); + if (types === undefined) { + throw new Error("expected content types root"); + } + expect( + elementsOf(types, "Default").map((entry) => [ + attributeOf(entry, "Extension"), + attributeOf(entry, "ContentType"), + ]), + ).toEqual([ + ["rels", "application/vnd.openxmlformats-package.relationships+xml"], + ["xml", "application/xml"], + ]); + expect( + elementsOf(types, "Override").map((entry) => + attributeOf(entry, "PartName"), + ), + ).toEqual( + expect.arrayContaining([ + "/xl/workbook.xml", + "/xl/styles.xml", + "/xl/sharedStrings.xml", + "/xl/worksheets/sheet1.xml", + "/docProps/core.xml", + "/docProps/app.xml", + ]), + ); + }); +}); + +// A one-empty-sheet ContentSheet matching singleSheetDocument's own shape, for fixtures that mutate the sheet rather than the cells. +function emptySheetFixture(name: string): ContentSheet { + return { + name, + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }; +} + +describe("buildXlsxPackageFromContent: sheetData grouping, dimension, merges, and cols exactness", () => { + it("sorts out-of-order cells into ascending rows and columns and keeps a row with no cells for its own height", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 2, + column: 1, + value: { kind: "number", value: 22 }, + displayText: "22", + }, + { + row: 0, + column: 2, + value: { kind: "number", value: 2 }, + displayText: "2", + }, + { + row: 0, + column: 0, + value: { kind: "number", value: 0 }, + displayText: "0", + }, + ]), + ); + const sheet: ContentSheet = { + ...emptySheetFixture("Sheet1"), + rows: [{ index: 1, heightPt: 24 }], + }; + const pkg2 = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [sheet], + }); + const worksheet2 = rootElement(pkg2.parts["xl/worksheets/sheet1.xml"]); + if (worksheet2 === undefined) { + throw new Error("expected worksheet root"); + } + const rows2 = elementsOf(requireChild(worksheet2, "sheetData"), "row"); + expect( + rows2.map((row) => [ + attributeOf(row, "r"), + attributeOf(row, "ht"), + attributeOf(row, "customHeight"), + ]), + ).toEqual([["2", "24", "true"]]); + + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected worksheet root"); + } + const rows = elementsOf(requireChild(worksheet, "sheetData"), "row"); + expect( + rows.map((row) => [ + attributeOf(row, "r"), + elementsOf(row, "c").map((cell) => attributeOf(cell, "r")), + ]), + ).toEqual([ + ["1", ["A1", "C1"]], + ["3", ["B3"]], + ]); + }); + + it("derives the dimension from whichever of cells, columns, and rows reaches furthest", () => { + const dimensionOf = (sheet: ContentSheet): string => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [sheet], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected worksheet root"); + } + return attr(requireChild(worksheet, "dimension"), "ref") ?? ""; + }; + const base = emptySheetFixture("Sheet1"); + expect(dimensionOf(base)).toBe("A1"); + expect( + dimensionOf({ + ...base, + cells: [ + { + row: 4, + column: 2, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + ], + }), + ).toBe("A1:C5"); + expect(dimensionOf({ ...base, columns: [{ index: 5, widthPt: 80 }] })).toBe( + "A1:F1", + ); + expect(dimensionOf({ ...base, rows: [{ index: 9, heightPt: 12 }] })).toBe( + "A1:A10", + ); + }); + + it("writes each merge's ref from its own spans, one row-only and one column-only", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "wide" }, + displayText: "wide", + colSpan: 3, + }, + { + row: 1, + column: 0, + value: { kind: "string", value: "tall" }, + displayText: "tall", + rowSpan: 2, + }, + ]), + ); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected worksheet root"); + } + const mergeCells = requireChild(worksheet, "mergeCells"); + expect(attr(mergeCells, "count")).toBe("2"); + expect( + elementsOf(mergeCells, "mergeCell").map((cell) => + attributeOf(cell, "ref"), + ), + ).toEqual(["A1:C1", "A2:A3"]); + }); + + it("writes a hidden column with no width as hidden alone, and a widthed column with customWidth", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + ...emptySheetFixture("Sheet1"), + columns: [ + { index: 0, hidden: true }, + { index: 1, widthPt: 96 }, + ], + }, + ], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected worksheet root"); + } + const cols = requireChild(worksheet, "cols"); + expect( + elementsOf(cols, "col").map((col) => [ + attributeOf(col, "min"), + attributeOf(col, "max"), + attributeOf(col, "width") !== undefined, + attributeOf(col, "customWidth"), + attributeOf(col, "hidden"), + ]), + ).toEqual([ + ["1", "1", false, undefined, "true"], + ["2", "2", true, "true", undefined], + ]); + }); +}); + +describe("buildXlsxPackageFromContent: styles part scaffolding counts and exact reserved entries", () => { + it("carries element counts equal to the element lists and the exact cellStyleXfs/cellStyles scaffolding", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + font: { fontFamily: "Arial", sizePt: 12, bold: true }, + }, + ]), + ); + const styles = styleSheetOf(pkg); + for (const tag of ["fonts", "fills", "borders", "cellXfs"]) { + const element = requireChild(styles, tag); + expect(attr(element, "count")).toBe( + String( + elementsOf(element, tag === "cellXfs" ? "xf" : tag.slice(0, -1)) + .length, + ), + ); + } + const cellStyleXfs = requireChild(styles, "cellStyleXfs"); + expect(attr(cellStyleXfs, "count")).toBe("1"); + expect( + elementsOf(cellStyleXfs, "xf").map((xf) => [ + attributeOf(xf, "numFmtId"), + attributeOf(xf, "fontId"), + attributeOf(xf, "fillId"), + attributeOf(xf, "borderId"), + ]), + ).toEqual([["0", "0", "0", "0"]]); + const cellStyles = requireChild(styles, "cellStyles"); + expect(attr(cellStyles, "count")).toBe("1"); + expect( + elementsOf(cellStyles, "cellStyle").map((style) => [ + attributeOf(style, "name"), + attributeOf(style, "xfId"), + attributeOf(style, "builtinId"), + ]), + ).toEqual([["Normal", "0", "0"]]); + // The font element spells its toggles and size/name in CT_Font's own child order. + const fonts = requireChild(styles, "fonts"); + const arial = elementsOf(fonts, "font")[1]!; + expect( + elementsOf(arial, "b").length + + elementsOf(arial, "sz").length + + elementsOf(arial, "name").length, + ).toBe(3); + expect(attr(requireChild(arial, "sz"), "val")).toBe("12"); + expect(attr(requireChild(arial, "name"), "val")).toBe("Arial"); + }); +}); From 00886470953d041f57ae3f7e46cbdd9e297d2597 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:20:08 +0100 Subject: [PATCH 52/63] test(ooxml.js): cover docx write.ts's cross-part sharing, minting order, and property edges The content-addressed embedded payload shared between a header part and the body (one embeddings file and one override, two part-local relationships); the styles part's ids in sorted order whatever order the document first referenced them in; a tracked paragraph's own properties kept alongside the change on its paragraph mark; lifted-image placement aligned through a hyperlink-wrapped run without ever reusing the wrapper itself; a list control with no options writing no list items; the checked check-box spelling; the push-button richText fallback; the residue-refusal error carrying its underlying parse error as the cause; a docPartList residue restored exactly as a docPartObj one; a block-scoped field's instruction preserving space and its characters following the paragraph's own properties; the typed end character of a no-paragraph field extent's minted closing paragraph; increasing ids across two block-scoped bookmarks; comment ids minted from one when no comment carries an explicit one; the omitted w:type for an ordinary-typed note; a header part's runs as live w:t content rather than deleted text; and a header part's embedded object emitted as a real embeddings file with its override. --- .../ooxml.js/src/typed/docx/write.test.ts | 582 ++++++++++++++++++ 1 file changed, 582 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index 25e662a4e6..7bfc96de5b 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -5554,3 +5554,585 @@ describe("buildDocxPackageFromContent: part scaffolding XML exactness", () => { expect(attr(modified, "xsi:type")).toBe("dcterms:W3CDTF"); }); }); + +describe("buildDocxPackageFromContent: cross-part payload sharing, minting order, and remaining property edges", () => { + function bodyOf(written: Package): XmlElement { + const documentRoot = rootElement(written.parts["word/document.xml"]); + const body = + documentRoot === undefined + ? undefined + : childrenWithTag(documentRoot, "w:body")[0]; + if (body === undefined) { + throw new Error("expected body"); + } + return body; + } + + const nestedWordprocessing = () => ({ + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text: "nested" }] }], + }, + ], + }); + + const embeddedBlock = () => ({ + kind: "embeddedObject" as const, + objectKind: "wordprocessing" as const, + document: nestedWordprocessing(), + frame: { widthPt: 100, heightPt: 60 }, + }); + + it("content-addresses one embedded payload across a header part and the body: one file, one override, two part-local relationships", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [embeddedBlock()], + }, + ], + headerFooterParts: [ + { + path: "word/header1.xml", + kind: "header", + blocks: [embeddedBlock()], + }, + ], + }); + const embeddingFiles = Object.keys(written.parts).filter((name) => + name.startsWith("word/embeddings/"), + ); + expect(embeddingFiles).toEqual(["word/embeddings/oleObject1.docx"]); + const headerRels = rootElement( + written.parts["word/_rels/header1.xml.rels"], + ); + const headerOleRel = + headerRels === undefined + ? [] + : elementsWithTag([headerRels], "Relationship").filter( + (rel) => + attr(rel, "Type") === + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject", + ); + expect(headerOleRel.map((rel) => attr(rel, "Target"))).toEqual([ + "embeddings/oleObject1.docx", + ]); + const documentRels = rootElement( + written.parts["word/_rels/document.xml.rels"], + ); + const bodyOleRel = + documentRels === undefined + ? [] + : elementsWithTag([documentRels], "Relationship").filter( + (rel) => + attr(rel, "Type") === + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject", + ); + expect(bodyOleRel.map((rel) => attr(rel, "Target"))).toEqual([ + "embeddings/oleObject1.docx", + ]); + }); + + it("sorts the styles part's collected ids, whatever order the document first referenced them in", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "paragraph", styleId: "Zebra", runs: [{ text: "z" }] }, + { kind: "paragraph", styleId: "Alpha", runs: [{ text: "a" }] }, + ], + }, + ], + }); + const stylesRoot = rootElement(written.parts["word/styles.xml"]); + expect( + stylesRoot === undefined + ? [] + : elementsWithTag([stylesRoot], "w:style").map((style) => + attr(style, "w:styleId"), + ), + ).toEqual(["Normal", "DefaultParagraphFont", "Alpha", "Zebra"]); + }); + + it("keeps a tracked paragraph's own properties alongside the change on its paragraph mark", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { + kind: "provenance", + change: "insertion", + author: "Editor", + }, + }, + { + kind: "paragraph", + styleId: "Styled", + runs: [{ text: "kept" }], + }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const paragraph = bodyOf(written).children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + if (paragraph === undefined) { + throw new Error("expected a paragraph"); + } + const pPr = childrenWithTag(paragraph, "w:pPr")[0]!; + expect( + pPr.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toEqual(["w:pStyle", "w:rPr"]); + }); + + it("aligns lifted-image placement through a hyperlink-wrapped run without reusing it", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [ + { text: "intro" }, + { text: "", hyperlink: "https://example.com/a" }, + { text: "" }, + ], + }, + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 72, + heightPt: 36, + }, + ], + }, + ], + }); + const paragraph = bodyOf(written).children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + if (paragraph === undefined) { + throw new Error("expected a paragraph"); + } + const runChildren = paragraph.children.filter( + (child): child is XmlElement => child.type === "element", + ); + // The drawing lands in the trailing PLAIN empty run; the hyperlink wrapper keeps exactly its own run and gains nothing. + const hyperlink = runChildren.find((child) => child.tag === "w:hyperlink"); + if (hyperlink === undefined) { + throw new Error("expected a hyperlink run"); + } + expect( + hyperlink.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toEqual(["w:r"]); + expect(elementsWithTag([hyperlink], "w:drawing")).toHaveLength(0); + const runs = runChildren.filter((child) => child.tag === "w:r"); + const lastRun = runs[runs.length - 1]!; + expect(elementsWithTag([lastRun], "w:drawing")).toHaveLength(1); + }); + + it("never reuses an empty hyperlink-wrapped run itself for a lifted image, only plain empty runs", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "paragraph", + runs: [ + { text: "", hyperlink: "https://example.com/b" }, + { text: "" }, + ], + }, + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 72, + heightPt: 36, + }, + ], + }, + ], + }); + const paragraph = bodyOf(written).children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + if (paragraph === undefined) { + throw new Error("expected a paragraph"); + } + const hyperlink = paragraph.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:hyperlink", + ); + if (hyperlink === undefined) { + throw new Error("expected a hyperlink run"); + } + expect(elementsWithTag([hyperlink], "w:drawing")).toHaveLength(0); + }); + + it("writes a list control with no options as its own element with no list items", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "comboBox" }, + }, + { kind: "paragraph", runs: [{ text: "choose" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const sdtPr = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:sdtPr", + )[0]!; + const comboBox = childrenWithTag(sdtPr, "w:comboBox")[0]!; + expect(childrenWithTag(comboBox, "w:listItem")).toHaveLength(0); + }); + + it("spells a checked check-box's state w14:val 1", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { + kind: "contentControl", + controlType: "checkbox", + checked: true, + }, + }, + { kind: "paragraph", runs: [{ text: "ticked" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const sdtPr = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:sdtPr", + )[0]!; + expect(attr(elementsWithTag([sdtPr], "w14:checked")[0]!, "w14:val")).toBe( + "1", + ); + }); + + it("writes a push-button control as the richText fallback element", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "button" }, + }, + { kind: "paragraph", runs: [{ text: "press" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const sdtPr = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:sdtPr", + )[0]!; + expect( + sdtPr.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => child.tag), + ).toContain("w:richText"); + }); + + it("carries the underlying parse error as the residue-refusal's cause", () => { + let thrown: Error | undefined; + try { + buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { + kind: "contentControl", + controlType: "richText", + source: { format: "docx", xml: "not xml <" }, + }, + }, + { kind: "paragraph", runs: [{ text: "x" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + } catch (error) { + thrown = error as Error; + } + expect(thrown?.message).toMatch(/does not parse as XML/); + expect(thrown?.cause).toBeInstanceOf(Error); + }); + + it("restores a docPartList residue exactly as it restores a docPartObj one", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { + kind: "contentControl", + controlType: "richText", + source: { + format: "docx", + xml: '', + }, + }, + }, + { kind: "paragraph", runs: [{ text: "listed" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const sdtPr = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:sdtPr", + )[0]!; + const docPartList = childrenWithTag(sdtPr, "w:docPartList")[0]!; + expect( + elementsWithTag([docPartList], "w:docPartGallery").map((gallery) => + attr(gallery, "w:val"), + ), + ).toEqual(["Table of Contents"]); + }); + + it("writes a block-scoped field's instruction with space preservation and its characters after any paragraph properties", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { kind: "field", instruction: " TOC " }, + }, + { + kind: "paragraph", + styleId: "Fielded", + runs: [{ text: "inside" }], + }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const paragraph = bodyOf(written).children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + if (paragraph === undefined) { + throw new Error("expected a paragraph"); + } + const described = paragraph.children + .filter((child): child is XmlElement => child.type === "element") + .map((child) => { + if (child.tag !== "w:r") { + return child.tag; + } + const fldChar = childrenWithTag(child, "w:fldChar")[0]; + if (fldChar !== undefined) { + return `fld:${attr(fldChar, "w:fldCharType")}`; + } + if (childrenWithTag(child, "w:instrText").length > 0) { + return "instr"; + } + return "run"; + }); + expect(described).toEqual([ + "w:pPr", + "fld:begin", + "instr", + "fld:separate", + "run", + "fld:end", + ]); + const instr = elementsWithTag([paragraph], "w:instrText")[0]!; + expect(attr(instr, "xml:space")).toBe("preserve"); + }); + + it("closes a no-paragraph field extent's minted paragraph with a typed end character", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { kind: "field", instruction: "empty" }, + }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const paragraphs = bodyOf(written).children.filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:p", + ); + const closing = paragraphs[paragraphs.length - 1]!; + const fldChars = elementsWithTag([closing], "w:fldChar"); + expect(fldChars.map((run) => attr(run, "w:fldCharType"))).toEqual(["end"]); + }); + + it("mints increasing ids across two block-scoped bookmarks", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "constructStart", + descriptor: { + kind: "anchor", + anchorType: "bookmark", + name: "blockOne", + }, + }, + { kind: "paragraph", runs: [{ text: "one" }] }, + { kind: "constructEnd" }, + { + kind: "constructStart", + descriptor: { + kind: "anchor", + anchorType: "bookmark", + name: "blockTwo", + }, + }, + { kind: "paragraph", runs: [{ text: "two" }] }, + { kind: "constructEnd" }, + ], + }, + ], + }); + const starts = elementsWithTag([bodyOf(written)], "w:bookmarkStart"); + expect(starts.map((start) => attr(start, "w:id"))).toEqual(["1", "2"]); + expect(starts.map((start) => attr(start, "w:name"))).toEqual([ + "blockOne", + "blockTwo", + ]); + }); + + it("mints comment ids from one when no comment carries an explicit id", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + comments: [ + { author: "First", text: "one" }, + { author: "Second", text: "two" }, + ], + }); + const commentsRoot = rootElement(written.parts["word/comments.xml"]); + expect( + commentsRoot === undefined + ? [] + : elementsWithTag([commentsRoot], "w:comment").map((comment) => + attr(comment, "w:id"), + ), + ).toEqual(["1", "2"]); + }); + + it("omits w:type for a note whose recorded type is the ordinary normal", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + endnotes: [{ id: "3", type: "normal", text: "plain note" }], + }); + const notesRoot = rootElement(written.parts["word/endnotes.xml"]); + const entries = + notesRoot === undefined + ? [] + : elementsWithTag([notesRoot], "w:endnote").filter( + (note) => attr(note, "w:id") === "3", + ); + expect(attr(entries[0]!, "w:type")).toBeUndefined(); + }); + + it("writes a header part's runs as live w:t content, never as deleted text", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + headerFooterParts: [ + { + path: "word/header1.xml", + kind: "header", + blocks: [{ kind: "paragraph", runs: [{ text: "running head" }] }], + }, + ], + }); + const headerRoot = rootElement(written.parts["word/header1.xml"]); + if (headerRoot === undefined) { + throw new Error("expected header root"); + } + expect(elementsWithTag([headerRoot], "w:delText")).toHaveLength(0); + expect( + elementsWithTag([headerRoot], "w:t").map((t) => + t.children + .map((child) => (child.type === "text" ? child.value : "")) + .join(""), + ), + ).toEqual(["running head"]); + }); + + it("emits a header part's embedded object as a real embeddings file with its override", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + headerFooterParts: [ + { + path: "word/header1.xml", + kind: "header", + blocks: [embeddedBlock()], + }, + ], + }); + expect(Object.keys(written.parts)).toContain( + "word/embeddings/oleObject1.docx", + ); + const typesRoot = rootElement(written.parts["[Content_Types].xml"]); + expect( + typesRoot === undefined + ? [] + : elementsWithTag([typesRoot], "Override").map( + (override) => attr(override, "PartName") ?? "", + ), + ).toContain("/word/embeddings/oleObject1.docx"); + }); +}); From 0217d201eab820b401e5fe1338f3c611100249b7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:22:22 +0100 Subject: [PATCH 53/63] test(ooxml.js): assert xlsx build's carried-name and derived print-name reconciliation A carried sheet-scoped _xlnm.Print_Area writes verbatim and suppresses the structured printRange derivation for its own scope; a carried name of a different name or a different scope leaves the derivation to add the sheet-local reserved name beside it. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index ad2a57b38d..ddc37c25f3 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -3998,3 +3998,75 @@ describe("buildXlsxPackageFromContent: styles part scaffolding counts and exact expect(attr(requireChild(arial, "name"), "val")).toBe("Arial"); }); }); + +describe("buildXlsxPackageFromContent: the names array and the derived print names reconcile by name and scope", () => { + function workbookWithNames(names: ContentDefinedName[]): Package { + return buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + names, + sheets: [ + { + name: "Printed", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + printRange: { + startRow: 0, + startColumn: 0, + endRow: 4, + endColumn: 1, + }, + }, + }, + ], + }); + } + + function definedNameRows( + pkg: Package, + ): [string, string | undefined, string][] { + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected workbook root"); + } + const definedNames = requireChild(workbook, "definedNames"); + return elementsOf(definedNames, "definedName").map((name) => [ + attributeOf(name, "name") ?? "", + attributeOf(name, "localSheetId"), + textContent(name), + ]); + } + + it("writes a carried sheet-scoped Print_Area verbatim and suppresses the structured derivation for the same scope", () => { + const pkg = workbookWithNames([ + { + name: "_xlnm.Print_Area", + refersTo: "Printed!$C$3:$D$9", + scopeSheetIndex: 0, + }, + ]); + expect(definedNameRows(pkg)).toEqual([ + ["_xlnm.Print_Area", "0", "Printed!$C$3:$D$9"], + ]); + }); + + it("derives the print area beside a carried name of a different scope or a different name", () => { + const pkg = workbookWithNames([ + { name: "MyRange", refersTo: "Printed!$A$1" }, + { + name: "_xlnm.Print_Area", + refersTo: "Other!$A$1:$B$2", + scopeSheetIndex: 3, + }, + ]); + expect(definedNameRows(pkg)).toEqual([ + ["MyRange", undefined, "Printed!$A$1"], + ["_xlnm.Print_Area", "3", "Other!$A$1:$B$2"], + ["_xlnm.Print_Area", "0", "Printed!$A$1:$B$5"], + ]); + }); +}); From e2d92963205632bb1ccb374e23031c831b434e25 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:31:29 +0100 Subject: [PATCH 54/63] refactor(ooxml.js): spell the trailing-run hyperlink exclusion once in docx write.ts buildRun always wraps a hyperlink-carrying run in its own w:hyperlink element, so run.hyperlink !== undefined and runElement.tag !== "w:r" state the same fact twice in trailingEmptyRunConstructs' trailing-scan guard -- either one alone still caught the case, leaving each spelling's own behaviour unobservable. The structural tag test stays; the run-field duplicate goes. --- packages/ooxml.js/src/typed/docx/write.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/docx/write.ts b/packages/ooxml.js/src/typed/docx/write.ts index ac945d64d7..dbdb1f89c9 100644 --- a/packages/ooxml.js/src/typed/docx/write.ts +++ b/packages/ooxml.js/src/typed/docx/write.ts @@ -772,11 +772,11 @@ function trailingEmptyRunElements( for (let index = paragraph.runs.length - 1; index >= 0; index--) { const run = paragraph.runs[index]; const runElement = runElements[index]; + // No separate run.hyperlink check: buildRun always wraps a hyperlink-carrying run in its own w:hyperlink element, so the structural tag test below already states that fact -- spelling it twice left each spelling unobservable (either one alone still caught the case). if ( run === undefined || runElement === undefined || run.text !== "" || - run.hyperlink !== undefined || runElement.tag !== "w:r" ) { break; From a8f30650daf8e9dd8cddec56b6e73286a439e9a3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:31:32 +0100 Subject: [PATCH 55/63] test(ooxml.js): pin docx write.ts's lifted-image run alignment and note body XML A lifted image reuses the paragraph's own trailing empty run through a hyperlink-wrapped run without minting a fresh one beside it, and a note's text writes as its own paragraph-run-text triple inside the note element. --- .../ooxml.js/src/typed/docx/write.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index 7bfc96de5b..7f0ed54a95 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -5747,6 +5747,13 @@ describe("buildDocxPackageFromContent: cross-part payload sharing, minting order const runs = runChildren.filter((child) => child.tag === "w:r"); const lastRun = runs[runs.length - 1]!; expect(elementsWithTag([lastRun], "w:drawing")).toHaveLength(1); + // The drawing reuses the paragraph's own trailing empty run: no fresh run is minted beside it, so the paragraph still carries exactly its two direct w:r children (the hyperlink wrapper holds the third). + expect( + paragraph.children.filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:r", + ), + ).toHaveLength(2); }); it("never reuses an empty hyperlink-wrapped run itself for a lifted image, only plain empty runs", () => { @@ -6136,3 +6143,31 @@ describe("buildDocxPackageFromContent: cross-part payload sharing, minting order ).toContain("/word/embeddings/oleObject1.docx"); }); }); + +describe("buildDocxPackageFromContent: note part body structure", () => { + it("writes each note's text as its own paragraph-run-text triple inside the note element", () => { + const written = buildDocxPackageFromContent({ + sections: [{ ...emptyBodySection(), blocks: [] }], + footnotes: [{ id: "4", text: "the note body" }], + }); + const notesRoot = rootElement(written.parts["word/footnotes.xml"]); + if (notesRoot === undefined) { + throw new Error("expected footnotes root"); + } + const note = elementsWithTag([notesRoot], "w:footnote").find( + (entry) => attr(entry, "w:id") === "4", + ); + if (note === undefined) { + throw new Error("expected the carried note"); + } + const paragraph = childrenWithTag(note, "w:p")[0]!; + const run = childrenWithTag(paragraph, "w:r")[0]!; + expect( + elementsWithTag([run], "w:t").map((t) => + t.children + .map((child) => (child.type === "text" ? child.value : "")) + .join(""), + ), + ).toEqual(["the note body"]); + }); +}); From 019a47a03099543e4d3d85d6238ba8f64581f266 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:42:17 +0100 Subject: [PATCH 56/63] docs(ooxml.js): record docx write.ts's protocol-verified 99.45% mutation floor --- packages/ooxml.js/stryker.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index 4456e88695..b6a21b1937 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,7 +2,7 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since had three further rounds of direct structural coverage added (optional part emission: exact internal relationships and [Content_Types].xml overrides for styles/numbering/comments/footnotes/endnotes, their absence when empty, header/footer part override naming and typing, and style ids collected from header-part blocks; flow assembly: pending page-break materialisation before a leading image and at flow end, lifted-image return to trailing empty runs in order with text-bearing and hyperlink-wrapped runs excluded, and internal-link wrap precedence -- the longer extent wins a shared start regardless of the constructs array's own order, and a slice carrying an external hyperlink stays plain; and exact run-content/styles/notes/control XML: tab and newline splitting, the styles part's Normal-filtered entries, comments/endnotes id minting past the highest of several explicit ids with Word's separator boilerplate, drop-down lists, and the unchecked check-box spelling); scoped-mutating it alone now measures 83.95% of its own 1744 valid mutants, up from 74.44% measured immediately beforehand in the same session against the identical source (the earlier-recorded 74.53% was a different run of the same code -- the TypeScript-checker phase's own classification nondeterminism moves the valid-mutant count between runs, as the anomaly note below records). docx/read.ts has since been closed to a measured, protocol-verified floor: two rounds of direct structural coverage (page geometry and toggle fallbacks, page-break split offset accounting, drawing alt-text/float-position edges, lifted-element deletion guards, discovery-order tie-break fixtures for constructs sharing one extent range, section-split extent re-indexing, theme resolution order, header-part joins, and image anchor offsets across tab/br/cr/delText children) plus removal of the guard expressions that were behaviourally dead by construction took its scoped score 75.87% -> 86.64% -> 96.12% -> 99.16% -> 99.40% of its own 1648 valid mutants, with every step measured by a fresh scoped run. Its five remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing every time): the vMerge grid-column accumulator's `col += gridSpan` -> `-=` (column indices are only ever compared for equality between rows via indexOf, and a uniform sign flip preserves which cells align); `position < firstContentIndex` -> `<=` and `position > lastContentIndex` -> `>=` in recordParagraphRangeMarkers (a range-marker half is never itself a content-bearing child, so it can never sit at exactly the first or last content index); and the two block-scoped field `order: state.order++` -> `order--` sites in scanParagraphFields (post-decrement evaluates to the same used value as post-increment, only shifting later sites' counters, and no construct discovered after a block-scoped field can share that field's extent range since every same-range partner -- whole-paragraph tracked change, paragraph-scoped bookmark pair, enclosing sdt or flow-level change, bracketing body-level bookmark -- is discovered before it). So this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since been closed to a measured, protocol-verified floor: five further rounds of direct structural coverage (optional part emission: exact internal relationships and [Content_Types].xml overrides for styles/numbering/comments/footnotes/endnotes, their absence when empty, header/footer part override naming and typing, and style ids collected from header-part blocks; flow assembly: pending page-break materialisation before a leading image and at flow end, lifted-image return to trailing empty runs in order with text-bearing and hyperlink-wrapped runs excluded, and internal-link wrap precedence -- the longer extent wins a shared start regardless of the constructs array's own order, and a slice carrying an external hyperlink stays plain; exact run-content/styles/notes/control XML: tab and newline splitting, the styles part's Normal-filtered sorted entries, comments/endnotes id minting past the highest of several explicit ids and from one when none is explicit, with Word's separator boilerplate, drop-down and combo lists, and both check-box spellings; internal-link boundary shapes: adjacent links each wrapping, crossing/zero-width/start-past-the-runs extents staying plain, a single-run wrap; media and embedded-object emission: each raster format's own extension and content-type Default, one shared media file and relationship per repeated payload, sequential numbering of distinct payloads, the svg and serialiser-less-presentation and drawing refusals, the injected serialiser port, and the cross-part content-addressed embeddings file shared between a header and the body; and remaining edges: a tracked paragraph's own properties kept beside its paragraph-mark change, the styles part's sort order, list controls with no options, the button richText fallback, docPartList residue restoration, the residue-refusal's cause, block-scoped field characters after paragraph properties, block-scoped bookmark id minting, note body structure, and a header part's runs as live text with its embedded object emitted) plus two guard refactors (the stale index-space link-overlap check in wrapInternalLinks removed -- it could only fire false positives on adjacent links, losing a descriptor the writer could write, and genuine nesting is already unrepresentable by the sort order plus the first/last lookup; the trailing-run hyperlink exclusion spelled once -- run.hyperlink and runElement.tag !== "w:r" stated the same fact twice, each rescuing the other's mutants) took its scoped score 74.44% -> 83.95% -> 96.79% -> 98.90% -> 99.45% of its own 1089 valid mutants, every step a fresh scoped run. Its six remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing all 1766 tests every time): the interleaveRunConstructExtents all-empty early return's first operand `bookmarks.length === 0` -> `false` and its body emptied (with no bookmark, field, or comment-range extent the map path below emits byte-identical output, so both spellings of "skip it" agree); `last === -1` -> `false` in wrapInternalLinks' unwrappable-slice guard (last is -1 only when the end names no element, and then either first is also -1 -- caught by the first operand -- or first resolved, where `last < first` holds as -1 < first, so the skip still happens); and buildTable's `rowSpan > 1` -> `>= 1`/`true` active.set guards (a rowSpan-1 cell's entry would carry remaining 0, which never passes the covered.remaining > 0 gate a later row checks, so the entry is inert); and buildConstructNodes' provenance `tag !== undefined` -> `true` (a formatChange descriptor threads itself as the ambient provenance, but TRACKED_CHANGE_TAG_BY_CHANGE[formatChange] is undefined, so no change element is ever emitted from it and the threading changes nothing). docx/read.ts has since been closed to a measured, protocol-verified floor: two rounds of direct structural coverage (page geometry and toggle fallbacks, page-break split offset accounting, drawing alt-text/float-position edges, lifted-element deletion guards, discovery-order tie-break fixtures for constructs sharing one extent range, section-split extent re-indexing, theme resolution order, header-part joins, and image anchor offsets across tab/br/cr/delText children) plus removal of the guard expressions that were behaviourally dead by construction took its scoped score 75.87% -> 86.64% -> 96.12% -> 99.16% -> 99.40% of its own 1648 valid mutants, with every step measured by a fresh scoped run. Its five remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing every time): the vMerge grid-column accumulator's `col += gridSpan` -> `-=` (column indices are only ever compared for equality between rows via indexOf, and a uniform sign flip preserves which cells align); `position < firstContentIndex` -> `<=` and `position > lastContentIndex` -> `>=` in recordParagraphRangeMarkers (a range-marker half is never itself a content-bearing child, so it can never sit at exactly the first or last content index); and the two block-scoped field `order: state.order++` -> `order--` sites in scanParagraphFields (post-decrement evaluates to the same used value as post-increment, only shifting later sites' counters, and no construct discovered after a block-scoped field can share that field's extent range since every same-range partner -- whole-paragraph tracked change, paragraph-scoped bookmark pair, enclosing sdt or flow-level change, bracketing body-level bookmark -- is discovered before it). So this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. // // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. breakThreshold: 83, From 930228d66c22e14fa8380cf19c36c728cf6ba162 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:58:59 +0100 Subject: [PATCH 57/63] refactor(ooxml.js): drop xlsx build's dead print-titles presence guard buildPrintTitlesValue already returns undefined when neither repeat range is present and the value check at the call site skips that, so the separate repeatRows/repeatColumns presence operand stated the same fact twice and was behaviourally dead. --- packages/ooxml.js/src/typed/xlsx/build.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/build.ts b/packages/ooxml.js/src/typed/xlsx/build.ts index a9f9efb5b3..ea7fff4471 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.ts @@ -315,10 +315,8 @@ function buildDefinedNameElements( ), ); } - if ( - (repeatRows !== undefined || repeatColumns !== undefined) && - !carriedNames.has(definedNameKey(XLNM_PRINT_TITLES, sheetIndex)) - ) { + // No repeatRows/repeatColumns presence guard ahead of this call: buildPrintTitlesValue itself returns undefined when neither is present, and the value check below skips that, so a separate presence spelling stated the same fact twice. + if (!carriedNames.has(definedNameKey(XLNM_PRINT_TITLES, sheetIndex))) { const value = buildPrintTitlesValue( sheet.name, repeatRows, From 4e6a8077fe40bd2b882aaae084f5678442205110 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 17:59:09 +0100 Subject: [PATCH 58/63] test(ooxml.js): assert xlsx build's part roots, scope-keyed suppression, and row/cell edges Every scaffolding part's own root tag and namespace declaration; both timestamps written with the W3CDTF type attribute; a carried sheet-scoped Print_Area suppressing the structured derivation for the same non-zero sheet; a plain cell left at the default style index with a single cellXfs entry beside a verticalAlignment-only cell's own applyAlignment'd xf; applyFont's true spelling and the middle-to-center vertical mapping; the dxfs count matching its entries; a hidden-only row free of height attributes and a height-only row free of hidden; a square page treated as portrait; and no conditionalFormatting, dataValidations, or tableParts elements at all for a plain sheet. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index ddc37c25f3..2e00c3c1c5 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -4070,3 +4070,286 @@ describe("buildXlsxPackageFromContent: the names array and the derived print nam ]); }); }); + +describe("buildXlsxPackageFromContent: part roots, scope-keyed name suppression, and row/cell edge exactness", () => { + it("writes each scaffolding part under its own root tag and namespace declaration", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: { createdIso: "2026-09-16T08:00:00Z" }, + sheets: [emptySheetFixture("Sheet1")], + }); + const types = rootElement(pkg.parts["[Content_Types].xml"]); + expect(types?.tag).toBe("Types"); + expect(attr(types!, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/content-types", + ); + const packageRels = rootElement(pkg.parts["_rels/.rels"]); + expect(packageRels?.tag).toBe("Relationships"); + expect(attr(packageRels!, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/relationships", + ); + const workbookRels = rootElement(pkg.parts["xl/_rels/workbook.xml.rels"]); + expect(workbookRels?.tag).toBe("Relationships"); + expect(attr(workbookRels!, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/relationships", + ); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + expect(workbook?.tag).toBe("workbook"); + expect(attr(workbook!, "xmlns:r")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + ); + const sharedStrings = rootElement(pkg.parts["xl/sharedStrings.xml"]); + expect(sharedStrings?.tag).toBe("sst"); + expect(attr(sharedStrings!, "xmlns")).toBe( + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + ); + const app = rootElement(pkg.parts["docProps/app.xml"]); + expect(app?.tag).toBe("Properties"); + expect(attr(app!, "xmlns")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties", + ); + const core = rootElement(pkg.parts["docProps/core.xml"]); + expect(core?.tag).toBe("cp:coreProperties"); + }); + + it("writes both timestamps with the W3CDTF type attribute", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: { + createdIso: "2026-09-16T08:00:00Z", + modifiedIso: "2026-09-16T09:30:00Z", + }, + sheets: [emptySheetFixture("Sheet1")], + }); + const core = rootElement(pkg.parts["docProps/core.xml"]); + if (core === undefined) { + throw new Error("expected core properties root"); + } + const created = elementsOf(core, "dcterms:created")[0]!; + expect(attr(created, "xsi:type")).toBe("dcterms:W3CDTF"); + expect(textContent(created)).toBe("2026-09-16T08:00:00Z"); + const modified = elementsOf(core, "dcterms:modified")[0]!; + expect(attr(modified, "xsi:type")).toBe("dcterms:W3CDTF"); + expect(textContent(modified)).toBe("2026-09-16T09:30:00Z"); + }); + + it("suppresses a sheet's derived print area with a carried name scoped to that same non-zero sheet", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + names: [ + { + name: "_xlnm.Print_Area", + refersTo: "Beta!$C$2:$D$4", + scopeSheetIndex: 1, + }, + ], + sheets: [ + emptySheetFixture("Alpha"), + { + ...emptySheetFixture("Beta"), + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + printRange: { + startRow: 0, + startColumn: 0, + endRow: 4, + endColumn: 1, + }, + }, + }, + ], + }); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected workbook root"); + } + const definedNames = requireChild(workbook, "definedNames"); + expect( + elementsOf(definedNames, "definedName").map((name) => [ + attributeOf(name, "name"), + attributeOf(name, "localSheetId"), + textContent(name), + ]), + ).toEqual([["_xlnm.Print_Area", "1", "Beta!$C$2:$D$4"]]); + }); + + it("leaves a plain cell at the default style index with a single cellXfs entry, and gives a verticalAlignment-only cell its own xf", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + { + row: 1, + column: 0, + value: { kind: "number", value: 2 }, + displayText: "2", + verticalAlignment: "top", + }, + ]), + ); + expect(attributeOf(writtenCell(pkg, "A1"), "s")).toBe("0"); + const verticalIndex = attributeOf(writtenCell(pkg, "A2"), "s"); + expect(verticalIndex).not.toBe("0"); + const styles = styleSheetOf(pkg); + const cellXfs = requireChild(styles, "cellXfs"); + const entries = elementsOf(cellXfs, "xf"); + expect(entries).toHaveLength(2); + const verticalXf = entries[Number(verticalIndex)]!; + expect(attributeOf(verticalXf, "applyAlignment")).toBe("true"); + const alignment = requireChild(verticalXf, "alignment"); + expect(attributeOf(alignment, "vertical")).toBe("top"); + }); + + it("spells applyFont true on a fonted xf and maps a middle vertical alignment to xlsx's own center token", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "fonted" }, + displayText: "fonted", + font: { bold: true }, + }, + { + row: 1, + column: 0, + value: { kind: "string", value: "middle" }, + displayText: "middle", + verticalAlignment: "middle", + }, + ]), + ); + const styles = styleSheetOf(pkg); + const cellXfs = requireChild(styles, "cellXfs"); + const fontedIndex = Number(attributeOf(writtenCell(pkg, "A1"), "s")); + const fonted = elementsOf(cellXfs, "xf")[fontedIndex]!; + expect(attributeOf(fonted, "applyFont")).toBe("true"); + const middleIndex = Number(attributeOf(writtenCell(pkg, "A2"), "s")); + const middle = elementsOf(cellXfs, "xf")[middleIndex]!; + expect(attributeOf(requireChild(middle, "alignment"), "vertical")).toBe( + "center", + ); + }); + + it("carries the dxfs count equal to its dxf entries for a styled conditional-format rule", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "number", value: 5 }, + displayText: "5", + }, + ]), + ); + // Re-use the conditional-format vocabulary through the sheet field the builder reads. + const pkgWithRule = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + ...emptySheetFixture("Sheet1"), + conditionalFormats: [ + { + type: "cellIs", + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + priority: 1, + operator: "greaterThan", + formula1: "4", + style: { textColor: { r: 1, g: 0, b: 0 } }, + }, + ], + }, + ], + }); + const styles = styleSheetOf(pkgWithRule); + const dxfs = requireChild(styles, "dxfs"); + expect(attr(dxfs, "count")).toBe(String(elementsOf(dxfs, "dxf").length)); + expect(elementsOf(dxfs, "dxf").length).toBeGreaterThan(0); + // A workbook with no styled rule writes no dxfs element at all (already covered elsewhere) -- this fixture only supplies the count. + expect( + styleSheetOf(pkg).children.filter( + (c) => c.type === "element" && c.tag === "dxfs", + ), + ).toHaveLength(0); + }); + + it("writes a row declared only hidden with no height attributes, and one declared only tall with no hidden attribute", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + ...emptySheetFixture("Sheet1"), + rows: [ + { index: 0, hidden: true }, + { index: 1, heightPt: 30 }, + ], + }, + ], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected worksheet root"); + } + const rows = elementsOf(requireChild(worksheet, "sheetData"), "row"); + const hidden = rows.find((row) => attributeOf(row, "r") === "1")!; + expect(attributeOf(hidden, "hidden")).toBe("true"); + expect(attributeOf(hidden, "ht")).toBeUndefined(); + expect(attributeOf(hidden, "customHeight")).toBeUndefined(); + const tall = rows.find((row) => attributeOf(row, "r") === "2")!; + expect(attributeOf(tall, "ht")).toBe("30"); + expect(attributeOf(tall, "customHeight")).toBe("true"); + expect(attributeOf(tall, "hidden")).toBeUndefined(); + }); + + it("treats a square page as portrait, since only a strictly wider page is landscape", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + ...emptySheetFixture("Sheet1"), + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + pageSize: { widthPt: 200, heightPt: 200 }, + }, + }, + ], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected worksheet root"); + } + expect(attr(requireChild(worksheet, "pageSetup"), "orientation")).toBe( + "portrait", + ); + }); + + it("writes no conditionalFormatting, dataValidations, or tableParts elements for a plain sheet", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "number", value: 1 }, + displayText: "1", + }, + ]), + ); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected worksheet root"); + } + expect(childrenWithTag(worksheet, "conditionalFormatting")).toHaveLength(0); + expect(childrenWithTag(worksheet, "dataValidations")).toHaveLength(0); + expect(childrenWithTag(worksheet, "tableParts")).toHaveLength(0); + }); +}); From f2b02b39d3244665974328f15c151b2d181b451b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 18:10:41 +0100 Subject: [PATCH 59/63] refactor(ooxml.js): drop xlsx build's dead worksheet-extras fallbacks sheetExtras is index-aligned with the very sheets the worksheet-part map walks (both derive from the one sheets array), so the optional chain and the ?? empty-array fallback on the tableRelIds lookup were dead spellings of an index that always resolves. --- packages/ooxml.js/src/typed/xlsx/build.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/build.ts b/packages/ooxml.js/src/typed/xlsx/build.ts index ea7fff4471..2b2fe9a16b 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.ts @@ -1180,16 +1180,18 @@ export function buildXlsxPackageFromContent( }); // Building every worksheet part first, before touching xl/sharedStrings.xml or xl/styles.xml, is load-bearing: buildCellElement interns every literal string value into `sharedStrings` and every non-General number format into `cellFormats` as a side effect while it walks each sheet's cells, buildConditionalFormattingElements interns every styled conditional-format rule into `dxfTable` the same way, and buildSharedStringsPart/buildStylesPart below must all see the FULLY populated tables. - const worksheetParts = sheets.map((sheet, index) => - buildWorksheetPart( + // No optional chain or ?? fallback on the extras lookup: sheetExtras is index-aligned with the very sheets this map walks (both derive from the one sheets array), so the lookup always resolves and the fallbacks were dead spellings -- the non-null assertion is the same index-invariant spelling parseFlow's own stack top uses. + const worksheetParts = sheets.map((sheet, index) => { + const extras = sheetExtras[index]!; + return buildWorksheetPart( sheet, sharedStrings, cellFormats, dxfTable, - sheetExtras[index]?.drawingRelId, - sheetExtras[index]?.tableRelIds ?? [], - ), - ); + extras.drawingRelId, + extras.tableRelIds, + ); + }); const parts: Package["parts"] = { "[Content_Types].xml": buildContentTypesPart( From 4bdf7b5c8aaa5801395162494987eae77558257d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 18:10:44 +0100 Subject: [PATCH 60/63] test(ooxml.js): cover xlsx build's bottom-aligned default and exact minimal override set A bottom-aligned cell writes no vertical attribute at all, and a minimal document declares exactly its six overrides and nothing else. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 2e00c3c1c5..3b1df74237 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -4353,3 +4353,49 @@ describe("buildXlsxPackageFromContent: part roots, scope-keyed name suppression, expect(childrenWithTag(worksheet, "tableParts")).toHaveLength(0); }); }); + +describe("buildXlsxPackageFromContent: bottom alignment and the exact minimal override set", () => { + it("writes no vertical attribute at all for a bottom-aligned cell, the documented default", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "bottom" }, + displayText: "bottom", + verticalAlignment: "bottom", + }, + ]), + ); + const styles = styleSheetOf(pkg); + const cellXfs = requireChild(styles, "cellXfs"); + const index = Number(attributeOf(writtenCell(pkg, "A1"), "s")); + const xf = elementsOf(cellXfs, "xf")[index]!; + const alignment = requireChild(xf, "alignment"); + expect(attributeOf(alignment, "vertical")).toBeUndefined(); + }); + + it("declares exactly the six overrides a minimal document carries, and nothing else", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [emptySheetFixture("Only")], + }); + const types = rootElement(pkg.parts["[Content_Types].xml"]); + if (types === undefined) { + throw new Error("expected content types root"); + } + expect( + elementsOf(types, "Override").map((override) => + attributeOf(override, "PartName"), + ), + ).toEqual([ + "/xl/workbook.xml", + "/xl/styles.xml", + "/xl/sharedStrings.xml", + "/xl/worksheets/sheet1.xml", + "/docProps/core.xml", + "/docProps/app.xml", + ]); + }); +}); From b1b779fbe919d3c5fe9b2aa7fe081c532b73d225 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 18:16:15 +0100 Subject: [PATCH 61/63] docs(ooxml.js): record xlsx build.ts's triaged 87.66% floor and its verified equivalents --- packages/ooxml.js/stryker.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index b6a21b1937..5f2f8ba1a9 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,7 +2,7 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. docx/write.ts has since been closed to a measured, protocol-verified floor: five further rounds of direct structural coverage (optional part emission: exact internal relationships and [Content_Types].xml overrides for styles/numbering/comments/footnotes/endnotes, their absence when empty, header/footer part override naming and typing, and style ids collected from header-part blocks; flow assembly: pending page-break materialisation before a leading image and at flow end, lifted-image return to trailing empty runs in order with text-bearing and hyperlink-wrapped runs excluded, and internal-link wrap precedence -- the longer extent wins a shared start regardless of the constructs array's own order, and a slice carrying an external hyperlink stays plain; exact run-content/styles/notes/control XML: tab and newline splitting, the styles part's Normal-filtered sorted entries, comments/endnotes id minting past the highest of several explicit ids and from one when none is explicit, with Word's separator boilerplate, drop-down and combo lists, and both check-box spellings; internal-link boundary shapes: adjacent links each wrapping, crossing/zero-width/start-past-the-runs extents staying plain, a single-run wrap; media and embedded-object emission: each raster format's own extension and content-type Default, one shared media file and relationship per repeated payload, sequential numbering of distinct payloads, the svg and serialiser-less-presentation and drawing refusals, the injected serialiser port, and the cross-part content-addressed embeddings file shared between a header and the body; and remaining edges: a tracked paragraph's own properties kept beside its paragraph-mark change, the styles part's sort order, list controls with no options, the button richText fallback, docPartList residue restoration, the residue-refusal's cause, block-scoped field characters after paragraph properties, block-scoped bookmark id minting, note body structure, and a header part's runs as live text with its embedded object emitted) plus two guard refactors (the stale index-space link-overlap check in wrapInternalLinks removed -- it could only fire false positives on adjacent links, losing a descriptor the writer could write, and genuine nesting is already unrepresentable by the sort order plus the first/last lookup; the trailing-run hyperlink exclusion spelled once -- run.hyperlink and runElement.tag !== "w:r" stated the same fact twice, each rescuing the other's mutants) took its scoped score 74.44% -> 83.95% -> 96.79% -> 98.90% -> 99.45% of its own 1089 valid mutants, every step a fresh scoped run. Its six remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing all 1766 tests every time): the interleaveRunConstructExtents all-empty early return's first operand `bookmarks.length === 0` -> `false` and its body emptied (with no bookmark, field, or comment-range extent the map path below emits byte-identical output, so both spellings of "skip it" agree); `last === -1` -> `false` in wrapInternalLinks' unwrappable-slice guard (last is -1 only when the end names no element, and then either first is also -1 -- caught by the first operand -- or first resolved, where `last < first` holds as -1 < first, so the skip still happens); and buildTable's `rowSpan > 1` -> `>= 1`/`true` active.set guards (a rowSpan-1 cell's entry would carry remaining 0, which never passes the covered.remaining > 0 gate a later row checks, so the entry is inert); and buildConstructNodes' provenance `tag !== undefined` -> `true` (a formatChange descriptor threads itself as the ambient provenance, but TRACKED_CHANGE_TAG_BY_CHANGE[formatChange] is undefined, so no change element is ever emitted from it and the threading changes nothing). docx/read.ts has since been closed to a measured, protocol-verified floor: two rounds of direct structural coverage (page geometry and toggle fallbacks, page-break split offset accounting, drawing alt-text/float-position edges, lifted-element deletion guards, discovery-order tie-break fixtures for constructs sharing one extent range, section-split extent re-indexing, theme resolution order, header-part joins, and image anchor offsets across tab/br/cr/delText children) plus removal of the guard expressions that were behaviourally dead by construction took its scoped score 75.87% -> 86.64% -> 96.12% -> 99.16% -> 99.40% of its own 1648 valid mutants, with every step measured by a fresh scoped run. Its five remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing every time): the vMerge grid-column accumulator's `col += gridSpan` -> `-=` (column indices are only ever compared for equality between rows via indexOf, and a uniform sign flip preserves which cells align); `position < firstContentIndex` -> `<=` and `position > lastContentIndex` -> `>=` in recordParagraphRangeMarkers (a range-marker half is never itself a content-bearing child, so it can never sit at exactly the first or last content index); and the two block-scoped field `order: state.order++` -> `order--` sites in scanParagraphFields (post-decrement evaluates to the same used value as post-increment, only shifting later sites' counters, and no construct discovered after a block-scoped field can share that field's extent range since every same-range partner -- whole-paragraph tracked change, paragraph-scoped bookmark pair, enclosing sdt or flow-level change, bracketing body-level bookmark -- is discovered before it). So this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly, then workbook/relationship/shared-string exactness, sheetData grouping and dimension, merges, cols, carried-name reconciliation, part roots and namespace declarations, scope-keyed print-name suppression, row and cell edges, alignment mappings, dxfs counts, and the exact minimal override set) plus removal of two behaviourally dead guards (the print-titles presence operand, subsumed by buildPrintTitlesValue's own undefined return; the worksheet-extras optional chain and ?? fallback on an index-aligned lookup); scoped-mutating it alone now measures 87.66% of its own roughly 550 valid mutants, up from 69.3-69.5% and originally 32.83%. Its remaining 68 flagged survivors were exhaustively triaged by hand-applying each exact mutation and running the real suite: all but three are the tool-measurement anomaly the note below records (killed by existing tests despite the [Survived] verdict -- 65 of them reproducibly), and the last three are protocol-verified equivalents passing the whole 1777-test suite: definedNameKey's `localSheetId ?? ""` fallback string (the undefined-segment key of a workbook-global name is only ever ADDED to carriedNames, never looked up -- every derivation lookup passes a numeric sheet index, so the segment's spelling changes nothing); and buildCellElement's `cell.font !== undefined ||` -> `true` and `format === undefined && decoration === undefined` -> `false` (an explicitly-passed all-undefined decoration interns to the same signature -- and the same default index 0 -- as no decoration at all, and interning the General format with no decoration resolves to that same index, so both produce byte-identical output). docx/write.ts has since been closed to a measured, protocol-verified floor: five further rounds of direct structural coverage (optional part emission: exact internal relationships and [Content_Types].xml overrides for styles/numbering/comments/footnotes/endnotes, their absence when empty, header/footer part override naming and typing, and style ids collected from header-part blocks; flow assembly: pending page-break materialisation before a leading image and at flow end, lifted-image return to trailing empty runs in order with text-bearing and hyperlink-wrapped runs excluded, and internal-link wrap precedence -- the longer extent wins a shared start regardless of the constructs array's own order, and a slice carrying an external hyperlink stays plain; exact run-content/styles/notes/control XML: tab and newline splitting, the styles part's Normal-filtered sorted entries, comments/endnotes id minting past the highest of several explicit ids and from one when none is explicit, with Word's separator boilerplate, drop-down and combo lists, and both check-box spellings; internal-link boundary shapes: adjacent links each wrapping, crossing/zero-width/start-past-the-runs extents staying plain, a single-run wrap; media and embedded-object emission: each raster format's own extension and content-type Default, one shared media file and relationship per repeated payload, sequential numbering of distinct payloads, the svg and serialiser-less-presentation and drawing refusals, the injected serialiser port, and the cross-part content-addressed embeddings file shared between a header and the body; and remaining edges: a tracked paragraph's own properties kept beside its paragraph-mark change, the styles part's sort order, list controls with no options, the button richText fallback, docPartList residue restoration, the residue-refusal's cause, block-scoped field characters after paragraph properties, block-scoped bookmark id minting, note body structure, and a header part's runs as live text with its embedded object emitted) plus two guard refactors (the stale index-space link-overlap check in wrapInternalLinks removed -- it could only fire false positives on adjacent links, losing a descriptor the writer could write, and genuine nesting is already unrepresentable by the sort order plus the first/last lookup; the trailing-run hyperlink exclusion spelled once -- run.hyperlink and runElement.tag !== "w:r" stated the same fact twice, each rescuing the other's mutants) took its scoped score 74.44% -> 83.95% -> 96.79% -> 98.90% -> 99.45% of its own 1089 valid mutants, every step a fresh scoped run. Its six remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing all 1766 tests every time): the interleaveRunConstructExtents all-empty early return's first operand `bookmarks.length === 0` -> `false` and its body emptied (with no bookmark, field, or comment-range extent the map path below emits byte-identical output, so both spellings of "skip it" agree); `last === -1` -> `false` in wrapInternalLinks' unwrappable-slice guard (last is -1 only when the end names no element, and then either first is also -1 -- caught by the first operand -- or first resolved, where `last < first` holds as -1 < first, so the skip still happens); and buildTable's `rowSpan > 1` -> `>= 1`/`true` active.set guards (a rowSpan-1 cell's entry would carry remaining 0, which never passes the covered.remaining > 0 gate a later row checks, so the entry is inert); and buildConstructNodes' provenance `tag !== undefined` -> `true` (a formatChange descriptor threads itself as the ambient provenance, but TRACKED_CHANGE_TAG_BY_CHANGE[formatChange] is undefined, so no change element is ever emitted from it and the threading changes nothing). docx/read.ts has since been closed to a measured, protocol-verified floor: two rounds of direct structural coverage (page geometry and toggle fallbacks, page-break split offset accounting, drawing alt-text/float-position edges, lifted-element deletion guards, discovery-order tie-break fixtures for constructs sharing one extent range, section-split extent re-indexing, theme resolution order, header-part joins, and image anchor offsets across tab/br/cr/delText children) plus removal of the guard expressions that were behaviourally dead by construction took its scoped score 75.87% -> 86.64% -> 96.12% -> 99.16% -> 99.40% of its own 1648 valid mutants, with every step measured by a fresh scoped run. Its five remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing every time): the vMerge grid-column accumulator's `col += gridSpan` -> `-=` (column indices are only ever compared for equality between rows via indexOf, and a uniform sign flip preserves which cells align); `position < firstContentIndex` -> `<=` and `position > lastContentIndex` -> `>=` in recordParagraphRangeMarkers (a range-marker half is never itself a content-bearing child, so it can never sit at exactly the first or last content index); and the two block-scoped field `order: state.order++` -> `order--` sites in scanParagraphFields (post-decrement evaluates to the same used value as post-increment, only shifting later sites' counters, and no construct discovered after a block-scoped field can share that field's extent range since every same-range partner -- whole-paragraph tracked change, paragraph-scoped bookmark pair, enclosing sdt or flow-level change, bracketing body-level bookmark -- is discovered before it). So this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. // // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. breakThreshold: 83, From c0be1c920f6fa1990aea2a51fe32bada89dc730e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 04:57:45 +0100 Subject: [PATCH 62/63] fix(ooxml.js): match the docx write tests' fixtures to the content schema The embedded-object fixtures omitted the frame's xPt/yPt, which BoxSchema requires, and leaned on `as const` for their block and document kinds, which also froze the nested arrays as readonly and left them unassignable. Each fixture now names its own ContentBlock variant instead, so the kinds narrow without that side effect. The cell-border fixture built its colour from red/green/blue fields, but Color is r/g/b in 0..1, so colorToRgbHex read undefined from all three. It is built from its hex spelling now. No assertion moves: that test reads the four side tags, w:val and w:sz, never w:color. --- .../ooxml.js/src/typed/docx/write.test.ts | 90 +++++++++++-------- 1 file changed, 54 insertions(+), 36 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index 7f0ed54a95..1c68ede8e0 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -4,7 +4,10 @@ import type { ContentBlock, ContentSection, } from "document-schema.js"; -import { findConstructMarkerImbalance } from "document-schema.js"; +import { + findConstructMarkerImbalance, + rgbHexToColor, +} from "document-schema.js"; import type { Package } from "../../model/package"; import type { XmlElement, XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; @@ -3615,7 +3618,8 @@ describe("buildDocxPackageFromContent: media and embedded-object emission", () = kind: "embeddedObject", objectKind: "wordprocessing", document, - frame: { widthPt, heightPt: 60 }, + // The frame's origin is part of the schema's Box, but this writer reads only the extent: w:dxaOrig/w:dyaOrig carry the width and height, and nothing emits xPt/yPt. + frame: { xPt: 0, yPt: 0, widthPt, heightPt: 60 }, }; } @@ -3727,7 +3731,7 @@ describe("buildDocxPackageFromContent: media and embedded-object emission", () = kind: "embeddedObject", objectKind: "drawing", document: { kind: "drawing", metadata: {}, pages: [] }, - frame: { widthPt: 100, heightPt: 60 }, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 60 }, }, ], }, @@ -3739,17 +3743,20 @@ describe("buildDocxPackageFromContent: media and embedded-object emission", () = }); it("refuses an embedded presentation with no injected serialiser, and serialises it through the port when one is injected", () => { - const presentation = { + const presentation: Extract< + ContentBlock, + { kind: "embeddedObject" } + >["document"] = { kind: "presentation", metadata: {}, slides: [], - } as const; - const block = { + }; + const block: Extract = { kind: "embeddedObject", objectKind: "presentation", document: presentation, - frame: { widthPt: 100, heightPt: 60 }, - } as const; + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 60 }, + }; expect(() => buildDocxPackageFromContent({ sections: [{ ...emptyBodySection(), blocks: [block] }], @@ -4399,7 +4406,7 @@ describe("buildDocxPackageFromContent: table grid and vertical-merge arithmetic" const border = (style?: "solid" | "dashed" | "dotted" | "double") => ({ style, widthPt: 1, - color: { red: 17, green: 34, blue: 51 }, + color: rgbHexToColor("112233"), }); const written = buildDocxPackageFromContent({ sections: [ @@ -4939,11 +4946,14 @@ describe("buildDocxPackageFromContent: flow assembly and section breaks", () => }); it("materialises a pending break before an embedded object, then places the object inside that break paragraph", () => { - const nested = { + const nested: Extract< + ContentBlock, + { kind: "embeddedObject" } + >["document"] = { kind: "wordprocessing", metadata: {}, sections: [], - } as const; + }; const written = buildDocxPackageFromContent({ sections: [ { @@ -4954,7 +4964,7 @@ describe("buildDocxPackageFromContent: flow assembly and section breaks", () => kind: "embeddedObject", objectKind: "wordprocessing", document: nested, - frame: { widthPt: 100, heightPt: 60 }, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 60 }, }, ], }, @@ -4967,25 +4977,27 @@ describe("buildDocxPackageFromContent: flow assembly and section breaks", () => }); it("appends an embedded object with no trailing empty run to its preceding paragraph, and one with no paragraph at all to a fresh paragraph", () => { - const nested = (text: string) => - ({ - kind: "wordprocessing", - metadata: {}, - sections: [ - { - pageSize: { widthPt: 612, heightPt: 792 }, - margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, - blocks: [{ kind: "paragraph", runs: [{ text }] }], - }, - ], - }) as const; - const object = (text: string) => - ({ - kind: "embeddedObject", - objectKind: "wordprocessing", - document: nested(text), - frame: { widthPt: 100, heightPt: 60 }, - }) as const; + const nested = ( + text: string, + ): Extract["document"] => ({ + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [{ kind: "paragraph", runs: [{ text }] }], + }, + ], + }); + const object = ( + text: string, + ): Extract => ({ + kind: "embeddedObject", + objectKind: "wordprocessing", + document: nested(text), + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 60 }, + }); const attached = buildDocxPackageFromContent({ sections: [ { @@ -5568,7 +5580,10 @@ describe("buildDocxPackageFromContent: cross-part payload sharing, minting order return body; } - const nestedWordprocessing = () => ({ + const nestedWordprocessing = (): Extract< + ContentBlock, + { kind: "embeddedObject" } + >["document"] => ({ kind: "wordprocessing", metadata: {}, sections: [ @@ -5580,11 +5595,14 @@ describe("buildDocxPackageFromContent: cross-part payload sharing, minting order ], }); - const embeddedBlock = () => ({ - kind: "embeddedObject" as const, - objectKind: "wordprocessing" as const, + const embeddedBlock = (): Extract< + ContentBlock, + { kind: "embeddedObject" } + > => ({ + kind: "embeddedObject", + objectKind: "wordprocessing", document: nestedWordprocessing(), - frame: { widthPt: 100, heightPt: 60 }, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 60 }, }); it("content-addresses one embedded payload across a header part and the body: one file, one override, two part-local relationships", () => { From 9d29da4762157c3ecfb11552354c3bb2ecc473d2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 06:19:53 +0100 Subject: [PATCH 63/63] chore(ooxml.js): raise the mutation break threshold to 95 A complete run over the whole package measures 96.91% of 6839 valid mutants, so the derivation rule on PackageStrykerOptions.breakThreshold gives 95: floor the score to 96, then take off a one-point margin for the 0.42% classified Timeout. The comment is cut back to what stays true: how the gate was derived, where the 211 undetected mutants still sit, which of the remaining survivors were checked by hand and are genuine equivalents, and the caution that a Survived verdict on xlsx/build.ts needs reproducing manually before it is believed. The round-by-round history it used to carry is a snapshot, not a fact about the package, and the per-file counts now live in the tracking issue. --- packages/ooxml.js/stryker.config.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index 5f2f8ba1a9..7dd842e8c1 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,8 +2,10 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly, then workbook/relationship/shared-string exactness, sheetData grouping and dimension, merges, cols, carried-name reconciliation, part roots and namespace declarations, scope-keyed print-name suppression, row and cell edges, alignment mappings, dxfs counts, and the exact minimal override set) plus removal of two behaviourally dead guards (the print-titles presence operand, subsumed by buildPrintTitlesValue's own undefined return; the worksheet-extras optional chain and ?? fallback on an index-aligned lookup); scoped-mutating it alone now measures 87.66% of its own roughly 550 valid mutants, up from 69.3-69.5% and originally 32.83%. Its remaining 68 flagged survivors were exhaustively triaged by hand-applying each exact mutation and running the real suite: all but three are the tool-measurement anomaly the note below records (killed by existing tests despite the [Survived] verdict -- 65 of them reproducibly), and the last three are protocol-verified equivalents passing the whole 1777-test suite: definedNameKey's `localSheetId ?? ""` fallback string (the undefined-segment key of a workbook-global name is only ever ADDED to carriedNames, never looked up -- every derivation lookup passes a numeric sheet index, so the segment's spelling changes nothing); and buildCellElement's `cell.font !== undefined ||` -> `true` and `format === undefined && decoration === undefined` -> `false` (an explicitly-passed all-undefined decoration interns to the same signature -- and the same default index 0 -- as no decoration at all, and interning the General format with no decoration resolves to that same index, so both produce byte-identical output). docx/write.ts has since been closed to a measured, protocol-verified floor: five further rounds of direct structural coverage (optional part emission: exact internal relationships and [Content_Types].xml overrides for styles/numbering/comments/footnotes/endnotes, their absence when empty, header/footer part override naming and typing, and style ids collected from header-part blocks; flow assembly: pending page-break materialisation before a leading image and at flow end, lifted-image return to trailing empty runs in order with text-bearing and hyperlink-wrapped runs excluded, and internal-link wrap precedence -- the longer extent wins a shared start regardless of the constructs array's own order, and a slice carrying an external hyperlink stays plain; exact run-content/styles/notes/control XML: tab and newline splitting, the styles part's Normal-filtered sorted entries, comments/endnotes id minting past the highest of several explicit ids and from one when none is explicit, with Word's separator boilerplate, drop-down and combo lists, and both check-box spellings; internal-link boundary shapes: adjacent links each wrapping, crossing/zero-width/start-past-the-runs extents staying plain, a single-run wrap; media and embedded-object emission: each raster format's own extension and content-type Default, one shared media file and relationship per repeated payload, sequential numbering of distinct payloads, the svg and serialiser-less-presentation and drawing refusals, the injected serialiser port, and the cross-part content-addressed embeddings file shared between a header and the body; and remaining edges: a tracked paragraph's own properties kept beside its paragraph-mark change, the styles part's sort order, list controls with no options, the button richText fallback, docPartList residue restoration, the residue-refusal's cause, block-scoped field characters after paragraph properties, block-scoped bookmark id minting, note body structure, and a header part's runs as live text with its embedded object emitted) plus two guard refactors (the stale index-space link-overlap check in wrapInternalLinks removed -- it could only fire false positives on adjacent links, losing a descriptor the writer could write, and genuine nesting is already unrepresentable by the sort order plus the first/last lookup; the trailing-run hyperlink exclusion spelled once -- run.hyperlink and runElement.tag !== "w:r" stated the same fact twice, each rescuing the other's mutants) took its scoped score 74.44% -> 83.95% -> 96.79% -> 98.90% -> 99.45% of its own 1089 valid mutants, every step a fresh scoped run. Its six remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing all 1766 tests every time): the interleaveRunConstructExtents all-empty early return's first operand `bookmarks.length === 0` -> `false` and its body emptied (with no bookmark, field, or comment-range extent the map path below emits byte-identical output, so both spellings of "skip it" agree); `last === -1` -> `false` in wrapInternalLinks' unwrappable-slice guard (last is -1 only when the end names no element, and then either first is also -1 -- caught by the first operand -- or first resolved, where `last < first` holds as -1 < first, so the skip still happens); and buildTable's `rowSpan > 1` -> `>= 1`/`true` active.set guards (a rowSpan-1 cell's entry would carry remaining 0, which never passes the covered.remaining > 0 gate a later row checks, so the entry is inert); and buildConstructNodes' provenance `tag !== undefined` -> `true` (a formatChange descriptor threads itself as the ambient provenance, but TRACKED_CHANGE_TAG_BY_CHANGE[formatChange] is undefined, so no change element is ever emitted from it and the threading changes nothing). docx/read.ts has since been closed to a measured, protocol-verified floor: two rounds of direct structural coverage (page geometry and toggle fallbacks, page-break split offset accounting, drawing alt-text/float-position edges, lifted-element deletion guards, discovery-order tie-break fixtures for constructs sharing one extent range, section-split extent re-indexing, theme resolution order, header-part joins, and image anchor offsets across tab/br/cr/delText children) plus removal of the guard expressions that were behaviourally dead by construction took its scoped score 75.87% -> 86.64% -> 96.12% -> 99.16% -> 99.40% of its own 1648 valid mutants, with every step measured by a fresh scoped run. Its five remaining survivors are each protocol-verified equivalents (the exact mutation applied by hand and the real suite re-run against it, passing every time): the vMerge grid-column accumulator's `col += gridSpan` -> `-=` (column indices are only ever compared for equality between rows via indexOf, and a uniform sign flip preserves which cells align); `position < firstContentIndex` -> `<=` and `position > lastContentIndex` -> `>=` in recordParagraphRangeMarkers (a range-marker half is never itself a content-bearing child, so it can never sit at exactly the first or last content index); and the two block-scoped field `order: state.order++` -> `order--` sites in scanParagraphFields (post-decrement evaluates to the same used value as post-increment, only shifting later sites' counters, and no construct discovered after a block-scoped field can share that field's extent range since every same-range partner -- whole-paragraph tracked change, paragraph-scoped bookmark pair, enclosing sdt or flow-level change, bracketing body-level bookmark -- is discovered before it). So this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor now that xlsx/build.ts and both docx/write.ts and docx/read.ts have been substantially closed. + // Derived by the rule documented on PackageStrykerOptions.breakThreshold, from a complete run over the whole package. That run measures 96.91% of 6839 valid mutants, which floors to 96, less a one-point margin for the 0.42% classified Timeout. The package is not yet at a genuine 100, and the 211 still-undetected mutants are concentrated in xlsx/build.ts, pptx/read.ts, xlsx/conditional-format.ts and xlsx/styles.ts, with a single survivor each in docx/write.ts, docx/read.ts, shared/drawingml.ts and three further xlsx modules; https://github.com/ExaDev/documents.js/issues/1296 carries the per-file counts and tracks closing them. Nothing here is suppressed to reach this score: the package carries no per-mutant ignore comments at all, and every mutant closed so far was either killed by a real test or removed by restructuring the code so the mutation had no node to apply to. // - // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. - breakThreshold: 83, + // The survivors left in docx/write.ts and docx/read.ts were each checked by hand, by applying the exact mutation and re-running the real suite, and are genuine equivalents rather than missing tests: interleaveRunConstructExtents' all-empty early return and wrapInternalLinks' `last === -1` guard in the writer, whose mutated spellings both still skip the same work; buildTable's `rowSpan > 1` active.set guards, whose rowSpan-1 entry carries remaining 0 and so never passes the later covered.remaining > 0 gate; buildConstructNodes' provenance tag threading, which emits no change element for a formatChange; and in the reader the vMerge grid-column accumulator's sign, the two recordParagraphRangeMarkers bounds that a range-marker half can never sit exactly on, and scanParagraphFields' order post-increment, which evaluates to the same value it already used. Closing those means restructuring the code, not adding tests. + // + // A caution before trusting this file's own report on xlsx/build.ts: several of its survivors were directly disproven. Applying the exact mutation the report names (build.ts's `declarations.length > 0` changed to `true`, for one) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the same runner config Stryker's own vitest-runner uses, fails real tests every time. So a Survived verdict on this file is a claim to reproduce by hand first, not proof that a test is missing. + breakThreshold: 95, });