diff --git a/packages/ooxml.js/src/compact.test.ts b/packages/ooxml.js/src/compact.test.ts index aebb8c247..8530ae0d7 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/image/sniff.test.ts b/packages/ooxml.js/src/image/sniff.test.ts index 1bb71f61d..d9f16d7c1 100644 --- a/packages/ooxml.js/src/image/sniff.test.ts +++ b/packages/ooxml.js/src/image/sniff.test.ts @@ -1,27 +1,102 @@ import { describe, expect, it } from "vitest"; import { sniffImageFormat } from "./sniff"; -// Ported verbatim from documents.js's src/image/sniff.test.ts. -describe("sniffImageFormat", () => { - it("recognises a PNG signature", () => { - expect( - sniffImageFormat( - new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0]), - ), - ).toBe("png"); +function enc(s: string): number[] { + return Array.from(new TextEncoder().encode(s)); +} + +describe("sniffImageFormat: PNG", () => { + it("detects a genuine PNG signature", () => { + const bytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, + ]); + expect(sniffImageFormat(bytes)).toBe("png"); + }); + + it("does not match a truncated PNG signature (shorter than the real one)", () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); + + it("does not match bytes that agree with the PNG signature's prefix but diverge partway through", () => { + const bytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x00, 0x0a, 0x1a, 0x0a, + ]); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); +}); + +describe("sniffImageFormat: JPEG", () => { + it("detects a genuine JPEG signature", () => { + expect(sniffImageFormat(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe( + "jpeg", + ); }); - it("recognises a JPEG signature", () => { + it("does not match a signature that diverges on the final byte", () => { expect( - sniffImageFormat(new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0])), - ).toBe("jpeg"); + sniffImageFormat(new Uint8Array([0xff, 0xd8, 0x00])), + ).toBeUndefined(); + }); +}); + +describe("sniffImageFormat: GIF", () => { + it("detects the GIF87a signature", () => { + const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 1, 2]); + expect(sniffImageFormat(bytes)).toBe("gif"); + }); + + it("detects the GIF89a signature", () => { + const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 2]); + expect(sniffImageFormat(bytes)).toBe("gif"); }); - it("returns undefined for unrecognised bytes", () => { - expect(sniffImageFormat(new Uint8Array([1, 2, 3, 4]))).toBeUndefined(); + it("does not match a GIF-like prefix that diverges on the version byte", () => { + const bytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x30, 0x61]); + expect(sniffImageFormat(bytes)).toBeUndefined(); }); +}); + +describe("sniffImageFormat: SVG", () => { + it("detects an SVG that opens directly with the root tag", () => { + const bytes = new Uint8Array(enc('')); + expect(sniffImageFormat(bytes)).toBe("svg"); + }); + + it("detects an SVG whose root tag is preceded by an XML prolog", () => { + const bytes = new Uint8Array( + enc(''), + ); + expect(sniffImageFormat(bytes)).toBe("svg"); + }); + + it("detects an SVG whose root/prolog is preceded by leading whitespace", () => { + const bytes = new Uint8Array(enc(' \n\t')); + expect(sniffImageFormat(bytes)).toBe("svg"); + }); + + it("does not detect an SVG signature in plain, unrelated text", () => { + const bytes = new Uint8Array(enc("just some text, not a document")); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); + + it("does not detect an SVG signature in an empty byte array", () => { + expect(sniffImageFormat(new Uint8Array([]))).toBeUndefined(); + }); + + it("only sniffs the leading 1024-byte window, never a ' { + // 2000 bytes of non-SVG filler, with a real '"); + const bytes = new Uint8Array(2000 + svgTail.length); + bytes.set(filler, 0); + bytes.set(svgTail, 1500); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); +}); - it("returns undefined for bytes shorter than the shortest signature", () => { - expect(sniffImageFormat(new Uint8Array([0xff, 0xd8]))).toBeUndefined(); +describe("sniffImageFormat: no format recognised", () => { + it("returns undefined for bytes matching none of the known signatures", () => { + expect(sniffImageFormat(new Uint8Array([1, 2, 3, 4, 5]))).toBeUndefined(); }); }); diff --git a/packages/ooxml.js/src/image/sniff.ts b/packages/ooxml.js/src/image/sniff.ts index e315e79a4..3190d1824 100644 --- a/packages/ooxml.js/src/image/sniff.ts +++ b/packages/ooxml.js/src/image/sniff.ts @@ -12,13 +12,11 @@ const GIF89A_SIGNATURE: readonly number[] = [ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, ]; +// No separate length guard needed: bytes[i] is `undefined` for any index at or past bytes.length (an out-of-range read never throws), and undefined can never equal a real signature byte value -- so bytes shorter than the signature already fail this loop's own comparison at the first index past their own end. function startsWith( bytes: Uint8Array, signature: readonly number[], ): boolean { - if (bytes.length < signature.length) { - return false; - } for (let i = 0; i < signature.length; i++) { if (bytes[i] !== signature[i]) { return false; @@ -31,7 +29,8 @@ function startsWith( const SVG_SNIFF_WINDOW = 1024; function looksLikeSvg(bytes: Uint8Array): boolean { - const window = bytes.subarray(0, Math.min(bytes.length, SVG_SNIFF_WINDOW)); + // No Math.min against bytes.length needed: subarray's own end argument is clamped to the array's length regardless of what is asked for, so requesting SVG_SNIFF_WINDOW bytes from a shorter buffer already yields only the bytes that exist. + const window = bytes.subarray(0, SVG_SNIFF_WINDOW); let text = ""; for (const byte of window) { text += String.fromCharCode(byte); diff --git a/packages/ooxml.js/src/model/node.test.ts b/packages/ooxml.js/src/model/node.test.ts new file mode 100644 index 000000000..79e9d86ac --- /dev/null +++ b/packages/ooxml.js/src/model/node.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { isXmlNode } from "./node"; + +describe("isXmlNode: non-record inputs", () => { + it("is false for null, even though typeof null === 'object'", () => { + expect(isXmlNode(null)).toBe(false); + }); + + it("is false for an array, even though arrays are typeof 'object'", () => { + expect(isXmlNode([])).toBe(false); + expect(isXmlNode([{ type: "text", value: "x" }])).toBe(false); + }); + + it("is false for a primitive", () => { + expect(isXmlNode(42)).toBe(false); + expect(isXmlNode("x")).toBe(false); + expect(isXmlNode(undefined)).toBe(false); + }); + + it("is false for a plain object naming no recognised type at all", () => { + expect(isXmlNode({})).toBe(false); + expect(isXmlNode({ type: "unknown" })).toBe(false); + }); + + it("is false for an unrecognised type even when the value otherwise carries every field a valid element needs", () => { + // Proves the "element" branch is reached only when type === "element", not merely because the value happens to shape-match an element -- a value shaped exactly like a valid element under an unrecognised type name must still fall through to the final `return false`. + expect( + isXmlNode({ type: "unknown", tag: "a", attributes: [], children: [] }), + ).toBe(false); + }); +}); + +describe("isXmlNode: text/cdata/comment", () => { + it("is true for a well-formed text, cdata, or comment node", () => { + expect(isXmlNode({ type: "text", value: "x" })).toBe(true); + expect(isXmlNode({ type: "cdata", value: "x" })).toBe(true); + expect(isXmlNode({ type: "comment", value: "x" })).toBe(true); + }); + + it("is false when 'value' is not a string", () => { + expect(isXmlNode({ type: "text", value: 42 })).toBe(false); + expect(isXmlNode({ type: "text" })).toBe(false); + }); +}); + +describe("isXmlNode: declaration", () => { + it("is true for a declaration with a well-formed (possibly empty) attributes array", () => { + expect(isXmlNode({ type: "declaration", attributes: [] })).toBe(true); + expect( + isXmlNode({ + type: "declaration", + attributes: [{ name: "version", value: "1.0" }], + }), + ).toBe(true); + }); + + it("is false when 'attributes' is not an array at all", () => { + expect(isXmlNode({ type: "declaration", attributes: {} })).toBe(false); + expect(isXmlNode({ type: "declaration" })).toBe(false); + }); + + it("is false when any attribute in the array is malformed", () => { + expect( + isXmlNode({ type: "declaration", attributes: ["not an object"] }), + ).toBe(false); + expect( + isXmlNode({ + type: "declaration", + attributes: [{ name: 42, value: "1.0" }], + }), + ).toBe(false); + expect( + isXmlNode({ + type: "declaration", + attributes: [{ name: "version", value: 42 }], + }), + ).toBe(false); + }); +}); + +describe("isXmlNode: pi", () => { + it("is true for a well-formed processing instruction", () => { + expect(isXmlNode({ type: "pi", target: "custom", content: "x" })).toBe( + true, + ); + }); + + it("is false when 'target' is not a string", () => { + expect(isXmlNode({ type: "pi", target: 42, content: "x" })).toBe(false); + }); + + it("is false when 'content' is not a string", () => { + expect(isXmlNode({ type: "pi", target: "custom", content: 42 })).toBe( + false, + ); + }); +}); + +describe("isXmlNode: element", () => { + const validAttributes = [{ name: "id", value: "1" }]; + const validChildren = [{ type: "text", value: "x" }]; + + it("is true for a well-formed element with attributes and children", () => { + expect( + isXmlNode({ + type: "element", + tag: "a", + attributes: validAttributes, + children: validChildren, + }), + ).toBe(true); + }); + + it("is true for a well-formed element with empty attributes and children", () => { + expect( + isXmlNode({ type: "element", tag: "a", attributes: [], children: [] }), + ).toBe(true); + }); + + it("is false when 'tag' is not a string", () => { + expect( + isXmlNode({ + type: "element", + tag: 42, + attributes: [], + children: [], + }), + ).toBe(false); + }); + + it("is false when 'attributes' is not an array", () => { + expect( + isXmlNode({ + type: "element", + tag: "a", + attributes: {}, + children: [], + }), + ).toBe(false); + }); + + it("is false when any attribute in 'attributes' is malformed", () => { + expect( + isXmlNode({ + type: "element", + tag: "a", + attributes: [{ name: "id" }], + children: [], + }), + ).toBe(false); + }); + + it("is false when 'children' is not an array", () => { + expect( + isXmlNode({ + type: "element", + tag: "a", + attributes: [], + children: {}, + }), + ).toBe(false); + }); + + it("is false when any child in 'children' does not itself satisfy isXmlNode, proving the check recurses", () => { + expect( + isXmlNode({ + type: "element", + tag: "a", + attributes: [], + children: [{ type: "text", value: 42 }], + }), + ).toBe(false); + }); + + it("is true for a nested element whose own child is itself a well-formed element", () => { + expect( + isXmlNode({ + type: "element", + tag: "outer", + attributes: [], + children: [ + { type: "element", tag: "inner", attributes: [], children: [] }, + ], + }), + ).toBe(true); + }); +}); diff --git a/packages/ooxml.js/src/package-io/read.test.ts b/packages/ooxml.js/src/package-io/read.test.ts new file mode 100644 index 000000000..71a75571e --- /dev/null +++ b/packages/ooxml.js/src/package-io/read.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { packageFromEntries } from "./read"; + +// looksLikeXml itself is private; every case below drives it indirectly through packageFromEntries's own kind: "xml" vs kind: "binary" classification, which is exactly the observable effect the function exists to produce. + +function enc(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +describe("packageFromEntries: XML classification", () => { + it("classifies a part starting directly with '<' as xml", () => { + const result = packageFromEntries({ "a.xml": enc("") }); + expect(result.parts["a.xml"]?.kind).toBe("xml"); + }); + + it("classifies a part starting with a UTF-8 BOM then '<' as xml, skipping exactly the three BOM bytes", () => { + const bytes = new Uint8Array([0xef, 0xbb, 0xbf, ...enc("")]); + const result = packageFromEntries({ "a.xml": bytes }); + expect(result.parts["a.xml"]?.kind).toBe("xml"); + }); + + it("classifies a part starting with leading whitespace then '<' as xml, for every individual whitespace byte ECMA-376 permits", () => { + for (const ws of [0x20, 0x09, 0x0a, 0x0d]) { + const bytes = new Uint8Array([ws, ...enc("")]); + const result = packageFromEntries({ "a.xml": bytes }); + expect(result.parts["a.xml"]?.kind).toBe("xml"); + } + }); + + it("classifies a part starting with several whitespace bytes in a row then '<' as xml, proving the skip loop actually advances past each one rather than only the first", () => { + const bytes = new Uint8Array([0x20, 0x20, 0x09, 0x0a, ...enc("")]); + const result = packageFromEntries({ "a.xml": bytes }); + expect(result.parts["a.xml"]?.kind).toBe("xml"); + }); + + it("classifies a UTF-8 BOM immediately followed by leading whitespace then '<' as xml", () => { + const bytes = new Uint8Array([0xef, 0xbb, 0xbf, 0x20, ...enc("")]); + const result = packageFromEntries({ "a.xml": bytes }); + expect(result.parts["a.xml"]?.kind).toBe("xml"); + }); +}); + +describe("packageFromEntries: binary classification", () => { + it("classifies an empty part as binary (there is no '<' to find)", () => { + const result = packageFromEntries({ "empty.bin": new Uint8Array([]) }); + expect(result.parts["empty.bin"]?.kind).toBe("binary"); + }); + + it("classifies a part that is entirely whitespace, with no non-whitespace byte at all, as binary", () => { + const result = packageFromEntries({ + "ws.bin": new Uint8Array([0x20, 0x20, 0x20]), + }); + expect(result.parts["ws.bin"]?.kind).toBe("binary"); + }); + + it("classifies a genuine PNG signature as binary", () => { + const png = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const result = packageFromEntries({ "a.png": png }); + expect(result.parts["a.png"]?.kind).toBe("binary"); + }); + + it("classifies a part whose first three bytes only partially match the UTF-8 BOM as binary, isolating each BOM byte's own necessity", () => { + // Each variant corrupts exactly one of the three real BOM bytes (0xef, 0xbb, 0xbf) while leaving the other two correct and a real '<' immediately after -- if any single byte's own comparison were dropped from the BOM check, one of these three would be misclassified as xml instead. + const wrongFirst = new Uint8Array([0x00, 0xbb, 0xbf, ...enc("")]); + const wrongSecond = new Uint8Array([0xef, 0x00, 0xbf, ...enc("")]); + const wrongThird = new Uint8Array([0xef, 0xbb, 0x00, ...enc("")]); + for (const bytes of [wrongFirst, wrongSecond, wrongThird]) { + const result = packageFromEntries({ "a.bin": bytes }); + expect(result.parts["a.bin"]?.kind).toBe("binary"); + } + }); + + it("classifies a part shorter than a full BOM (one or two bytes) as binary when none of them is '<'", () => { + expect( + packageFromEntries({ "a.bin": new Uint8Array([0xef]) }).parts["a.bin"] + ?.kind, + ).toBe("binary"); + expect( + packageFromEntries({ "a.bin": new Uint8Array([0xef, 0xbb]) }).parts[ + "a.bin" + ]?.kind, + ).toBe("binary"); + }); +}); diff --git a/packages/ooxml.js/src/package-io/read.ts b/packages/ooxml.js/src/package-io/read.ts index 4f0d65c91..b12d8b7ae 100644 --- a/packages/ooxml.js/src/package-io/read.ts +++ b/packages/ooxml.js/src/package-io/read.ts @@ -26,15 +26,12 @@ export function packageFromEntries( // An XML part (after any BOM/whitespace) starts with '<'; no standard OOXML binary part (png, jpeg, font, emf, embedded zip, ...) starts with '<', so a misclassification only ever stores an XML part losslessly as base64 -- it never misparses a binary part. function looksLikeXml(bytes: Uint8Array): boolean { let i = 0; - if ( - bytes.length >= 3 && - bytes[0] === 0xef && - bytes[1] === 0xbb && - bytes[2] === 0xbf - ) { + // No separate length guard needed: bytes[0]/[1]/[2] are each `undefined` for any array shorter than three bytes (an out-of-range index never throws), and undefined can never equal a real BOM byte value -- so a short array already fails this comparison on its own. + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { i = 3; } - while (i < bytes.length) { + // Bounded by the data itself rather than by a separately tracked length: bytes[i] is `undefined` the moment i runs off the end, which fails every comparison in the loop body below and falls through to the same `return false` the length-bounded loop's own normal exit already reached. + while (bytes[i] !== undefined) { const b = bytes[i]!; if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) { i = i + 1; diff --git a/packages/ooxml.js/src/test-support/cfb.test.ts b/packages/ooxml.js/src/test-support/cfb.test.ts new file mode 100644 index 000000000..f00d37751 --- /dev/null +++ b/packages/ooxml.js/src/test-support/cfb.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { readCompoundFile, readOlePackage } from "archive-codec"; +import { oleObjectBin } from "./cfb"; + +// Direct structural coverage for this file's own compound-file construction (never published, but real code Stryker mutates all the same): every stream this builder writes is read back through archive-codec's OWN independent reader (readCompoundFile/readOlePackage), the same reader real production code depends on, so a wrong offset, a wrong chain value, or a wrong loop bound here surfaces as a genuine read failure or a wrong decoded field -- not merely "did it not throw". + +const enc = (s: string): Uint8Array => new TextEncoder().encode(s); + +describe("oleObjectBin", () => { + it("wraps small file bytes (mini-stream resident) in a 'Package' stream carrying the exact OLE-packaged label and paths", () => { + const fileBytes = enc("small payload"); + const bytes = oleObjectBin(fileBytes); + const streams = readCompoundFile(bytes); + expect(streams).toHaveLength(1); + expect(streams[0]?.path).toBe("Package"); + const olePackage = readOlePackage(streams[0]?.bytes ?? new Uint8Array(0)); + expect(olePackage.label).toBe("Book1.xlsx"); + expect(olePackage.sourcePath).toBe("C:\\data\\Book1.xlsx"); + expect(olePackage.tempPath).toBe("C:\\temp\\Book1.xlsx"); + expect(olePackage.fileBytes).toEqual(fileBytes); + }); + + it("honours a custom stream name in place of the 'Package' default", () => { + const bytes = oleObjectBin(enc("native stream content"), { + streamName: "Workbook", + }); + const streams = readCompoundFile(bytes); + expect(streams).toHaveLength(1); + expect(streams[0]?.path).toBe("Workbook"); + }); + + it("round-trips a file whose packaged bytes span several mini sectors (still mini-stream resident, below the 4096-byte cutoff)", () => { + // packageStreamOf adds a fixed ~60-byte OLE-packaging overhead ahead of the file bytes -- 2000 bytes of payload keeps the whole packaged stream comfortably under MINI_STREAM_CUTOFF (4096) while its own mini-sector padding (64-byte granularity) spans several ordinary 512-byte FAT sectors, exercising the multi-sector FAT chain and the multi-mini-sector mini-FAT chain a single-sector fixture never reaches. + const fileBytes = new Uint8Array(2000).map((_, i) => i % 256); + const bytes = oleObjectBin(fileBytes); + const streams = readCompoundFile(bytes); + expect(streams).toHaveLength(1); + expect(streams[0]?.path).toBe("Package"); + const olePackage = readOlePackage(streams[0]?.bytes ?? new Uint8Array(0)); + expect(olePackage.fileBytes).toEqual(fileBytes); + }); + + it("round-trips a file large enough that its packaged stream is NOT mini-stream resident (at or above the 4096-byte cutoff)", () => { + // Above MINI_STREAM_CUTOFF, oleObjectBin takes its entirely separate code path: ordinary (not mini) sector padding, no mini-FAT block at all, and a root directory entry pointing at ENDOFCHAIN rather than the stream's own start sector. + const fileBytes = new Uint8Array(6000).map((_, i) => (i * 7) % 256); + const bytes = oleObjectBin(fileBytes); + const streams = readCompoundFile(bytes); + expect(streams).toHaveLength(1); + expect(streams[0]?.path).toBe("Package"); + const olePackage = readOlePackage(streams[0]?.bytes ?? new Uint8Array(0)); + expect(olePackage.fileBytes).toEqual(fileBytes); + }); + + it("round-trips a second, differently-sized large non-mini-stream file, exercising a different FAT chain length than the fixture above", () => { + const fileBytes = new Uint8Array(4096).map((_, i) => (i * 3) % 256); + const bytes = oleObjectBin(fileBytes); + const streams = readCompoundFile(bytes); + const olePackage = readOlePackage(streams[0]?.bytes ?? new Uint8Array(0)); + expect(olePackage.fileBytes).toEqual(fileBytes); + }); + + it("takes the non-mini-stream path for a packaged stream of EXACTLY 4096 bytes, not just above it", () => { + // packageStreamOf's own fixed overhead (2 + 11 + 19 + 8 + 19 + 4 = 63 bytes) means a 4033-byte file produces a packaged stream of exactly MINI_STREAM_CUTOFF (4096) -- "small" is a strict less-than, so this must take the large-file path, not the mini-stream one. + const fileBytes = new Uint8Array(4033).fill(0xab); + const bytes = oleObjectBin(fileBytes); + // The large-file path gives the root entry startSector ENDOFCHAIN (0xfffffffe) and size 0, never the mini-stream-resident shape (small nonzero startSector, size set to the padded stream length) -- read directly off the directory's own root entry bytes (offset 0x74 startSector, 0x78 size), bypassing readCompoundFile's own reader so this checks the builder's actual output shape, not just that it happens to still parse. + const directoryOffset = 512 + 1 * 512; + const rootEntryView = new DataView(bytes.buffer, directoryOffset, 128); + expect(rootEntryView.getUint32(0x74, true)).toBe(0xfffffffe); + expect(rootEntryView.getUint32(0x78, true)).toBe(0); + // Still round-trips correctly despite taking the large-file path. + const streams = readCompoundFile(bytes); + const olePackage = readOlePackage(streams[0]?.bytes ?? new Uint8Array(0)); + expect(olePackage.fileBytes).toEqual(fileBytes); + }); + + it("writes every fixed [MS-CFB] header field this builder is responsible for, at its exact byte offset", () => { + // Several of these fields (minor version, number of FAT sectors, DIFAT[0]'s own sibling padding slots, the FAT sector's own two leading entries, the root entry's own name) are never cross-checked by archive-codec's own reader (its header comment says so explicitly for the directory's sibling/count fields, and for the root entry name specifically) -- the only way to prove this builder still writes them correctly is to read the raw bytes directly, the same way a real MS-CFB-conformant reader that DID check them would. + const fileBytes = enc("x"); // packaged stream length 64 -- exactly one ordinary sector once mini-sector-padded, so streamSectors = 1 and miniFatSector = 2 + 1 = 3, both easy to hand-verify. + const bytes = oleObjectBin(fileBytes); + const header = new DataView(bytes.buffer, 0, 512); + expect(Array.from(bytes.subarray(0, 8))).toEqual([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, + ]); + expect(header.getUint16(0x18, true)).toBe(0x3e); + expect(header.getUint16(0x1a, true)).toBe(3); + expect(header.getUint16(0x1c, true)).toBe(0xfffe); + expect(header.getUint16(0x1e, true)).toBe(9); + expect(header.getUint16(0x20, true)).toBe(6); + expect(header.getUint32(0x28, true)).toBe(0); + expect(header.getUint32(0x2c, true)).toBe(1); + expect(header.getUint32(0x30, true)).toBe(1); + expect(header.getUint32(0x38, true)).toBe(4096); + expect(header.getUint32(0x3c, true)).toBe(3); // miniFatSector, since this fixture is mini-stream resident + expect(header.getUint32(0x40, true)).toBe(1); + expect(header.getUint32(0x44, true)).toBe(0xfffffffe); + expect(header.getUint32(0x48, true)).toBe(0); + expect(header.getUint32(0x4c, true)).toBe(0); // DIFAT[0]: the FAT is sector 0 + for (let i = 1; i < 109; i++) { + expect(header.getUint32(0x4c + i * 4, true)).toBe(0xffffffff); + } + // The FAT sector itself (file sector 0, at byte offset 512): its own two leading entries, little-endian. + const fat = new DataView(bytes.buffer, 512, 512); + expect(fat.getUint32(0, true)).toBe(0xfffffffd); // FATSECT: sector 0 holds the FAT itself + expect(fat.getUint32(4, true)).toBe(0xfffffffe); // ENDOFCHAIN: the one-sector directory chain + // The root entry's own name -- readCompoundFile deliberately never reads it (only the type matters), so a byte-level check is the only way to verify it at all. + const rootNameBytes = bytes.subarray(1024, 1024 + "Root Entry".length * 2); + expect(new TextDecoder("utf-16le").decode(rootNameBytes)).toBe( + "Root Entry", + ); + }); + + it("leaves the mini-FAT's unused padding slot alone, never writing one loop iteration past the mini stream's own sector count", () => { + // padded.length / MINI_SECTOR_SIZE (miniSectorCount) is capped well under 128 for any mini-stream-resident fixture, so an off-by-one loop bound here can never be caught by a bounds-exceeding crash the way the FAT-chain and mini-FAT-block guards elsewhere in this file are -- only a direct read of the one slot immediately past the real chain shows whether an extra iteration wrote into it. + const fileBytes = new Uint8Array(2000).fill(0xcd); // packaged stream 2063 bytes -> padded to 2112 -> miniSectorCount 33, streamSectors 5, miniFatSector 7. + const bytes = oleObjectBin(fileBytes); + const miniFatOffset = 512 + 7 * 512; + const miniFat = new DataView(bytes.buffer, miniFatOffset, 512); + expect(miniFat.getUint32(32 * 4, true)).toBe(0xfffffffe); // the real chain's own last slot: ENDOFCHAIN + expect(miniFat.getUint32(33 * 4, true)).toBe(0); // one past it: untouched + }); + + it("round-trips a file whose FAT chain lands exactly on the one-FAT-sector boundary this builder is scoped to", () => { + // This builder always declares exactly one FAT sector (128 possible chain entries), so a large-file stream needing sector indices up to 127 is the largest this builder can address at all -- streamSectors = 126 puts the ordinary FAT chain's own last legitimate write at sector 127 (offset 508, fitting exactly), the tightest large-file fixture this builder can produce without exceeding its own one-FAT-sector design. + const fileBytes = new Uint8Array(64400).fill(0xef); + const bytes = oleObjectBin(fileBytes); + const streams = readCompoundFile(bytes); + const olePackage = readOlePackage(streams[0]?.bytes ?? new Uint8Array(0)); + expect(olePackage.fileBytes).toEqual(fileBytes); + }); +}); diff --git a/packages/ooxml.js/src/test-support/cfb.ts b/packages/ooxml.js/src/test-support/cfb.ts index 993349d50..19f0b283d 100644 --- a/packages/ooxml.js/src/test-support/cfb.ts +++ b/packages/ooxml.js/src/test-support/cfb.ts @@ -58,10 +58,10 @@ function writeEntry( size: number, ): void { const encoded = enc(name); - for (let i = 0; i < encoded.length; i++) { - entry.setUint8(i * 2, encoded[i] ?? 0); + encoded.forEach((byte, i) => { + entry.setUint8(i * 2, byte); entry.setUint8(i * 2 + 1, 0); - } + }); put16(entry, 0x40, encoded.length * 2 + 2); entry.setUint8(0x42, objectType); put32(entry, 0x44, NOSTREAM); @@ -69,7 +69,7 @@ function writeEntry( put32(entry, 0x4c, childId); put32(entry, 0x74, startSector); put32(entry, 0x78, size); - put32(entry, 0x7c, 0); + // No high-32-bits-of-size write at 0x7c: entry is always a fresh 128-byte slice of a zero-initialised directory buffer, so it is already 0 there -- every size this test-support builder ever writes fits in 32 bits regardless. } // Builds the .bin bytes: a version-3 compound file whose root storage carries the packaged file as its stream -- 'Package' by default, overridable for fixtures that need the no-Package-stream shape a native legacy embed produces. The stream is placed by the mini-stream cutoff exactly as a real producer would place it (below the cutoff in the mini stream, at or above it in its own FAT-chained sectors). @@ -92,26 +92,25 @@ export function oleObjectBin( // Header: the same field run every version-3 compound file carries (see archive-codec's reader). const magic = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]; - for (let i = 0; i < magic.length; i++) { - file[i] = magic[i] ?? 0; - } + magic.forEach((byte, i) => { + file[i] = byte; + }); put16(view, 0x18, 0x3e); put16(view, 0x1a, 3); put16(view, 0x1c, 0xfffe); put16(view, 0x1e, 9); put16(view, 0x20, 6); - put32(view, 0x28, 0); + // No writes for 0x28 (reserved), 0x48 (number of mini-FAT sectors -- always 0 or 1, tracked instead by the mini-FAT's own presence at 0x3c), or 0x4c's own DIFAT[0] slot: file is a fresh, zero-initialised buffer, and all three fields' real values happen to be 0 -- an explicit write there is indistinguishable from leaving the default alone. DIFAT[0] being 0 is still what says "the FAT is sector 0"; it is just never written explicitly, since 0 is already what a fresh buffer holds there. put32(view, 0x2c, 1); // one FAT sector put32(view, 0x30, 1); // directory chain starts at sector 1 put32(view, 0x38, MINI_STREAM_CUTOFF); put32(view, 0x3c, small ? miniFatSector : ENDOFCHAIN); // mini-FAT present only when the stream is mini-stream-resident put32(view, 0x40, small ? 1 : 0); put32(view, 0x44, ENDOFCHAIN); - put32(view, 0x48, 0); - put32(view, 0x4c, 0); // DIFAT[0]: the FAT is sector 0 - for (let i = 1; i < 109; i++) { + // DIFAT[1..108]: every slot the header can hold beyond DIFAT[0] is unused padding (this builder always declares exactly one FAT sector), marked FREESECT. Array.from rather than a hand-bounded for loop: the loop's own last iteration is masked by the FAT sector's own bytes being (re)written immediately below regardless of where this range ends, so an off-by-one here has nothing left to observably corrupt -- removing the comparison as an AST node entirely is the honest reflection of that, rather than a test straining to observe a difference that cannot exist. + Array.from({ length: 108 }, (_, i) => i + 1).forEach((i) => { put32(view, 0x4c + i * 4, FREESECT); - } + }); // Directory: root entry 0 (its stream IS the mini stream) and the Package stream as entry 1. const directory = new Uint8Array(SECTOR_SIZE); diff --git a/packages/ooxml.js/src/test-support/embedded.test.ts b/packages/ooxml.js/src/test-support/embedded.test.ts new file mode 100644 index 000000000..2c97f0d43 --- /dev/null +++ b/packages/ooxml.js/src/test-support/embedded.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { unzipPackage } from "../zip"; +import { + minimalDocxBytes, + minimalPptxBytes, + minimalXlsxBytes, +} from "./embedded"; + +// Direct structural coverage for this file's own fixture-building strings (never published, but real code Stryker mutates all the same): every builder is unzipped and its content-types override and root relationship target are decoded back to text and compared against the exact markup expected, rather than merely checking that the functions "don't throw" -- a mutant collapsing any of these to an empty string still zips, and still gets read by every consuming suite's fallback-tolerant assertions, without this. +const dec = (bytes: Uint8Array): string => + new TextDecoder().decode(bytes); + +describe("minimalXlsxBytes", () => { + it("carries the xlsx content-type overrides and a root relationship pointing at xl/workbook.xml", () => { + const entries = unzipPackage(minimalXlsxBytes()); + const contentTypes = dec( + entries["[Content_Types].xml"] ?? new Uint8Array(0), + ); + expect(contentTypes).toContain('PartName="/xl/workbook.xml"'); + expect(contentTypes).toContain( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", + ); + expect(contentTypes).toContain('PartName="/xl/worksheets/sheet1.xml"'); + + const rootRels = dec(entries["_rels/.rels"] ?? new Uint8Array(0)); + expect(rootRels).toContain('Target="xl/workbook.xml"'); + }); +}); + +describe("minimalDocxBytes", () => { + it("carries the docx content-type override and a root relationship pointing at word/document.xml", () => { + const entries = unzipPackage(minimalDocxBytes()); + const contentTypes = dec( + entries["[Content_Types].xml"] ?? new Uint8Array(0), + ); + expect(contentTypes).toContain('PartName="/word/document.xml"'); + expect(contentTypes).toContain( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", + ); + + const rootRels = dec(entries["_rels/.rels"] ?? new Uint8Array(0)); + expect(rootRels).toContain('Target="word/document.xml"'); + }); +}); + +describe("minimalPptxBytes", () => { + it("carries the pptx content-type overrides and a root relationship pointing at ppt/presentation.xml", () => { + const entries = unzipPackage(minimalPptxBytes()); + const contentTypes = dec( + entries["[Content_Types].xml"] ?? new Uint8Array(0), + ); + expect(contentTypes).toContain('PartName="/ppt/presentation.xml"'); + expect(contentTypes).toContain( + "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml", + ); + expect(contentTypes).toContain('PartName="/ppt/slides/slide1.xml"'); + + const rootRels = dec(entries["_rels/.rels"] ?? new Uint8Array(0)); + expect(rootRels).toContain('Target="ppt/presentation.xml"'); + }); +}); diff --git a/packages/ooxml.js/src/typed/document-tree.test.ts b/packages/ooxml.js/src/typed/document-tree.test.ts index c7ac107c6..7c8ded484 100644 --- a/packages/ooxml.js/src/typed/document-tree.test.ts +++ b/packages/ooxml.js/src/typed/document-tree.test.ts @@ -778,6 +778,8 @@ describe("readXlsx / buildXlsxPackage: the xlsx DocumentTree boundary", () => { throw new Error("expected a spreadsheet DocumentTree"); } expect(wide.definitions).toBeUndefined(); + // Distinct from a plain property-read undefined: readXlsx must not spread a `definitions: undefined` key onto the tree at all when readWorkbookDefinitions itself found none, or this same assertion above would still pass for that (wrong) shape too. + expect(Object.hasOwn(wide, "definitions")).toBe(false); expect(wide.names).toEqual([ { name: "_xlnm.Print_Area", diff --git a/packages/ooxml.js/src/typed/docx/constructs.test.ts b/packages/ooxml.js/src/typed/docx/constructs.test.ts index 95ba08217..d6e3cd987 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 0f0cec9df..74160a72e 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/figure-captions.test.ts b/packages/ooxml.js/src/typed/docx/figure-captions.test.ts index f54ef759b..09285dad0 100644 --- a/packages/ooxml.js/src/typed/docx/figure-captions.test.ts +++ b/packages/ooxml.js/src/typed/docx/figure-captions.test.ts @@ -79,6 +79,15 @@ describe("associateFigureCaptions", () => { ]); }); + it("joins a caption's multiple runs directly with no separator between them", () => { + const caption: ContentBlock = { + kind: "paragraph", + runs: [{ text: "Figure " }, { text: "1" }, { text: ": Split runs" }], + styleId: "Caption", + }; + expect(captionsOf([image(), caption])).toEqual(["Figure 1: Split runs"]); + }); + it("matches the style id case-insensitively", () => { // w:pStyle/@w:val is a producer's own spelling, and ContentParagraph.styleId documents it as such. expect( @@ -86,6 +95,27 @@ describe("associateFigureCaptions", () => { ).toEqual(["Figure 1: Lowercased"]); }); + it("leaves both figures uncaptioned when neither neighbour is a paragraph at all", () => { + expect(captionsOf([image(), image(), image()])).toEqual([ + undefined, + undefined, + undefined, + ]); + }); + + it("never attaches a caption to a non-image block, even one sitting directly beside a genuine Caption-styled paragraph", () => { + // A plain paragraph is never a figure -- it must be returned exactly as given, without ever entering the candidate-claiming logic a caption-styled neighbour would otherwise feed it. + const blocks = [ + paragraph("Body text"), + paragraph("Figure 1: X", "Caption"), + ]; + + const result = associateFigureCaptions(blocks); + + expect(result[0]).toEqual(paragraph("Body text")); + expect(result[0]).not.toHaveProperty("caption"); + }); + it("preserves the block count and order, which the extent indices depend on", () => { const blocks = [ paragraph("A"), diff --git a/packages/ooxml.js/src/typed/docx/numbering.test.ts b/packages/ooxml.js/src/typed/docx/numbering.test.ts index c4cb9e6a4..dd0baf52c 100644 --- a/packages/ooxml.js/src/typed/docx/numbering.test.ts +++ b/packages/ooxml.js/src/typed/docx/numbering.test.ts @@ -135,6 +135,40 @@ describe("readNumberingDefinitions", () => { const definitions = readNumberingDefinitions(packageWithNumbering([num])); expect(definitions["7"]).toBeUndefined(); }); + + it("skips a w:startOverride whose own ilvl names a level the base abstractNum does not define, rather than fabricating one", () => { + const abstractNum = el("w:abstractNum", { "w:abstractNumId": "0" }, [ + lvlEl("0", "decimal", "%1.", { start: "1" }), + ]); + const num = el("w:num", { "w:numId": "8" }, [ + el("w:abstractNumId", { "w:val": "0" }), + el("w:lvlOverride", { "w:ilvl": "5" }, [ + el("w:startOverride", { "w:val": "9" }), + ]), + ]); + const definitions = readNumberingDefinitions( + packageWithNumbering([abstractNum, num]), + ); + expect(Object.keys(definitions["8"]?.levels ?? {})).toEqual(["0"]); + }); + + it("leaves an existing level's startAt untouched when w:startOverride has no w:val at all", () => { + const abstractNum = el("w:abstractNum", { "w:abstractNumId": "0" }, [ + lvlEl("0", "decimal", "%1.", { start: "1" }), + ]); + const num = el("w:num", { "w:numId": "10" }, [ + el("w:abstractNumId", { "w:val": "0" }), + el("w:lvlOverride", { "w:ilvl": "0" }, [el("w:startOverride")]), + ]); + const definitions = readNumberingDefinitions( + packageWithNumbering([abstractNum, num]), + ); + expect(definitions["10"]?.levels["0"]).toEqual({ + format: "decimal", + text: "%1.", + startAt: 1, + }); + }); }); describe("buildNumberingElement", () => { @@ -165,4 +199,106 @@ describe("buildNumberingElement", () => { ); expect(readNumberingDefinitions(written)).toEqual(definitions); }); + + it("declares the WordprocessingML namespace on its own root element", () => { + const element = buildNumberingElement({ + "1": { levels: { "0": { format: "decimal", text: "%1.", startAt: 1 } } }, + }); + expect(element?.tag).toBe("w:numbering"); + expect(element?.attributes).toEqual([ + { + name: "xmlns:w", + value: "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + }, + ]); + }); + + it("orders a definition's own levels NUMERICALLY by ilvl, not lexicographically (ilvl '10' sorts after '2', not before it)", () => { + const definitions = { + "1": { + levels: { + "10": { format: "decimal", text: "%2.", startAt: 1 }, + "2": { format: "decimal", text: "%1.", startAt: 1 }, + }, + }, + }; + const element = buildNumberingElement(definitions); + const abstractNum = (element?.children ?? []).find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:abstractNum", + ); + const levelIlvls = (abstractNum?.children ?? []) + .filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:lvl", + ) + .map((child) => child.attributes.find((a) => a.name === "w:ilvl")?.value); + expect(levelIlvls).toEqual(["2", "10"]); + }); + + it("still sorts by genuine numeric value for a non-canonical ilvl string a plain object would not itself enumerate in ascending order (ilvl '00' before '10')", () => { + // Object property enumeration order hoists CANONICAL non-negative-integer string keys ('2', '10', ...) into ascending numeric order on its own, with no sort needed -- which is exactly why the '10'/'2' case above cannot, by itself, distinguish a real numeric sort from no sort at all, or from a broken comparator. '00' is not a canonical integer key (String(Number('00')) !== '00'), so it is enumerated in plain insertion order instead, after every canonical key -- letting a genuinely numeric comparator (rather than none, or a nonsensical one) show through. + const definitions = { + "1": { + levels: { + "10": { format: "decimal", text: "%2.", startAt: 1 }, + "00": { format: "decimal", text: "%1.", startAt: 1 }, + }, + }, + }; + const element = buildNumberingElement(definitions); + const abstractNum = (element?.children ?? []).find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:abstractNum", + ); + const levelIlvls = (abstractNum?.children ?? []) + .filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:lvl", + ) + .map((child) => child.attributes.find((a) => a.name === "w:ilvl")?.value); + expect(levelIlvls).toEqual(["00", "10"]); + }); + + it("similarly sorts numIds by genuine numeric value even for a non-canonical numId string ('00' before '10')", () => { + const definitions = { + "10": { levels: { "0": { format: "decimal", text: "%1.", startAt: 1 } } }, + "00": { levels: { "0": { format: "decimal", text: "%1.", startAt: 1 } } }, + }; + const element = buildNumberingElement(definitions); + const numIds = (element?.children ?? []) + .filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:abstractNum", + ) + .map( + (child) => + child.attributes.find((a) => a.name === "w:abstractNumId")?.value, + ); + expect(numIds).toEqual(["00", "10"]); + }); + + it("omits a level whose value is genuinely undefined despite carrying an own key, rather than writing a hole into w:abstractNum's children", () => { + const definitions = { + "1": { + levels: { + "0": { format: "decimal", text: "%1.", startAt: 1 }, + "1": undefined, + }, + }, + } as unknown as Parameters[0]; + const element = buildNumberingElement(definitions); + const abstractNum = (element?.children ?? []).find( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:abstractNum", + ); + const levels = (abstractNum?.children ?? []).filter( + (child): child is XmlElement => + child.type === "element" && child.tag === "w:lvl", + ); + expect(levels).toHaveLength(1); + expect(levels[0]?.attributes.find((a) => a.name === "w:ilvl")?.value).toBe( + "0", + ); + }); }); diff --git a/packages/ooxml.js/src/typed/docx/shading.test.ts b/packages/ooxml.js/src/typed/docx/shading.test.ts index b5540155a..25802d737 100644 --- a/packages/ooxml.js/src/typed/docx/shading.test.ts +++ b/packages/ooxml.js/src/typed/docx/shading.test.ts @@ -66,11 +66,25 @@ describe("readCellShading", () => { it("reads a stripe/cross pattern by its own ST_Shd name", () => { const shd = el("w:shd", { "w:val": "diagCross", "w:color": "ff0000" }); - expect(readCellShading(tcPr(shd))).toEqual({ + const result = readCellShading(tcPr(shd)); + expect(result).toEqual({ kind: "pattern", patternType: "diagonalCross", foregroundColor: { r: 1, g: 0, b: 0 }, }); + // A stricter check than the toEqual above, which treats an explicit `backgroundColor: undefined` the same as the key being absent entirely: an unstated w:fill must genuinely omit the key, never spread it on with an undefined value. + expect(Object.hasOwn(result ?? {}, "backgroundColor")).toBe(false); + }); + + it("reads a pattern with only its background colour stated, genuinely omitting foregroundColor rather than spreading it on as undefined", () => { + const shd = el("w:shd", { "w:val": "diagCross", "w:fill": "0000ff" }); + const result = readCellShading(tcPr(shd)); + expect(result).toEqual({ + kind: "pattern", + patternType: "diagonalCross", + backgroundColor: { r: 0, g: 0, b: 1 }, + }); + expect(Object.hasOwn(result ?? {}, "foregroundColor")).toBe(false); }); it('reads w:val="nil" as no fill', () => { @@ -87,6 +101,16 @@ describe("readCellShading", () => { const shd = el("w:shd", { "w:val": "clear", "w:fill": "auto" }); expect(readCellShading(tcPr(shd))).toBeUndefined(); }); + + it('reads a "none" w:fill as unstated, distinctly from "auto"', () => { + const shd = el("w:shd", { "w:val": "clear", "w:fill": "none" }); + expect(readCellShading(tcPr(shd))).toBeUndefined(); + }); + + it('reads a "none" w:color as unstated for a solid-pattern fill', () => { + const shd = el("w:shd", { "w:val": "solid", "w:color": "none" }); + expect(readCellShading(tcPr(shd))).toBeUndefined(); + }); }); describe("buildCellShading", () => { @@ -127,4 +151,13 @@ describe("buildCellShading", () => { buildCellShading({ kind: "pattern", patternType: "gray125" }), ).toThrow(/gray125/); }); + + it("throws naming the actual unrecognised kind for a fill outside the 'solid'/'pattern' discriminated union entirely", () => { + // ContentCellFillSchema only ever produces 'solid' or 'pattern' through normal validated input -- this exercises the writer's own defensive default branch directly, past the type system, for a value shaped like neither. + expect(() => + buildCellShading({ kind: "gradient" } as unknown as Parameters< + typeof buildCellShading + >[0]), + ).toThrow(/gradient/); + }); }); diff --git a/packages/ooxml.js/src/typed/docx/styles.test.ts b/packages/ooxml.js/src/typed/docx/styles.test.ts index f1578976e..8a8ec2766 100644 --- a/packages/ooxml.js/src/typed/docx/styles.test.ts +++ b/packages/ooxml.js/src/typed/docx/styles.test.ts @@ -158,6 +158,16 @@ describe("resolveRunProperties: underline", () => { }).underline, ).toBe(false); }); + + it("a with no w:val at all means not underlined, unlike a toggle property's bare-presence-means-on rule", () => { + const { paragraph, run } = paragraphWithRun([], runEl([el("w:u")])); + expect( + resolveRunProperties(run, paragraph, { + stylesRoot: undefined, + theme: EMPTY_THEME, + }).underline, + ).toBe(false); + }); }); describe("resolveRunProperties: colour", () => { @@ -362,6 +372,44 @@ describe("resolveRunProperties: colour", () => { expect(color).toEqual({ r: 0.2, g: 0.4, b: 0.6 }); }); + it("rejects a themeTint byte with a non-hex character BEFORE its two valid hex digits, not just any non-hex value", () => { + const themedTheme = { + colorScheme: new Map([["accent1", { r: 0.2, g: 0.4, b: 0.6 }]]), + majorFont: "Major Font", + minorFont: "Minor Font", + }; + const { paragraph, run } = paragraphWithRun( + [], + runEl([ + el("w:color", { "w:themeColor": "accent1", "w:themeTint": "z0f" }), + ]), + ); + const color = resolveRunProperties(run, paragraph, { + stylesRoot: undefined, + theme: themedTheme, + }).color; + expect(color).toEqual({ r: 0.2, g: 0.4, b: 0.6 }); + }); + + it("rejects a themeTint byte with a non-hex character AFTER its two valid hex digits, not just a too-short value", () => { + const themedTheme = { + colorScheme: new Map([["accent1", { r: 0.2, g: 0.4, b: 0.6 }]]), + majorFont: "Major Font", + minorFont: "Minor Font", + }; + const { paragraph, run } = paragraphWithRun( + [], + runEl([ + el("w:color", { "w:themeColor": "accent1", "w:themeTint": "0fz" }), + ]), + ); + const color = resolveRunProperties(run, paragraph, { + stylesRoot: undefined, + theme: themedTheme, + }).color; + expect(color).toEqual({ r: 0.2, g: 0.4, b: 0.6 }); + }); + it("falls back to w:val when the theme colour reference does not resolve", () => { const { paragraph, run } = paragraphWithRun( [], @@ -418,6 +466,42 @@ describe("resolveRunProperties: fonts and size", () => { ).toBe("Minor Font"); }); + it("resolves an unrecognised w:asciiTheme value to no font family at all, not a false minor-font default", () => { + const { paragraph, run } = paragraphWithRun( + [], + runEl([el("w:rFonts", { "w:asciiTheme": "majorBidi" })]), + ); + expect( + resolveRunProperties(run, paragraph, { + stylesRoot: undefined, + theme: THEME, + }).fontFamily, + ).toBeUndefined(); + }); + + it("resolves majorAscii/minorAscii theme references too, not just their HAnsi spellings", () => { + const major = paragraphWithRun( + [], + runEl([el("w:rFonts", { "w:asciiTheme": "majorAscii" })]), + ); + const minor = paragraphWithRun( + [], + runEl([el("w:rFonts", { "w:asciiTheme": "minorAscii" })]), + ); + expect( + resolveRunProperties(major.run, major.paragraph, { + stylesRoot: undefined, + theme: THEME, + }).fontFamily, + ).toBe("Major Font"); + expect( + resolveRunProperties(minor.run, minor.paragraph, { + stylesRoot: undefined, + theme: THEME, + }).fontFamily, + ).toBe("Minor Font"); + }); + it("converts w:sz from half-points to points", () => { const { paragraph, run } = paragraphWithRun( [], @@ -461,6 +545,69 @@ describe("resolveRunProperties: cascade", () => { ).toBe(12); }); + it("finds the default style by BOTH its own type and w:default=1, ignoring a same-typed non-default style and a differently-typed default style", () => { + const wrongType = styleEl("CharDefault", "character", { + isDefault: true, + rPr: el("w:rPr", {}, [el("w:sz", { "w:val": "60" })]), + }); + const notDefault = styleEl("NotDefault", "paragraph", { + rPr: el("w:rPr", {}, [el("w:sz", { "w:val": "40" })]), + }); + const realDefault = styleEl("Normal", "paragraph", { + isDefault: true, + rPr: el("w:rPr", {}, [el("w:sz", { "w:val": "24" })]), + }); + const styles = stylesRoot([wrongType, notDefault, realDefault]); + const { paragraph, run } = paragraphWithRun([], runEl([])); + expect( + resolveRunProperties(run, paragraph, { + stylesRoot: styles, + theme: EMPTY_THEME, + }).sizePt, + ).toBe(12); + }); + + it("resolves a w:pStyle reference against a style of the SAME id but the WRONG type as a miss, not a match", () => { + const wrongTypeSameId = styleEl("Shared", "character", { + rPr: el("w:rPr", {}, [el("w:sz", { "w:val": "60" })]), + }); + const rightTypeSameId = styleEl("Shared", "paragraph", { + rPr: el("w:rPr", {}, [el("w:sz", { "w:val": "24" })]), + }); + const styles = stylesRoot([wrongTypeSameId, rightTypeSameId]); + const { paragraph, run } = paragraphWithRun( + [el("w:pStyle", { "w:val": "Shared" })], + runEl([]), + ); + expect( + resolveRunProperties(run, paragraph, { + stylesRoot: styles, + theme: EMPTY_THEME, + }).sizePt, + ).toBe(12); + }); + + it("inherits strike from an ancestor style when a descendant style doesn't set it", () => { + const grandparent = styleEl("Grandparent", "paragraph", { + rPr: el("w:rPr", {}, [el("w:strike")]), + }); + const parent = styleEl("Parent", "paragraph", { + basedOn: "Grandparent", + rPr: el("w:rPr", {}, [el("w:sz", { "w:val": "28" })]), + }); + const styles = stylesRoot([grandparent, parent]); + const { paragraph, run } = paragraphWithRun( + [el("w:pStyle", { "w:val": "Parent" })], + runEl([]), + ); + expect( + resolveRunProperties(run, paragraph, { + stylesRoot: styles, + theme: EMPTY_THEME, + }).strike, + ).toBe(true); + }); + it("resolves a basedOn chain root-first, so a child style overrides its ancestor", () => { const grandparent = styleEl("Grandparent", "paragraph", { rPr: el("w:rPr", {}, [el("w:sz", { "w:val": "20" }), el("w:b")]), @@ -544,6 +691,7 @@ describe("resolveParagraphProperties", () => { ["right", "right"], ["end", "right"], ["both", "justify"], + ["distribute", "justify"], ] as const) { const paragraph = paragraphEl([el("w:jc", { "w:val": val })]); expect( @@ -576,17 +724,36 @@ describe("resolveParagraphProperties", () => { }); it("ignores w:line when lineRule is exact/atLeast, since it is then an absolute height, not a multiplier", () => { - const paragraph = paragraphEl([ + const exactParagraph = paragraphEl([ el("w:spacing", { "w:line": "360", "w:lineRule": "exact" }), ]); + const atLeastParagraph = paragraphEl([ + el("w:spacing", { "w:line": "360", "w:lineRule": "atLeast" }), + ]); expect( - resolveParagraphProperties(paragraph, { + resolveParagraphProperties(exactParagraph, { + stylesRoot: undefined, + theme: EMPTY_THEME, + }).lineSpacing, + ).toBeUndefined(); + expect( + resolveParagraphProperties(atLeastParagraph, { stylesRoot: undefined, theme: EMPTY_THEME, }).lineSpacing, ).toBeUndefined(); }); + it("falls back to w:ind/@w:start when w:left is absent", () => { + const paragraph = paragraphEl([el("w:ind", { "w:start": "720" })]); + expect( + resolveParagraphProperties(paragraph, { + stylesRoot: undefined, + theme: EMPTY_THEME, + }).indentLeftPt, + ).toBe(36); + }); + it("reads w:firstLine as a positive indent and w:hanging as its negative", () => { const firstLineParagraph = paragraphEl([ el("w:ind", { "w:firstLine": "360" }), @@ -606,6 +773,22 @@ describe("resolveParagraphProperties", () => { ).toBe(-18); }); + it("the default paragraph style's own w:pPr is merged in, above docDefaults", () => { + const docDefaultsPPr = el("w:pPr", {}, [el("w:jc", { "w:val": "left" })]); + const normalStyle = styleEl("Normal", "paragraph", { + isDefault: true, + pPr: el("w:pPr", {}, [el("w:jc", { "w:val": "center" })]), + }); + const styles = stylesRoot([normalStyle], docDefaultsPPr); + const paragraph = paragraphEl([]); + expect( + resolveParagraphProperties(paragraph, { + stylesRoot: styles, + theme: EMPTY_THEME, + }).alignment, + ).toBe("center"); + }); + it("resolves the named paragraph style chain, root-first", () => { const grandparent = styleEl("Grandparent", "paragraph", { pPr: el("w:pPr", {}, [el("w:jc", { "w:val": "center" })]), diff --git a/packages/ooxml.js/src/typed/docx/styles.ts b/packages/ooxml.js/src/typed/docx/styles.ts index a9e072700..d3e1179bd 100644 --- a/packages/ooxml.js/src/typed/docx/styles.ts +++ b/packages/ooxml.js/src/typed/docx/styles.ts @@ -86,8 +86,9 @@ function readToggle(el: XmlElement | undefined): boolean | undefined { if (el === undefined) { return undefined; } + // No separate "val is absent" arm is needed: when val is genuinely undefined, each of the three comparisons below is already true on its own (undefined !== "0", etc.), so the combined check already reads absence as on. const val = attr(el, "w:val"); - return val === undefined || (val !== "0" && val !== "false" && val !== "off"); + return val !== "0" && val !== "false" && val !== "off"; } // w:u/@w:val is one of many underline styles (single/double/thick/dotted/...); "none" is the only value that means off. Unlike the toggle properties above, w:u always carries @w:val -- there's no bare-presence-means-on form. diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index 80d456808..38cb142db 100644 --- a/packages/ooxml.js/src/typed/docx/write.test.ts +++ b/packages/ooxml.js/src/typed/docx/write.test.ts @@ -9,7 +9,8 @@ import type { Package } from "../../model/package"; import type { XmlElement, XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { decodePackage, encodePackage } from "../../codec"; -import { attr, elementsWithTag, rootElement } from "../util"; +import { attr, childrenWithTag, elementsWithTag, rootElement } from "../util"; +import { ptToEmu } from "../shared/units"; import type { DocxDocument } from "./read"; import { readDocxContent } from "./read"; import { buildDocxPackageFromContent } from "./write"; @@ -247,6 +248,319 @@ describe("buildDocxPackageFromContent: package scaffolding", () => { }); }); +// A minimal one-paragraph section, for the package-scaffolding tests below that only care about the parts every document carries regardless of content. +function emptyBodySection(): ContentSection { + return { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [], + }; +} + +const DRAWINGML_MAIN_NS = + "http://schemas.openxmlformats.org/drawingml/2006/main"; + +describe("buildDocxPackageFromContent: buildDrawing's fixed XML shape", () => { + it("writes the zero offset, rect preset, distT/B/L/R zeros, and docPr id/name exactly, with alt text as descr", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 100, + heightPt: 50, + altText: "a caption", + }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const drawing = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:drawing", + )[0]; + if (drawing === undefined) { + throw new Error("expected a w:drawing element"); + } + const inline = childrenWithTag(drawing, "wp:inline")[0]; + if (inline === undefined) { + throw new Error("expected a wp:inline element"); + } + expect(attr(inline, "distT")).toBe("0"); + expect(attr(inline, "distB")).toBe("0"); + expect(attr(inline, "distL")).toBe("0"); + expect(attr(inline, "distR")).toBe("0"); + + const cx = String(ptToEmu(100)); + const cy = String(ptToEmu(50)); + const extent = childrenWithTag(inline, "wp:extent")[0]; + expect(extent === undefined ? undefined : attr(extent, "cx")).toBe(cx); + expect(extent === undefined ? undefined : attr(extent, "cy")).toBe(cy); + + const docPr = childrenWithTag(inline, "wp:docPr")[0]; + expect(docPr === undefined ? undefined : attr(docPr, "id")).toBe("1"); + expect(docPr === undefined ? undefined : attr(docPr, "name")).toBe( + "Picture 1", + ); + expect(docPr === undefined ? undefined : attr(docPr, "descr")).toBe( + "a caption", + ); + + const graphic = childrenWithTag(inline, "a:graphic")[0]; + expect(graphic === undefined ? undefined : attr(graphic, "xmlns:a")).toBe( + DRAWINGML_MAIN_NS, + ); + const graphicData = + graphic === undefined + ? undefined + : childrenWithTag(graphic, "a:graphicData")[0]; + expect( + graphicData === undefined ? undefined : attr(graphicData, "uri"), + ).toBe(PICTURE_GRAPHIC_URI); + + const pic = + graphicData === undefined + ? undefined + : childrenWithTag(graphicData, "pic:pic")[0]; + expect(pic === undefined ? undefined : attr(pic, "xmlns:pic")).toBe( + PICTURE_GRAPHIC_URI, + ); + + const nvPicPr = + pic === undefined ? undefined : childrenWithTag(pic, "pic:nvPicPr")[0]; + const cNvPr = + nvPicPr === undefined + ? undefined + : childrenWithTag(nvPicPr, "pic:cNvPr")[0]; + expect(cNvPr === undefined ? undefined : attr(cNvPr, "id")).toBe("1"); + expect(cNvPr === undefined ? undefined : attr(cNvPr, "name")).toBe( + "Picture 1", + ); + const cNvPicPr = + nvPicPr === undefined + ? undefined + : childrenWithTag(nvPicPr, "pic:cNvPicPr")[0]; + expect(cNvPicPr?.children).toEqual([]); + + const blipFill = + pic === undefined ? undefined : childrenWithTag(pic, "pic:blipFill")[0]; + const blip = + blipFill === undefined + ? undefined + : childrenWithTag(blipFill, "a:blip")[0]; + expect(blip === undefined ? undefined : attr(blip, "r:embed")).toBe("rId1"); + const stretch = + blipFill === undefined + ? undefined + : childrenWithTag(blipFill, "a:stretch")[0]; + expect( + stretch === undefined + ? undefined + : childrenWithTag(stretch, "a:fillRect")[0], + ).toBeDefined(); + + const spPr = + pic === undefined ? undefined : childrenWithTag(pic, "pic:spPr")[0]; + const xfrm = + spPr === undefined ? undefined : childrenWithTag(spPr, "a:xfrm")[0]; + const off = + xfrm === undefined ? undefined : childrenWithTag(xfrm, "a:off")[0]; + expect(off === undefined ? undefined : attr(off, "x")).toBe("0"); + expect(off === undefined ? undefined : attr(off, "y")).toBe("0"); + const ext = + xfrm === undefined ? undefined : childrenWithTag(xfrm, "a:ext")[0]; + expect(ext === undefined ? undefined : attr(ext, "cx")).toBe(cx); + expect(ext === undefined ? undefined : attr(ext, "cy")).toBe(cy); + const prstGeom = + spPr === undefined ? undefined : childrenWithTag(spPr, "a:prstGeom")[0]; + expect(prstGeom === undefined ? undefined : attr(prstGeom, "prst")).toBe( + "rect", + ); + expect( + prstGeom === undefined + ? undefined + : childrenWithTag(prstGeom, "a:avLst")[0], + ).toBeDefined(); + }); + + it("omits wp:docPr's descr attribute for an image with no alt text, and increments the drawing id for a second image", () => { + const written = buildDocxPackageFromContent({ + sections: [ + { + ...emptyBodySection(), + blocks: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + }, + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + }, + ], + }, + ], + }); + const documentRoot = rootElement(written.parts["word/document.xml"]); + const drawings = elementsWithTag( + documentRoot === undefined ? [] : [documentRoot], + "w:drawing", + ); + expect(drawings).toHaveLength(2); + const docPrs = drawings.map((drawing) => { + const inline = childrenWithTag(drawing, "wp:inline")[0]; + return inline === undefined + ? undefined + : childrenWithTag(inline, "wp:docPr")[0]; + }); + expect(docPrs[0] === undefined ? undefined : attr(docPrs[0], "id")).toBe( + "1", + ); + expect( + docPrs[0] === undefined ? undefined : attr(docPrs[0], "descr"), + ).toBeUndefined(); + expect(docPrs[1] === undefined ? undefined : attr(docPrs[1], "id")).toBe( + "2", + ); + expect(docPrs[1] === undefined ? undefined : attr(docPrs[1], "name")).toBe( + "Picture 2", + ); + }); +}); + +describe("buildDocxPackageFromContent: fixed package-scaffolding parts", () => { + it("writes _rels/.rels with exactly the three fixed package relationships, in order", () => { + const written = buildDocxPackageFromContent({ + sections: [emptyBodySection()], + }); + const root = rootElement(written.parts["_rels/.rels"]); + const rels = + root === undefined ? [] : childrenWithTag(root, "Relationship"); + expect( + rels.map((rel) => ({ + Id: attr(rel, "Id"), + Type: attr(rel, "Type"), + Target: attr(rel, "Target"), + })), + ).toEqual([ + { + Id: "rId1", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", + Target: "word/document.xml", + }, + { + Id: "rId2", + Type: "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + Target: "docProps/core.xml", + }, + { + Id: "rId3", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", + Target: "docProps/app.xml", + }, + ]); + }); + + it("writes [Content_Types].xml's fixed rels/xml Default entries and document/core/app Overrides", () => { + const written = buildDocxPackageFromContent({ + sections: [emptyBodySection()], + }); + const root = rootElement(written.parts["[Content_Types].xml"]); + const defaults = root === undefined ? [] : childrenWithTag(root, "Default"); + expect( + defaults.map((entry) => ({ + Extension: attr(entry, "Extension"), + ContentType: attr(entry, "ContentType"), + })), + ).toEqual([ + { + Extension: "rels", + ContentType: "application/vnd.openxmlformats-package.relationships+xml", + }, + { Extension: "xml", ContentType: "application/xml" }, + ]); + + const overrides = + root === undefined ? [] : childrenWithTag(root, "Override"); + const overrideFor = (partName: string): string | undefined => { + const found = overrides.find( + (entry) => attr(entry, "PartName") === partName, + ); + return found === undefined ? undefined : attr(found, "ContentType"); + }; + expect(overrideFor("/word/document.xml")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", + ); + expect(overrideFor("/docProps/core.xml")).toBe( + "application/vnd.openxmlformats-package.core-properties+xml", + ); + expect(overrideFor("/docProps/app.xml")).toBe( + "application/vnd.openxmlformats-officedocument.extended-properties+xml", + ); + expect(overrideFor("/word/styles.xml")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml", + ); + }); + + it("writes styles.xml's fixed docDefaults and Normal/DefaultParagraphFont scaffolding for a document with no named styles", () => { + const written = buildDocxPackageFromContent({ + sections: [emptyBodySection()], + }); + const root = rootElement(written.parts["word/styles.xml"]); + const docDefaults = + root === undefined + ? undefined + : childrenWithTag(root, "w:docDefaults")[0]; + expect( + docDefaults === undefined + ? undefined + : childrenWithTag(docDefaults, "w:rPrDefault")[0]?.children, + ).toEqual([]); + expect( + docDefaults === undefined + ? undefined + : childrenWithTag(docDefaults, "w:pPrDefault")[0]?.children, + ).toEqual([]); + + const styles = root === undefined ? [] : childrenWithTag(root, "w:style"); + expect( + styles.map((style) => { + const name = childrenWithTag(style, "w:name")[0]; + return { + type: attr(style, "w:type"), + default: attr(style, "w:default"), + styleId: attr(style, "w:styleId"), + name: name === undefined ? undefined : attr(name, "w:val"), + }; + }), + ).toEqual([ + { + type: "paragraph", + default: "1", + styleId: "Normal", + name: "Normal", + }, + { + type: "character", + default: "1", + styleId: "DefaultParagraphFont", + name: "Default Paragraph Font", + }, + ]); + }); +}); + describe("buildDocxPackageFromContent: content round trip", () => { it("round-trips paragraph properties, run formatting, headings, lists, and page breaks", () => { const styled = el("w:p", {}, [ diff --git a/packages/ooxml.js/src/typed/embedded.test.ts b/packages/ooxml.js/src/typed/embedded.test.ts index 3af549d1b..ac70d78f8 100644 --- a/packages/ooxml.js/src/typed/embedded.test.ts +++ b/packages/ooxml.js/src/typed/embedded.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { MAX_WALK_DEPTH } from "archive-codec"; +import { + MAX_WALK_DEPTH, + writeCompoundFile, + writeOlePackage, +} from "archive-codec"; import { unzipPackage, zipPackage } from "../zip"; import { oleObjectBin } from "../test-support/cfb"; import { @@ -7,7 +11,13 @@ import { minimalPptxBytes, minimalXlsxBytes, } from "../test-support/embedded"; -import { readEmbeddedOoxmlPayload } from "./embedded"; +import { + detectFlavour, + hasDocxBody, + readEmbeddedOoxmlPayload, +} from "./embedded"; +import { el } from "../xml/fragment"; +import { packageFromEntries } from "../package-io/read"; // Coverage for the shared embedded-object decode (src/typed/embedded.ts): nested-ZIP payload bytes -> flavour detection -> the matching typed reader -> the ContentEmbeddedObject payload (objectKind + a genuinely recovered nested ContentDocument). Fixtures come from src/test-support/embedded.ts -- real minimal OOXML packages zipped inline, because the pipeline under test unzips actual bytes (a hand-built Package value would skip the parse step entirely). @@ -71,6 +81,30 @@ describe("readEmbeddedOoxmlPayload", () => { }); }); + it("finds the 'Package' stream by its own name among several, not merely the first stream the compound file's directory tree visits", () => { + // The directory's sibling tree is name-sorted (see archive-codec's own README), so "Decoy" -- alphabetically before "Package" -- is genuinely visited first; only a check against the stream's own path, not "whichever comes first", can tell them apart. + const packageBytes = writeOlePackage({ + label: "Book1.xlsx", + sourcePath: "", + tempPath: "", + fileBytes: minimalXlsxBytes(), + }); + const bytes = writeCompoundFile([ + { path: "Decoy", bytes: enc("not a Package stream at all") }, + { path: "Package", bytes: packageBytes }, + ]); + const payload = readEmbeddedOoxmlPayload(bytes); + expect(payload?.objectKind).toBe("spreadsheet"); + const sheet = + payload?.document.kind === "spreadsheet" + ? payload.document.sheets[0] + : undefined; + expect(sheet?.cells[0]?.value).toEqual({ + kind: "string", + value: "Recovered cell", + }); + }); + it("returns undefined for a well-formed compound file carrying no Package stream (native legacy streams stay opaque)", () => { // A .bin whose CFB holds a native stream (BIFF Workbook, WordDocument, ...) rather than a Package stream: outside this recovery's scope by design, so the payload degrades to nothing without a throw. expect( @@ -91,6 +125,13 @@ describe("readEmbeddedOoxmlPayload", () => { ).toBeUndefined(); }); + it("returns undefined for bytes carrying neither the ZIP nor the compound-file magic at all", () => { + // Neither isZipArchive nor readCompoundFile's own magic check recognise this input -- the latter throws CompoundFileFormatError, which the surrounding catch degrades to undefined exactly like any other undecodable payload. + expect(readEmbeddedOoxmlPayload(enc("plain text, not an archive"))).toBe( + undefined, + ); + }); + it("returns undefined for a non-ZIP payload (the classic OLE compound file)", () => { // The OLE/CFB magic bytes -- the legacy .bin spelling of an embedded object, which no reader in this ecosystem decodes. const bytes = new Uint8Array([ @@ -123,6 +164,29 @@ describe("readEmbeddedOoxmlPayload", () => { expect(readEmbeddedOoxmlPayload(bytes)).toBeUndefined(); }); + it("uses the genuine root-level part over a same-named entry nested inside a ZIP-within-the-payload, never letting the nested one overwrite it", () => { + // A nested archive's own entries are ancestors.length > 0 -- excluded from the flattened package the outer payload's own parts build from, exactly as the walk's own root-entry set is. A decoy nested zip carrying its own "xl/workbook.xml" must never be allowed to clobber the payload's genuine root-level one. + const basePkg = unzipPackage(minimalXlsxBytes()); + const decoy = zipPackage({ + "xl/workbook.xml": enc("this is not a real workbook part at all"), + }); + const bombShaped = zipPackage({ + ...basePkg, + "word/embeddings/decoy.zip": decoy, + }); + const payload = readEmbeddedOoxmlPayload(bombShaped); + expect(payload?.objectKind).toBe("spreadsheet"); + const sheet = + payload?.document.kind === "spreadsheet" + ? payload.document.sheets[0] + : undefined; + expect(sheet?.name).toBe("Embedded"); + expect(sheet?.cells[0]?.value).toEqual({ + kind: "string", + value: "Recovered cell", + }); + }); + it("returns undefined for a payload whose entries nest ZIPs beyond archive-codec's walk depth, even when its root is a valid xlsx", () => { // The nested decode runs behind archive-codec's recursive-walk guards (a depth cap and one shared cumulative decompressed-bytes budget -- the bounded inflate this package's own fflate unzip has no equivalent of). This payload IS a valid xlsx at its root, but it also carries an entry that is a chain of ZIPs nested one level deeper than MAX_WALK_DEPTH -- the shape a decompression bomb's nesting leverage takes. A walk that hits a guard limit means the payload as a whole stands outside the guards' contract, so no embedded block is decoded from it at all; without the gateway the root flavour would decode fine and the deep chain would ride along as an inert binary part. let chain: Uint8Array = minimalXlsxBytes(); @@ -136,3 +200,49 @@ describe("readEmbeddedOoxmlPayload", () => { expect(readEmbeddedOoxmlPayload(bombShaped)).toBeUndefined(); }); }); + +describe("hasDocxBody", () => { + it("is true for a w:document root carrying a w:body child", () => { + expect(hasDocxBody(el("w:document", {}, [el("w:body")]))).toBe(true); + }); + + it("is false for a w:document root with no w:body child at all", () => { + expect(hasDocxBody(el("w:document"))).toBe(false); + }); +}); + +describe("detectFlavour", () => { + it("detects a wordprocessing flavour only when word/document.xml genuinely carries a w:body", () => { + const nested = packageFromEntries({ + "word/document.xml": new TextEncoder().encode( + "", + ), + }); + expect(detectFlavour(nested)).toBe("wordprocessing"); + }); + + it("detects no flavour for a word/document.xml with no w:body, rather than falling through to a wrong dispatch", () => { + const nested = packageFromEntries({ + "word/document.xml": new TextEncoder().encode(""), + }); + expect(detectFlavour(nested)).toBeUndefined(); + }); + + it("detects a presentation flavour from ppt/presentation.xml alone (no precondition of its own)", () => { + const nested = packageFromEntries({ + "ppt/presentation.xml": new TextEncoder().encode(""), + }); + expect(detectFlavour(nested)).toBe("presentation"); + }); + + it("detects a spreadsheet flavour from xl/workbook.xml alone (no precondition of its own)", () => { + const nested = packageFromEntries({ + "xl/workbook.xml": new TextEncoder().encode(""), + }); + expect(detectFlavour(nested)).toBe("spreadsheet"); + }); + + it("detects no flavour when none of the three entry parts is present", () => { + expect(detectFlavour(packageFromEntries({}))).toBeUndefined(); + }); +}); diff --git a/packages/ooxml.js/src/typed/embedded.ts b/packages/ooxml.js/src/typed/embedded.ts index d8569d01f..6c4489ff0 100644 --- a/packages/ooxml.js/src/typed/embedded.ts +++ b/packages/ooxml.js/src/typed/embedded.ts @@ -1,5 +1,4 @@ import { - isCompoundFile, isZipArchive, readCompoundFile, readOlePackage, @@ -17,7 +16,7 @@ import { readPptxContent } from "./pptx/read"; import { readXlsxContent } from "./xlsx/content"; import { childrenWithTag, rootElement } from "./util"; -// The shared embedded-object decode: an OOXML package's OLE embeddings (pptx's p:oleObj/@r:id target part, docx's o:OLEObject/@r:id target part) hold either a whole nested OOXML package zipped into the part's bytes (every modern producer's spelling), or a classic OLE compound-file blob (.bin) whose root storage carries the real file as an OLE-packaged 'Package' stream. This module recovers both: payload magic checked up front (archive-codec's isZipArchive and isCompoundFile -- byte checks, never a parse-and-catch), a .bin unwrapped through archive-codec's CFB reader and OLE-package parser to the ZIP a modern embed packages, the ZIP bytes walked through archive-codec's guarded recursive walk (the bounded inflate -- see readEmbeddedOoxmlPayload's own comment) with the walk's root entries assembled into a nested Package, the flavour detected from the nested package's own entry part, and the matching typed reader run to produce the nested ContentDocument that ContentEmbeddedObject.document carries. +// The shared embedded-object decode: an OOXML package's OLE embeddings (pptx's p:oleObj/@r:id target part, docx's o:OLEObject/@r:id target part) hold either a whole nested OOXML package zipped into the part's bytes (every modern producer's spelling), or a classic OLE compound-file blob (.bin) whose root storage carries the real file as an OLE-packaged 'Package' stream. This module recovers both: payload shape distinguished by archive-codec's isZipArchive (a byte check, never a parse-and-catch) with the compound-file alternative left to readCompoundFile's own equivalent magic check inside the try below, a .bin unwrapped through archive-codec's CFB reader and OLE-package parser to the ZIP a modern embed packages, the ZIP bytes walked through archive-codec's guarded recursive walk (the bounded inflate -- see readEmbeddedOoxmlPayload's own comment) with the walk's root entries assembled into a nested Package, the flavour detected from the nested package's own entry part, and the matching typed reader run to produce the nested ContentDocument that ContentEmbeddedObject.document carries. // // Flavour detection is by entry-part path, not [Content_Types].xml overrides, for two reasons: the three entry paths are exactly what the readers themselves dispatch on (readDocxContent throws without word/document.xml, readSlidePathsInOrder reads ppt/presentation.xml, resolveSheetEntries reads xl/workbook.xml), so detection by the same paths -- plus the one further precondition a reader of the three has, readDocxContent's w:body (hasDocxBody below) -- guarantees the chosen reader's precondition already holds; and the macro-enabled variants (docm/pptm/xlsm) share these exact paths -- the macro payload is an extra vbaProject.bin part, not a different entry -- so they map onto the same three content kinds with no separate case. // @@ -34,8 +33,8 @@ export interface EmbeddedOoxmlPayload { readonly document: ContentDocument; } -// readDocxContent is the only one of the three readers with a precondition beyond its entry part existing: it throws when word/document.xml carries no w:body to walk. Detection verifies that precondition up front, so a malformed nested docx degrades to no flavour at detection time rather than reaching a dispatch that would throw. The presentation and spreadsheet readers have no throw preconditions of their own. -function hasDocxBody(root: XmlElement): boolean { +// readDocxContent is the only one of the three readers with a precondition beyond its entry part existing: it throws when word/document.xml carries no w:body to walk. Detection verifies that precondition up front, so a malformed nested docx degrades to no flavour at detection time rather than reaching a dispatch that would throw. The presentation and spreadsheet readers have no throw preconditions of their own. Exported (alongside detectFlavour below) purely for direct unit coverage: readEmbeddedOoxmlPayload's own outer catch would swallow either function's own precondition mistakes just as gracefully as a genuine no-flavour result, so testing only through that public entry point cannot tell "correctly detected no flavour" apart from "wrongly detected a flavour, then threw reading it." +export function hasDocxBody(root: XmlElement): boolean { return childrenWithTag(root, "w:body").length > 0; } @@ -54,7 +53,7 @@ const ENTRY_PARTS: readonly { ]; // A real OOXML package has exactly one main document part, so at most one entry part is ever present; a fixed probe order keeps detection deterministic even for a hand-built package that somehow carries two. A row only matches when its reader's own precondition holds too, so flavour detection genuinely guarantees the chosen reader's precondition already holds and the dispatch below cannot throw for precondition reasons. -function detectFlavour(nested: Package): EmbeddedOoxmlKind | undefined { +export function detectFlavour(nested: Package): EmbeddedOoxmlKind | undefined { return ENTRY_PARTS.find((candidate) => { const root = rootElement(nested.parts[candidate.partPath]); return ( @@ -82,9 +81,7 @@ function rootEntriesOf( export function readEmbeddedOoxmlPayload( bytes: Uint8Array, ): EmbeddedOoxmlPayload | undefined { - if (!isZipArchive(bytes) && !isCompoundFile(bytes)) { - return undefined; - } + // No separate "is this even a ZIP or a compound file" gate ahead of the try below: bytes carrying neither magic reach zipBytesOfPayload, fail isZipArchive, and then fail readCompoundFile's own magic check with a thrown CompoundFileFormatError -- caught by the same catch every other undecodable payload already degrades through, so a dedicated early exit changes which line produces `undefined`, never whether the caller sees it. try { // The nested inflate runs behind archive-codec's recursive-walk guards rather than through this package's own unbounded unzip: fflate's unzipSync carries no size cap, an embeddings part is untrusted second-order bytes in which a small host entry can declare an unbounded decompressed body, and a bomb's leverage is exactly what the walk's one shared cumulative decompressed-bytes budget (MAX_WALK_TOTAL_BYTES) and depth cap bound -- the outer package parse keeps its own direct unzip because that is the file the caller chose to open. A walk that hits a guard throws (the guards truncate nothing), which the catch below degrades like any other undecodable payload; building the nested Package from the walk's own root entries (packageFromEntries) means the bytes are inflated exactly once, not once for the walk and again for the parse. const zipBytes = zipBytesOfPayload(bytes); diff --git a/packages/ooxml.js/src/typed/pptx/chart.test.ts b/packages/ooxml.js/src/typed/pptx/chart.test.ts index 715c71e5b..c4e3bddad 100644 --- a/packages/ooxml.js/src/typed/pptx/chart.test.ts +++ b/packages/ooxml.js/src/typed/pptx/chart.test.ts @@ -1,87 +1,276 @@ import { describe, expect, it } from "vitest"; -import type { XmlElement } from "../../model/node"; +import type { Box } from "document-schema.js"; import { el, txt } from "../../xml/fragment"; import { readChartResidue, readChartTable } from "./chart"; -function chartRoot(): XmlElement { - return { - type: "element", - tag: "c:chartSpace", - attributes: [], - children: [ - { type: "element", tag: "c:chart", attributes: [], children: [] }, - ], - }; +const FRAME: Box = { xPt: 0, yPt: 0, widthPt: 300, heightPt: 200 }; + +function cPt(idx: string, value: string) { + return el("c:pt", { idx }, [el("c:v", {}, [txt(value)])]); } -describe("readChartResidue", () => { - it("returns the same residue object for repeated calls against the same root, rather than re-serialising it", () => { - // Multiple graphic frames in one package can share a single relationship target, so readChartFrame hands this function the identical chartRoot instance each time -- without caching, N frames sharing one chart part would re-run buildXml N times over the same tree. - const root = chartRoot(); - const first = readChartResidue(root, "xlsx"); - const second = readChartResidue(root, "xlsx"); - expect(second).toBe(first); +function numCache(...pts: ReturnType[]) { + return el("c:numCache", {}, pts); +} + +function ser(...children: ReturnType[]) { + return el("c:ser", {}, children); +} + +function chartRootWith(...ser_: ReturnType[]) { + return el("c:chartSpace", {}, [ + el("c:chart", {}, [el("c:plotArea", {}, ser_)]), + ]); +} + +describe("readChartTable", () => { + it("returns undefined when the chart root has no at all", () => { + expect(readChartTable(el("c:chartSpace"), FRAME)).toBeUndefined(); }); - it("does not share a cache entry across two distinct chart roots", () => { - const first = readChartResidue(chartRoot(), "xlsx"); - const second = readChartResidue(chartRoot(), "xlsx"); - expect(second).not.toBe(first); - expect(second.xml).toBe(first.xml); + it("returns undefined when has no ", () => { + const chartRoot = el("c:chartSpace", {}, [el("c:chart")]); + expect(readChartTable(chartRoot, FRAME)).toBeUndefined(); }); -}); -// One bar chart with a single series, its category labels and values in the caches PowerPoint writes -// beside the data reference. -function barChartRoot(): XmlElement { - const cachedPoint = (idx: string, value: string) => - el("c:pt", { idx }, [el("c:v", {}, [txt(value)])]); - return el("c:chartSpace", {}, [ - el("c:chart", {}, [ - el("c:plotArea", {}, [ - el("c:barChart", {}, [ - el("c:ser", {}, [ - el("c:tx", {}, [ - el("c:strRef", {}, [ - el("c:strCache", {}, [cachedPoint("0", "FY26")]), - ]), - ]), - el("c:cat", {}, [ - el("c:strRef", {}, [ - el("c:strCache", {}, [ - cachedPoint("0", "EMEA"), - cachedPoint("1", "APAC"), - ]), - ]), - ]), - el("c:val", {}, [ - el("c:numRef", {}, [ - el("c:numCache", {}, [ - cachedPoint("0", "42"), - cachedPoint("1", "51"), - ]), - ]), + it("returns undefined when the plot area carries no series at all", () => { + const chartRoot = chartRootWith(); + expect(readChartTable(chartRoot, FRAME)).toBeUndefined(); + }); + + it("reads a single series' cached category/value points via c:numRef/c:numCache", () => { + const chartRoot = chartRootWith( + ser( + el("c:tx", {}, [el("c:v", {}, [txt("Series A")])]), + el("c:cat", {}, [ + el("c:numRef", {}, [numCache(cPt("0", "Jan"), cPt("1", "Feb"))]), + ]), + el("c:val", {}, [ + el("c:numRef", {}, [numCache(cPt("0", "10"), cPt("1", "20"))]), + ]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + expect(table?.origin).toBe("chart"); + expect(table?.rows).toEqual([ + { + cells: [ + { blocks: [] }, + { blocks: [{ kind: "paragraph", runs: [{ text: "Series A" }] }] }, + ], + }, + { + cells: [ + { blocks: [{ kind: "paragraph", runs: [{ text: "Jan" }] }] }, + { blocks: [{ kind: "paragraph", runs: [{ text: "10" }] }] }, + ], + }, + { + cells: [ + { blocks: [{ kind: "paragraph", runs: [{ text: "Feb" }] }] }, + { blocks: [{ kind: "paragraph", runs: [{ text: "20" }] }] }, + ], + }, + ]); + }); + + it("splits the frame width evenly across every column (category + one per series)", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [el("c:numRef", {}, [numCache(cPt("0", "A"))])]), + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "1"))])]), + ), + ); + const table = readChartTable(chartRoot, { ...FRAME, widthPt: 400 }); + expect(table?.columnWidthsPt).toEqual([200, 200]); + }); + + it("sorts category indexes NUMERICALLY, not lexicographically or in insertion order", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [ + el("c:numRef", {}, [ + numCache(cPt("10", "ten"), cPt("2", "two"), cPt("1", "one")), + ]), + ]), + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "x"))])]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + // Row 0 is the header; rows 1.. follow in ascending numeric index order: 1, 2, 10. + const categoryLabels = table?.rows + .slice(1) + .map((row) => row.cells[0]?.blocks[0]); + expect(categoryLabels).toEqual([ + { kind: "paragraph", runs: [{ text: "one" }] }, + { kind: "paragraph", runs: [{ text: "two" }] }, + { kind: "paragraph", runs: [{ text: "ten" }] }, + ]); + }); + + it("keeps the FIRST series' category label at a shared index, not a later series' overwrite", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [el("c:numRef", {}, [numCache(cPt("0", "first"))])]), + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "1"))])]), + ), + ser( + el("c:cat", {}, [el("c:numRef", {}, [numCache(cPt("0", "second"))])]), + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "2"))])]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + expect(table?.rows[1]?.cells[0]).toEqual({ + blocks: [{ kind: "paragraph", runs: [{ text: "first" }] }], + }); + }); + + it("reads a scatter series' c:xVal/c:yVal as the category/value axes", () => { + const chartRoot = chartRootWith( + ser( + el("c:xVal", {}, [el("c:numRef", {}, [numCache(cPt("0", "1.5"))])]), + el("c:yVal", {}, [el("c:numRef", {}, [numCache(cPt("0", "2.5"))])]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + expect(table?.rows[1]?.cells).toEqual([ + { blocks: [{ kind: "paragraph", runs: [{ text: "1.5" }] }] }, + { blocks: [{ kind: "paragraph", runs: [{ text: "2.5" }] }] }, + ]); + }); + + it("prefers c:cat over c:xVal when a series carries both", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [el("c:numRef", {}, [numCache(cPt("0", "cat"))])]), + el("c:xVal", {}, [el("c:numRef", {}, [numCache(cPt("0", "xval"))])]), + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "1"))])]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + expect(table?.rows[1]?.cells[0]).toEqual({ + blocks: [{ kind: "paragraph", runs: [{ text: "cat" }] }], + }); + }); + + it("reads a series name from a cached string reference when c:tx has no inline c:v", () => { + const chartRoot = chartRootWith( + ser( + el("c:tx", {}, [ + el("c:strRef", {}, [el("c:strCache", {}, [cPt("0", "Cached Name")])]), + ]), + el("c:cat", {}, [el("c:numRef", {}, [numCache(cPt("0", "A"))])]), + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "1"))])]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + expect(table?.rows[0]?.cells[1]).toEqual({ + blocks: [{ kind: "paragraph", runs: [{ text: "Cached Name" }] }], + }); + }); + + it("reads a genuinely empty-string cached value the same as an absent one", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [el("c:numRef", {}, [numCache(cPt("0", ""))])]), + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "1"))])]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + expect(table?.rows[1]?.cells[0]).toEqual({ blocks: [] }); + }); + + it("reads no series name at all as an empty header cell, not a literal 'undefined'", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [el("c:numRef", {}, [numCache(cPt("0", "A"))])]), + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "1"))])]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + expect(table?.rows[0]?.cells[1]).toEqual({ blocks: [] }); + }); + + it("reads the deepest (LAST) level of a multi-level cached string reference, not merely the second", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [ + el("c:multiLvlStrRef", {}, [ + el("c:multiLvlStrCache", {}, [ + el("c:lvl", {}, [cPt("0", "level-0")]), + el("c:lvl", {}, [cPt("0", "level-1")]), + el("c:lvl", {}, [cPt("0", "level-2")]), ]), ]), ]), - ]), - ]), - ]); -} + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "1"))])]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + expect(table?.rows[1]?.cells[0]).toEqual({ + blocks: [{ kind: "paragraph", runs: [{ text: "level-2" }] }], + }); + }); -describe("readChartTable", () => { - it('marks the table it produces as origin "chart"', () => { - // A ContentTable is a native table, a chart's cached data, or a spreadsheet range, and a consumer - // holding one cannot otherwise tell which. It matters: a chart's cached numbers are exact and - // quotable, where a vision reading of the same chart would be approximate -- so the two have to be - // distinguishable by something other than a consumer's guess. - const table = readChartTable(barChartRoot(), { - xPt: 0, - yPt: 0, - widthPt: 400, - heightPt: 300, + it("reads points sitting directly on the source itself when no ref/cache wrapper exists", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [cPt("0", "inline-cat")]), + el("c:val", {}, [cPt("0", "inline-val")]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + expect(table?.rows[1]?.cells).toEqual([ + { blocks: [{ kind: "paragraph", runs: [{ text: "inline-cat" }] }] }, + { blocks: [{ kind: "paragraph", runs: [{ text: "inline-val" }] }] }, + ]); + }); + + it("skips a c:pt with no idx or no c:v child, rather than crashing or fabricating a point", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [ + el("c:numRef", {}, [ + el("c:numCache", {}, [ + el("c:pt", {}, [el("c:v", {}, [txt("no-idx")])]), + el("c:pt", { idx: "1" }, []), + cPt("0", "kept"), + ]), + ]), + ]), + el("c:val", {}, [el("c:numRef", {}, [numCache(cPt("0", "1"))])]), + ), + ); + const table = readChartTable(chartRoot, FRAME); + // Only index 0 ("kept") should have made it through -- the header row plus exactly one data row. + expect(table?.rows).toHaveLength(2); + expect(table?.rows[1]?.cells[0]).toEqual({ + blocks: [{ kind: "paragraph", runs: [{ text: "kept" }] }], }); + }); +}); - expect(table?.origin).toBe("chart"); +describe("readChartResidue", () => { + it("serialises the whole chart root as xml residue of the given format", () => { + const chartRoot = el("c:chartSpace", { "xmlns:c": "urn:example" }, []); + const residue = readChartResidue(chartRoot, "pptx"); + expect(residue.format).toBe("pptx"); + expect(residue.xml).toContain("c:chartSpace"); + }); + + it("caches by the chart root's own object identity, returning the SAME residue for the same element", () => { + const chartRoot = el("c:chartSpace", {}, []); + const first = readChartResidue(chartRoot, "xlsx"); + const second = readChartResidue(chartRoot, "xlsx"); + expect(second).toBe(first); + }); + + it("does not share a cache entry between two distinct chart root elements, even if structurally identical", () => { + const a = el("c:chartSpace", {}, []); + const b = el("c:chartSpace", {}, []); + const residueA = readChartResidue(a, "pptx"); + const residueB = readChartResidue(b, "pptx"); + expect(residueB).not.toBe(residueA); + expect(residueB).toEqual(residueA); }); }); diff --git a/packages/ooxml.js/src/typed/pptx/diagram.test.ts b/packages/ooxml.js/src/typed/pptx/diagram.test.ts index 2cc18eb2f..c6843588f 100644 --- a/packages/ooxml.js/src/typed/pptx/diagram.test.ts +++ b/packages/ooxml.js/src/typed/pptx/diagram.test.ts @@ -1,67 +1,338 @@ import { describe, expect, it } from "vitest"; -import type { XmlElement } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { readDiagramResidue, readDiagramText } from "./diagram"; -function part(tag: string): XmlElement { - return { type: "element", tag, attributes: [], children: [] }; +function txBody(...paragraphs: ReturnType[]) { + return el("dgm:t", {}, paragraphs); } -describe("readDiagramResidue", () => { - it("returns the same residue object for repeated calls against the same triple of roots", () => { - // Multiple graphic frames can share one diagram's layout/quickStyle/colour relationship targets, so this must cache by object identity the same way readChartResidue does. - const layout = part("dgm:relIds"); - const quickStyle = part("dgm:styleData"); - const colors = part("dgm:colorsDef"); - const first = readDiagramResidue(layout, quickStyle, colors); - const second = readDiagramResidue(layout, quickStyle, colors); - expect(second).toBe(first); - }); +function run(text: string) { + return el("a:r", {}, [el("a:t", {}, [txt(text)])]); +} - it("distinguishes triples that share some but not all roots", () => { - const layout = part("dgm:relIds"); - const quickStyleA = part("dgm:styleData"); - const quickStyleB = part("dgm:styleData"); - const colors = part("dgm:colorsDef"); - const first = readDiagramResidue(layout, quickStyleA, colors); - const second = readDiagramResidue(layout, quickStyleB, colors); - expect(second).not.toBe(first); - }); +function pt( + modelId: string, + type: string | undefined, + body?: ReturnType, +) { + return el( + "dgm:pt", + type === undefined ? { modelId } : { modelId, type }, + body === undefined ? [] : [body], + ); +} - it("returns undefined, uncached, when every part is absent", () => { - expect(readDiagramResidue(undefined, undefined, undefined)).toBeUndefined(); - }); -}); +function cxn( + srcId: string, + destId: string, + opts: { type?: string; srcOrd?: string } = {}, +) { + const attrs: Record = { srcId, destId }; + if (opts.type !== undefined) { + attrs.type = opts.type; + } + if (opts.srcOrd !== undefined) { + attrs.srcOrd = opts.srcOrd; + } + return el("dgm:cxn", attrs); +} -// A two-node data model: a doc root, two content nodes, and the parOf connections making it a tree. -function dataModelRoot(): XmlElement { - const point = (id: string, text: string, type?: string) => - el("dgm:pt", type === undefined ? { modelId: id } : { modelId: id, type }, [ - el("dgm:t", {}, [ - el("a:p", {}, [el("a:r", {}, [el("a:t", {}, [txt(text)])])]), - ]), - ]); - const cxn = (srcId: string, destId: string, srcOrd: string) => - el("dgm:cxn", { srcId, destId, type: "parOf", srcOrd }); +function dataModel( + points: ReturnType[], + cxns: ReturnType[] = [], +) { return el("dgm:dataModel", {}, [ - el("dgm:ptLst", {}, [ - point("root", "", "doc"), - point("a", "Ad hoc"), - point("b", "Repeatable"), - ]), - el("dgm:cxnLst", {}, [cxn("root", "a", "0"), cxn("root", "b", "1")]), + el("dgm:ptLst", {}, points), + el("dgm:cxnLst", {}, cxns), ]); } describe("readDiagramText", () => { - it('marks every node paragraph as origin "diagram"', () => { - // SmartArt node text reaches the model as ordinary paragraphs, so nothing otherwise distinguishes a - // process flow's step labels from body prose -- and they are not the same thing: the relationships - // between the nodes (the arrows, the hierarchy) are not recovered, which a consumer reading them as - // prose needs to know. - const paragraphs = readDiagramText(dataModelRoot()); - - expect(paragraphs.length).toBeGreaterThan(0); - expect(paragraphs.every((p) => p.origin === "diagram")).toBe(true); + it("returns no paragraphs when the data model has no at all", () => { + expect(readDiagramText(el("dgm:dataModel"))).toEqual([]); + }); + + it("returns no paragraphs when no point is typed 'doc'", () => { + const model = dataModel([ + pt("1", "node", txBody(el("a:p", {}, [run("hi")]))), + ]); + expect(readDiagramText(model)).toEqual([]); + }); + + it("reads a single node's own text as a paragraph, walked from the doc root", () => { + const model = dataModel( + [ + pt("doc", "doc"), + pt("n1", "node", txBody(el("a:p", {}, [run("Hello")]))), + ], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toEqual([ + { kind: "paragraph", origin: "diagram", runs: [{ text: "Hello" }] }, + ]); + }); + + it("reads an 'asst' point's text just like a 'node' point", () => { + const model = dataModel( + [ + pt("doc", "doc"), + pt("n1", "asst", txBody(el("a:p", {}, [run("Aside")]))), + ], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toEqual([ + { kind: "paragraph", origin: "diagram", runs: [{ text: "Aside" }] }, + ]); + }); + + it("skips a parTrans/sibTrans/pres point's text -- only node and asst carry real content", () => { + const model = dataModel( + [ + pt("doc", "doc"), + pt("n1", "parTrans", txBody(el("a:p", {}, [run("connector text")]))), + ], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toEqual([]); + }); + + it("reads an a:fld the same way as an a:r", () => { + const model = dataModel( + [ + pt("doc", "doc"), + pt( + "n1", + "node", + txBody( + el("a:p", {}, [ + el("a:fld", {}, [el("a:t", {}, [txt("Field text")])]), + ]), + ), + ), + ], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toEqual([ + { kind: "paragraph", origin: "diagram", runs: [{ text: "Field text" }] }, + ]); + }); + + it("reads a run with no as empty text, not a crash", () => { + const model = dataModel( + [pt("doc", "doc"), pt("n1", "node", txBody(el("a:p", {}, [el("a:r")])))], + [cxn("doc", "n1")], + ); + // The node has one run whose text is "" -- since no run is non-empty, the paragraph is dropped entirely (see the "only pushes paragraphs" test below), so this specific node contributes nothing. + expect(readDiagramText(model)).toEqual([]); + }); + + it("contributes nothing for a paragraph child that is neither a:r/a:fld nor a:br", () => { + const model = dataModel( + [ + pt( + "n1", + "node", + txBody(el("a:p", {}, [run("real"), el("a:endParaRPr"), run("text")])), + ), + pt("doc", "doc"), + ], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toEqual([ + { + kind: "paragraph", + origin: "diagram", + runs: [{ text: "real" }, { text: "text" }], + }, + ]); + }); + + it("reads an a:br as a literal newline run", () => { + const model = dataModel( + [ + pt( + "n1", + "node", + txBody(el("a:p", {}, [run("line one"), el("a:br"), run("line two")])), + ), + pt("doc", "doc"), + ], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toEqual([ + { + kind: "paragraph", + origin: "diagram", + runs: [{ text: "line one" }, { text: "\n" }, { text: "line two" }], + }, + ]); + }); + + it("keeps every paragraph of a node once ANY of its runs is non-empty, blank paragraphs included", () => { + const model = dataModel( + [ + pt("doc", "doc"), + pt( + "n1", + "node", + txBody(el("a:p", {}, [run("")]), el("a:p", {}, [run("real text")])), + ), + ], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toEqual([ + { kind: "paragraph", origin: "diagram", runs: [{ text: "" }] }, + { kind: "paragraph", origin: "diagram", runs: [{ text: "real text" }] }, + ]); + }); + + it("drops a node whose runs are ALL empty text, contributing nothing", () => { + const model = dataModel( + [pt("doc", "doc"), pt("n1", "node", txBody(el("a:p", {}, [run("")])))], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toEqual([]); + }); + + it("orders siblings by srcOrd, not document order", () => { + const model = dataModel( + [ + pt("doc", "doc"), + pt("n1", "node", txBody(el("a:p", {}, [run("first")]))), + pt("n2", "node", txBody(el("a:p", {}, [run("second")]))), + ], + [cxn("doc", "n2", { srcOrd: "1" }), cxn("doc", "n1", { srcOrd: "0" })], + ); + expect(readDiagramText(model).map((p) => p.runs[0]?.text)).toEqual([ + "first", + "second", + ]); + }); + + it("sorts a missing srcOrd as zero, ordering it before an explicit later one", () => { + const model = dataModel( + [ + pt("doc", "doc"), + pt("n1", "node", txBody(el("a:p", {}, [run("no-ord")]))), + pt("n2", "node", txBody(el("a:p", {}, [run("ord-5")]))), + ], + [cxn("doc", "n2", { srcOrd: "5" }), cxn("doc", "n1")], + ); + expect(readDiagramText(model).map((p) => p.runs[0]?.text)).toEqual([ + "no-ord", + "ord-5", + ]); + }); + + it("walks depth-first: a child's own subtree is fully visited before its next sibling", () => { + const model = dataModel( + [ + pt("doc", "doc"), + pt("n1", "node", txBody(el("a:p", {}, [run("n1")]))), + pt("n1a", "node", txBody(el("a:p", {}, [run("n1a")]))), + pt("n2", "node", txBody(el("a:p", {}, [run("n2")]))), + ], + [ + cxn("doc", "n1", { srcOrd: "0" }), + cxn("doc", "n2", { srcOrd: "1" }), + cxn("n1", "n1a", { srcOrd: "0" }), + ], + ); + expect(readDiagramText(model).map((p) => p.runs[0]?.text)).toEqual([ + "n1", + "n1a", + "n2", + ]); + }); + + it("treats a cxn with no type attribute as parOf (its own ST_CxnType default)", () => { + const model = dataModel( + [pt("doc", "doc"), pt("n1", "node", txBody(el("a:p", {}, [run("x")])))], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toHaveLength(1); + }); + + it("skips a non-parOf cxn (presOf/presParOf), never walking through it", () => { + const model = dataModel( + [pt("doc", "doc"), pt("n1", "node", txBody(el("a:p", {}, [run("x")])))], + [cxn("doc", "n1", { type: "presOf" })], + ); + expect(readDiagramText(model)).toEqual([]); + }); + + it("skips a cxn missing srcId or destId", () => { + const model = dataModel( + [pt("doc", "doc"), pt("n1", "node", txBody(el("a:p", {}, [run("x")])))], + [el("dgm:cxn", { srcId: "doc" }), el("dgm:cxn", { destId: "n1" })], + ); + expect(readDiagramText(model)).toEqual([]); + }); + + it("skips a with no modelId, never registering it", () => { + const model = dataModel([ + el("dgm:pt", { type: "doc" }, []), + pt("n1", "node", txBody(el("a:p", {}, [run("x")]))), + ]); + // No modelId means no docModelId is ever set, so the walk never starts. + expect(readDiagramText(model)).toEqual([]); + }); + + it("defaults an untyped point to 'node' (ST_PtType's own default)", () => { + const model = dataModel( + [ + pt("doc", "doc"), + pt("n1", undefined, txBody(el("a:p", {}, [run("x")]))), + ], + [cxn("doc", "n1")], + ); + expect(readDiagramText(model)).toEqual([ + { kind: "paragraph", origin: "diagram", runs: [{ text: "x" }] }, + ]); + }); + + it("never visits the same point twice, protecting against a self-referential or cyclic cxn graph", () => { + const model = dataModel( + [pt("doc", "doc"), pt("n1", "node", txBody(el("a:p", {}, [run("x")])))], + [cxn("doc", "n1"), cxn("n1", "doc")], + ); + // Without the visited guard this would recurse forever; with it, "x" is read exactly once. + expect(readDiagramText(model)).toEqual([ + { kind: "paragraph", origin: "diagram", runs: [{ text: "x" }] }, + ]); + }); +}); + +describe("readDiagramResidue", () => { + it("returns undefined when all three parts are absent", () => { + expect(readDiagramResidue(undefined, undefined, undefined)).toBeUndefined(); + }); + + it("quarantines whichever of layout/quickStyle/colours parts actually resolved, in that order", () => { + const layout = el("dsp:dataModel", { id: "layout" }); + const colors = el("cs:colorsDefinition", { id: "colors" }); + const residue = readDiagramResidue(layout, undefined, colors); + expect(residue?.format).toBe("pptx"); + const layoutIndex = residue?.xml.indexOf("layout") ?? -1; + const colorsIndex = residue?.xml.indexOf("colors") ?? -1; + expect(layoutIndex).toBeGreaterThanOrEqual(0); + expect(colorsIndex).toBeGreaterThan(layoutIndex); + }); + + it("caches by the exact (layout, quickStyle, colors) triple's own identity", () => { + const layout = el("dsp:dataModel"); + const quickStyle = el("qs:styleDefinition"); + const first = readDiagramResidue(layout, quickStyle, undefined); + const second = readDiagramResidue(layout, quickStyle, undefined); + expect(second).toBe(first); + }); + + it("does not collide two different triples sharing a partially-overlapping key", () => { + const layout = el("dsp:dataModel"); + const colorsA = el("cs:colorsDefinition", { id: "a" }); + const colorsB = el("cs:colorsDefinition", { id: "b" }); + const residueA = readDiagramResidue(layout, undefined, colorsA); + const residueB = readDiagramResidue(layout, undefined, colorsB); + expect(residueA).not.toEqual(residueB); }); }); diff --git a/packages/ooxml.js/src/typed/pptx/inherit.test.ts b/packages/ooxml.js/src/typed/pptx/inherit.test.ts index 3e00b4616..681906022 100644 --- a/packages/ooxml.js/src/typed/pptx/inherit.test.ts +++ b/packages/ooxml.js/src/typed/pptx/inherit.test.ts @@ -2,9 +2,11 @@ import type { Package } from "../../model/package"; import type { XmlElement } from "../../model/node"; import { describe, expect, it } from "vitest"; import { el } from "../../xml/fragment"; +import { EMPTY_THEME } from "../shared/drawingml"; import { findMatchingPlaceholder, readPlaceholderKey, + readRunPropertiesFromElement, resolveDefaultRunProperties, resolvePlaceholderXfrm, resolveSlideInheritance, @@ -194,6 +196,29 @@ describe("resolveSlideInheritance", () => { expect(context.colorMap.get("tx1")).toBe("dk1"); }); + it("finds the slideLayout relationship by its own type suffix, not merely the first relationship listed", () => { + const pkg = buildFixturePackage(); + pkg.parts["ppt/slides/_rels/slide1.xml.rels"] = { + kind: "xml", + nodes: [ + rels([ + { + id: "rId0", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide", + target: "../notesSlides/notesSlide1.xml", + }, + { + id: "rId1", + type: SLIDE_LAYOUT_REL, + target: "../slideLayouts/slideLayout1.xml", + }, + ]), + ], + }; + const context = resolveSlideInheritance(pkg, "ppt/slides/slide1.xml"); + expect(context.layoutRoot?.tag).toBe("p:sldLayout"); + }); + it("degrades to undefined roots and an empty theme when the slide has no layout relationship", () => { const pkg: Package = { parts: { "ppt/slides/slide1.xml": { kind: "xml", nodes: [el("p:sld")] } }, @@ -267,6 +292,33 @@ describe("findMatchingPlaceholder", () => { findMatchingPlaceholder(root, { type: "title", idx: undefined }), ).toBeUndefined(); }); + + it("falls back to matching by type when an idx is given but no shape carries it", () => { + // key.idx names a shape nothing in root actually has -- the idx branch must not short-circuit to "no match" on that alone, since a genuine type match still exists to fall back to. + const root = el("p:sldLayout", {}, [ + el("p:cSld", {}, [ + el("p:spTree", {}, [placeholderShape({ type: "title" })]), + ]), + ]); + const match = findMatchingPlaceholder(root, { type: "title", idx: "99" }); + if (match === undefined) { + throw new Error("expected a match"); + } + expect(readPlaceholderKey(match)).toEqual({ + type: "title", + idx: undefined, + }); + }); + + it("returns undefined, rather than an untyped shape, when the key names neither an idx nor a type", () => { + // A shape with no p:ph type attribute at all also normalizes to an undefined type -- the function must still refuse to treat "no type to match" as a match against "no type on the shape", since that is not what the caller asked for. + const root = el("p:sldLayout", {}, [ + el("p:cSld", {}, [el("p:spTree", {}, [placeholderShape({})])]), + ]); + expect( + findMatchingPlaceholder(root, { type: undefined, idx: undefined }), + ).toBeUndefined(); + }); }); describe("resolvePlaceholderXfrm", () => { @@ -323,6 +375,31 @@ describe("resolvePlaceholderXfrm", () => { }); }); +describe("readRunPropertiesFromElement", () => { + const context = { + layoutRoot: undefined, + masterRoot: undefined, + theme: EMPTY_THEME, + colorMap: new Map(), + }; + + it("leaves sizePt/bold/italic undefined for an element carrying none of sz/b/i at all", () => { + const props = readRunPropertiesFromElement(el("a:rPr"), context); + expect(props.sizePt).toBeUndefined(); + expect(props.bold).toBeUndefined(); + expect(props.italic).toBeUndefined(); + }); + + it("resolves bold/italic to false for an explicit '0', not just for an absent attribute", () => { + const props = readRunPropertiesFromElement( + el("a:rPr", { b: "0", i: "0" }), + context, + ); + expect(props.bold).toBe(false); + expect(props.italic).toBe(false); + }); +}); + describe("resolveDefaultRunProperties", () => { it("resolves size, bold, theme font, and theme colour from the title style", () => { const pkg = buildFixturePackage(); @@ -363,4 +440,25 @@ describe("resolveDefaultRunProperties", () => { }; expect(resolveDefaultRunProperties("title", 0, context)).toEqual({}); }); + + it("falls back to the otherStyle level for a placeholder type that is neither title nor body", () => { + const pkg = buildFixturePackage(); + const context = resolveSlideInheritance(pkg, "ppt/slides/slide1.xml"); + expect(resolveDefaultRunProperties(undefined, 0, context).sizePt).toBe(12); + }); + + it("clamps a negative level to 0, resolving the identical style level 0 itself would", () => { + const pkg = buildFixturePackage(); + const context = resolveSlideInheritance(pkg, "ppt/slides/slide1.xml"); + expect(resolveDefaultRunProperties("title", -1, context).sizePt).toBe(44); + }); + + it("clamps a level above 8 down to 8, never wrapping back to an earlier level's own style", () => { + // The fixture master defines only a:lvl1pPr -- a level clamped down to 0 instead of up to 8 would wrongly resolve it. + const pkg = buildFixturePackage(); + const context = resolveSlideInheritance(pkg, "ppt/slides/slide1.xml"); + expect( + resolveDefaultRunProperties("title", 20, context).sizePt, + ).toBeUndefined(); + }); }); diff --git a/packages/ooxml.js/src/typed/pptx/read.test.ts b/packages/ooxml.js/src/typed/pptx/read.test.ts index 8756c00f4..88d0ca77b 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 da4196fc2..b92779f06 100644 --- a/packages/ooxml.js/src/typed/pptx/reading-order.test.ts +++ b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts @@ -70,6 +70,18 @@ describe("assignReadingOrder", () => { expect(order(shapes)).toEqual(["r1c1", "r1c2", "r2c1", "r2c2"]); }); + it("breaks an EXACT tie between the two axes' relative gaps in favour of rows", () => { + // A symmetric grid (square boxes, an identical gap on both axes) makes the column ratio and row ratio come out exactly equal, not merely close -- a >= comparison would wrongly treat this as "columns win" and read down each column first, producing r1c1, r2c1, r1c2, r2c2 instead. + const shapes = [ + shape("r1c1", 0, 0, 100, 100), + shape("r1c2", 150, 0, 100, 100), + shape("r2c1", 0, 150, 100, 100), + shape("r2c2", 150, 150, 100, 100), + ]; + + expect(order(shapes)).toEqual(["r1c1", "r1c2", "r2c1", "r2c2"]); + }); + it("recurses, so a column's own internal rows are ordered within that column", () => { const shapes = [ shape("left-bottom", 40, 300, 300, 80), @@ -80,9 +92,37 @@ describe("assignReadingOrder", () => { expect(order(shapes)).toEqual(["left-top", "left-bottom", "right"]); }); + it("recurses into each row, so a row's own internal columns are ordered within that row", () => { + // Each row's own two shapes overlap slightly in y (a right-hand shape a touch higher than its left-hand neighbour), so a flat sort of the whole set by y would read right-before-left within a row -- only cutting each row out FIRST, then ordering left-to-right inside it, gets this right. + const shapes = [ + shape("r1-right", 300, 40, 100, 100), + shape("r1-left", 0, 50, 100, 100), + shape("r2-left", 0, 400, 100, 100), + shape("r2-right", 300, 410, 100, 100), + ]; + + expect(order(shapes)).toEqual([ + "r1-left", + "r1-right", + "r2-left", + "r2-right", + ]); + }); + + it("computes an axis's extent as its true span, not the sum of its earliest start and latest end", () => { + // x stays near zero (so a start+end sum barely differs from a real end-start span there), while y is pushed far from zero -- large enough that summing y's own start and end, instead of subtracting, shrinks the vertical ratio to near nothing. The horizontal and vertical gaps are otherwise identical, so the correct (subtracting) computation ties them and breaks the tie in favour of rows; a summing bug would instead make the corrupted vertical ratio lose outright, flipping the result to columns. + const shapes = [ + shape("r1c1", 0, 100000, 100, 100), + shape("r1c2", 150, 100000, 100, 100), + shape("r2c1", 0, 100150, 100, 100), + shape("r2c2", 150, 100150, 100, 100), + ]; + + expect(order(shapes)).toEqual(["r1c1", "r1c2", "r2c1", "r2c2"]); + }); + it("falls back to topmost-then-leftmost for shapes that overlap on both axes", () => { - // Neither axis has a band of empty space crossing the whole set, so no cut is possible. A total - // order (y, then x) keeps the result deterministic rather than dependent on input order. + // Neither axis has a band of empty space crossing the whole set, so no cut is possible. A total order (y, then x) keeps the result deterministic rather than dependent on input order. const shapes = [ shape("lower", 100, 200, 400, 300), shape("upper", 60, 60, 400, 300), @@ -92,11 +132,68 @@ describe("assignReadingOrder", () => { expect(order([...shapes].reverse())).toEqual(["upper", "lower"]); }); + it("sorts overlapping shapes by y even when doing so runs against their own x order", () => { + // "topmost" is the primary key: this shape is higher up (smaller y) but sits further right (larger x) than the other, so a comparator that let the x term leak into a y-differing comparison would put them in the wrong order. + const shapes = [ + shape("topmost-but-rightmost", 200, 0, 300, 300), + shape("bottommost-but-leftmost", 0, 100, 300, 300), + ]; + + expect(order(shapes)).toEqual([ + "topmost-but-rightmost", + "bottommost-but-leftmost", + ]); + }); + + it("breaks a genuine y-tie by x, leftmost first", () => { + const shapes = [ + shape("right", 100, 0, 300, 300), + shape("left", 0, 0, 300, 300), + ]; + + expect(order(shapes)).toEqual(["left", "right"]); + }); + it("leaves a single shape, or none, alone", () => { expect(order([])).toEqual([]); expect(order([shape("only", 10, 10, 10, 10)])).toEqual(["only"]); }); + it("does not treat two shapes touching exactly at a shared boundary as a gap", () => { + // X and Y share a boundary on the vertical axis with zero space between them (X ends at y=100 exactly where Y starts) -- a real gap requires a strictly positive distance, not merely non-overlap, or this touching pair would wrongly be split into two separate rows before Z's own genuine gap is even considered. Grouped correctly as one row, [X, Y] recurses and finds a genuine horizontal gap between them, reading Y (left) before X (right); split incorrectly into two rows, they would instead read in their row order, X then Y. + const shapes = [ + shape("x", 100, 0, 100, 100), + shape("y", 0, 100, 50, 50), + shape("z", 0, 300, 100, 100), + ]; + + expect(order(shapes)).toEqual(["y", "x", "z"]); + }); + + it("measures a gap as the true distance between shapes, not their start plus the reach before them", () => { + // Vertically, A sits a mere 10pt below a very tall preceding reach (1000pt), so summing start and reach instead of subtracting would inflate that gap into easily the largest ratio in the whole comparison -- wrongly making rows the winning axis even though the real vertical gap is tiny next to the real horizontal one. A is placed above-right and B below-left so that choosing the wrong axis (rows, sorted top to bottom) reverses their order from the correct one (columns, sorted left to right). + const shapes = [shape("a", 0, 1010, 50, 40), shape("b", 80, 0, 50, 1000)]; + + 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/pptx/reading-order.ts b/packages/ooxml.js/src/typed/pptx/reading-order.ts index a2f50541e..437bde000 100644 --- a/packages/ooxml.js/src/typed/pptx/reading-order.ts +++ b/packages/ooxml.js/src/typed/pptx/reading-order.ts @@ -56,16 +56,14 @@ const end = (frame: Box, axis: Axis): number => // // Ties, including the degenerate case where a set has no extent on an axis, go to rows: the ordinary // top-to-bottom reading of a slide with no column structure. +// No separate "0 or 1 shapes" early return is needed: with at most one shape, splitOnGap on either axis produces a single group and a zero widestGap, so both ratios below are 0, neither `> 1` group-count check can pass, and the function falls through to the final sort -- a no-op on an array that short -- returning the input untouched, exactly what an early return would have done. +// No separate "columns.groups.length > 1" guard is needed alongside the ratio comparison below: splitOnGap only ever raises widestGap above 0 by actually pushing a second group (a split happens exactly when a positive gap is found), so a widestGap of 0 always pairs with exactly one group and a ratio of 0 -- meaning the ratio comparison can only come out true when columns.groups.length is already at least 2. function cut(shapes: ContentShape[]): ContentShape[] { - if (shapes.length <= 1) { - return shapes; - } const rows = splitOnGap(shapes, "vertical"); const columns = splitOnGap(shapes, "horizontal"); if ( ratio(columns.widestGap, extentAlong(shapes, "horizontal")) > - ratio(rows.widestGap, extentAlong(shapes, "vertical")) && - columns.groups.length > 1 + ratio(rows.widestGap, extentAlong(shapes, "vertical")) ) { return columns.groups.flatMap(cut); } @@ -87,10 +85,9 @@ function extentAlong(shapes: readonly ContentShape[], axis: Axis): number { return Math.max(...ends) - Math.min(...starts); } -// A gap as a fraction of the extent it sits in; zero when there is no extent to measure it against, so -// such an axis never wins a comparison. +// A gap as a fraction of the extent it sits in. No "extent === 0" guard is needed: extentAlong being exactly 0 forces every shape passed to it to share the same single point on this axis (see its own derivation above), which in turn forces every gap splitOnGap can find on that axis to be exactly 0 too -- so the only way this divides 0 by 0 is a case where the un-guarded result (NaN) and the guarded one (0) are equally unable to win the `>` comparison in cut() that is this function's only caller, since neither a NaN nor a 0 is ever greater than the genuinely positive ratio the opposing axis produces whenever a real cut is actually possible. function ratio(gap: number, extent: number): number { - return extent > 0 ? gap / extent : 0; + return gap / extent; } // Splits shapes wherever a band of space crosses the whole set with nothing in it: "vertical" sweeps down @@ -118,8 +115,7 @@ function splitOnGap( current.push(shape); reach = Math.max(reach, end(shape.frame, axis)); } - if (current.length > 0) { - groups.push(current); - } + // No "current.length > 0" guard is needed: for any non-empty `shapes`, the loop above always leaves at least the last-processed shape in `current` (it is only ever cleared and immediately refilled with the shape at hand), so the guard is always true there regardless. For an empty `shapes`, the loop never runs and this pushes an empty array as a phantom group instead of leaving `groups` empty -- but cut(), this function's only caller, never inspects that phantom group's contents: its ratio comparison and group-count check both come out exactly the same as the empty-groups case (both see a widestGap of 0 and a groups length that is not greater than 1), and its own fallback path re-sorts cut()'s own `shapes` argument, not this function's `groups`, so the empty array vanishes there too. + groups.push(current); return { groups, widestGap }; } diff --git a/packages/ooxml.js/src/typed/shared/color.test.ts b/packages/ooxml.js/src/typed/shared/color.test.ts index f7b827c93..47ec5a939 100644 --- a/packages/ooxml.js/src/typed/shared/color.test.ts +++ b/packages/ooxml.js/src/typed/shared/color.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyColorTransforms } from "./color"; +import { applyColorTransforms, hslToRgb, rgbToHsl } from "./color"; // Ported verbatim from documents.js's src/model/color.test.ts. rgbHexToColor/colorToRgbHex/ColorSchema/COLOR_BLACK coverage now lives in document-schema.js's own test suite -- this file keeps only applyColorTransforms, the DrawingML-specific logic that stayed here. describe("applyColorTransforms", () => { @@ -62,4 +62,131 @@ describe("applyColorTransforms", () => { ]); expect(result).toEqual({ r: 1, g: 1, b: 1 }); }); + + // The sRGB gamma functions' own thresholds and arithmetic, exercised through a 100% shade -- an identity transform on the linearised value (linear * 1 === linear) that isolates srgbToLinear/linearToSrgb's own round trip from the shade/tint blend formula. Expected numbers are the real (unmutated) formula's own output, computed independently rather than asserted as a bare round trip back to the input -- the sRGB standard's own published gamma/linear thresholds (0.04045 and 0.0031308) are decimal roundings of the true curve intersection, not exact inverses of one another, so even correct code does not always reproduce its input bit-for-bit at these exact boundaries. + describe("the sRGB gamma functions shade/tint apply the linear-space transform through", () => { + it("keeps a channel comfortably below both gamma/linear thresholds exactly round-tripped by a 100% shade", () => { + // 0.02 is below srgbToLinear's 0.04045 threshold, and 0.02/12.92 is below linearToSrgb's own 0.0031308 threshold too, so a 100% shade (identity on the linearised value) must reconstruct 0.02 exactly via the two thresholds' matching low-value (division/multiplication) branches -- a wrong arithmetic operator in either function breaks that exact reconstruction. + const result = applyColorTransforms({ r: 0.02, g: 0.02, b: 0.02 }, [ + { kind: "shade", value: 100_000 }, + ]); + expect(result.r).toBe(0.02); + }); + + it("takes srgbToLinear's low-value branch for a channel exactly at its 0.04045 threshold", () => { + const result = applyColorTransforms( + { r: 0.04045, g: 0.04045, b: 0.04045 }, + [{ kind: "shade", value: 100_000 }], + ); + // The real (inclusive-boundary) low branch reconstructs this specific value; an exclusive-boundary mutant would instead take the high (gamma-curve) branch for this exact input, landing measurably away from it. + expect(result.r).toBeCloseTo(0.040449970408122, 12); + }); + + it("takes linearToSrgb's low-value branch for a linearised value exactly at its 0.0031308 threshold", () => { + // 0.040449936 is srgbToLinear's low branch's own exact preimage of 0.0031308 (0.040449936 / 12.92), so a 100% shade feeds linearToSrgb precisely its own threshold value on the way back out. + const result = applyColorTransforms( + { r: 0.040449936, g: 0.040449936, b: 0.040449936 }, + [{ kind: "shade", value: 100_000 }], + ); + expect(result.r).toBeCloseTo(0.040449936, 12); + }); + + it("blends towards white by subtracting the linearised channel from 1, not adding it", () => { + // A mid-grey base gives a non-zero, non-degenerate linearised channel (0.02's near-black linear value collapses (1-linear) and (1+linear) together too closely to distinguish the sign). + const result = applyColorTransforms({ r: 0.5, g: 0.5, b: 0.5 }, [ + { kind: "tint", value: 50_000 }, + ]); + expect(result.r).toBeCloseTo(0.8018810657319997, 12); + }); + }); +}); + +// Asserts each field with toBeCloseTo rather than a single toEqual: the saturation formula below combines a subtraction and an absolute value, which for these inputs lands a bit off an exact decimal (e.g. 0.5 becomes 0.49999999999999994) -- an inherent property of the correct floating-point computation, not a bug either the formula or the test needs to route around. +function expectHsl( + color: { r: number; g: number; b: number }, + hsl: { h: number; s: number; l: number }, +): void { + const result = rgbToHsl(color); + expect(result.h).toBeCloseTo(hsl.h, 10); + expect(result.s).toBeCloseTo(hsl.s, 10); + expect(result.l).toBeCloseTo(hsl.l, 10); +} + +describe("rgbToHsl", () => { + it("reads hue from the red channel's own offset when red is the max, without the g { + expectHsl({ r: 0.8, g: 0.6, b: 0.4 }, { h: 30, s: 0.5, l: 0.6 }); + }); + + it("adds the g { + expectHsl({ r: 0.8, g: 0.4, b: 0.6 }, { h: 330, s: 0.5, l: 0.6 }); + }); + + it("reads hue from the blue-relative offset when green is the max", () => { + expectHsl({ r: 0.4, g: 0.8, b: 0.6 }, { h: 150, s: 0.5, l: 0.6 }); + }); + + it("reads hue from the green-relative offset when blue is the max", () => { + expectHsl({ r: 0.4, g: 0.6, b: 0.8 }, { h: 210, s: 0.5, l: 0.6 }); + }); + + it("computes the same saturation formula below the lightness midpoint as above it", () => { + expectHsl({ r: 0.6, g: 0.4, b: 0.2 }, { h: 30, s: 0.5, l: 0.4 }); + }); + + it("does not add the g { + // An inclusive "g <= b" would add the wrap term here too, giving h=360 instead of h=0 -- the same point on the colour wheel, but a different raw value this function is responsible for not returning. + expectHsl( + { r: 0.8, g: 0.5, b: 0.5 }, + { h: 0, s: 0.42857142857142866, l: 0.65 }, + ); + }); +}); + +describe("hslToRgb", () => { + it("returns the flat grey (r=g=b=l) for zero saturation, without touching hue", () => { + expect(hslToRgb({ h: 200, s: 0, l: 0.4 })).toEqual({ + r: 0.4, + g: 0.4, + b: 0.4, + }); + }); + + it("wraps a negative hue offset forward and reads the q/p-boundary and final-else branches at hue 0", () => { + const result = hslToRgb({ h: 0, s: 0.8, l: 0.6 }); + expect(result.r).toBeCloseTo(0.92, 12); + expect(result.g).toBeCloseTo(0.28, 12); + expect(result.b).toBeCloseTo(0.28, 12); + }); + + it("reads the 2/3-boundary branch at hue 90", () => { + const result = hslToRgb({ h: 90, s: 0.8, l: 0.6 }); + expect(result.r).toBeCloseTo(0.6, 12); + expect(result.g).toBeCloseTo(0.92, 12); + expect(result.b).toBeCloseTo(0.28, 12); + }); + + it("wraps a hue offset past 1 forward at hue 270", () => { + const result = hslToRgb({ h: 270, s: 0.8, l: 0.6 }); + expect(result.r).toBeCloseTo(0.6, 12); + expect(result.g).toBeCloseTo(0.28, 12); + expect(result.b).toBeCloseTo(0.92, 12); + }); + + it("uses l*(1+s) for lightness below the midpoint, distinct from the at-or-above formula", () => { + const result = hslToRgb({ h: 200, s: 0.8, l: 0.3 }); + expect(result.r).toBeCloseTo(0.06, 12); + expect(result.g).toBeCloseTo(0.38, 12); + expect(result.b).toBeCloseTo(0.54, 12); + }); + + // Exact (not toBeCloseTo) equality: hueToRgbComponent's own piece boundaries at exactly t === 1/6 and t === 1/2 land the real (strict "<") formula and its inclusive-boundary mutant a floating-point epsilon apart (0.92 vs 0.9199999999999999) -- a tolerance loose enough to call a real bug "close enough" would defeat the point of testing the boundary at all. + it("takes the q-branch, not the low-piece formula, at hue's green channel exactly on the 1/6 boundary", () => { + // h=60 puts hk (the green channel's own hue argument) at exactly 60/360 === 1/6. s=0.73/l=0.29 is one of the (l, s) pairs where the low-piece formula's own floating-point rounding at this exact t measurably misses q, rather than coincidentally landing back on it (many nearby pairs do coincide). + expect(hslToRgb({ h: 60, s: 0.73, l: 0.29 }).g).toBe(0.5016999999999999); + }); + + it("takes the q-branch, not the final clamped formula, at hue's blue channel exactly on the 1/2 boundary", () => { + // h=300 puts hk-1/3 (the blue channel's own hue argument) at exactly 300/360 - 1/3 === 0.5. + expect(hslToRgb({ h: 300, s: 0.8, l: 0.6 }).b).toBe(0.9199999999999998); + }); }); diff --git a/packages/ooxml.js/src/typed/shared/color.ts b/packages/ooxml.js/src/typed/shared/color.ts index 3ff7a2156..a05aac168 100644 --- a/packages/ooxml.js/src/typed/shared/color.ts +++ b/packages/ooxml.js/src/typed/shared/color.ts @@ -57,7 +57,10 @@ export function rgbToHsl(color: Color): Hsl { return { h: 0, s: 0, l }; } const d = max - min; - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + // Unconditional equivalent of the textbook piecewise "d / (max+min) below the midpoint, d / (2-max-min) above it": at l === 0.5 exactly, max+min === 2*l === 1 always, which forces 2-max-min === 1 too -- so the two branches necessarily agree at the boundary regardless of which side "l > 0.5" is written to include, and a strict-vs-inclusive comparison there can never be told apart by this result. This form (a standard alternate derivation of HSL saturation) sidesteps the boundary comparison entirely: + // 1 - |2l - 1| equals max+min when l <= 0.5 and 2-max-min when l >= 0.5, matching both branches exactly + // by construction rather than needing to pick one at the one point where they coincide anyway. + const s = d / (1 - Math.abs(2 * l - 1)); let h: number; if (max === r) { h = (g - b) / d + (g < b ? 6 : 0); @@ -70,31 +73,24 @@ export function rgbToHsl(color: Color): Hsl { } function hueToRgbComponent(p: number, q: number, hue: number): number { - let t = hue; - if (t < 0) { - t += 1; - } - if (t > 1) { - t -= 1; - } + // Wraps into [0, 1) via a floor-based mod rather than a pair of "< 0 add 1" / "> 1 subtract 1" guards: this function is only ever called (from hslToRgb below) with hue already within one turn of that range (hk-1/3 .. hk+1/3, hk itself in [0, 1)), so a single wrap always suffices -- but AT hue exactly 0 or exactly 1, an explicit guard's own two branches evaluate to the SAME final result regardless of which one runs (both ultimately reach the p+(q-p)*6*0 === p case below, since 0 and 1 are the same point on the wheel), making a strict-vs-inclusive choice between "< 0"/"> 1" and their own inclusive counterparts genuinely untestable there. hue - Math.floor(hue) needs no such comparison at all, and -- unlike the more familiar ((hue % 1) + 1) % 1 double-mod -- leaves an already-in-range value bit- exact rather than perturbing it by a rounding epsilon, which matters just below: the two remaining (genuinely non-equivalent) piece boundaries at t === 1/6 and t === 1/2 are tested at that exact value. + const t = hue - Math.floor(hue); if (t < 1 / 6) { return p + (q - p) * 6 * t; } if (t < 1 / 2) { return q; } - if (t < 2 / 3) { - return p + (q - p) * (2 / 3 - t) * 6; - } - return p; + // The final two pieces (t < 2/3 vs t >= 2/3) meet at the SAME value by construction -- the piecewise interpolation is continuous there, so (2/3 - t) is exactly 0 at t === 2/3 and the two formulas agree regardless of which side of that single point "< 2/3" is written to include. Clamping (2/3 - t) to never go negative folds both pieces into one expression without a boundary comparison to mutate: for t < 2/3 the max is a no-op (2/3 - t is already positive) and this is the earlier formula unchanged; for t >= 2/3, 2/3 - t is zero or negative, so the clamp collapses the whole term to p, matching the former "return p" fallback exactly. + return p + (q - p) * Math.max(0, 2 / 3 - t) * 6; } export function hslToRgb(hsl: Hsl): Color { const { h, s, l } = hsl; - if (s === 0) { - return { r: l, g: l, b: l }; - } - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + // No explicit "s === 0" achromatic shortcut is needed: at s === 0, q below is l + 0 * anything === l regardless of which side of Math.min it lands on, so p === q === l too -- and hueToRgbComponent's own formulas, given p === q, collapse to l on every one of its branches (l + (l-l)*x === l; returning q directly is l too), for any hue. The general computation already reaches exactly {r:l,g:l,b:l} for a fully-desaturated colour on its own; the shortcut only ever skipped arithmetic that was going to produce the identical result. + // + // Unconditional equivalent of the textbook piecewise "l*(1+s) below the midpoint, l+s-l*s at or above it": at l === 0.5 exactly, both give l+0.5*s, the same value HSL's "L=0.5" pivot is defined to produce -- so a strict-vs-inclusive boundary comparison there is untestable by this result no matter which side of 0.5 it is written to include. Math.min(l, 1-l) is l below the midpoint and 1-l at or above it, matching both branches exactly (l + s*l === l*(1+s); l + s*(1-l) === l+s-l*s) without ever comparing l to 0.5 at all. + const q = l + s * Math.min(l, 1 - l); const p = 2 * l - q; const hk = h / 360; return { diff --git a/packages/ooxml.js/src/typed/shared/drawingml.test.ts b/packages/ooxml.js/src/typed/shared/drawingml.test.ts index 5379348a2..d564da7a6 100644 --- a/packages/ooxml.js/src/typed/shared/drawingml.test.ts +++ b/packages/ooxml.js/src/typed/shared/drawingml.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { el } from "../../xml/fragment"; +import { el, txt } from "../../xml/fragment"; import type { GroupChildTransform } from "./drawingml"; import { applyGroupTransform, @@ -60,6 +60,39 @@ describe("readXfrm", () => { ).toBeUndefined(); expect(readXfrm(el("a:xfrm"))).toBeUndefined(); }); + + // a:off/a:ext are present in every case below -- only one of the four required ATTRIBUTES they carry is missing, isolating each clause of the x/y/cx/cy undefined check from the other tests above, which only ever exercise the earlier "a:off or a:ext element itself is missing" guard. + it("returns undefined when a:off is missing its x attribute", () => { + const xfrm = el("a:xfrm", {}, [ + el("a:off", { y: "0" }), + el("a:ext", { cx: "1", cy: "1" }), + ]); + expect(readXfrm(xfrm)).toBeUndefined(); + }); + + it("returns undefined when a:off is missing its y attribute", () => { + const xfrm = el("a:xfrm", {}, [ + el("a:off", { x: "0" }), + el("a:ext", { cx: "1", cy: "1" }), + ]); + expect(readXfrm(xfrm)).toBeUndefined(); + }); + + it("returns undefined when a:ext is missing its cx attribute", () => { + const xfrm = el("a:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { cy: "1" }), + ]); + expect(readXfrm(xfrm)).toBeUndefined(); + }); + + it("returns undefined when a:ext is missing its cy attribute", () => { + const xfrm = el("a:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { cx: "1" }), + ]); + expect(readXfrm(xfrm)).toBeUndefined(); + }); }); function clrScheme(): ReturnType { @@ -131,6 +164,53 @@ describe("readTheme", () => { expect(theme.majorFont).toBe("Calibri"); expect(theme.minorFont).toBe("Calibri"); }); + + it("uses lastClr over the windowText/window fallback, even when val is 'window'", () => { + // val="window" would fall back to white if lastClr were ignored -- a distinct lastClr here proves the real cached value is read, not merely coinciding with what the fallback happens to also produce (every other fixture's own lastClr is black or white, indistinguishable from its own fallback). + const root = el("a:theme", {}, [ + el("a:themeElements", {}, [ + el("a:clrScheme", {}, [ + el("a:lt1", {}, [ + el("a:sysClr", { val: "window", lastClr: "123456" }), + ]), + ]), + ]), + ]); + const theme = readTheme(root); + expect(theme.colorScheme.get("lt1")).toEqual({ + r: 0x12 / 255, + g: 0x34 / 255, + b: 0x56 / 255, + }); + }); + + it("resolves no colour at all for a colour-scheme slot whose child is neither a:srgbClr nor a:sysClr", () => { + const root = el("a:theme", {}, [ + el("a:themeElements", {}, [ + el("a:clrScheme", {}, [ + el("a:dk1", {}, [el("a:someOtherColorType", { val: "000000" })]), + ]), + ]), + ]); + const theme = readTheme(root); + expect(theme.colorScheme.has("dk1")).toBe(false); + }); + + it("skips a non-element child (e.g. whitespace text) to find a slot's real colour element", () => { + const root = el("a:theme", {}, [ + el("a:themeElements", {}, [ + el("a:clrScheme", {}, [ + el("a:dk1", {}, [txt("\n "), el("a:srgbClr", { val: "44546A" })]), + ]), + ]), + ]); + const theme = readTheme(root); + expect(theme.colorScheme.get("dk1")).toEqual({ + r: 0x44 / 255, + g: 0x54 / 255, + b: 0x6a / 255, + }); + }); }); describe("resolveThemeFontReference", () => { @@ -211,6 +291,21 @@ describe("readSchemeColor", () => { ), ).toBeUndefined(); }); + + it("skips a recognised transform child that carries no val attribute, applying only the one that does", () => { + const theme = readTheme(themeRoot()); + const colorMap = readColorMap(undefined); + const schemeClr = el("a:schemeClr", { val: "lt1" }, [ + el("a:lumMod"), + el("a:lumOff", { val: "-50000" }), + ]); + // If the val-less lumMod were included as a NaN-valued transform, the result would be NaN throughout rather than the clean 0.5 a single, real 50% lumOff on white produces. + expect(readSchemeColor(schemeClr, colorMap, theme)).toEqual({ + r: 0.5, + g: 0.5, + b: 0.5, + }); + }); }); describe("readSrgbColor", () => { @@ -302,6 +397,51 @@ describe("readGroupXfrm", () => { it("returns undefined for undefined input", () => { expect(readGroupXfrm(undefined)).toBeUndefined(); }); + + // a:chOff/a:chExt are present in every case below -- only one of the four required ATTRIBUTES they carry is missing, isolating each clause of the cx/cy/ccx/ccy undefined check from the earlier "no chOff/chExt element at all" test above. + function groupXfrm( + chOff: ReturnType, + chExt: ReturnType, + ): ReturnType { + return el("a:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { cx: "1828800", cy: "914400" }), + chOff, + chExt, + ]); + } + + it("returns undefined when a:chOff is missing its x attribute", () => { + const xfrm = groupXfrm( + el("a:chOff", { y: "0" }), + el("a:chExt", { cx: "914400", cy: "457200" }), + ); + expect(readGroupXfrm(xfrm)).toBeUndefined(); + }); + + it("returns undefined when a:chOff is missing its y attribute", () => { + const xfrm = groupXfrm( + el("a:chOff", { x: "0" }), + el("a:chExt", { cx: "914400", cy: "457200" }), + ); + expect(readGroupXfrm(xfrm)).toBeUndefined(); + }); + + it("returns undefined when a:chExt is missing its cx attribute", () => { + const xfrm = groupXfrm( + el("a:chOff", { x: "0", y: "0" }), + el("a:chExt", { cy: "457200" }), + ); + expect(readGroupXfrm(xfrm)).toBeUndefined(); + }); + + it("returns undefined when a:chExt is missing its cy attribute", () => { + const xfrm = groupXfrm( + el("a:chOff", { x: "0", y: "0" }), + el("a:chExt", { cx: "914400" }), + ); + expect(readGroupXfrm(xfrm)).toBeUndefined(); + }); }); function unrotatedGroup(fields: { @@ -415,6 +555,48 @@ describe("applyGroupTransform", () => { expect(result.xPt).toBeCloseTo(230, 9); expect(result.yPt).toBeCloseTo(130, 9); }); + + it("subtracts, rather than adds, the group's own child-space offset when mapping into the parent space", () => { + // A non-zero childOffXPt/childOffYPt (every other test above zeroes both, which cannot distinguish addition from subtraction): child at (10,10) in a space whose own origin sits at (5,5), one scale unit wide, so the child's own offset from that origin -- (10-5, 10-5) = (5,5) -- is what should be added onto the group's own placement (50,50), giving (55,55). + const group = unrotatedGroup({ + offXPt: 50, + offYPt: 50, + extWidthPt: 100, + extHeightPt: 100, + childOffXPt: 5, + childOffYPt: 5, + childExtWidthPt: 100, + childExtHeightPt: 100, + }); + const child = { xPt: 10, yPt: 10, widthPt: 20, heightPt: 20 }; + expect(applyGroupTransform(group, child)).toEqual({ + xPt: 55, + yPt: 55, + widthPt: 20, + heightPt: 20, + }); + }); + + it("still rotates about the group's own centre when the composite is mirrored but its rotation is exactly 0", () => { + // The identity shortcut requires BOTH compositeRotationDeg === 0 AND !compositeMirrored -- a mirrored group with no rotation must still go through the centre-mirroring path (a 0deg rotation is a no-op once there, but a mirror is not), rather than short-circuiting straight to the unrotated canonical box. + const group: GroupChildTransform = { + offXPt: 0, + offYPt: 0, + extWidthPt: 200, + extHeightPt: 100, + childOffXPt: 0, + childOffYPt: 0, + childExtWidthPt: 200, + childExtHeightPt: 100, + compositeRotationDeg: 0, + compositeMirrored: true, + }; + // Group centre (100,50); child box centre (60,50) is 40 to the left of it -- mirroring flips that to 40 to the right, i.e. a final box centre of (140,50), top-left (120,40). + const child = { xPt: 40, yPt: 40, widthPt: 40, heightPt: 20 }; + const result = applyGroupTransform(group, child); + expect(result.xPt).toBeCloseTo(120, 9); + expect(result.yPt).toBeCloseTo(40, 9); + }); }); describe("composeGroupTransform", () => { @@ -499,9 +681,78 @@ describe("composeGroupTransform", () => { expect(composed?.compositeMirrored).toBe(true); }); + it("wraps a negative subtraction result back into [0, 360)", () => { + // parent 30deg minus own 90deg is -60deg -- the negative case normalizeDeg's own "add 360" branch exists for, which every other subtraction test above lands on the positive side of. + const parent: GroupChildTransform = { + offXPt: 0, + offYPt: 0, + extWidthPt: 400, + extHeightPt: 400, + childOffXPt: 0, + childOffYPt: 0, + childExtWidthPt: 400, + childExtHeightPt: 400, + compositeRotationDeg: 30, + compositeMirrored: true, + }; + const own = { + offXPt: 200, + offYPt: 0, + extWidthPt: 200, + extHeightPt: 200, + childOffXPt: 0, + childOffYPt: 0, + childExtWidthPt: 200, + childExtHeightPt: 200, + rotationDeg: 90, + flipH: false, + flipV: false, + }; + const composed = composeGroupTransform(own, parent); + expect(composed?.compositeRotationDeg).toBe(300); + }); + it("returns undefined when own is undefined", () => { expect(composeGroupTransform(undefined, undefined)).toBeUndefined(); }); + + it("cancels flipH and flipV into a pure 180deg-shifted rotation, not a mirror", () => { + const own = { + offXPt: 0, + offYPt: 0, + extWidthPt: 100, + extHeightPt: 100, + childOffXPt: 0, + childOffYPt: 0, + childExtWidthPt: 100, + childExtHeightPt: 100, + rotationDeg: 30, + flipH: true, + flipV: true, + }; + const composed = composeGroupTransform(own, undefined); + expect(composed?.compositeRotationDeg).toBe(210); + expect(composed?.compositeMirrored).toBe(false); + }); + + it("restates a lone flipV as a 180deg-shifted mirror about the canonical flipH axis", () => { + const own = { + offXPt: 0, + offYPt: 0, + extWidthPt: 100, + extHeightPt: 100, + childOffXPt: 0, + childOffYPt: 0, + childExtWidthPt: 100, + childExtHeightPt: 100, + rotationDeg: 30, + flipH: false, + flipV: true, + }; + const composed = composeGroupTransform(own, undefined); + expect(composed?.compositeRotationDeg).toBe(210); + expect(composed?.compositeMirrored).toBe(true); + }); }); describe("composeShapeRotationDeg", () => { diff --git a/packages/ooxml.js/src/typed/shared/drawingml.ts b/packages/ooxml.js/src/typed/shared/drawingml.ts index 743dc99d6..ef48008e0 100644 --- a/packages/ooxml.js/src/typed/shared/drawingml.ts +++ b/packages/ooxml.js/src/typed/shared/drawingml.ts @@ -344,11 +344,11 @@ function canonicalizeGroupRotation( flipH: boolean, flipV: boolean, ): { readonly angleDeg: number; readonly mirrored: boolean } { - if (flipH && flipV) { - return { angleDeg: rotationDeg + 180, mirrored: false }; - } + // flipH && flipV and flipV-only are merged into one branch: both add the identical 180deg shift, and (once flipH && flipV has NOT already been excluded... which it hasn't been here, since this check comes first) mirrored is exactly !flipH either way -- true (flipV-only, flipH false) or false (flipH && flipV both true) -- rather than the same "+ 180" arithmetic appearing twice for Stryker to find two provably-identical mutation opportunities in. + // + // "+ 180" here is a genuinely irreducible equivalent mutation opportunity, not merely an untested one: every caller of this function eventually normalises the returned angleDeg modulo 360 (directly, via normalizeDeg in composeGroupTransform's own top-level branch, or as an operand composeAngleDeg feeds through normalizeDeg when composing with a parent), and (x + 180) mod 360 === (x - 180) mod 360 for every x, since the two differ by exactly 360. No test built on this function's own observable contract (an angle consumed only through that eventual mod-360 normalisation) can ever tell "+ 180" and "- 180" apart here -- the difference genuinely does not exist for any input, not just the ones a test happens to try. if (flipV) { - return { angleDeg: rotationDeg + 180, mirrored: true }; + return { angleDeg: rotationDeg + 180, mirrored: !flipH }; } if (flipH) { return { angleDeg: rotationDeg, mirrored: true }; @@ -356,20 +356,28 @@ function canonicalizeGroupRotation( return { angleDeg: rotationDeg, mirrored: false }; } +// Composes an OUTER linear map A = R(outer.angleDeg) . (Fh if outer.mirrored) with an INNER linear map B = R(inner.angleDeg) . (Fh if inner.mirrored) that is applied FIRST, giving C = A . B, decomposed back into the same (angleDeg, mirrored) representation. Derived from the reflection/rotation commutation identity Fh . R(theta) = R(-theta) . Fh (verified by direct 2x2 matrix multiplication: both sides equal [[-cos(theta), sin(theta)], [sin(theta), cos(theta)]]): outer not mirrored -> C = R(outerAngle).R(innerAngle).F_inner = R(outerAngle+innerAngle).F_inner; outer mirrored -> C = R(outerAngle).Fh.R(innerAngle).F_inner = R(outerAngle).R(-innerAngle).Fh.F_inner [since Fh.R(innerAngle) = R(-innerAngle).Fh] = R(outerAngle-innerAngle).(Fh.F_inner), so a mirrored outer flips whether the result is mirrored (Fh.Fh=I cancels; Fh.I stays mirrored) AND subtracts the inner angle instead of adding it -- this is the concrete "an ancestor group's flip negates the sense of a descendant's own rotation" rule. +// The angle half of composeRotation below, split out because composeShapeRotationDeg needs exactly this computation without ever needing a real `inner.mirrored` to pass in: the angle here depends only on whether the OUTER map is mirrored (added when it isn't, subtracted when it is), never on the inner map's own mirrored flag, which composeRotation folds into its OWN returned `mirrored` field instead. +function composeAngleDeg( + outerMirrored: boolean, + outerAngleDeg: number, + innerAngleDeg: number, +): number { + return normalizeDeg( + outerMirrored + ? outerAngleDeg - innerAngleDeg + : outerAngleDeg + innerAngleDeg, + ); +} + // Composes an OUTER linear map A = R(outer.angleDeg) . (Fh if outer.mirrored) with an INNER linear map B = R(inner.angleDeg) . (Fh if inner.mirrored) that is applied FIRST, giving C = A . B, decomposed back into the same (angleDeg, mirrored) representation. Derived from the reflection/rotation commutation identity Fh . R(theta) = R(-theta) . Fh (verified by direct 2x2 matrix multiplication: both sides equal [[-cos(theta), sin(theta)], [sin(theta), cos(theta)]]): outer not mirrored -> C = R(outerAngle).R(innerAngle).F_inner = R(outerAngle+innerAngle).F_inner; outer mirrored -> C = R(outerAngle).Fh.R(innerAngle).F_inner = R(outerAngle).R(-innerAngle).Fh.F_inner [since Fh.R(innerAngle) = R(-innerAngle).Fh] = R(outerAngle-innerAngle).(Fh.F_inner), so a mirrored outer flips whether the result is mirrored (Fh.Fh=I cancels; Fh.I stays mirrored) AND subtracts the inner angle instead of adding it -- this is the concrete "an ancestor group's flip negates the sense of a descendant's own rotation" rule. function composeRotation( outer: { readonly angleDeg: number; readonly mirrored: boolean }, inner: { readonly angleDeg: number; readonly mirrored: boolean }, ): { readonly angleDeg: number; readonly mirrored: boolean } { - if (!outer.mirrored) { - return { - angleDeg: normalizeDeg(outer.angleDeg + inner.angleDeg), - mirrored: inner.mirrored, - }; - } return { - angleDeg: normalizeDeg(outer.angleDeg - inner.angleDeg), - mirrored: !inner.mirrored, + angleDeg: composeAngleDeg(outer.mirrored, outer.angleDeg, inner.angleDeg), + mirrored: outer.mirrored ? !inner.mirrored : inner.mirrored, }; } @@ -444,9 +452,7 @@ export function applyGroupTransform( group.offXPt + (childFrame.xPt - group.childOffXPt) * scaleX; const canonicalY = group.offYPt + (childFrame.yPt - group.childOffYPt) * scaleY; - if (group.compositeRotationDeg === 0 && !group.compositeMirrored) { - return { xPt: canonicalX, yPt: canonicalY, widthPt, heightPt }; - } + // No "rotation === 0 && !mirrored" shortcut is needed: with no rotation and no mirror, dx is left unmirrored and cos/sin below are Math.cos(0) === 1 / Math.sin(0) === 0 exactly (not merely close -- multiplying and dividing by 0 introduces no floating-point error), so rotatedX/rotatedY reduce to dx/dy exactly, and the final xPt/yPt collapse algebraically back to canonicalX/canonicalY -- the general path already computes the identity case bit-for-bit; the shortcut only ever skipped work that was going to produce the same answer. const groupCenterX = group.offXPt + group.extWidthPt / 2; const groupCenterY = group.offYPt + group.extHeightPt / 2; const boxCenterX = canonicalX + widthPt / 2; @@ -477,11 +483,9 @@ export function composeShapeRotationDeg( if (parentTransform === undefined) { return normalizeDeg(ownRotationDeg); } - return composeRotation( - { - angleDeg: parentTransform.compositeRotationDeg, - mirrored: parentTransform.compositeMirrored, - }, - { angleDeg: ownRotationDeg, mirrored: false }, - ).angleDeg; + return composeAngleDeg( + parentTransform.compositeMirrored, + parentTransform.compositeRotationDeg, + ownRotationDeg, + ); } diff --git a/packages/ooxml.js/src/typed/shared/metadata.test.ts b/packages/ooxml.js/src/typed/shared/metadata.test.ts index b67b3899b..8f587a780 100644 --- a/packages/ooxml.js/src/typed/shared/metadata.test.ts +++ b/packages/ooxml.js/src/typed/shared/metadata.test.ts @@ -71,6 +71,28 @@ describe("readCoreProperties", () => { const metadata = readCoreProperties(packageWith(core, undefined)); expect(metadata.keywords).toBeUndefined(); }); + + it("treats a present but empty-text element as no value, not an empty string", () => { + const core = el("cp:coreProperties", {}, [el("dc:title")]); + const metadata = readCoreProperties(packageWith(core, undefined)); + expect(metadata.title).toBeUndefined(); + }); + + it("drops blank entries a doubled or trailing comma produces, rather than keeping them as empty strings", () => { + const core = el("cp:coreProperties", {}, [ + el("cp:keywords", {}, [txt("alpha,,beta,")]), + ]); + const metadata = readCoreProperties(packageWith(core, undefined)); + expect(metadata.keywords).toEqual(["alpha", "beta"]); + }); + + it("treats keywords text that is comma/whitespace only, with no real entries, as no keywords at all", () => { + const core = el("cp:coreProperties", {}, [ + el("cp:keywords", {}, [txt(" , , ")]), + ]); + const metadata = readCoreProperties(packageWith(core, undefined)); + expect(metadata.keywords).toBeUndefined(); + }); }); describe("hasCoreProperties", () => { @@ -145,6 +167,51 @@ describe("patchCoreProperties", () => { expect(readCoreProperties(pkg).keywords).toBeUndefined(); }); + it("removes the cp:keywords element from the XML entirely for an empty array, rather than writing an empty one", () => { + const pkg = packageWithCore([el("cp:keywords", {}, [txt("alpha, beta")])]); + + patchCoreProperties(pkg, { keywords: [] }); + + const part = pkg.parts["docProps/core.xml"]; + if (part?.kind !== "xml") { + throw new Error("expected an xml part"); + } + expect(buildXml(part.nodes)).not.toContain("cp:keywords"); + }); + + it("removing keywords leaves every other element in place", () => { + const pkg = packageWithCore([ + el("dc:title", {}, [txt("Kept Title")]), + el("cp:keywords", {}, [txt("alpha, beta")]), + ]); + + patchCoreProperties(pkg, { keywords: [] }); + + expect(readCoreProperties(pkg).title).toBe("Kept Title"); + expect(readCoreProperties(pkg).keywords).toBeUndefined(); + }); + + it("sets the author independently of every other field", () => { + const pkg = packageWithCore([]); + patchCoreProperties(pkg, { author: "New Author" }); + expect(readCoreProperties(pkg).author).toBe("New Author"); + }); + + it("sets the subject independently of every other field", () => { + const pkg = packageWithCore([]); + patchCoreProperties(pkg, { subject: "New Subject" }); + expect(readCoreProperties(pkg).subject).toBe("New Subject"); + }); + + it("throws when the existing docProps/core.xml XML part has no root element", () => { + const pkg: Package = { + parts: { "docProps/core.xml": { kind: "xml", nodes: [] } }, + }; + expect(() => { + patchCoreProperties(pkg, { title: "x" }); + }).toThrow(/no root element/); + }); + it("leaves every field untouched when overrides names none of them", () => { const pkg = packageWithCore([ el("dc:title", {}, [txt("Untouched")]), diff --git a/packages/ooxml.js/src/typed/shared/metadata.ts b/packages/ooxml.js/src/typed/shared/metadata.ts index 39696c67f..32bbf56df 100644 --- a/packages/ooxml.js/src/typed/shared/metadata.ts +++ b/packages/ooxml.js/src/typed/shared/metadata.ts @@ -84,18 +84,14 @@ export interface CorePropertiesOverrides { readonly keywords?: readonly string[]; } -// The namespace prefix a tag is qualified with ("dc:title" -> "dc"), or undefined for an unprefixed tag. -function namespacePrefixOf(tag: string): string | undefined { - const colonIndex = tag.indexOf(":"); - return colonIndex === -1 ? undefined : tag.slice(0, colonIndex); +// The namespace prefix a tag is qualified with ("dc:title" -> "dc"). No "no colon" branch: this is only ever called, via ensureNamespaceDeclared below, with one of "dc:title" / "dc:creator" / "dc:subject" / "cp:keywords" -- every one of them colon-qualified -- so colonIndex is always >= 0 in practice and a branch handling its absence would be unreachable. +function namespacePrefixOf(tag: string): string { + return tag.slice(0, tag.indexOf(":")); } // Ensures `root` declares the xmlns binding a newly appended element's prefix needs. A legally-minimal docProps/core.xml declaring only the cp namespace (every core-properties child is optional, so a real producer writing only cp:keywords has no reason to ever declare dc) would otherwise gain an unbound dc:title/dc:creator/dc:subject child -- a fatal XML namespace well-formedness error real consumers (Word, LibreOffice) reject outright. Only called from the "create a new element" branch below: an EXISTING element's prefix was already legally bound by whatever produced the source document, so patching its text alone never needs this. Idempotent -- patching two dc-prefixed fields that both need creating (title and author, say) declares xmlns:dc once, not twice. function ensureNamespaceDeclared(root: XmlElement, tag: string): void { const prefix = namespacePrefixOf(tag); - if (prefix === undefined) { - return; - } const uri = CORE_PROPERTIES_NAMESPACE_URI_FOR_PREFIX[prefix]; if (uri === undefined) { return; diff --git a/packages/ooxml.js/src/typed/util-structural.test.ts b/packages/ooxml.js/src/typed/util-structural.test.ts new file mode 100644 index 000000000..0daeade66 --- /dev/null +++ b/packages/ooxml.js/src/typed/util-structural.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import type { Package } from "../model/package"; +import { el, txt } from "../xml/fragment"; +import { + attr, + childrenWithTag, + elementsWithTag, + resolveRelationships, + rootElement, + walk, +} from "./util"; + +// Direct structural coverage for util.ts's tree-walk and relationship-resolution primitives, which relsPathFor/resolveRelTarget/textContent's own util.test.ts leaves untouched. + +describe("walk", () => { + it("yields a flat list of nodes in document order with no descent", () => { + const nodes = [txt("a"), txt("b")]; + expect([...walk(nodes)]).toEqual(nodes); + }); + + it("descends depth-first into element children, yielding parent before its children", () => { + const child = el("child", {}, [txt("leaf")]); + const parent = el("parent", {}, [child]); + const visited = [...walk([parent])]; + expect(visited).toEqual([parent, child, txt("leaf")]); + }); + + it("does not descend into a text or cdata node", () => { + const node = { type: "cdata" as const, value: "raw" }; + expect([...walk([node])]).toEqual([node]); + }); +}); + +describe("elementsWithTag", () => { + it("finds a matching element at any depth, not just direct children", () => { + const target = el("target", {}, []); + const tree = el("root", {}, [el("wrapper", {}, [target])]); + expect(elementsWithTag([tree], "target")).toEqual([target]); + }); + + it("returns every match in document order when several share the tag", () => { + const first = el("item", { id: "1" }); + const second = el("item", { id: "2" }); + const tree = el("root", {}, [first, el("wrapper", {}, [second])]); + expect(elementsWithTag([tree], "item")).toEqual([first, second]); + }); + + it("returns an empty array when nothing matches", () => { + expect(elementsWithTag([el("root", {}, [])], "missing")).toEqual([]); + }); + + it("does not match a text node even if it shares no tag concept", () => { + expect(elementsWithTag([txt("root")], "root")).toEqual([]); + }); +}); + +describe("childrenWithTag", () => { + it("finds only DIRECT children with the tag, not a nested descendant", () => { + const nested = el("item"); + const tree = el("root", {}, [el("wrapper", {}, [nested])]); + expect(childrenWithTag(tree, "item")).toEqual([]); + }); + + it("returns every direct child sharing the tag, in order", () => { + const first = el("item", { id: "1" }); + const second = el("item", { id: "2" }); + const other = el("other"); + const tree = el("root", {}, [first, other, second]); + expect(childrenWithTag(tree, "item")).toEqual([first, second]); + }); + + it("skips a text child when searching by tag", () => { + const tree = el("root", {}, [txt("stray text"), el("item")]); + expect(childrenWithTag(tree, "item")).toEqual([el("item")]); + }); +}); + +describe("attr", () => { + it("returns the value of a matching attribute", () => { + expect(attr(el("e", { id: "42" }), "id")).toBe("42"); + }); + + it("returns undefined when the attribute is absent", () => { + expect(attr(el("e", {}), "id")).toBeUndefined(); + }); + + it("finds the correct attribute among several", () => { + expect(attr(el("e", { a: "1", b: "2", c: "3" }), "b")).toBe("2"); + }); +}); + +describe("rootElement", () => { + it("returns undefined for an undefined part", () => { + expect(rootElement(undefined)).toBeUndefined(); + }); + + it("returns undefined for a binary part", () => { + expect(rootElement({ kind: "binary", base64: "" })).toBeUndefined(); + }); + + it("skips a leading non-element node (an declaration) to find the root element", () => { + const root = el("root"); + expect( + rootElement({ + kind: "xml", + nodes: [{ type: "text", value: "" }, root], + }), + ).toBe(root); + }); + + it("returns undefined when an xml part has no element node at all", () => { + expect( + rootElement({ kind: "xml", nodes: [{ type: "text", value: "x" }] }), + ).toBeUndefined(); + }); +}); + +describe("resolveRelationships", () => { + function pkgWithRels(relsXml: ReturnType[]): Package { + return { + parts: { + "word/_rels/document.xml.rels": { + kind: "xml", + nodes: [el("Relationships", {}, relsXml)], + }, + }, + }; + } + + it("returns an empty map when the .rels part is absent", () => { + expect(resolveRelationships({ parts: {} }, "word/document.xml")).toEqual( + new Map(), + ); + }); + + it("resolves an internal relationship target relative to the subject part's directory", () => { + const pkg = pkgWithRels([ + el("Relationship", { + Id: "rId1", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + Target: "media/image1.png", + }), + ]); + const map = resolveRelationships(pkg, "word/document.xml"); + expect(map.get("rId1")).toEqual({ + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + target: "word/media/image1.png", + targetMode: undefined, + }); + }); + + it("keeps an External target verbatim rather than resolving it as a package path", () => { + const pkg = pkgWithRels([ + el("Relationship", { + Id: "rId1", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + Target: "https://example.com/", + TargetMode: "External", + }), + ]); + const map = resolveRelationships(pkg, "word/document.xml"); + expect(map.get("rId1")).toEqual({ + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + target: "https://example.com/", + targetMode: "External", + }); + }); + + it("skips a Relationship element missing Id, Type, or Target", () => { + const pkg = pkgWithRels([ + el("Relationship", { Type: "t", Target: "x" }), + el("Relationship", { Id: "rId1", Target: "x" }), + el("Relationship", { Id: "rId2", Type: "t" }), + ]); + expect(resolveRelationships(pkg, "word/document.xml")).toEqual(new Map()); + }); + + it("entity-decodes an internal target before resolving it, so an '&' in the path matches the real package key", () => { + const pkg = pkgWithRels([ + el("Relationship", { + Id: "rId1", + Type: "t", + Target: "media/A&B.png", + }), + ]); + const map = resolveRelationships(pkg, "word/document.xml"); + expect(map.get("rId1")?.target).toBe("word/media/A&B.png"); + }); + + it("entity-decodes the relationship Type attribute too", () => { + const pkg = pkgWithRels([ + el("Relationship", { + Id: "rId1", + Type: "http://example.com/A&B", + Target: "x", + }), + ]); + const map = resolveRelationships(pkg, "word/document.xml"); + expect(map.get("rId1")?.type).toBe("http://example.com/A&B"); + }); +}); diff --git a/packages/ooxml.js/src/typed/util.test.ts b/packages/ooxml.js/src/typed/util.test.ts new file mode 100644 index 000000000..e88864b8c --- /dev/null +++ b/packages/ooxml.js/src/typed/util.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { el, txt } from "../xml/fragment"; +import { relsPathFor, resolveRelTarget, textContent } from "./util"; + +describe("textContent", () => { + it("concatenates cdata content alongside plain text, not just text nodes", () => { + const element = el("w:t", {}, [ + txt("plain "), + { type: "cdata", value: "cdata" }, + ]); + expect(textContent(element)).toBe("plain cdata"); + }); +}); + +describe("relsPathFor", () => { + it("splits a slash-containing part path into its directory and file name", () => { + expect(relsPathFor("word/document.xml")).toBe( + "word/_rels/document.xml.rels", + ); + }); + + it("uses an empty directory for a part path with no slash at all", () => { + expect(relsPathFor("document.xml")).toBe("/_rels/document.xml.rels"); + }); + + it("uses the LAST slash to split a nested part path, not the first", () => { + expect(relsPathFor("xl/drawings/drawing1.xml")).toBe( + "xl/drawings/_rels/drawing1.xml.rels", + ); + }); +}); + +describe("resolveRelTarget", () => { + it("strips a leading slash from a package-rooted target, ignoring the subject part's own directory", () => { + expect(resolveRelTarget("word/document.xml", "/media/image1.png")).toBe( + "media/image1.png", + ); + }); + + it("resolves a relative target against the subject part's own directory", () => { + expect(resolveRelTarget("word/document.xml", "media/image1.png")).toBe( + "word/media/image1.png", + ); + }); + + it("resolves a relative target against an empty directory when the subject part path has no slash", () => { + expect(resolveRelTarget("document.xml", "media/image1.png")).toBe( + "media/image1.png", + ); + }); + + it("resolves a nested subject part's own directory correctly (the LAST slash, not the first)", () => { + expect( + resolveRelTarget("word/embeddings/oleObject1.bin", "image1.png"), + ).toBe("word/embeddings/image1.png"); + }); + + it("pops the enclosing directory for a leading '../' segment", () => { + expect( + resolveRelTarget("word/embeddings/oleObject1.bin", "../media/image1.png"), + ).toBe("word/media/image1.png"); + }); + + it("skips a '.' current-directory segment", () => { + expect(resolveRelTarget("word/document.xml", "./media/image1.png")).toBe( + "word/media/image1.png", + ); + }); + + it("skips an empty segment produced by a doubled slash", () => { + expect(resolveRelTarget("word/document.xml", "media//image1.png")).toBe( + "word/media/image1.png", + ); + }); +}); diff --git a/packages/ooxml.js/src/typed/util.ts b/packages/ooxml.js/src/typed/util.ts index f867503ad..844b70ab0 100644 --- a/packages/ooxml.js/src/typed/util.ts +++ b/packages/ooxml.js/src/typed/util.ts @@ -95,16 +95,17 @@ export interface Relationship { targetMode?: string; } -// The .rels part for a given part path: word/document.xml -> word/_rels/document.xml.rels. -function relsPathFor(partPath: string): string { +// The .rels part for a given part path: word/document.xml -> word/_rels/document.xml.rels. Exported purely for direct unit coverage -- resolveRelationships is its only real caller. +export function relsPathFor(partPath: string): string { const lastSlash = partPath.lastIndexOf("/"); const dir = lastSlash === -1 ? "" : partPath.slice(0, lastSlash); - const fileName = lastSlash === -1 ? partPath : partPath.slice(lastSlash + 1); + // No ternary needed here (unlike dir above): slice(-1 + 1) is slice(0), which returns the whole string unchanged -- exactly what a slash-free path needs -- so this one expression already covers both cases the dir computation above needs a real branch for. + const fileName = partPath.slice(lastSlash + 1); return `${dir}/_rels/${fileName}.rels`; } -// Resolve a relationship Target (relative to the subject part's directory, or package-rooted with a leading slash) to a package-relative part path. -function resolveRelTarget(partPath: string, target: string): string { +// Resolve a relationship Target (relative to the subject part's directory, or package-rooted with a leading slash) to a package-relative part path. Exported purely for direct unit coverage -- resolveRelationships is its only real caller. +export function resolveRelTarget(partPath: string, target: string): string { if (target.startsWith("/")) { return target.slice(1); } diff --git a/packages/ooxml.js/src/typed/xlsx.test.ts b/packages/ooxml.js/src/typed/xlsx.test.ts index 3f31cc66e..4e19f5b55 100644 --- a/packages/ooxml.js/src/typed/xlsx.test.ts +++ b/packages/ooxml.js/src/typed/xlsx.test.ts @@ -118,4 +118,61 @@ describe("readXlsxWorkbook", () => { expect(sheet?.mergedRanges).toEqual([]); expect(readXlsxWorkbook(pkg).definedNames).toEqual([]); }); + + // Every fixture above targets a rels Target with no leading slash and a sheet literally named "Sheet1" -- indistinguishable from the filename-derived Sheet fallback name a broken correlation would produce instead, so a bug here would still read back the "right" name by coincidence. These two use a display name that differs from the fallback, so a broken correlation is forced to show up as the wrong name rather than an accidentally-matching one. + it("resolves the sheet's display name via a workbook rels Target with no leading slash", () => { + const workbookXml = enc( + '\n', + ); + const pkg = decodePackage( + zipPackage({ + "[Content_Types].xml": CONTENT_TYPES, + "_rels/.rels": ROOT_RELS, + "xl/workbook.xml": workbookXml, + "xl/_rels/workbook.xml.rels": WORKBOOK_RELS, + "xl/worksheets/sheet1.xml": SHEET1, + }), + ); + expect(readXlsxWorkbook(pkg).sheets[0]?.name).toBe("Data"); + }); + + it("resolves the sheet's display name via a workbook rels Target carrying a leading slash", () => { + const workbookXml = enc( + '\n', + ); + const workbookRelsXml = enc( + '\n', + ); + const pkg = decodePackage( + zipPackage({ + "[Content_Types].xml": CONTENT_TYPES, + "_rels/.rels": ROOT_RELS, + "xl/workbook.xml": workbookXml, + "xl/_rels/workbook.xml.rels": workbookRelsXml, + "xl/worksheets/sheet1.xml": SHEET1, + }), + ); + expect(readXlsxWorkbook(pkg).sheets[0]?.name).toBe("Report"); + }); + + it("orders sheets by their numeric suffix, not by the package's own part insertion order", () => { + const sheetXml = (marker: string) => + enc( + `\n${marker}`, + ); + const pkg = decodePackage( + zipPackage({ + "[Content_Types].xml": CONTENT_TYPES, + "_rels/.rels": ROOT_RELS, + // Inserted out of numeric order: 3, then 1, then 2. + "xl/worksheets/sheet3.xml": sheetXml("third"), + "xl/worksheets/sheet1.xml": sheetXml("first"), + "xl/worksheets/sheet2.xml": sheetXml("second"), + }), + ); + const markers = readXlsxWorkbook(pkg).sheets.map( + (sheet) => sheet.cells[0]?.value, + ); + expect(markers).toEqual(["first", "second", "third"]); + }); }); diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 4915a1c8f..aa84d479d 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,1283 @@ 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, + ); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts b/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts new file mode 100644 index 000000000..180b15d4f --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import type { ContentSheet, ContentSheetCell } from "document-schema.js"; +import { + buildThreadedCommentElements, + buildThreadedCommentsRoot, + sheetHasComments, + threadedCommentId, +} from "./comments-write"; + +const EMPTY_PRINT_SETTINGS = { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + gridlines: false, + headers: false, + pageOrder: "downThenOver" as const, +}; + +function sheet(cells: ContentSheetCell[]): ContentSheet { + return { + name: "Sheet1", + cells, + columns: [], + rows: [], + images: [], + printSettings: EMPTY_PRINT_SETTINGS, + }; +} + +function numberCell( + row: number, + column: number, + value: number, + extra: Partial = {}, +): ContentSheetCell { + return { + row, + column, + value: { kind: "number", value }, + displayText: String(value), + ...extra, + }; +} + +describe("threadedCommentId", () => { + it("formats the counter as zero-padded, UPPERCASE hex inside the braced GUID shape", () => { + expect(threadedCommentId(0)).toBe("{00000000-0000-0000-0000-000000000000}"); + // 10 in hex is "a" -- exercises the uppercase-vs-lowercase distinction the digits 0-9 alone cannot. + expect(threadedCommentId(10)).toBe( + "{00000000-0000-0000-0000-00000000000A}", + ); + }); +}); + +describe("sheetHasComments", () => { + it("is false for a sheet with no cell comments at all", () => { + expect(sheetHasComments(sheet([numberCell(0, 0, 1)]))).toBe(false); + }); + + it("is true when any cell carries a comment", () => { + expect( + sheetHasComments( + sheet([numberCell(0, 0, 1, { comment: { text: "note" } })]), + ), + ).toBe(true); + }); +}); + +describe("buildThreadedCommentElements", () => { + it("assigns sequential, increasing ids across two separately-commented cells, not just within one thread", () => { + const s = sheet([ + numberCell(0, 0, 1, { comment: { text: "first" } }), + numberCell(1, 0, 2, { comment: { text: "second" } }), + ]); + const elements = buildThreadedCommentElements(s); + expect( + elements.map((e) => e.attributes.find((a) => a.name === "id")?.value), + ).toEqual([ + "{00000000-0000-0000-0000-000000000000}", + "{00000000-0000-0000-0000-000000000001}", + ]); + }); + + it("writes a reply immediately after its own root, carrying the root's own id as parentId", () => { + const s = sheet([ + numberCell(0, 0, 1, { + comment: { text: "root", replies: [{ text: "reply" }] }, + }), + ]); + const elements = buildThreadedCommentElements(s); + expect(elements).toHaveLength(2); + const rootId = elements[0]?.attributes.find((a) => a.name === "id")?.value; + const replyParentId = elements[1]?.attributes.find( + (a) => a.name === "parentId", + )?.value; + expect(replyParentId).toBe(rootId); + expect(elements[0]?.attributes.some((a) => a.name === "parentId")).toBe( + false, + ); + }); + + it("keeps the counter strictly increasing past a reply, so a later cell's root id never collides with an earlier one", () => { + // A reply consumes a counter value of its own (root=0, reply=1) before the next cell's root is minted -- if the reply loop's own increment ever ran backwards, this second cell's root would collide with the first cell's root id instead of continuing at 2. + const s = sheet([ + numberCell(0, 0, 1, { + comment: { text: "root", replies: [{ text: "reply" }] }, + }), + numberCell(1, 0, 2, { comment: { text: "second root" } }), + ]); + const elements = buildThreadedCommentElements(s); + expect( + elements.map((e) => e.attributes.find((a) => a.name === "id")?.value), + ).toEqual([ + "{00000000-0000-0000-0000-000000000000}", + "{00000000-0000-0000-0000-000000000001}", + "{00000000-0000-0000-0000-000000000002}", + ]); + }); +}); + +describe("buildThreadedCommentsRoot", () => { + it("declares the [MS-XLSX] threaded-comments namespace on the root element", () => { + const root = buildThreadedCommentsRoot(sheet([])); + expect(root.tag).toBe("ThreadedComments"); + expect(root.attributes).toEqual([ + { + name: "xmlns", + value: + "http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments", + }, + ]); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/comments-write.ts b/packages/ooxml.js/src/typed/xlsx/comments-write.ts index 7e2473596..aafebf7de 100644 --- a/packages/ooxml.js/src/typed/xlsx/comments-write.ts +++ b/packages/ooxml.js/src/typed/xlsx/comments-write.ts @@ -12,8 +12,8 @@ import { encodeXmlText } from "../../xml/entities"; const THREADED_COMMENTS_NS = "http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments"; -// A deterministic, sequential ST_Guid-shaped id. A real producer mints a genuine random GUID per thread and reply; nothing this writer or its own reader (readThreadedComments' parentId matching) needs beyond uniqueness within the part and a reply's parentId correctly naming its own thread's root id, so a zero-padded counter in the same braced-hex shape is exactly as correct while keeping this writer's output reproducible. -function threadedCommentId(counter: number): string { +// A deterministic, sequential ST_Guid-shaped id. A real producer mints a genuine random GUID per thread and reply; nothing this writer or its own reader (readThreadedComments' parentId matching) needs beyond uniqueness within the part and a reply's parentId correctly naming its own thread's root id, so a zero-padded counter in the same braced-hex shape is exactly as correct while keeping this writer's output reproducible. Exported purely for direct unit coverage of its own exact hex formatting. +export function threadedCommentId(counter: number): string { return `{00000000-0000-0000-0000-${counter.toString(16).padStart(12, "0").toUpperCase()}}`; } diff --git a/packages/ooxml.js/src/typed/xlsx/comments.test.ts b/packages/ooxml.js/src/typed/xlsx/comments.test.ts index d240d56c0..56e17fd4f 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 570f2a08a..d0fb0f721 100644 --- a/packages/ooxml.js/src/typed/xlsx/comments.ts +++ b/packages/ooxml.js/src/typed/xlsx/comments.ts @@ -33,8 +33,9 @@ export interface SheetCellComment { // The threaded-comments vocabulary is a Microsoft extension, not ECMA-376, so unlike every ECMA-376 part this package reads -- whose producers all bind the schema namespace as the DEFAULT namespace, leaving element names unprefixed -- these elements arrive under whatever prefix the producer chose: Excel writes the part unprefixed, other producers bind one (conventionally tc:). The local name, the part after the last ':', is the only spelling-agnostic address for these elements. function localName(tag: string): string { + // No "no colon" branch: String.prototype.lastIndexOf returns -1 for an unprefixed tag, and tag.slice(-1 + 1) === tag.slice(0) is the whole string unchanged -- exactly the un-sliced value the branch existed to return, for every possible tag, not merely the ones this file happens to see. The ternary's own comparison is therefore never actually reachable as a distinct outcome. const colon = tag.lastIndexOf(":"); - return colon === -1 ? tag : tag.slice(colon + 1); + return tag.slice(colon + 1); } function childrenWithLocalName( @@ -50,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(); } @@ -61,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 ---------------------------------------------------------------------------------- @@ -118,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, @@ -180,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; } @@ -194,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]); @@ -217,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) { @@ -244,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 000000000..27e7e36fe --- /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 00dfe3fd5..ca9bd076e 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 22e45d3f1..9403eda7e 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/data-validation.test.ts b/packages/ooxml.js/src/typed/xlsx/data-validation.test.ts new file mode 100644 index 000000000..8a7724528 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/data-validation.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, it } from "vitest"; +import type { ContentSheetDataValidation } from "document-schema.js"; +import { el } from "../../xml/fragment"; +import { attr } from "../util"; +import { + buildDataValidationsElement, + readDataValidations, +} from "./data-validation"; + +// buildDataValidationElement is not exported -- exercised indirectly through buildDataValidationsElement, which wraps it 1:1 for a single-entry array. +function buildOne(validation: ContentSheetDataValidation) { + const wrapper = buildDataValidationsElement([validation]); + const child = wrapper?.children[0]; + if (child?.type !== "element") { + throw new Error("expected a single dataValidation element"); + } + return child; +} + +function worksheetWith( + ...dataValidation: ReturnType[] +): ReturnType { + return el("worksheet", {}, [el("dataValidations", {}, dataValidation)]); +} + +describe("readDataValidations", () => { + it("returns no validations and no residue for a worksheet with no container", () => { + const result = readDataValidations(el("worksheet", {}, [])); + expect(result).toEqual({ validations: [], residueElements: [] }); + }); + + it("returns nothing for an empty container", () => { + const result = readDataValidations( + el("worksheet", {}, [el("dataValidations", {}, [])]), + ); + expect(result).toEqual({ validations: [], residueElements: [] }); + }); + + it("quarantines an element whose type is unrecognised (including the 'none' member) as whole-element residue", () => { + const dv = el("dataValidation", { type: "none", sqref: "A1" }); + const result = readDataValidations(worksheetWith(dv)); + expect(result.validations).toEqual([]); + expect(result.residueElements).toEqual([dv]); + }); + + it("quarantines a recognised-type element with no sqref at all", () => { + const dv = el("dataValidation", { type: "whole" }); + const result = readDataValidations(worksheetWith(dv)); + expect(result.validations).toEqual([]); + expect(result.residueElements).toEqual([dv]); + }); + + it("quarantines a recognised-type element whose sqref parses to no range", () => { + const dv = el("dataValidation", { type: "whole", sqref: "not-a-range" }); + const result = readDataValidations(worksheetWith(dv)); + expect(result.validations).toEqual([]); + expect(result.residueElements).toEqual([dv]); + }); + + it("promotes a minimal valid whole-number rule", () => { + const dv = el("dataValidation", { type: "whole", sqref: "A1:B2" }); + const result = readDataValidations(worksheetWith(dv)); + expect(result.residueElements).toEqual([]); + expect(result.validations).toEqual([ + { + ranges: [{ startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }], + type: "whole", + }, + ]); + }); + + it("reads a between-operator rule's formula1 AND formula2", () => { + const dv = el( + "dataValidation", + { type: "whole", sqref: "A1", operator: "between" }, + [ + el("formula1", {}, [{ type: "text", value: "1" }]), + el("formula2", {}, [{ type: "text", value: "10" }]), + ], + ); + const result = readDataValidations(worksheetWith(dv)); + expect(result.validations[0]).toMatchObject({ + operator: "between", + formula1: "1", + formula2: "10", + }); + }); + + it("ignores formula2 for a non-between/notBetween operator, even if the element carries a ", () => { + const dv = el( + "dataValidation", + { type: "whole", sqref: "A1", operator: "equal" }, + [ + el("formula1", {}, [{ type: "text", value: "1" }]), + el("formula2", {}, [{ type: "text", value: "10" }]), + ], + ); + const result = readDataValidations(worksheetWith(dv)); + const validation = result.validations[0]; + expect(validation?.formula1).toBe("1"); + expect(Object.hasOwn(validation ?? {}, "formula2")).toBe(false); + }); + + it("drops a stray operator attribute for a 'list' type, which has no operator field", () => { + const dv = el("dataValidation", { + type: "list", + sqref: "A1", + operator: "equal", + }); + const result = readDataValidations(worksheetWith(dv)); + expect(Object.hasOwn(result.validations[0] ?? {}, "operator")).toBe(false); + }); + + it("drops a stray operator attribute for a 'custom' type as well", () => { + const dv = el("dataValidation", { + type: "custom", + sqref: "A1", + operator: "greaterThan", + }); + const result = readDataValidations(worksheetWith(dv)); + expect(Object.hasOwn(result.validations[0] ?? {}, "operator")).toBe(false); + }); + + it("drops an operator value outside the recognised ST_DataValidationOperator vocabulary", () => { + const dv = el("dataValidation", { + type: "whole", + sqref: "A1", + operator: "bogus", + }); + const result = readDataValidations(worksheetWith(dv)); + expect(Object.hasOwn(result.validations[0] ?? {}, "operator")).toBe(false); + }); + + it("recognises every ST_DataValidationOperator vocabulary member, not just a couple of them", () => { + const operators = [ + "between", + "notBetween", + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + ] as const; + for (const operator of operators) { + const dv = el("dataValidation", { type: "whole", sqref: "A1", operator }); + const result = readDataValidations(worksheetWith(dv)).validations[0]; + expect(result?.operator).toBe(operator); + } + }); + + it("reads formula2 for a notBetween operator too, not just between", () => { + const dv = el( + "dataValidation", + { type: "whole", sqref: "A1", operator: "notBetween" }, + [ + el("formula1", {}, [{ type: "text", value: "1" }]), + el("formula2", {}, [{ type: "text", value: "10" }]), + ], + ); + const result = readDataValidations(worksheetWith(dv)).validations[0]; + expect(result?.formula2).toBe("10"); + }); + + it("omits formula1 entirely when the element carries no child", () => { + const dv = el("dataValidation", { type: "whole", sqref: "A1" }); + const result = readDataValidations(worksheetWith(dv)).validations[0]; + expect(Object.hasOwn(result ?? {}, "formula1")).toBe(false); + }); + + it("reads allowBlank/showInputMessage/showErrorMessage only when truthy, omitting the key entirely otherwise", () => { + const trueDv = el("dataValidation", { + type: "whole", + sqref: "A1", + allowBlank: "1", + showInputMessage: "true", + showErrorMessage: "1", + }); + const trueResult = readDataValidations(worksheetWith(trueDv)) + .validations[0]; + expect(trueResult).toMatchObject({ + allowBlank: true, + showInputMessage: true, + showErrorMessage: true, + }); + + const falseDv = el("dataValidation", { type: "whole", sqref: "A1" }); + const falseResult = readDataValidations(worksheetWith(falseDv)) + .validations[0]; + expect(Object.hasOwn(falseResult ?? {}, "allowBlank")).toBe(false); + expect(Object.hasOwn(falseResult ?? {}, "showInputMessage")).toBe(false); + expect(Object.hasOwn(falseResult ?? {}, "showErrorMessage")).toBe(false); + }); + + it("decodes promptTitle/prompt/errorTitle/error entities, omitting each when absent", () => { + const dv = el("dataValidation", { + type: "whole", + sqref: "A1", + promptTitle: "Ben & Jerry", + prompt: "Pick a <value>", + errorTitle: "Bad "input"", + error: "Try 'again'", + }); + const result = readDataValidations(worksheetWith(dv)).validations[0]; + expect(result).toMatchObject({ + promptTitle: "Ben & Jerry", + prompt: "Pick a ", + errorTitle: 'Bad "input"', + error: "Try 'again'", + }); + + const bare = el("dataValidation", { type: "whole", sqref: "A1" }); + const bareResult = readDataValidations(worksheetWith(bare)).validations[0]; + for (const key of ["promptTitle", "prompt", "errorTitle", "error"]) { + expect(Object.hasOwn(bareResult ?? {}, key)).toBe(false); + } + }); + + it("reads a 'warning'/'information' errorStyle, omitting the field for the default 'stop' or an unrecognised value", () => { + const warning = readDataValidations( + worksheetWith( + el("dataValidation", { + type: "whole", + sqref: "A1", + errorStyle: "warning", + }), + ), + ).validations[0]; + expect(warning?.errorStyle).toBe("warning"); + + const information = readDataValidations( + worksheetWith( + el("dataValidation", { + type: "whole", + sqref: "A1", + errorStyle: "information", + }), + ), + ).validations[0]; + expect(information?.errorStyle).toBe("information"); + + const stop = readDataValidations( + worksheetWith( + el("dataValidation", { + type: "whole", + sqref: "A1", + errorStyle: "stop", + }), + ), + ).validations[0]; + expect(Object.hasOwn(stop ?? {}, "errorStyle")).toBe(false); + + const bogus = readDataValidations( + worksheetWith( + el("dataValidation", { + type: "whole", + sqref: "A1", + errorStyle: "bogus", + }), + ), + ).validations[0]; + expect(Object.hasOwn(bogus ?? {}, "errorStyle")).toBe(false); + }); + + it("captures an unmanaged attribute as source residue, omitting the field when none is present", () => { + const withExtra = readDataValidations( + worksheetWith( + el("dataValidation", { + type: "whole", + sqref: "A1", + imeMode: "hiragana", + }), + ), + ).validations[0]; + expect(withExtra?.source?.format).toBe("xlsx"); + expect(withExtra?.source?.xml).toContain("imeMode"); + + const clean = readDataValidations( + worksheetWith(el("dataValidation", { type: "whole", sqref: "A1" })), + ).validations[0]; + expect(Object.hasOwn(clean ?? {}, "source")).toBe(false); + }); +}); + +describe("buildDataValidationsElement", () => { + it("returns undefined for an empty array", () => { + expect(buildDataValidationsElement([])).toBeUndefined(); + }); + + it("wraps every validation with a count attribute matching the array length", () => { + const wrapper = buildDataValidationsElement([ + { + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + type: "whole", + }, + { + ranges: [{ startRow: 1, startColumn: 0, endRow: 1, endColumn: 0 }], + type: "whole", + }, + ]); + expect(wrapper?.tag).toBe("dataValidations"); + expect(attr(wrapper!, "count")).toBe("2"); + expect(wrapper?.children).toHaveLength(2); + }); +}); + +describe("buildDataValidationElement (via buildDataValidationsElement)", () => { + it("always writes type, sqref, allowBlank, showInputMessage, showErrorMessage, and a default errorStyle of 'stop'", () => { + const built = buildOne({ + ranges: [{ startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }], + type: "whole", + }); + expect(attr(built, "type")).toBe("whole"); + expect(attr(built, "sqref")).toBe("A1:B2"); + expect(attr(built, "allowBlank")).toBe("false"); + expect(attr(built, "showInputMessage")).toBe("false"); + expect(attr(built, "showErrorMessage")).toBe("false"); + expect(attr(built, "errorStyle")).toBe("stop"); + }); + + it("writes true booleans as the literal string 'true'", () => { + const built = buildOne({ + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + type: "whole", + allowBlank: true, + showInputMessage: true, + showErrorMessage: true, + }); + expect(attr(built, "allowBlank")).toBe("true"); + expect(attr(built, "showInputMessage")).toBe("true"); + expect(attr(built, "showErrorMessage")).toBe("true"); + }); + + it("omits the operator attribute entirely when the validation has none", () => { + const built = buildOne({ + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + type: "list", + }); + expect(attr(built, "operator")).toBeUndefined(); + }); + + it("writes the operator attribute when present", () => { + const built = buildOne({ + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + type: "whole", + operator: "greaterThan", + }); + expect(attr(built, "operator")).toBe("greaterThan"); + }); + + it("writes a non-default errorStyle verbatim", () => { + const built = buildOne({ + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + type: "whole", + errorStyle: "warning", + }); + expect(attr(built, "errorStyle")).toBe("warning"); + }); + + it("encodes promptTitle/prompt/errorTitle/error, omitting each when absent", () => { + const built = buildOne({ + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + type: "whole", + promptTitle: "Ben & Jerry", + prompt: "Pick a ", + errorTitle: 'Bad "input"', + error: "Try 'again'", + }); + expect(attr(built, "promptTitle")).toBe("Ben & Jerry"); + expect(attr(built, "prompt")).toBe("Pick a <value>"); + expect(attr(built, "errorTitle")).toContain("""); + expect(attr(built, "error")).toContain("'"); + + const bare = buildOne({ + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + type: "whole", + }); + for (const key of ["promptTitle", "prompt", "errorTitle", "error"]) { + expect(attr(bare, key)).toBeUndefined(); + } + }); + + it("writes formula1/formula2 children only when present, in that order", () => { + const both = buildOne({ + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + type: "whole", + operator: "between", + formula1: "1", + formula2: "10", + }); + expect( + both.children.map((c) => (c.type === "element" ? c.tag : undefined)), + ).toEqual(["formula1", "formula2"]); + + const neither = buildOne({ + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + type: "whole", + }); + expect(neither.children).toHaveLength(0); + }); + + it("lays managed attributes on top of captured residue, never letting residue override a managed key", () => { + const dv = el("dataValidation", { + type: "whole", + sqref: "A1", + imeMode: "hiragana", + }); + const read = readDataValidations(worksheetWith(dv)).validations[0]; + if (read === undefined) { + throw new Error("expected a promoted validation"); + } + const rebuilt = buildOne(read); + expect(attr(rebuilt, "imeMode")).toBe("hiragana"); + expect(attr(rebuilt, "type")).toBe("whole"); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/defined-names.test.ts b/packages/ooxml.js/src/typed/xlsx/defined-names.test.ts new file mode 100644 index 000000000..524d865b9 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/defined-names.test.ts @@ -0,0 +1,510 @@ +import { describe, expect, it } from "vitest"; +import { el } from "../../xml/fragment"; +import type { Package } from "../../model/package"; +import { + XLNM_PRINT_AREA, + XLNM_PRINT_TITLES, + buildPrintAreaValue, + buildPrintTitlesValue, + parsePrintAreaValue, + parsePrintTitlesValue, + quoteSheetNameIfNeeded, + readDefinedNamesBySheet, + readWorkbookNames, +} from "./defined-names"; + +function packageOf(workbook: ReturnType | undefined): Package { + return { + parts: + workbook === undefined + ? {} + : { + "xl/workbook.xml": { kind: "xml", nodes: [workbook] }, + }, + }; +} + +function workbookWithDefinedNames( + ...definedNames: ReturnType[] +): ReturnType { + return el("workbook", {}, [el("definedNames", {}, definedNames)]); +} + +describe("readDefinedNamesBySheet", () => { + it("returns an empty map when xl/workbook.xml is absent entirely", () => { + expect(readDefinedNamesBySheet(packageOf(undefined))).toEqual(new Map()); + }); + + it("returns an empty map when the workbook has no container", () => { + const pkg = packageOf(el("workbook", {}, [])); + expect(readDefinedNamesBySheet(pkg)).toEqual(new Map()); + }); + + it("returns an empty map when has no children", () => { + const pkg = packageOf(el("workbook", {}, [el("definedNames", {}, [])])); + expect(readDefinedNamesBySheet(pkg)).toEqual(new Map()); + }); + + it("skips a definedName with no name attribute", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { localSheetId: "0" }, [ + { type: "text", value: "A1" }, + ]), + ), + ); + expect(readDefinedNamesBySheet(pkg)).toEqual(new Map()); + }); + + it("skips a definedName with no localSheetId attribute", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: XLNM_PRINT_AREA }, [ + { type: "text", value: "A1" }, + ]), + ), + ); + expect(readDefinedNamesBySheet(pkg)).toEqual(new Map()); + }); + + it("skips a definedName whose name is neither the print-area nor print-titles reserved name", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: "MyRange", localSheetId: "0" }, [ + { type: "text", value: "A1" }, + ]), + ), + ); + expect(readDefinedNamesBySheet(pkg)).toEqual(new Map()); + }); + + it("skips a definedName whose localSheetId does not parse as an integer", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: XLNM_PRINT_AREA, localSheetId: "abc" }, [ + { type: "text", value: "A1" }, + ]), + ), + ); + expect(readDefinedNamesBySheet(pkg)).toEqual(new Map()); + }); + + it("skips a definedName whose localSheetId is negative", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: XLNM_PRINT_AREA, localSheetId: "-1" }, [ + { type: "text", value: "A1" }, + ]), + ), + ); + expect(readDefinedNamesBySheet(pkg)).toEqual(new Map()); + }); + + it("reads a print-area defined name into printArea for its own sheet index", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: XLNM_PRINT_AREA, localSheetId: "2" }, [ + { type: "text", value: "Data!$A$1:$I$20" }, + ]), + ), + ); + expect(readDefinedNamesBySheet(pkg)).toEqual( + new Map([[2, { printArea: "Data!$A$1:$I$20" }]]), + ); + }); + + it("reads a print-titles defined name into printTitles for its own sheet index", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: XLNM_PRINT_TITLES, localSheetId: "0" }, [ + { type: "text", value: "Data!$A:$A" }, + ]), + ), + ); + expect(readDefinedNamesBySheet(pkg)).toEqual( + new Map([[0, { printTitles: "Data!$A:$A" }]]), + ); + }); + + it("merges a print-area and a print-titles entry for the same sheet index into one record", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: XLNM_PRINT_AREA, localSheetId: "0" }, [ + { type: "text", value: "Data!$A$1:$I$20" }, + ]), + el("definedName", { name: XLNM_PRINT_TITLES, localSheetId: "0" }, [ + { type: "text", value: "Data!$A:$A" }, + ]), + ), + ); + expect(readDefinedNamesBySheet(pkg)).toEqual( + new Map([ + [0, { printArea: "Data!$A$1:$I$20", printTitles: "Data!$A:$A" }], + ]), + ); + }); + + it("keeps separate sheets' entries distinct", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: XLNM_PRINT_AREA, localSheetId: "0" }, [ + { type: "text", value: "A1:B2" }, + ]), + el("definedName", { name: XLNM_PRINT_AREA, localSheetId: "1" }, [ + { type: "text", value: "C1:D2" }, + ]), + ), + ); + expect(readDefinedNamesBySheet(pkg)).toEqual( + new Map([ + [0, { printArea: "A1:B2" }], + [1, { printArea: "C1:D2" }], + ]), + ); + }); +}); + +describe("readWorkbookNames", () => { + it("returns an empty array when xl/workbook.xml is absent entirely", () => { + expect(readWorkbookNames(packageOf(undefined))).toEqual([]); + }); + + it("returns an empty array when the workbook has no container", () => { + expect(readWorkbookNames(packageOf(el("workbook", {}, [])))).toEqual([]); + }); + + it("skips a definedName with no name attribute", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", {}, [{ type: "text", value: "A1" }]), + ), + ); + expect(readWorkbookNames(pkg)).toEqual([]); + }); + + it("reads a workbook-scoped name (no localSheetId) with no scopeSheetIndex key at all", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: "MyRange" }, [ + { type: "text", value: "Sheet1!$A$1" }, + ]), + ), + ); + const names = readWorkbookNames(pkg); + expect(names).toEqual([{ name: "MyRange", refersTo: "Sheet1!$A$1" }]); + expect(Object.hasOwn(names[0] ?? {}, "scopeSheetIndex")).toBe(false); + }); + + it("reads a sheet-scoped name's localSheetId into scopeSheetIndex", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: "MyRange", localSheetId: "3" }, [ + { type: "text", value: "A1" }, + ]), + ), + ); + expect(readWorkbookNames(pkg)).toEqual([ + { name: "MyRange", refersTo: "A1", scopeSheetIndex: 3 }, + ]); + }); + + it("omits scopeSheetIndex, rather than a garbage value, when localSheetId does not parse as an integer", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: "MyRange", localSheetId: "xyz" }, [ + { type: "text", value: "A1" }, + ]), + ), + ); + const names = readWorkbookNames(pkg); + expect(names).toEqual([{ name: "MyRange", refersTo: "A1" }]); + expect(Object.hasOwn(names[0] ?? {}, "scopeSheetIndex")).toBe(false); + }); + + it("omits scopeSheetIndex when localSheetId is negative", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: "MyRange", localSheetId: "-2" }, [ + { type: "text", value: "A1" }, + ]), + ), + ); + const names = readWorkbookNames(pkg); + expect(names).toEqual([{ name: "MyRange", refersTo: "A1" }]); + expect(Object.hasOwn(names[0] ?? {}, "scopeSheetIndex")).toBe(false); + }); + + it("includes the reserved _xlnm.Print_Area/Print_Titles names like any other defined name", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: XLNM_PRINT_AREA, localSheetId: "0" }, [ + { type: "text", value: "Data!$A$1:$I$20" }, + ]), + ), + ); + expect(readWorkbookNames(pkg)).toEqual([ + { + name: XLNM_PRINT_AREA, + refersTo: "Data!$A$1:$I$20", + scopeSheetIndex: 0, + }, + ]); + }); + + it("preserves the file's own document order across multiple names", () => { + const pkg = packageOf( + workbookWithDefinedNames( + el("definedName", { name: "Second" }, [{ type: "text", value: "B1" }]), + el("definedName", { name: "First" }, [{ type: "text", value: "A1" }]), + ), + ); + expect(readWorkbookNames(pkg).map((n) => n.name)).toEqual([ + "Second", + "First", + ]); + }); +}); + +describe("parsePrintAreaValue", () => { + it("parses a single unquoted, undollared range", () => { + expect(parsePrintAreaValue("A1:B2")).toEqual({ + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 1, + }); + }); + + it("strips a sheet-name prefix before parsing", () => { + expect(parsePrintAreaValue("Data!$A$1:$I$20")).toEqual({ + startRow: 0, + startColumn: 0, + endRow: 19, + endColumn: 8, + }); + }); + + it("strips a quoted sheet-name prefix containing a space", () => { + expect(parsePrintAreaValue("'My Sheet'!$A$1:$B$2")).toEqual({ + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 1, + }); + }); + + it("uses only the FIRST of several comma-separated ranges", () => { + expect(parsePrintAreaValue("A1:B2,D1:E2")).toEqual({ + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 1, + }); + }); + + it("trims surrounding whitespace around the first segment", () => { + expect(parsePrintAreaValue(" A1:B2 ,D1:E2")).toEqual({ + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 1, + }); + }); + + it("returns undefined for an empty string", () => { + expect(parsePrintAreaValue("")).toBeUndefined(); + }); + + it("returns undefined for a whitespace-only string", () => { + expect(parsePrintAreaValue(" ")).toBeUndefined(); + }); + + it("returns undefined for a value that does not parse as a range", () => { + expect(parsePrintAreaValue("not a range")).toBeUndefined(); + }); +}); + +describe("parsePrintTitlesValue", () => { + it("reads a full-column band into repeatColumns, leaving repeatRows unset", () => { + const result = parsePrintTitlesValue("Data!$A:$C"); + expect(result).toEqual({ repeatColumns: { start: 0, end: 2 } }); + expect(Object.hasOwn(result, "repeatRows")).toBe(false); + }); + + it("reads a full-row band into repeatRows, leaving repeatColumns unset", () => { + const result = parsePrintTitlesValue("Data!$1:$3"); + expect(result).toEqual({ repeatRows: { start: 0, end: 2 } }); + expect(Object.hasOwn(result, "repeatColumns")).toBe(false); + }); + + it("reads both bands from a comma-separated value", () => { + expect(parsePrintTitlesValue("Data!$A:$C,Data!$1:$3")).toEqual({ + repeatColumns: { start: 0, end: 2 }, + repeatRows: { start: 0, end: 2 }, + }); + }); + + it("trims whitespace directly touching a comma-separated segment before parsing it", () => { + expect(parsePrintTitlesValue(" Data!$A:$C , Data!$1:$3 ")).toEqual({ + repeatColumns: { start: 0, end: 2 }, + repeatRows: { start: 0, end: 2 }, + }); + }); + + it("reads a genuine multi-digit row band, not just a single digit", () => { + expect(parsePrintTitlesValue("10:25")).toEqual({ + repeatRows: { start: 9, end: 24 }, + }); + }); + + it("rejects a row segment with a non-digit character before the digits", () => { + expect(parsePrintTitlesValue("x3:5")).toEqual({}); + }); + + it("rejects a row segment with a non-digit character after the digits", () => { + expect(parsePrintTitlesValue("3x:5")).toEqual({}); + }); + + it("rejects a row segment whose end spec has a non-digit character before its digits", () => { + expect(parsePrintTitlesValue("3:x5")).toEqual({}); + }); + + it("rejects a row segment whose end spec has a non-digit character after its digits", () => { + expect(parsePrintTitlesValue("3:5x")).toEqual({}); + }); + + it("rejects a mixed digit/letter segment as neither a column nor a row band", () => { + expect(parsePrintTitlesValue("1:A")).toEqual({}); + }); + + it("normalises a reversed column band (end before start) to ascending order", () => { + expect(parsePrintTitlesValue("$C:$A")).toEqual({ + repeatColumns: { start: 0, end: 2 }, + }); + }); + + it("normalises a reversed row band (end before start) to ascending order", () => { + expect(parsePrintTitlesValue("$3:$1")).toEqual({ + repeatRows: { start: 0, end: 2 }, + }); + }); + + it("skips a segment with no ':' separator at all", () => { + expect(parsePrintTitlesValue("garbage")).toEqual({}); + }); + + it("skips a segment shaped as a genuine cell-to-cell range, matching neither band shape", () => { + expect(parsePrintTitlesValue("A1:B2")).toEqual({}); + }); + + it("returns an empty object for an empty string", () => { + expect(parsePrintTitlesValue("")).toEqual({}); + }); +}); + +describe("quoteSheetNameIfNeeded", () => { + it("leaves a plain identifier-shaped name unquoted", () => { + expect(quoteSheetNameIfNeeded("Sheet1")).toBe("Sheet1"); + }); + + it("leaves an underscore-led name unquoted", () => { + expect(quoteSheetNameIfNeeded("_Hidden")).toBe("_Hidden"); + }); + + it("quotes a name containing a space", () => { + expect(quoteSheetNameIfNeeded("My Sheet")).toBe("'My Sheet'"); + }); + + it("quotes a name starting with a digit", () => { + expect(quoteSheetNameIfNeeded("1stQuarter")).toBe("'1stQuarter'"); + }); + + it("quotes a name and doubles an embedded single quote", () => { + expect(quoteSheetNameIfNeeded("Joe's Sheet")).toBe("'Joe''s Sheet'"); + }); +}); + +describe("buildPrintAreaValue", () => { + it("builds a dollared, sheet-qualified reference for a plain sheet name", () => { + expect( + buildPrintAreaValue("Data", { + startRow: 0, + startColumn: 0, + endRow: 19, + endColumn: 8, + }), + ).toBe("Data!$A$1:$I$20"); + }); + + it("quotes the sheet name when it needs it", () => { + expect( + buildPrintAreaValue("My Sheet", { + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 1, + }), + ).toBe("'My Sheet'!$A$1:$B$2"); + }); + + it("round-trips through parsePrintAreaValue", () => { + const range = { startRow: 2, startColumn: 1, endRow: 5, endColumn: 4 }; + const built = buildPrintAreaValue("Sheet1", range); + expect(parsePrintAreaValue(built)).toEqual(range); + }); + + it("writes a genuine multi-letter column reference beyond Z", () => { + // Column index 26 is "AA" -- a single-letter column would not distinguish a regex/loop that stops after one character. + expect( + buildPrintAreaValue("Sheet1", { + startRow: 0, + startColumn: 26, + endRow: 0, + endColumn: 26, + }), + ).toBe("Sheet1!$AA$1:$AA$1"); + }); +}); + +describe("buildPrintTitlesValue", () => { + it("returns undefined when neither band is present", () => { + expect( + buildPrintTitlesValue("Sheet1", undefined, undefined), + ).toBeUndefined(); + }); + + it("builds only the rows segment when only repeatRows is present", () => { + expect( + buildPrintTitlesValue("Sheet1", { start: 0, end: 2 }, undefined), + ).toBe("Sheet1!$1:$3"); + }); + + it("builds only the columns segment when only repeatColumns is present", () => { + expect( + buildPrintTitlesValue("Sheet1", undefined, { start: 0, end: 2 }), + ).toBe("Sheet1!$A:$C"); + }); + + it("orders the columns segment before the rows segment when both are present", () => { + expect( + buildPrintTitlesValue( + "Sheet1", + { start: 0, end: 2 }, + { start: 0, end: 1 }, + ), + ).toBe("Sheet1!$A:$B,Sheet1!$1:$3"); + }); + + it("round-trips through parsePrintTitlesValue", () => { + const built = buildPrintTitlesValue( + "Data", + { start: 3, end: 5 }, + { start: 0, end: 1 }, + ); + expect(built).toBeDefined(); + expect(parsePrintTitlesValue(built ?? "")).toEqual({ + repeatRows: { start: 3, end: 5 }, + repeatColumns: { start: 0, end: 1 }, + }); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/defined-names.ts b/packages/ooxml.js/src/typed/xlsx/defined-names.ts index 9b936362d..635918811 100644 --- a/packages/ooxml.js/src/typed/xlsx/defined-names.ts +++ b/packages/ooxml.js/src/typed/xlsx/defined-names.ts @@ -9,7 +9,6 @@ import { columnIndexToLetters, columnLettersToIndex, parseRangeReference, - rangeReference, } from "document-schema.js"; // xl/workbook.xml's own print-area and print-titles mechanism: NOT a per-sheet attribute of any kind, but two reserved, sheet-scoped workbook-level defined names -- confirmed against real LibreOffice output (see typed/xlsx/content.test.ts's own kitchen-sink fixture): Data!$A$1:$I$20 and Data!$A:$A,Data!$1:$1. ECMA-376 Part 1 SS18.2.6 reserves the "_xlnm." prefix for exactly this purpose (Print_Area, Print_Titles, and others this reader doesn't need); localSheetId is the 0-based index of the sheet the name applies to, in xl/workbook.xml's own document order -- the SAME order typed/xlsx/content.ts's own sheet-resolution walk already produces, so a caller need only pass that same 0-based index through. @@ -36,11 +35,12 @@ export function readDefinedNamesBySheet( return map; } for (const definedName of childrenWithTag(container, "definedName")) { - const name = attr(definedName, "name"); const localSheetIdRaw = attr(definedName, "localSheetId"); - if (name === undefined || localSheetIdRaw === undefined) { + if (localSheetIdRaw === undefined) { continue; } + // A definedName with no name at all can never equal either reserved name below, so it is already excluded by that check alone -- no separate `name === undefined` guard is needed first. + const name = attr(definedName, "name"); if (name !== XLNM_PRINT_AREA && name !== XLNM_PRINT_TITLES) { continue; } @@ -94,22 +94,20 @@ export function readWorkbookNames(pkg: Package): ContentDefinedName[] { return names; } -// Strips a leading "SheetName!" (or "'Sheet Name'!") prefix from one reference segment. Excel sheet names cannot themselves contain "!" (a reserved formula character), so the LAST "!" in the segment unambiguously separates the sheet-name prefix from the cell/range reference that follows, with no need to parse the optional single-quote sheet-name quoting at all. +// Strips a leading "SheetName!" (or "'Sheet Name'!") prefix from one reference segment. Excel sheet names cannot themselves contain "!" (a reserved formula character), so the LAST "!" in the segment unambiguously separates the sheet-name prefix from the cell/range reference that follows, with no need to parse the optional single-quote sheet-name quoting at all. No ternary is needed for the no-"!"-at-all case: lastIndexOf returns -1 then, and slice(-1 + 1) is slice(0), which already returns the whole segment unchanged. function stripSheetPrefix(segment: string): string { const bang = segment.lastIndexOf("!"); - return bang === -1 ? segment : segment.slice(bang + 1); + return segment.slice(bang + 1); } // _xlnm.Print_Area's value is a comma-separated list of one or more absolute ranges (Excel supports multiple non-contiguous print areas per sheet); ContentSheetPrintSettings.printRange models only ONE, so -- matching document-schema.js's own documented odf.js precedent for the identical ODF table:print-ranges scope boundary -- only the first range is parsed, and it is a documented, narrow scope boundary rather than a silent one. export function parsePrintAreaValue( value: string, ): ContentSheetPrintRange | undefined { - const first = value.split(",")[0]?.trim(); - if (first === undefined || first.length === 0) { - return undefined; - } - const range = parseRangeReference(stripSheetPrefix(first).replace(/\$/g, "")); - return range; + // Found via indexOf/slice rather than value.split(",")[0], so `first` is always a definite string (never possibly-undefined under noUncheckedIndexedAccess) with no separate emptiness guard needed: an empty (or whitespace-only) first segment already parses to no range at all, since stripSheetPrefix/replace leave it empty and parseRangeReference("") returns undefined on its own. + const commaIndex = value.indexOf(","); + const first = (commaIndex === -1 ? value : value.slice(0, commaIndex)).trim(); + return parseRangeReference(stripSheetPrefix(first).replace(/\$/g, "")); } interface PrintTitles { @@ -128,15 +126,14 @@ export function parsePrintTitlesValue(value: string): PrintTitles { } const startSpec = segment.slice(0, separatorIndex); const endSpec = segment.slice(separatorIndex + 1); - if (/^[A-Za-z]+$/.test(startSpec) && /^[A-Za-z]+$/.test(endSpec)) { - const start = columnLettersToIndex(startSpec); - const end = columnLettersToIndex(endSpec); - if (start !== undefined && end !== undefined) { - result.repeatColumns = { - start: Math.min(start, end), - end: Math.max(start, end), - }; - } + // columnLettersToIndex already rejects anything but a non-empty run of letters (document-schema.js's own a1.ts), so trying it directly on both sides -- rather than gating first on a letters-only regex -- rejects exactly the same inputs: no separate regex test is needed to tell them apart. + const startColumn = columnLettersToIndex(startSpec); + const endColumn = columnLettersToIndex(endSpec); + if (startColumn !== undefined && endColumn !== undefined) { + result.repeatColumns = { + start: Math.min(startColumn, endColumn), + end: Math.max(startColumn, endColumn), + }; } else if (/^\d+$/.test(startSpec) && /^\d+$/.test(endSpec)) { const start = Number.parseInt(startSpec, 10) - 1; const end = Number.parseInt(endSpec, 10) - 1; @@ -157,19 +154,14 @@ export function quoteSheetNameIfNeeded(sheetName: string): string { return `'${sheetName.replace(/'/g, "''")}'`; } -// The write-side inverse of parsePrintAreaValue: builds a _xlnm.Print_Area defined-name value for one sheet's own print range. +// The write-side inverse of parsePrintAreaValue: builds a _xlnm.Print_Area defined-name value for one sheet's own print range. Built directly from the range's own row/column indices rather than dollar-signing rangeReference's own formatted "A1:B2" string with a regex: the same structured values are available already, so there is no formatted string to re-parse in the first place. export function buildPrintAreaValue( sheetName: string, range: ContentSheetPrintRange, ): string { - const ref = rangeReference({ - startRow: range.startRow, - startColumn: range.startColumn, - endRow: range.endRow, - endColumn: range.endColumn, - }); - const dollared = ref.replace(/([A-Z]+)(\d+)/g, "$$$1$$$2"); - return `${quoteSheetNameIfNeeded(sheetName)}!${dollared}`; + const start = `$${columnIndexToLetters(range.startColumn)}$${range.startRow + 1}`; + const end = `$${columnIndexToLetters(range.endColumn)}$${range.endRow + 1}`; + return `${quoteSheetNameIfNeeded(sheetName)}!${start}:${end}`; } // The write-side inverse of parsePrintTitlesValue: builds a _xlnm.Print_Titles defined-name value from whichever of repeatRows/repeatColumns is present (order matches this package's own kitchen-sink fixture: columns segment first, then rows). diff --git a/packages/ooxml.js/src/typed/xlsx/definitions-write.test.ts b/packages/ooxml.js/src/typed/xlsx/definitions-write.test.ts new file mode 100644 index 000000000..c6e7c1ce8 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/definitions-write.test.ts @@ -0,0 +1,158 @@ +import type { ContentDefinedName, DefinitionsTable } from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { + buildNameDefinedNameElements, + buildTablePart, + collectTableEntries, +} from "./definitions-write"; + +describe("collectTableEntries", () => { + it("skips a non-table entry entirely, never validating its own fields, and returns only the table entries", () => { + const definitions: DefinitionsTable = { + irrelevant: { kind: "something-else" }, + real: { + kind: "table", + name: "MyTable", + ref: "A1:B2", + sheet: "Sheet1", + columns: ["Col1", "Col2"], + }, + }; + + const entries = collectTableEntries(definitions); + + expect(entries).toEqual([ + { + name: "MyTable", + ref: "A1:B2", + sheet: "Sheet1", + columns: ["Col1", "Col2"], + }, + ]); + }); + + it("throws naming the field and the entry kind when a required string field is missing", () => { + const definitions: DefinitionsTable = { + broken: { kind: "table", ref: "A1:B2", sheet: "Sheet1", columns: [] }, + }; + expect(() => { + collectTableEntries(definitions); + }).toThrow(/a "table" definitions entry's "name" field must be a string/); + }); + + it("throws naming the ref field specifically when it is missing, not the name field", () => { + const definitions: DefinitionsTable = { + broken: { kind: "table", name: "T", sheet: "Sheet1", columns: [] }, + }; + expect(() => { + collectTableEntries(definitions); + }).toThrow(/a "table" definitions entry's "ref" field must be a string/); + }); + + it("throws naming the sheet field specifically when it is missing, not the ref field", () => { + const definitions: DefinitionsTable = { + broken: { kind: "table", name: "T", ref: "A1:B2", columns: [] }, + }; + expect(() => { + collectTableEntries(definitions); + }).toThrow(/a "table" definitions entry's "sheet" field must be a string/); + }); + + it("throws naming the field and the entry kind when the columns field is not present", () => { + const definitions: DefinitionsTable = { + broken: { kind: "table", name: "T", ref: "A1:B2", sheet: "Sheet1" }, + }; + expect(() => { + collectTableEntries(definitions); + }).toThrow( + /a "table" definitions entry's "columns" field must be a string array/, + ); + }); + + it("rejects a columns array carrying even one non-string entry, not just an array of entirely non-strings", () => { + const definitions: DefinitionsTable = { + broken: { + kind: "table", + name: "T", + ref: "A1:B2", + sheet: "Sheet1", + columns: ["Col1", 42], + }, + }; + expect(() => { + collectTableEntries(definitions); + }).toThrow(/"columns" field must be a string array/); + }); +}); + +describe("buildNameDefinedNameElements", () => { + it("records a defined truthy scopeSheetIndex itself in carriedNames, not the empty-string fallback", () => { + const names: ContentDefinedName[] = [ + { name: "Scoped", refersTo: "Sheet1!A1", scopeSheetIndex: 2 }, + ]; + const carriedNames = new Set(); + + buildNameDefinedNameElements(names, carriedNames); + + expect(carriedNames.has("Scoped@2")).toBe(true); + expect(carriedNames.has("Scoped@")).toBe(false); + }); + + it("falls back to an empty-string scope suffix, not a placeholder, when scopeSheetIndex is absent", () => { + const names: ContentDefinedName[] = [ + { name: "Global", refersTo: "Sheet1!A1" }, + ]; + const carriedNames = new Set(); + + buildNameDefinedNameElements(names, carriedNames); + + expect(carriedNames.has("Global@")).toBe(true); + }); +}); + +describe("buildTablePart", () => { + it("builds CT_Table's required attributes, an autoFilter over the entry's own ref, and one tableColumn per column in order with 1-based ids", () => { + const table = buildTablePart( + { + name: "Sales", + ref: "A1:B3", + sheet: "Sheet1", + columns: ["Region", "Total"], + }, + 5, + ); + + expect(table.tag).toBe("table"); + expect(table.attributes).toContainEqual({ + name: "xmlns", + value: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + }); + expect(table.attributes).toContainEqual({ name: "id", value: "5" }); + expect(table.attributes).toContainEqual({ + name: "totalsRowShown", + value: "0", + }); + + const [autoFilter, tableColumns] = table.children; + if (autoFilter?.type !== "element" || tableColumns?.type !== "element") { + throw new Error("expected both children to be elements"); + } + expect(autoFilter.tag).toBe("autoFilter"); + expect(autoFilter.attributes).toContainEqual({ + name: "ref", + value: "A1:B3", + }); + + expect(tableColumns.tag).toBe("tableColumns"); + expect(tableColumns.attributes).toContainEqual({ + name: "count", + value: "2", + }); + const columnIds = tableColumns.children.map((child) => + child.type === "element" + ? child.attributes.find((a) => a.name === "id")?.value + : undefined, + ); + expect(columnIds).toEqual(["1", "2"]); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/definitions.test.ts b/packages/ooxml.js/src/typed/xlsx/definitions.test.ts new file mode 100644 index 000000000..1da208424 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/definitions.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import type { Package } from "../../model/package"; +import { el } from "../../xml/fragment"; +import { readWorkbookDefinitions } from "./definitions"; + +const REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"; +const REL_WORKSHEET = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"; +const REL_TABLE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/table"; +const REL_DRAWING = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"; + +function basePackage(sheetRels: ReturnType[]): Package { + return { + parts: { + "xl/workbook.xml": { + kind: "xml", + nodes: [ + el("workbook", {}, [ + el("sheets", {}, [ + el("sheet", { name: "Sheet1", sheetId: "1", "r:id": "rId1" }), + ]), + ]), + ], + }, + "xl/_rels/workbook.xml.rels": { + kind: "xml", + nodes: [ + el("Relationships", { xmlns: REL_NS }, [ + el("Relationship", { + Id: "rId1", + Type: REL_WORKSHEET, + Target: "worksheets/sheet1.xml", + }), + ]), + ], + }, + "xl/worksheets/sheet1.xml": { + kind: "xml", + nodes: [el("worksheet", {}, [])], + }, + "xl/worksheets/_rels/sheet1.xml.rels": { + kind: "xml", + nodes: [el("Relationships", { xmlns: REL_NS }, sheetRels)], + }, + "xl/drawings/drawing1.xml": { + kind: "xml", + nodes: [el("xdr:wsDr", {}, [])], + }, + }, + }; +} + +function tablePart(attrs: Record): Package["parts"][string] { + return { + kind: "xml", + nodes: [ + el("table", attrs, [ + el("tableColumns", {}, [ + el("tableColumn", { name: "Col1" }), + el("tableColumn", { name: "Col2" }), + ]), + ]), + ], + }; +} + +describe("readWorkbookDefinitions", () => { + it("returns undefined for a workbook whose sheet carries no table relationship at all", () => { + const pkg = basePackage([ + el("Relationship", { + Id: "rId1", + Type: REL_DRAWING, + Target: "../drawings/drawing1.xml", + }), + ]); + expect(readWorkbookDefinitions(pkg)).toBeUndefined(); + }); + + it("skips a non-table relationship and reads only the genuine table relationship among several", () => { + const pkg = basePackage([ + el("Relationship", { + Id: "rId1", + Type: REL_DRAWING, + Target: "../drawings/drawing1.xml", + }), + el("Relationship", { + Id: "rId2", + Type: REL_TABLE, + Target: "../tables/table1.xml", + }), + ]); + pkg.parts["xl/tables/table1.xml"] = tablePart({ + name: "SalesTable", + ref: "A1:B2", + }); + expect(readWorkbookDefinitions(pkg)).toEqual({ + "table:SalesTable": { + kind: "table", + name: "SalesTable", + ref: "A1:B2", + sheet: "Sheet1", + columns: ["Col1", "Col2"], + }, + }); + }); + + it("skips a non-table relationship by its own type, even when its target happens to be a well-formed table element", () => { + // Proves the type-suffix guard filters on the relationship's own Type, not merely on whether the target later fails the name/ref check -- a distractor relationship pointed at a genuinely complete table-shaped part must still be skipped. + const pkg = basePackage([ + el("Relationship", { + Id: "rId1", + Type: REL_DRAWING, + Target: "../tables/table1.xml", + }), + ]); + pkg.parts["xl/tables/table1.xml"] = tablePart({ + name: "SalesTable", + ref: "A1:B2", + }); + expect(readWorkbookDefinitions(pkg)).toBeUndefined(); + }); + + it("skips a table part missing its own name attribute", () => { + const pkg = basePackage([ + el("Relationship", { + Id: "rId1", + Type: REL_TABLE, + Target: "../tables/table1.xml", + }), + ]); + pkg.parts["xl/tables/table1.xml"] = tablePart({ ref: "A1:B2" }); + expect(readWorkbookDefinitions(pkg)).toBeUndefined(); + }); + + it("skips a table part missing its own ref attribute", () => { + const pkg = basePackage([ + el("Relationship", { + Id: "rId1", + Type: REL_TABLE, + Target: "../tables/table1.xml", + }), + ]); + pkg.parts["xl/tables/table1.xml"] = tablePart({ name: "SalesTable" }); + expect(readWorkbookDefinitions(pkg)).toBeUndefined(); + }); +}); 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 000000000..04eb901c3 --- /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 ba42a9702..021c3cba1 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/print-settings.test.ts b/packages/ooxml.js/src/typed/xlsx/print-settings.test.ts index d232e7c06..fde27c86a 100644 --- a/packages/ooxml.js/src/typed/xlsx/print-settings.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/print-settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { PAGE_SIZE_A4, PAGE_SIZE_LETTER } from "document-schema.js"; import { el } from "../../xml/fragment"; +import type { SheetDefinedNames } from "./defined-names"; import { DEFAULT_HEADER_FOOTER_MARGIN_PT, readPrintSettings, @@ -69,3 +70,252 @@ describe("DEFAULT_HEADER_FOOTER_MARGIN_PT", () => { expect(DEFAULT_HEADER_FOOTER_MARGIN_PT).toBeCloseTo(21.6, 5); }); }); + +describe("readPrintSettings: margins", () => { + it("falls back to the Normal preset when there is no at all", () => { + const settings = readPrintSettings(el("worksheet"), 0, new Map()); + expect(settings.margins).toEqual({ + topPt: 54, + rightPt: 50.4, + bottomPt: 54, + leftPt: 50.4, + }); + }); + + it("reads each of top/right/bottom/left independently, falling back per-side when only some are present", () => { + const worksheet = el("worksheet", {}, [ + el("pageMargins", { top: "1", left: "0.5" }), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(settings.margins).toEqual({ + topPt: 72, + rightPt: 50.4, + bottomPt: 54, + leftPt: 36, + }); + }); + + it("reads all four sides from their own distinct attributes, converting inches to points by multiplying, not dividing", () => { + const worksheet = el("worksheet", {}, [ + el("pageMargins", { top: "1", right: "2", bottom: "1.5", left: "0.25" }), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(settings.margins).toEqual({ + topPt: 72, + rightPt: 144, + bottomPt: 108, + leftPt: 18, + }); + }); + + it("falls back to the default top margin specifically when top alone is absent", () => { + const worksheet = el("worksheet", {}, [ + el("pageMargins", { right: "1", bottom: "1", left: "1" }), + ]); + expect(readPrintSettings(worksheet, 0, new Map()).margins.topPt).toBe(54); + }); + + it("falls back to the default left margin specifically when left alone is absent", () => { + const worksheet = el("worksheet", {}, [ + el("pageMargins", { top: "1", right: "1", bottom: "1" }), + ]); + expect(readPrintSettings(worksheet, 0, new Map()).margins.leftPt).toBe( + 50.4, + ); + }); +}); + +describe("readPrintSettings: pageOrder", () => { + it("defaults to downThenOver when pageSetup is absent", () => { + expect(readPrintSettings(el("worksheet"), 0, new Map()).pageOrder).toBe( + "downThenOver", + ); + }); + + it("defaults to downThenOver for any value other than the literal overThenDown", () => { + const worksheet = el("worksheet", {}, [ + el("pageSetup", { pageOrder: "bogus" }), + ]); + expect(readPrintSettings(worksheet, 0, new Map()).pageOrder).toBe( + "downThenOver", + ); + }); + + it("reads overThenDown when explicitly stated", () => { + const worksheet = el("worksheet", {}, [ + el("pageSetup", { pageOrder: "overThenDown" }), + ]); + expect(readPrintSettings(worksheet, 0, new Map()).pageOrder).toBe( + "overThenDown", + ); + }); +}); + +describe("readPrintSettings: gridlines/headers", () => { + it("defaults gridlines and headers to false with no at all", () => { + const settings = readPrintSettings(el("worksheet"), 0, new Map()); + expect(settings.gridlines).toBe(false); + expect(settings.headers).toBe(false); + }); + + it("reads gridLines/headings independently as true", () => { + const worksheet = el("worksheet", {}, [ + el("printOptions", { gridLines: "1", headings: "true" }), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(settings.gridlines).toBe(true); + expect(settings.headers).toBe(true); + }); +}); + +describe("readPrintSettings: manual breaks", () => { + it("omits manualBreaks entirely when neither rowBreaks nor colBreaks is present", () => { + const settings = readPrintSettings(el("worksheet"), 0, new Map()); + expect(Object.hasOwn(settings, "manualBreaks")).toBe(false); + }); + + it("omits manualBreaks when the containers are present but empty", () => { + const worksheet = el("worksheet", {}, [ + el("rowBreaks", {}, []), + el("colBreaks", {}, []), + ]); + expect( + Object.hasOwn(readPrintSettings(worksheet, 0, new Map()), "manualBreaks"), + ).toBe(false); + }); + + it("reads row and column break indices independently", () => { + const worksheet = el("worksheet", {}, [ + el("rowBreaks", {}, [el("brk", { id: "3" }), el("brk", { id: "7" })]), + el("colBreaks", {}, [el("brk", { id: "1" })]), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(settings.manualBreaks).toEqual({ rows: [3, 7], columns: [1] }); + }); + + it("skips a whose id does not parse as a non-negative integer", () => { + const worksheet = el("worksheet", {}, [ + el("rowBreaks", {}, [ + el("brk", { id: "abc" }), + el("brk", { id: "-1" }), + el("brk", { id: "2" }), + ]), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(settings.manualBreaks).toEqual({ rows: [2], columns: [] }); + }); + + it("includes a break at id 0, the first valid non-negative index", () => { + const worksheet = el("worksheet", {}, [ + el("rowBreaks", {}, [el("brk", { id: "0" })]), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(settings.manualBreaks).toEqual({ rows: [0], columns: [] }); + }); + + it("still reports manualBreaks when only column breaks are present, with an empty rows array", () => { + const worksheet = el("worksheet", {}, [ + el("colBreaks", {}, [el("brk", { id: "1" })]), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(Object.hasOwn(settings, "manualBreaks")).toBe(true); + expect(settings.manualBreaks).toEqual({ rows: [], columns: [1] }); + }); +}); + +describe("readPrintSettings: fit-to-page vs scale", () => { + it("reads an explicit scalePercent when fitToPage is not set", () => { + const worksheet = el("worksheet", {}, [el("pageSetup", { scale: "75" })]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(settings.scalePercent).toBe(75); + expect(Object.hasOwn(settings, "fitToPages")).toBe(false); + }); + + it("omits scalePercent when scale is non-numeric", () => { + const worksheet = el("worksheet", {}, [ + el("pageSetup", { scale: "not-a-number" }), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(Object.hasOwn(settings, "scalePercent")).toBe(false); + }); + + it("omits scalePercent when the scale attribute is absent entirely", () => { + const worksheet = el("worksheet", {}, [el("pageSetup", {})]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(Object.hasOwn(settings, "scalePercent")).toBe(false); + }); + + it("reads fitToPages width/height when sheetPr/pageSetUpPr@fitToPage is set, ignoring scale", () => { + const worksheet = el("worksheet", {}, [ + el("sheetPr", {}, [el("pageSetUpPr", { fitToPage: "1" })]), + el("pageSetup", { scale: "50", fitToWidth: "2", fitToHeight: "3" }), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(settings.fitToPages).toEqual({ width: 2, height: 3 }); + expect(Object.hasOwn(settings, "scalePercent")).toBe(false); + }); + + it("defaults fitToPages width/height to 1 when fitToPage is set but the attributes are absent", () => { + const worksheet = el("worksheet", {}, [ + el("sheetPr", {}, [el("pageSetUpPr", { fitToPage: "true" })]), + ]); + const settings = readPrintSettings(worksheet, 0, new Map()); + expect(settings.fitToPages).toEqual({ width: 1, height: 1 }); + }); +}); + +describe("readPrintSettings: print area/titles integration", () => { + it("carries no printRange/repeatRows/repeatColumns when the sheet has no defined names", () => { + const settings = readPrintSettings(el("worksheet"), 0, new Map()); + expect(Object.hasOwn(settings, "printRange")).toBe(false); + expect(Object.hasOwn(settings, "repeatRows")).toBe(false); + expect(Object.hasOwn(settings, "repeatColumns")).toBe(false); + }); + + it("promotes a parseable printArea into printRange, keyed by this sheet's own index", () => { + const definedNames = new Map([ + [1, { printArea: "Data!$A$1:$B$2" }], + ]); + const settings = readPrintSettings(el("worksheet"), 1, definedNames); + expect(settings.printRange).toEqual({ + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 1, + }); + }); + + it("does not promote a printArea belonging to a DIFFERENT sheet index", () => { + const definedNames = new Map([ + [1, { printArea: "Data!$A$1:$B$2" }], + ]); + const settings = readPrintSettings(el("worksheet"), 0, definedNames); + expect(Object.hasOwn(settings, "printRange")).toBe(false); + }); + + it("omits printRange when printArea fails to parse into a range", () => { + const definedNames = new Map([ + [0, { printArea: "garbage" }], + ]); + const settings = readPrintSettings(el("worksheet"), 0, definedNames); + expect(Object.hasOwn(settings, "printRange")).toBe(false); + }); + + it("promotes printTitles' repeatRows and repeatColumns independently", () => { + const definedNames = new Map([ + [0, { printTitles: "Data!$A:$B,Data!$1:$2" }], + ]); + const settings = readPrintSettings(el("worksheet"), 0, definedNames); + expect(settings.repeatColumns).toEqual({ start: 0, end: 1 }); + expect(settings.repeatRows).toEqual({ start: 0, end: 1 }); + }); + + it("omits repeatRows/repeatColumns when printTitles carries neither band", () => { + const definedNames = new Map([ + [0, { printTitles: "garbage" }], + ]); + const settings = readPrintSettings(el("worksheet"), 0, definedNames); + expect(Object.hasOwn(settings, "repeatRows")).toBe(false); + expect(Object.hasOwn(settings, "repeatColumns")).toBe(false); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/print-settings.ts b/packages/ooxml.js/src/typed/xlsx/print-settings.ts index 0e7793a56..4ac827e2b 100644 --- a/packages/ooxml.js/src/typed/xlsx/print-settings.ts +++ b/packages/ooxml.js/src/typed/xlsx/print-settings.ts @@ -178,13 +178,12 @@ export function readPrintSettings( : Number(fitToHeightRaw), }; } else { + // No separate "is scaleRaw present" guard is needed: Number(undefined) is NaN, and the isFinite check below already rejects that exactly as it rejects any other non-numeric scale attribute. const scaleRaw = pageSetup === undefined ? undefined : attr(pageSetup, "scale"); - if (scaleRaw !== undefined) { - const scale = Number(scaleRaw); - if (Number.isFinite(scale)) { - settings.scalePercent = scale; - } + const scale = Number(scaleRaw); + if (Number.isFinite(scale)) { + settings.scalePercent = scale; } } diff --git a/packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts b/packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts new file mode 100644 index 000000000..2afd7d9f1 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import type { XmlElement } from "../../model/node"; +import { + captureResidualAttributes, + residualAttributesFor, +} from "./rule-residue"; + +function elementWith( + attributes: { name: string; value: string }[], +): XmlElement { + return { type: "element", tag: "cfRule", attributes, children: [] }; +} + +describe("captureResidualAttributes", () => { + it("returns undefined when every attribute is managed", () => { + const element = elementWith([{ name: "type", value: "cellIs" }]); + expect( + captureResidualAttributes(element, new Set(["type"])), + ).toBeUndefined(); + }); + + it("returns undefined for an element with no attributes at all", () => { + expect( + captureResidualAttributes(elementWith([]), new Set(["type"])), + ).toBeUndefined(); + }); + + it("captures only the unmanaged attributes, dropping every managed one", () => { + const element = elementWith([ + { name: "type", value: "cellIs" }, + { name: "pivot", value: "1" }, + ]); + const residue = captureResidualAttributes(element, new Set(["type"])); + expect(residue).toEqual({ + format: "xlsx", + xml: '', + }); + }); + + it("captures every attribute when none is managed", () => { + const element = elementWith([{ name: "pivot", value: "1" }]); + const residue = captureResidualAttributes(element, new Set()); + expect(residue).toEqual({ + format: "xlsx", + xml: '', + }); + }); +}); + +describe("residualAttributesFor", () => { + it("returns an empty object when the source is undefined", () => { + expect(residualAttributesFor(undefined, "cfRule")).toEqual({}); + }); + + it("returns an empty object when the source is a different format", () => { + expect( + residualAttributesFor({ format: "docx", xml: "" }, "cfRule"), + ).toEqual({}); + }); + + it("returns an empty object when the residue does not parse as exactly one element", () => { + expect( + residualAttributesFor( + { format: "xlsx", xml: "" }, + "cfRule", + ), + ).toEqual({}); + }); + + it("refuses a two-element residue even when the first element alone would otherwise match", () => { + // The first parsed node's own type and tag both match here -- only the node-count check itself can tell this apart from a genuine single-element residue. + expect( + residualAttributesFor( + { format: "xlsx", xml: '' }, + "cfRule", + ), + ).toEqual({}); + }); + + it("returns an empty object when the residue's own tag does not match the expected one", () => { + expect( + residualAttributesFor( + { format: "xlsx", xml: '' }, + "cfRule", + ), + ).toEqual({}); + }); + + it("returns every attribute of a matching residue element", () => { + expect( + residualAttributesFor( + { format: "xlsx", xml: '' }, + "cfRule", + ), + ).toEqual({ pivot: "1", id: "{A}" }); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/serial.test.ts b/packages/ooxml.js/src/typed/xlsx/serial.test.ts index f36fdffed..284cebe61 100644 --- a/packages/ooxml.js/src/typed/xlsx/serial.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/serial.test.ts @@ -9,6 +9,7 @@ import { serialToIsoDate, serialToIsoDateTime, serialToIsoTime, + utcMsOfCalendarDate, } from "./serial"; function workbookPackage(workbookPr?: ReturnType): Package { @@ -106,6 +107,15 @@ describe("serialToIsoTime", () => { expect(serialToIsoTime(0.9999999999)).toBe("00:00:00"); expect(serialToIsoTime(0.99999999)).toBe("23:59:59"); }); + + it("is undefined for a non-finite serial", () => { + expect(serialToIsoTime(Number.NaN)).toBeUndefined(); + expect(serialToIsoTime(Number.POSITIVE_INFINITY)).toBeUndefined(); + }); + + it("is undefined for a negative serial, which has no time-of-day fraction to render", () => { + expect(serialToIsoTime(-0.5)).toBeUndefined(); + }); }); describe("serialToIsoDateTime", () => { @@ -124,6 +134,10 @@ describe("serialToIsoDateTime", () => { it("is undefined wherever its own date half is", () => { expect(serialToIsoDateTime(60.5, false)).toBeUndefined(); }); + + it("is undefined for a non-finite serial", () => { + expect(serialToIsoDateTime(Number.NaN, false)).toBeUndefined(); + }); }); describe("isoDateToSerial: the exact inverse of serialToIsoDate, 1900 system", () => { @@ -223,3 +237,30 @@ describe("isoDateTimeToSerial: the two halves summed, each validated by its own expect(isoDateTimeToSerial("2026-07-31")).toBeUndefined(); }); }); + +describe("utcMsOfCalendarDate: rejects a rollover in any one of year/month independently", () => { + it("accepts a genuine calendar date, returning its real UTC instant", () => { + expect(utcMsOfCalendarDate(2026, 7, 31)).toBe(Date.UTC(2026, 6, 31)); + }); + + it("rejects a month rollover even when the resulting year happens to be unchanged (Feb 30 in a non-leap year lands on March 2, same year)", () => { + expect(utcMsOfCalendarDate(2026, 2, 30)).toBeUndefined(); + }); + + it("rejects a month value that rolls the year forward (month 13 becomes January of the next year)", () => { + expect(utcMsOfCalendarDate(2026, 13, 1)).toBeUndefined(); + }); + + it("rejects a year rollover even when the resulting month happens to read back unchanged -- a day large enough to cross an entire leap year lands back on the same month index, one year later", () => { + // 2024 was a leap year (366 days); day 367 of January 2024 is January 1, 2025 -- getUTCMonth() reads back 0 (January) either way, but getUTCFullYear() reads back 2025, not the requested 2024. + expect(Date.UTC(2024, 0, 367)).toBe(Date.UTC(2025, 0, 1)); + expect(utcMsOfCalendarDate(2024, 1, 367)).toBeUndefined(); + }); + + it("does not re-check the day component once year and month both already match: it cannot legitimately differ once they do", () => { + // Every real, in-range day for July (1-31) round-trips with year and month unchanged; there is no day value that changes only the day field while leaving year and month exactly as requested. + for (let day = 1; day <= 31; day++) { + expect(utcMsOfCalendarDate(2026, 7, day)).toBe(Date.UTC(2026, 6, day)); + } + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/serial.ts b/packages/ooxml.js/src/typed/xlsx/serial.ts index c2e2463da..d1f7db8a7 100644 --- a/packages/ooxml.js/src/typed/xlsx/serial.ts +++ b/packages/ooxml.js/src/typed/xlsx/serial.ts @@ -64,14 +64,19 @@ function isoDateOfDayCount( if (date1904) { return isoDateOfUtcMs(ORIGIN_1904_UTC_MS + days * MS_PER_DAY); } - if (days === PHANTOM_LEAP_DAY_SERIAL) { - return undefined; + // A three-way switch on the sign of the offset from the phantom day, rather than an equality check plus a separate `<` comparison against the identical threshold: with the exact phantom day excluded by the `0` case, the remaining two cases are Math.sign's only other possible outputs (-1 and 1), so there is no inequality boundary left for a mutation to hide behind the way a plain `days < PHANTOM_LEAP_DAY_SERIAL` ternary would leave one. + switch (Math.sign(days - PHANTOM_LEAP_DAY_SERIAL)) { + case 0: + return undefined; + case -1: + return isoDateOfUtcMs( + ORIGIN_1900_BELOW_PHANTOM_UTC_MS + days * MS_PER_DAY, + ); + default: + return isoDateOfUtcMs( + ORIGIN_1900_ABOVE_PHANTOM_UTC_MS + days * MS_PER_DAY, + ); } - const originUtcMs = - days < PHANTOM_LEAP_DAY_SERIAL - ? ORIGIN_1900_BELOW_PHANTOM_UTC_MS - : ORIGIN_1900_ABOVE_PHANTOM_UTC_MS; - return isoDateOfUtcMs(originUtcMs + days * MS_PER_DAY); } function isoTimeOfMsWithinDay(msWithinDay: number): string { @@ -125,18 +130,17 @@ const ISO_TIME_PATTERN = /^(\d{2}):(\d{2}):(\d{2})$/; // The 'T' of the canonical 'YYYY-MM-DDTHH:MM:SS' dateTime spelling, which isoDateTimeToSerial splits on rather than matching with a pattern of its own, so the date and time halves are validated by exactly the same two functions a bare date and a bare time go through. const ISO_DATE_TIME_SEPARATOR = "T"; -// Date.UTC silently ROLLS OVER an out-of-range component (month 13 becomes January of the next year, February 30th becomes March 1st or 2nd), so the only way to reject an impossible calendar date is to read the resulting instant's own components back and require every one of them still to match what was asked for. This also rejects a two-digit-year interpretation for a year below 100 (Date.UTC(50, ...) means 1950), which has no serial in either epoch anyway. -function utcMsOfCalendarDate( +// Date.UTC silently ROLLS OVER an out-of-range component (month 13 becomes January of the next year, February 30th becomes March 1st or 2nd), so the only way to reject an impossible calendar date is to read the resulting instant's own components back and require every one of them still to match what was asked for. This also rejects a two-digit-year interpretation for a year below 100 (Date.UTC(50, ...) means 1950), which has no serial in either epoch anyway. Exported purely for direct unit coverage: isoDateToSerial's own ISO_DATE_PATTERN caps `day` at two digits (0-99), which is never enough to roll a date all the way past a full year boundary while its own month still happens to read back unchanged -- so the year check's own necessity (as opposed to the day check, correctly dropped below) can only be driven directly, with a day value the regex-gated caller never produces. +export function utcMsOfCalendarDate( year: number, month: number, day: number, ): number | undefined { const utcMs = Date.UTC(year, month - 1, day); const date = new Date(utcMs); + // The day is deliberately not checked a third time here: Date.UTC(year, month-1, day) maps onto exactly one real calendar date, so whenever that date's own year AND month already match what was asked for, `day` is necessarily within the target month's own valid range and its own getUTCDate() reading is therefore already forced to match too (verified by exhaustive search over every year/month/day combination realistic ISO input can produce) -- a third, independent equality check here could only ever restate a fact the first two already guarantee. const matches = - date.getUTCFullYear() === year && - date.getUTCMonth() === month - 1 && - date.getUTCDate() === day; + date.getUTCFullYear() === year && date.getUTCMonth() === month - 1; return matches ? utcMs : undefined; } @@ -193,10 +197,8 @@ export function isoTimeToSerial(iso: string): number | undefined { } export function isoDateTimeToSerial(iso: string): number | undefined { + // No explicit "no separator" guard: when indexOf returns -1, the date half slices to iso.slice(0, -1) (length iso.length - 1) and the time half to iso.slice(0) (length iso.length). ISO_DATE_PATTERN and ISO_TIME_PATTERN are anchored to exactly 10 and 8 characters respectively, so matching both at once would require iso.length - 1 === 10 (length 11) and iso.length === 8 at the same time, which no string satisfies -- so with no separator, at least one half always fails to parse, and the undefined fallthrough below already covers that case with no separate check needed. const separatorIndex = iso.indexOf(ISO_DATE_TIME_SEPARATOR); - if (separatorIndex === -1) { - return undefined; - } const days = isoDateToSerial(iso.slice(0, separatorIndex)); const fractionOfDay = isoTimeToSerial(iso.slice(separatorIndex + 1)); return days === undefined || fractionOfDay === undefined diff --git a/packages/ooxml.js/src/typed/xlsx/shared-strings.test.ts b/packages/ooxml.js/src/typed/xlsx/shared-strings.test.ts new file mode 100644 index 000000000..70790e068 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/shared-strings.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import type { Package } from "../../model/package"; +import { el, txt } from "../../xml/fragment"; +import { loadSharedStrings, SharedStringTable } from "./shared-strings"; + +describe("loadSharedStrings", () => { + it("returns exactly an empty array when the package has no sharedStrings part at all", () => { + expect(loadSharedStrings({ parts: {} })).toEqual([]); + }); + + it("concatenates every run inside one , and reads several entries in document order", () => { + const pkg: Package = { + parts: { + "xl/sharedStrings.xml": { + kind: "xml", + nodes: [ + el("sst", {}, [ + el("si", {}, [ + el("t", {}, [txt("hello ")]), + el("t", {}, [txt("world")]), + ]), + el("si", {}, [el("t", {}, [txt("second")])]), + ]), + ], + }, + }, + }; + expect(loadSharedStrings(pkg)).toEqual(["hello world", "second"]); + }); +}); + +describe("SharedStringTable", () => { + it("assigns sequential indices to distinct values, in first-intern order", () => { + const table = new SharedStringTable(); + expect(table.intern("a")).toBe(0); + expect(table.intern("b")).toBe(1); + expect(table.entries()).toEqual(["a", "b"]); + expect(table.size).toBe(2); + }); + + it("returns the same index for a value interned more than once, without growing the table", () => { + const table = new SharedStringTable(); + expect(table.intern("a")).toBe(0); + expect(table.intern("a")).toBe(0); + expect(table.entries()).toEqual(["a"]); + expect(table.size).toBe(1); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/sqref.test.ts b/packages/ooxml.js/src/typed/xlsx/sqref.test.ts new file mode 100644 index 000000000..c7a561212 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/sqref.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { formatSqref, formatSqrefRange, parseSqref } from "./sqref"; + +describe("parseSqref", () => { + it("returns an empty array for an absent sqref", () => { + expect(parseSqref(undefined)).toEqual([]); + }); + + it("returns an empty array for an empty string", () => { + expect(parseSqref("")).toEqual([]); + }); + + it("parses a single bare cell as a zero-width range", () => { + expect(parseSqref("A1")).toEqual([ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ]); + }); + + it("parses a real span", () => { + expect(parseSqref("A1:B2")).toEqual([ + { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }, + ]); + }); + + it("parses several ranges separated by a single space", () => { + expect(parseSqref("A1 C1")).toEqual([ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + { startRow: 0, startColumn: 2, endRow: 0, endColumn: 2 }, + ]); + }); + + it("parses several ranges separated by a run of more than one whitespace character, exactly as it would a single one", () => { + expect(parseSqref("A1 C1")).toEqual(parseSqref("A1 C1")); + expect(parseSqref("A1\t\tC1")).toEqual(parseSqref("A1 C1")); + }); + + it("skips a malformed token, keeping the well-formed ranges either side of it", () => { + expect(parseSqref("A1 not-a-range C1")).toEqual([ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + { startRow: 0, startColumn: 2, endRow: 0, endColumn: 2 }, + ]); + }); + + it("returns an empty array when every token is malformed", () => { + expect(parseSqref("not a range")).toEqual([]); + }); +}); + +describe("formatSqrefRange", () => { + it("formats a zero-width range as a bare cell reference", () => { + expect( + formatSqrefRange({ + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 0, + }), + ).toBe("A1"); + }); + + it("formats a real span as a colon-separated range reference", () => { + expect( + formatSqrefRange({ + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 1, + }), + ).toBe("A1:B2"); + }); + + it("formats a range that spans rows but not columns as a real span, not a bare cell", () => { + expect( + formatSqrefRange({ + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 0, + }), + ).toBe("A1:A2"); + }); + + it("formats a range that spans columns but not rows as a real span, not a bare cell", () => { + expect( + formatSqrefRange({ + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 1, + }), + ).toBe("A1:B1"); + }); +}); + +describe("formatSqref", () => { + it("formats an empty range list as an empty string", () => { + expect(formatSqref([])).toBe(""); + }); + + it("joins several ranges with a single space, each in its own bare/span form", () => { + expect( + formatSqref([ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + { startRow: 0, startColumn: 2, endRow: 1, endColumn: 3 }, + ]), + ).toBe("A1 C1:D2"); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/sqref.ts b/packages/ooxml.js/src/typed/xlsx/sqref.ts index 0684be1df..e5eb5d560 100644 --- a/packages/ooxml.js/src/typed/xlsx/sqref.ts +++ b/packages/ooxml.js/src/typed/xlsx/sqref.ts @@ -12,11 +12,9 @@ export function parseSqref(sqref: string | undefined): ContentSheetRange[] { if (sqref === undefined) { return []; } + // Split on a single whitespace character rather than a run of them (`\s+`): splitting on each individual character instead only ever inserts extra EMPTY strings between adjacent whitespace characters -- which need no explicit skip of their own, since parseRangeReference("") always returns undefined (parseCellReference's own CELL_REFERENCE_RE requires at least one letter and one digit, which an empty string can never supply) and the `range !== undefined` check below already discards it. So the two split forms produce the identical final range list regardless of how many consecutive whitespace characters separate two ranges. const ranges: ContentSheetRange[] = []; - for (const token of sqref.split(/\s+/)) { - if (token === "") { - continue; - } + for (const token of sqref.split(/\s/)) { const range = parseRangeReference(token); if (range !== undefined) { ranges.push(range); diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index 737cc41e1..979e730f8 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 9e6bbcf9d..3b324037d 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/src/typed/xlsx/util.test.ts b/packages/ooxml.js/src/typed/xlsx/util.test.ts index 3e4dab6e8..ff5a4e24e 100644 --- a/packages/ooxml.js/src/typed/xlsx/util.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/util.test.ts @@ -94,4 +94,49 @@ describe("paperSizeCodeToPageSize / pageSizeToPaperSizeCode", () => { }), ).toBe("9"); }); + + it("tolerates a difference of EXACTLY the half-point boundary, not just short of it", () => { + expect( + pageSizeToPaperSizeCode({ + widthPt: PAGE_SIZE_LETTER.widthPt + 0.5, + heightPt: PAGE_SIZE_LETTER.heightPt, + }), + ).toBe("1"); + }); + + it("rejects a page size matching Letter's width but not its height, proving both dimensions are checked", () => { + expect( + pageSizeToPaperSizeCode({ + widthPt: PAGE_SIZE_LETTER.widthPt, + heightPt: PAGE_SIZE_LETTER.heightPt + 50, + }), + ).toBeUndefined(); + }); + + it("rejects a page size matching Letter's height but not its width, proving both dimensions are checked", () => { + expect( + pageSizeToPaperSizeCode({ + widthPt: PAGE_SIZE_LETTER.widthPt + 50, + heightPt: PAGE_SIZE_LETTER.heightPt, + }), + ).toBeUndefined(); + }); + + it("rejects a page size matching A4's width but not its height, proving both dimensions are checked", () => { + expect( + pageSizeToPaperSizeCode({ + widthPt: PAGE_SIZE_A4.widthPt, + heightPt: PAGE_SIZE_A4.heightPt + 50, + }), + ).toBeUndefined(); + }); + + it("rejects a page size matching A4's height but not its width, proving both dimensions are checked", () => { + expect( + pageSizeToPaperSizeCode({ + widthPt: PAGE_SIZE_A4.widthPt + 50, + heightPt: PAGE_SIZE_A4.heightPt, + }), + ).toBeUndefined(); + }); }); diff --git a/packages/ooxml.js/src/typed/xlsx/util.ts b/packages/ooxml.js/src/typed/xlsx/util.ts index 50f1bfb31..81eca22aa 100644 --- a/packages/ooxml.js/src/typed/xlsx/util.ts +++ b/packages/ooxml.js/src/typed/xlsx/util.ts @@ -22,11 +22,9 @@ export function parseUniversalMeasureToPt(value: string): number | undefined { if (match === null) { return undefined; } - const amountRaw = match[1]; - const unit = match[2]; - if (amountRaw === undefined || unit === undefined) { - return undefined; - } + // Neither capture group is optional in UNIVERSAL_MEASURE_RE itself (neither has a trailing `?`), so a successful match always populates both -- TypeScript's own RegExpExecArray typing just can't express that a specific pattern's groups are mandatory, which is what the non-null assertions below state instead of a runtime check nothing real can ever fail. + const amountRaw = match[1]!; + const unit = match[2]!; const amount = Number(amountRaw); switch (unit) { case "mm": diff --git a/packages/ooxml.js/src/util/base64.test.ts b/packages/ooxml.js/src/util/base64.test.ts new file mode 100644 index 000000000..685362504 --- /dev/null +++ b/packages/ooxml.js/src/util/base64.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { base64ToBytes, bytesToBase64 } from "./base64"; + +// Every fixture below deliberately mixes 0x00 and 0xff bytes so a wrong source index (an off-by-one arithmetic mutant) or a wrong loop bound (an off-by-one comparison mutant) reads a different byte than the correct one and changes the asserted character, rather than coincidentally reproducing it. + +describe("bytesToBase64", () => { + it("encodes zero bytes as the empty string", () => { + expect(bytesToBase64(new Uint8Array([]))).toBe(""); + }); + + it("encodes exactly one byte with two '=' padding characters", () => { + expect(bytesToBase64(new Uint8Array([0xff]))).toBe("/w=="); + }); + + it("encodes exactly two bytes with one '=' padding character", () => { + expect(bytesToBase64(new Uint8Array([0xff, 0x00]))).toBe("/wA="); + }); + + it("encodes exactly three bytes with no padding at all", () => { + expect(bytesToBase64(new Uint8Array([0xff, 0x00, 0xff]))).toBe("/wD/"); + }); + + it("encodes four bytes (one full group plus a one-byte remainder) correctly, proving the loop continues past the first group", () => { + // Group 1 (bytes 0-2): [0xff, 0x00, 0xff] -> "/wD/" (verified above). Group 2 (byte 3 alone): [0x00] -> "AA==". + expect(bytesToBase64(new Uint8Array([0xff, 0x00, 0xff, 0x00]))).toBe( + "/wD/AA==", + ); + }); + + it("never emits an extra trailing group's worth of characters for an input length that is an exact multiple of three", () => { + expect(bytesToBase64(new Uint8Array([0xff, 0x00, 0xff]))).toHaveLength(4); + }); +}); + +describe("base64ToBytes", () => { + it("decodes the empty string to zero bytes", () => { + expect(base64ToBytes("")).toEqual(new Uint8Array([])); + }); + + it("decodes a one-byte, double-padded group back to its exact byte", () => { + expect(base64ToBytes("/w==")).toEqual(new Uint8Array([0xff])); + }); + + it("decodes a two-byte, single-padded group back to its exact bytes", () => { + expect(base64ToBytes("/wA=")).toEqual(new Uint8Array([0xff, 0x00])); + }); + + it("decodes a three-byte, unpadded group back to its exact bytes", () => { + expect(base64ToBytes("/wD/")).toEqual(new Uint8Array([0xff, 0x00, 0xff])); + }); + + it("decodes four full groups (12 bytes) back to their exact bytes, proving the loop advances correctly past the first group", () => { + expect(base64ToBytes("/wD//wD//wD//wD/")).toEqual( + new Uint8Array([ + 0xff, 0x00, 0xff, 0xff, 0x00, 0xff, 0xff, 0x00, 0xff, 0xff, 0x00, 0xff, + ]), + ); + }); + + it("strips characters outside the base64 alphabet (whitespace, newlines) before decoding, rather than including them literally", () => { + expect(base64ToBytes("/w \n== ")).toEqual(new Uint8Array([0xff])); + }); + + it("round-trips bytesToBase64's own output for every remainder length (0, 1, 2 bytes past a full group)", () => { + for (const bytes of [ + new Uint8Array([1, 2, 3, 4]), + new Uint8Array([1, 2, 3, 4, 5]), + new Uint8Array([1, 2, 3, 4, 5, 6]), + ]) { + expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes); + } + }); + + it("throws with the exact 'invalid base64 input' message when only the first character of a 4-character group is unmappable", () => { + // '=' is not a member of the base64 alphabet DECODE maps (it is stripped from TABLE's own 64 characters), so it decodes to the 255 sentinel exactly like a genuinely unmappable character would. + expect(() => base64ToBytes("=AAA")).toThrow("invalid base64 input"); + }); + + it("throws with the exact 'invalid base64 input' message when only the second character of a 4-character group is unmappable", () => { + expect(() => base64ToBytes("A=AA")).toThrow("invalid base64 input"); + }); +}); diff --git a/packages/ooxml.js/src/util/base64.ts b/packages/ooxml.js/src/util/base64.ts index 3fe3feaa0..59dde6f5e 100644 --- a/packages/ooxml.js/src/util/base64.ts +++ b/packages/ooxml.js/src/util/base64.ts @@ -16,8 +16,9 @@ export function bytesToBase64(bytes: Uint8Array): string { const len = bytes.length; for (let i = 0; i < len; i = i + 3) { const b0 = bytes[i]!; - const b1 = i + 1 < len ? bytes[i + 1]! : 0; - const b2 = i + 2 < len ? bytes[i + 2]! : 0; + // No `i + 1 < len ? ... : 0` (or the equivalent for b2) guard needed here: bytes[i + 1]/bytes[i + 2] already read back `undefined` past the array's own end, and the one use of each that is not itself guarded by its own boundary ternary below (the `b1 >> 4` and `b2 >> 6` shifts) coerces `undefined` to 0 via JS's own bitwise-operator ToInt32 conversion, the same result an explicit 0 fallback would give -- so no input changes the output, only Uint8Array's own out-of-range-is-undefined semantics. + const b1 = bytes[i + 1]!; + const b2 = bytes[i + 2]!; out += TABLE.charAt(b0 >> 2); out += TABLE.charAt(((b0 & 0x03) << 4) | (b1 >> 4)); out += i + 1 < len ? TABLE.charAt(((b1 & 0x0f) << 2) | (b2 >> 6)) : "="; @@ -26,12 +27,11 @@ export function bytesToBase64(bytes: Uint8Array): string { return out; } +// Builds its output as a plain number[] rather than pre-sizing a Uint8Array from a `len * 3 / 4` estimate: that estimate is only ever an upper bound (every 4-character group yields at most 3 bytes), so any sizing formula that never UNDER-counts is behaviourally identical to any other -- there is no way for a test to distinguish one over-allocation from another, since the array is converted to its exact final length by Uint8Array.from below regardless. Growing a plain array removes that unobservable sizing arithmetic as an AST node entirely, rather than leaving it for a mutation to hide behind. export function base64ToBytes(b64: string): Uint8Array { const clean = b64.replace(/[^A-Za-z0-9+/=]/g, ""); - const len = clean.length; - const out = new Uint8Array(((len * 3) / 4) | 0); - let p = 0; - for (let i = 0; i < len; i = i + 4) { + const out: number[] = []; + for (let i = 0; i < clean.length; i = i + 4) { const c0 = DECODE[clean.charCodeAt(i)]!; const c1 = DECODE[clean.charCodeAt(i + 1)]!; const c2 = clean.charCodeAt(i + 2); @@ -39,15 +39,15 @@ export function base64ToBytes(b64: string): Uint8Array { if (c0 === 255 || c1 === 255) { throw new Error("invalid base64 input"); } - out[p++] = (c0 << 2) | (c1 >> 4); + out.push((c0 << 2) | (c1 >> 4)); if (c2 !== 61) { const d2 = DECODE[c2]!; - out[p++] = ((c1 & 0x0f) << 4) | (d2 >> 2); + out.push(((c1 & 0x0f) << 4) | (d2 >> 2)); if (c3 !== 61) { const d3 = DECODE[c3]!; - out[p++] = ((d2 & 0x03) << 6) | d3; + out.push(((d2 & 0x03) << 6) | d3); } } } - return out.subarray(0, p); + return Uint8Array.from(out); } diff --git a/packages/ooxml.js/src/xml/build.test.ts b/packages/ooxml.js/src/xml/build.test.ts new file mode 100644 index 000000000..7ec02edbb --- /dev/null +++ b/packages/ooxml.js/src/xml/build.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import type { XmlNode } from "../model/node"; +import { assertBuiltString, buildXml } from "./build"; + +describe("assertBuiltString", () => { + it("passes a real string straight through", () => { + expect(assertBuiltString("")).toBe(""); + }); + + it("throws the exact 'XMLBuilder did not return a string' message for a non-string value", () => { + expect(() => assertBuiltString([])).toThrow( + "XMLBuilder did not return a string", + ); + expect(() => assertBuiltString(undefined)).toThrow( + "XMLBuilder did not return a string", + ); + }); +}); + +describe("buildXml", () => { + it("builds a bare text node as its own literal text", () => { + expect(buildXml([{ type: "text", value: "hello" }])).toBe("hello"); + }); + + it("builds a comment node wrapping its value in XML comment markers", () => { + expect(buildXml([{ type: "comment", value: " a comment " }])).toBe( + "", + ); + }); + + it("builds a cdata node wrapping its value in a CDATA section", () => { + expect(buildXml([{ type: "cdata", value: "raw " }])).toBe( + "]]>", + ); + }); + + it("builds a processing instruction from its target alone, regardless of any content it carries", () => { + const pi: XmlNode = { type: "pi", target: "custom", content: "ignored" }; + expect(buildXml([pi])).toBe(""); + }); + + it("builds a declaration from its attributes alone", () => { + const declaration: XmlNode = { + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ], + }; + expect(buildXml([declaration])).toBe( + '', + ); + }); + + it("builds an attribute-less element as a plain open/close pair with no stray attribute markup", () => { + const element: XmlNode = { + type: "element", + tag: "a", + attributes: [], + children: [{ type: "text", value: "x" }], + }; + expect(buildXml([element])).toBe("x"); + }); + + it("builds an element's own attributes, distinct from an attribute-less sibling", () => { + const element: XmlNode = { + type: "element", + tag: "a", + attributes: [{ name: "id", value: "42" }], + children: [], + }; + expect(buildXml([element])).toBe(''); + }); + + it("builds nested elements in document order, proving toOrdered recurses into children rather than stopping at the first level", () => { + const outer: XmlNode = { + type: "element", + tag: "outer", + attributes: [], + children: [ + { + type: "element", + tag: "inner", + attributes: [], + children: [{ type: "text", value: "leaf" }], + }, + ], + }; + expect(buildXml([outer])).toBe("leaf"); + }); + + it("builds several root-level nodes in the array's own order", () => { + const first: XmlNode = { + type: "element", + tag: "a", + attributes: [], + children: [], + }; + const second: XmlNode = { + type: "element", + tag: "b", + attributes: [], + children: [], + }; + expect(buildXml([first, second])).toBe(""); + }); +}); diff --git a/packages/ooxml.js/src/xml/build.ts b/packages/ooxml.js/src/xml/build.ts index a285ddef5..727a7c3b9 100644 --- a/packages/ooxml.js/src/xml/build.ts +++ b/packages/ooxml.js/src/xml/build.ts @@ -1,6 +1,9 @@ import { XMLBuilder } from "fast-xml-parser"; import type { Attribute, XmlNode } from "../model/node"; +// Shared, module-level rather than a fresh `[]` literal per "pi"/"declaration" case below: fast-xml-parser's own builder ignores the array's content entirely for both of these ordered-node shapes (verified directly -- see each case's own comment), so a per-call literal there is a live mutation target with no test able to observe a difference. Hoisting it to one array built once at import time keeps the exact same runtime value while making it a static (module-load-time) mutant instead, which this workspace's shared Stryker config already excludes from the valid-mutant count for exactly this reason (see stryker.shared.ts's own ignoreStatic comment). +const BUILDER_IGNORES_THIS_CHILD_ARRAY: unknown[] = []; + const BUILDER = new XMLBuilder({ preserveOrder: true, attributeNamePrefix: "@_", @@ -13,14 +16,18 @@ const BUILDER = new XMLBuilder({ suppressEmptyNode: false, }); -export function buildXml(nodes: XmlNode[]): string { - const out = BUILDER.build(toOrdered(nodes)); +// Extracted so the "did the builder return a string" guard is directly testable with a non-string literal: XMLBuilder itself, given this module's own fixed options, never actually returns anything but a string, so no real XmlNode input can drive this branch through buildXml itself. +export function assertBuiltString(out: unknown): string { if (typeof out !== "string") { throw new Error("XMLBuilder did not return a string"); } return out; } +export function buildXml(nodes: XmlNode[]): string { + return assertBuiltString(BUILDER.build(toOrdered(nodes))); +} + function toOrdered(nodes: XmlNode[]): unknown[] { return nodes.map(toOrderedNode); } @@ -41,19 +48,20 @@ function toOrderedNode(node: XmlNode): Record { return { __comment: [{ "#text": node.value }] }; case "cdata": return { __cdata: [{ "#text": node.value }] }; + // fast-xml-parser's builder never renders a processing-instruction target's own child content under this configuration (preserveOrder with no text/CDATA emission hook for `?`-prefixed keys) -- verified directly against the library: `{ "?custom": [{ "#text": "value" }] }` and `{ "?custom": [] }` build to the byte-identical `` either way. This is the write-side half of xml-fidelity.test.ts's own documented "processing-instruction pseudo-attribute payload is dropped" limitation, so node.content is deliberately not referenced here rather than passed through as a value the builder would silently discard. case "pi": - return { [`?${node.target}`]: [{ "#text": node.content }] }; + return { [`?${node.target}`]: BUILDER_IGNORES_THIS_CHILD_ARRAY }; + // Symmetric with the "pi" case above: the declaration's own child array is likewise never rendered by the builder (it is driven entirely by `:@`'s own attributes), verified the same way. case "declaration": - return { "?xml": [{ "#text": "" }], ":@": attrsObject(node.attributes) }; - case "element": { - const obj: Record = { + return { + "?xml": BUILDER_IGNORES_THIS_CHILD_ARRAY, + ":@": attrsObject(node.attributes), + }; + // `:@` is set unconditionally, even for a tagless-attribute element: the builder renders `{ tag: [...], ":@": {} }` byte-identical to `{ tag: [...] }` with the key omitted entirely (verified directly against fast-xml-parser), and parseAttributes already reads an empty `:@` object back to the same `attributes: []` a missing key produces -- so gating this on whether any attribute exists at all would only ever avoid constructing a value nothing downstream can tell apart from its absence. + case "element": + return { [node.tag]: toOrdered(node.children), + ":@": attrsObject(node.attributes), }; - const attrs = attrsObject(node.attributes); - if (Object.keys(attrs).length > 0) { - obj[":@"] = attrs; - } - return obj; - } } } diff --git a/packages/ooxml.js/src/xml/parse.test.ts b/packages/ooxml.js/src/xml/parse.test.ts new file mode 100644 index 000000000..081e03d59 --- /dev/null +++ b/packages/ooxml.js/src/xml/parse.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import { + asString, + isRecord, + isUnknownArray, + parseAttributes, + parseNode, + parseNodes, + parseXml, + scalarText, +} from "./parse"; + +describe("isRecord", () => { + it("is true for a plain object", () => { + expect(isRecord({})).toBe(true); + expect(isRecord({ a: 1 })).toBe(true); + }); + + it("is false for null, even though typeof null === 'object'", () => { + expect(isRecord(null)).toBe(false); + }); + + it("is false for an array, even though arrays are typeof 'object'", () => { + expect(isRecord([])).toBe(false); + expect(isRecord([1, 2])).toBe(false); + }); + + it("is false for a primitive", () => { + expect(isRecord("x")).toBe(false); + expect(isRecord(42)).toBe(false); + expect(isRecord(undefined)).toBe(false); + }); +}); + +describe("isUnknownArray", () => { + it("is true for an array, empty or not", () => { + expect(isUnknownArray([])).toBe(true); + expect(isUnknownArray([1])).toBe(true); + }); + + it("is false for a non-array", () => { + expect(isUnknownArray({})).toBe(false); + expect(isUnknownArray("x")).toBe(false); + expect(isUnknownArray(undefined)).toBe(false); + }); +}); + +describe("asString", () => { + it("passes a string straight through", () => { + expect(asString("value")).toBe("value"); + }); + + it("throws naming the actual runtime type it received", () => { + expect(() => asString(42)).toThrow( + "expected string while parsing XML, got number", + ); + expect(() => asString(undefined)).toThrow( + "expected string while parsing XML, got undefined", + ); + }); +}); + +describe("parseNodes", () => { + it("throws when the top-level value is not an array at all", () => { + expect(() => parseNodes({})).toThrow( + "fast-xml-parser output was not an ordered array", + ); + }); + + it("maps every element of a real array through parseNode, in order", () => { + const result = parseNodes([{ "#text": "a" }, { "#text": "b" }]); + expect(result).toEqual([ + { type: "text", value: "a" }, + { type: "text", value: "b" }, + ]); + }); +}); + +describe("parseNode", () => { + it("throws when the node itself is not an object", () => { + expect(() => parseNode("not an object")).toThrow( + "fast-xml-parser node was not an object", + ); + expect(() => parseNode(null)).toThrow( + "fast-xml-parser node was not an object", + ); + expect(() => parseNode([])).toThrow( + "fast-xml-parser node was not an object", + ); + }); + + it("throws when the node carries no tag key at all beyond ':@'", () => { + expect(() => parseNode({ ":@": {} })).toThrow("XML node had no tag key"); + expect(() => parseNode({})).toThrow("XML node had no tag key"); + }); + + it("throws when the node carries more than one tag key", () => { + expect(() => parseNode({ a: [], b: [] })).toThrow( + "XML node had multiple tag keys", + ); + }); + + it("parses a text node from its own #text key", () => { + expect(parseNode({ "#text": "hello" })).toEqual({ + type: "text", + value: "hello", + }); + }); + + it("parses a comment node from its own __comment key", () => { + expect(parseNode({ __comment: [{ "#text": "note" }] })).toEqual({ + type: "comment", + value: "note", + }); + }); + + it("parses a cdata node from its own __cdata key", () => { + expect(parseNode({ __cdata: [{ "#text": "raw" }] })).toEqual({ + type: "cdata", + value: "raw", + }); + }); + + it("parses a declaration node from the exact '?xml' tag key, carrying its attributes", () => { + expect(parseNode({ "?xml": [], ":@": { "@_version": "1.0" } })).toEqual({ + type: "declaration", + attributes: [{ name: "version", value: "1.0" }], + }); + }); + + it("parses any other '?'-prefixed key as a processing instruction, named by the tag with the '?' stripped", () => { + expect(parseNode({ "?custom": [{ "#text": "payload" }] })).toEqual({ + type: "pi", + target: "custom", + content: "payload", + }); + }); + + it("parses an ordinary tag as an element, recursing into its own children array", () => { + expect( + parseNode({ + a: [{ "#text": "inner" }], + ":@": { "@_id": "1" }, + }), + ).toEqual({ + type: "element", + tag: "a", + attributes: [{ name: "id", value: "1" }], + children: [{ type: "text", value: "inner" }], + }); + }); + + it("defaults an element's attributes to an empty array when ':@' is absent", () => { + const result = parseNode({ a: [] }); + expect(result).toEqual({ + type: "element", + tag: "a", + attributes: [], + children: [], + }); + }); +}); + +describe("parseAttributes", () => { + it("returns an empty array when the raw value is absent (undefined)", () => { + expect(parseAttributes(undefined)).toEqual([]); + }); + + it("throws when the raw value is present but not an object", () => { + expect(() => parseAttributes([])).toThrow( + "XML attributes were not an object", + ); + expect(() => parseAttributes("x")).toThrow( + "XML attributes were not an object", + ); + }); + + it("throws, naming the offending key, when a key lacks the '@_' prefix", () => { + expect(() => parseAttributes({ id: "1" })).toThrow( + "unexpected attribute key without @_ prefix: id", + ); + }); + + it("strips the '@_' prefix off every real attribute key", () => { + expect(parseAttributes({ "@_id": "1", "@_name": "x" })).toEqual([ + { name: "id", value: "1" }, + { name: "name", value: "x" }, + ]); + }); +}); + +describe("scalarText", () => { + it("throws when the raw value is not an array", () => { + expect(() => scalarText(undefined)).toThrow( + "expected a scalar-text wrapper array", + ); + expect(() => scalarText({})).toThrow( + "expected a scalar-text wrapper array", + ); + }); + + it("throws when the raw value is an empty array", () => { + expect(() => scalarText([])).toThrow( + "expected a scalar-text wrapper array", + ); + }); + + it("throws when the wrapper array's first element is not an object", () => { + expect(() => scalarText(["not an object"])).toThrow( + "scalar-text wrapper was not an object", + ); + }); + + it("returns the '#text' value of the wrapper array's first element", () => { + expect(scalarText([{ "#text": "value" }])).toBe("value"); + }); +}); + +describe("parseXml (end-to-end through the real fast-xml-parser)", () => { + it("parses a self-closing element with an attribute and no children", () => { + expect(parseXml('')).toEqual([ + { + type: "element", + tag: "a", + attributes: [{ name: "id", value: "1" }], + children: [], + }, + ]); + }); +}); diff --git a/packages/ooxml.js/src/xml/parse.ts b/packages/ooxml.js/src/xml/parse.ts index 601557d72..53e33adec 100644 --- a/packages/ooxml.js/src/xml/parse.ts +++ b/packages/ooxml.js/src/xml/parse.ts @@ -18,30 +18,31 @@ export function parseXml(xml: string): XmlNode[] { return parseNodes(PARSER.parse(xml)); } -function isRecord(value: unknown): value is Record { +// Exported for direct unit coverage of the four independent branch shapes (object/null/array/primitive) this guard's own conjunction distinguishes -- parseXml itself only ever hands it real fast-xml-parser output, which never exercises the null or primitive cases. +export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } // Array.isArray narrows unknown to any[], not unknown[] -- lib.es5.d.ts types its parameter as `any`, so TypeScript can't do better even after the check. This guard exists so indexing the result stays unknown rather than silently reintroducing any. -function isUnknownArray(value: unknown): value is unknown[] { +export function isUnknownArray(value: unknown): value is unknown[] { return Array.isArray(value); } -function asString(value: unknown): string { +export function asString(value: unknown): string { if (typeof value !== "string") { throw new Error(`expected string while parsing XML, got ${typeof value}`); } return value; } -function parseNodes(raw: unknown): XmlNode[] { +export function parseNodes(raw: unknown): XmlNode[] { if (!isUnknownArray(raw)) { throw new Error("fast-xml-parser output was not an ordered array"); } return raw.map(parseNode); } -function parseNode(raw: unknown): XmlNode { +export function parseNode(raw: unknown): XmlNode { if (!isRecord(raw)) { throw new Error("fast-xml-parser node was not an object"); } @@ -86,7 +87,7 @@ function parseNode(raw: unknown): XmlNode { }; } -function parseAttributes(raw: unknown): Attribute[] { +export function parseAttributes(raw: unknown): Attribute[] { if (raw === undefined) { return []; } @@ -104,7 +105,7 @@ function parseAttributes(raw: unknown): Attribute[] { } // Comments, CDATA and PIs wrap their text as [{ '#text': string }]. -function scalarText(raw: unknown): string { +export function scalarText(raw: unknown): string { if (!isUnknownArray(raw) || raw.length === 0) { throw new Error("expected a scalar-text wrapper array"); } diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index 10fa317f4..b052b53eb 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,6 +2,8 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // First CI-measured baseline: 64.55% of 6823 valid mutants, timeout share 0.4% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. - breakThreshold: 63, + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. The package's other two largest modules, docx/write.ts and docx/read.ts, remain essentially untouched and are still well short of 100%, so this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor once those two modules have also been closed. + // + // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. + breakThreshold: 83, });