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", diff --git a/packages/ooxml.js/src/typed/docx/constructs.test.ts b/packages/ooxml.js/src/typed/docx/constructs.test.ts index 95ba082173..d6e3cd9871 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,224 @@ 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([]); + }); + + // 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", () => { + // 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, filler(), filler(), filler(), filler(), endEl], + firstContentIndex: 0, + lastContentIndex: 2, + }; + const extents = runRangeMarkerExtents( + [half(startEl, "start", 0), half(endEl, "end", 5)], + index, + ); + expect(extents).toEqual([ + { 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", () => { + 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 +433,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 +1131,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"]); + }); }); 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. diff --git a/packages/ooxml.js/src/typed/docx/read.test.ts b/packages/ooxml.js/src/typed/docx/read.test.ts index b9a6507515..5dbbfb7934 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,2074 @@ 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]).toStrictEqual({ + id: "9", + author: "Reviewer", + text: "with id", + }); + // 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", () => { + 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")]), + ]), + el("w:footnote", {}, [el("w:p", {}, [textRun("no id or type")])]), + ]), + ], + }; + const doc = readDocxContent(pkg); + expect(doc.footnotes[0]).toStrictEqual({ + id: "4", + type: "continuationNotice", + text: "typed note", + }); + expect(doc.footnotes[1]).toStrictEqual({ text: "no id or type" }); + }); +}); + +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", + ); + }); +}); + +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(); + }); +}); + +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); + }); +}); + +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"); + }); +}); diff --git a/packages/ooxml.js/src/typed/docx/read.ts b/packages/ooxml.js/src/typed/docx/read.ts index 0eaae7e594..562d2e49ea 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 { @@ -344,12 +345,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 +394,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; } @@ -630,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, }); @@ -659,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, @@ -698,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) { @@ -781,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" @@ -794,6 +798,7 @@ function readParagraphRuns( node, "comment", node.tag === "w:commentRangeStart", + undefined, ); } } @@ -803,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 ); } @@ -949,10 +947,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, @@ -1073,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") ?? @@ -1112,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"); @@ -1190,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"); @@ -1219,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; } @@ -1387,17 +1378,15 @@ 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) { + // 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, - name: - family === "bookmark" && name !== undefined - ? decodeEntities(name) - : undefined, + name: name === undefined ? undefined : decodeEntities(name), kind: "start", index: leading ? paragraphIndex : endIndex, qualified: leading || trailing, @@ -1592,15 +1581,13 @@ 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 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", 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, diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index 80d4568082..1c68ede8e0 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -4,12 +4,16 @@ 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"; 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 +251,421 @@ 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"]); + 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( + 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"]); + 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) => ({ + 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("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()], + }); + 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", {}, [ @@ -450,6 +869,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" })]), @@ -1283,82 +1810,189 @@ describe("buildDocxPackageFromContent: construct round trip", () => { ).toBe("abc"); }); - it("refuses a run-level extent whose range does not name real runs, rather than writing markers at a made-up position", () => { - const faulty = { + 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" as const, - runs: [{ text: "x" }], + kind: "paragraph", + runs: [{ text: "a" }, { text: "b" }, { text: "c" }], constructs: [ { descriptor: { - kind: "anchor" as const, - anchorType: "bookmark" as const, - name: "beyond", + kind: "link", + target: { kind: "internal", anchor: "later-shorter" }, + }, + startRun: 1, + endRun: 3, + }, + { + descriptor: { + kind: "link", + target: { kind: "internal", anchor: "earlier-longer" }, }, startRun: 0, - endRun: 5, + endRun: 2, }, ], }, ], }, ], - }; - expect(() => buildDocxPackageFromContent(faulty)).toThrow( - /run-level construct extent/, - ); - }); - - it("round-trips a multi-paragraph complex field, putting its characters back inside the extent's own paragraphs", () => { - const source = docxPackage([ - el("w:p", {}, [ - el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "begin" })]), - el("w:r", {}, [el("w:instrText", {}, [txt(' TOC \\o "1-3" \\h ')])]), - el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "separate" })]), - el("w:r", {}, [el("w:t", {}, [txt("Chapter 1")])]), - ]), - para("Chapter 2"), - el("w:p", {}, [ - el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "end" })]), - ]), - ]); - const { before, after, written } = roundTrip(source); - expect(after).toEqual(before); - const start = after[0]?.blocks[0]; - expect( - start?.kind === "constructStart" ? start.descriptor : undefined, - ).toEqual({ kind: "field", instruction: ' TOC \\o "1-3" \\h ' }); + }); const document = rootElement(written.parts["word/document.xml"]); - const body = document === undefined ? undefined : document.children[0]; - // The field's own characters live inside the extent's paragraphs, so the body gains no paragraph of its own for them. + const hyperlinks = elementsWithTag( + document === undefined ? [] : [document], + "w:hyperlink", + ); + expect(hyperlinks).toHaveLength(1); expect( - body?.type === "element" - ? body.children.filter( - (child) => child.type === "element" && child.tag === "w:p", - ).length + hyperlinks[0]?.type === "element" + ? hyperlinks[0].attributes.find((a) => a.name === "w:anchor")?.value : undefined, - ).toBe(3); + ).toBe("earlier-longer"); }); - it("round-trips a simple field that is a paragraph's whole content", () => { - const sections = expectStableRoundTrip( - docxPackage([ - el("w:p", {}, [ - el("w:fldSimple", { "w:instr": " PAGE " }, [ - el("w:r", {}, [el("w:t", {}, [txt("4")])]), - ]), - ]), - ]), - ); - const start = sections[0]?.blocks[0]; - expect( - start?.kind === "constructStart" ? start.descriptor : undefined, - ).toEqual({ kind: "field", instruction: " PAGE " }); + 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: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { + kind: "paragraph" as const, + runs: [{ text: "x" }], + constructs: [ + { + descriptor: { + kind: "anchor" as const, + anchorType: "bookmark" as const, + name: "beyond", + }, + startRun: 0, + endRun: 5, + }, + ], + }, + ], + }, + ], + }; + expect(() => buildDocxPackageFromContent(faulty)).toThrow( + /run-level construct extent/, + ); + }); + + it("round-trips a multi-paragraph complex field, putting its characters back inside the extent's own paragraphs", () => { + const source = docxPackage([ + el("w:p", {}, [ + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "begin" })]), + el("w:r", {}, [el("w:instrText", {}, [txt(' TOC \\o "1-3" \\h ')])]), + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "separate" })]), + el("w:r", {}, [el("w:t", {}, [txt("Chapter 1")])]), + ]), + para("Chapter 2"), + el("w:p", {}, [ + el("w:r", {}, [el("w:fldChar", { "w:fldCharType": "end" })]), + ]), + ]); + const { before, after, written } = roundTrip(source); + expect(after).toEqual(before); + const start = after[0]?.blocks[0]; + expect( + start?.kind === "constructStart" ? start.descriptor : undefined, + ).toEqual({ kind: "field", instruction: ' TOC \\o "1-3" \\h ' }); + const document = rootElement(written.parts["word/document.xml"]); + const body = document === undefined ? undefined : document.children[0]; + // The field's own characters live inside the extent's paragraphs, so the body gains no paragraph of its own for them. + expect( + body?.type === "element" + ? body.children.filter( + (child) => child.type === "element" && child.tag === "w:p", + ).length + : undefined, + ).toBe(3); + }); + + it("round-trips a simple field that is a paragraph's whole content", () => { + const sections = expectStableRoundTrip( + docxPackage([ + el("w:p", {}, [ + el("w:fldSimple", { "w:instr": " PAGE " }, [ + el("w:r", {}, [el("w:t", {}, [txt("4")])]), + ]), + ]), + ]), + ); + const start = sections[0]?.blocks[0]; + expect( + start?.kind === "constructStart" ? start.descriptor : undefined, + ).toEqual({ kind: "field", instruction: " PAGE " }); }); // The kinds readDocxContent never produces, which a ContentDocument from another codec still can. Each writes its content and drops only the descriptor, since WordprocessingML has no block-level element for any of them -- what must never happen is an element written where it does not parse. @@ -1544,6 +2178,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")])]), @@ -1822,3 +2515,3677 @@ 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/footer2.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); + }); +}); + +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"); + }); +}); + +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, + // 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 }, + }; + } + + 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: { xPt: 0, yPt: 0, 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: Extract< + ContentBlock, + { kind: "embeddedObject" } + >["document"] = { + kind: "presentation", + metadata: {}, + slides: [], + }; + const block: Extract = { + kind: "embeddedObject", + objectKind: "presentation", + document: presentation, + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 60 }, + }; + 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: rgbHexToColor("112233"), + }); + 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: Extract< + ContentBlock, + { kind: "embeddedObject" } + >["document"] = { + kind: "wordprocessing", + metadata: {}, + sections: [], + }; + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { kind: "pageBreak" }, + { + kind: "embeddedObject", + objectKind: "wordprocessing", + document: nested, + frame: { xPt: 0, yPt: 0, 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, + ): 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: [ + { + ...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"); + }); +}); + +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 = (): 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" }] }], + }, + ], + }); + + const embeddedBlock = (): Extract< + ContentBlock, + { kind: "embeddedObject" } + > => ({ + kind: "embeddedObject", + objectKind: "wordprocessing", + document: nestedWordprocessing(), + 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", () => { + 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); + // 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", () => { + 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"); + }); +}); + +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"]); + }); +}); diff --git a/packages/ooxml.js/src/typed/docx/write.ts b/packages/ooxml.js/src/typed/docx/write.ts index 24795c8254..dbdb1f89c9 100644 --- a/packages/ooxml.js/src/typed/docx/write.ts +++ b/packages/ooxml.js/src/typed/docx/write.ts @@ -711,16 +711,15 @@ 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, 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( (a, b) => a.startRun - b.startRun || b.endRun - a.endRun, )) { @@ -739,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), @@ -777,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; 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(); + }); +}); 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. diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 4915a1c8f1..3b1df74237 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', () => { @@ -1945,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", () => { @@ -1957,3 +2286,2116 @@ 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); + const [worksheetRel] = worksheetRels; + if (worksheetRel === undefined) { + throw new Error("expected exactly one worksheet relationship"); + } + expect(attr(worksheetRel, "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"); + }); +}); + +// --- 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] }; +} + +// --- 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: number[]; + columns: 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); + }); +}); + +// --- 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, + ); + }); +}); + +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"); + }); +}); + +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"], + ]); + }); +}); + +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); + }); +}); + +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", + ]); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/build.ts b/packages/ooxml.js/src/typed/xlsx/build.ts index a9f9efb5b3..2b2fe9a16b 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, @@ -1182,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( 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 26a55322b1..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(); } @@ -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 }); } 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..27e7e36fe4 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts @@ -0,0 +1,1042 @@ +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("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( + "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); + }); +}); + +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 } = 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); + }); +}); + +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( + '', + ); + 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", () => { + 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: 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( + 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='true' only when percent is true, and omits it entirely otherwise", () => { + 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"); + 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, + ); + }); +}); + +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", + ); + 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='true' only when equalAverage is explicitly true, and omits it otherwise", () => { + 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", + ); + 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, never a phantom stdDev attribute", () => { + 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"); + 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, + ); + }); +}); + +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", + ); + 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", () => { + 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", + ); + 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, + ); + }); +}); + +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"]); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 00dfe3fd52..ca9bd076e3 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -14,14 +14,24 @@ 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, + 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"); @@ -845,6 +855,332 @@ 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 }); + expect(cells[0]?.displayText).toBe("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. @@ -1008,6 +1344,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, @@ -1430,6 +1768,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", () => { @@ -1614,7 +1954,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" })]), @@ -1628,7 +1973,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"), @@ -1729,6 +2074,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())) @@ -2038,6 +2398,424 @@ 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; + fromColOffEmu?: number; + fromRowOffEmu?: number; + fromColNodes?: XmlNode[]; + } = {}, +): XmlElement { + 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" })]), + 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", {}, fromColNodes ?? [txt("0")]), + el("xdr:colOff", {}, [txt(String(fromColOffEmu))]), + el("xdr:row", {}, [txt("0")]), + el("xdr:rowOff", {}, [txt(String(fromRowOffEmu))]), + ]), + 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); + }); + + 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. function worksheetOnlyPackage(worksheet: ReturnType): Package { const workbook = el("workbook", {}, [ @@ -2232,6 +3010,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..9403eda7e5 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, @@ -183,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 ""; } @@ -260,9 +259,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 +444,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 +463,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,20 +472,18 @@ 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]; + // 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; } @@ -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: [] }; } 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..04eb901c39 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts @@ -0,0 +1,770 @@ +import { beforeEach, 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", () => { + // 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", {}, [ + 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: 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 = { + 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("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( + 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", + ); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/drawings.ts b/packages/ooxml.js/src/typed/xlsx/drawings.ts index ba42a97020..021c3cba18 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. 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); +} + // 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,12 @@ 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 widthRaw = attr(col, "width"); - const widthPt = - widthRaw === undefined - ? undefined - : columnWidthCharsToPt(Number(widthRaw)); - if ( - Number.isInteger(min) && - Number.isInteger(max) && - min >= 1 && - max >= min - ) { + const min = parseIntAttr(col, "min"); + const max = parseIntAttr(col, "max"); + // 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({ min: min - 1, max: max - 1, @@ -73,21 +72,22 @@ 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; 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 htRaw = attr(row, "ht"); - const ht = htRaw === undefined ? Number.NaN : Number(htRaw); - if (Number.isInteger(r) && r >= 1 && Number.isFinite(ht)) { + const r = parseIntAttr(row, "r"); + const ht = Number(attr(row, "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); } } @@ -180,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; } @@ -256,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, @@ -316,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"], @@ -333,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, diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index 737cc41e18..979e730f89 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"; @@ -9,8 +10,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 +22,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 +632,787 @@ 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); + }); + + 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", () => { + 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 }; + sizePt?: number; + fontFamily?: string; + }, + 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("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( + { 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 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( + { 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" } }, + }); + }); + + 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: GENERAL_NUM_FMT_ID }, + { + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75, style: "solid" }, + }, + }, + ); + expect(explicit).toBe(implicit); + expect(table.cellFormatRecords()).toHaveLength(2); + }); + + 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 thin = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, + ); + const thick = table.intern( + { kind: "builtin", id: 9 }, + { borders: { left: { color: { r: 1, g: 0, b: 0 }, widthPt: 1.5 } } }, + ); + expect(table.cellFormatRecords()[thin]?.borderId).not.toBe( + table.cellFormatRecords()[thick]?.borderId, + ); + expect(table.borderDeclarations()).toHaveLength(3); + }); +}); + +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", () => { + 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..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)); @@ -98,14 +99,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 +239,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; } @@ -303,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]; @@ -424,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) { @@ -557,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}`; @@ -569,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)}` @@ -592,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}`; } @@ -815,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]; diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index 10fa317f4e..7dd842e8c1 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,6 +2,10 @@ 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, + // 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. + // + // 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, });