From bda0b303c9c837c6604cae4734b25c907b0be065 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:53:58 +0100 Subject: [PATCH 001/102] test(ooxml.js): cover base64 encode/decode boundaries and simplify decode buffer sizing Adds direct coverage for bytesToBase64/base64ToBytes across every input-length remainder (0, 1, 2 bytes past a full 3-byte group), the invalid-base64 throw for each of the two positions a malformed character can occupy in a 4-character group, and whitespace stripping before decode. base64ToBytes now builds its output as a plain number[] converted via Uint8Array.from rather than pre-sizing a Uint8Array from a `len * 3 / 4` estimate: that estimate is only ever an upper bound, so any formula that never under-counts is behaviourally identical to any other once the result is trimmed to its real length -- removing the sizing arithmetic as an AST node rather than leaving an unobservable estimate for a mutation to hide behind. --- packages/ooxml.js/src/util/base64.test.ts | 82 +++++++++++++++++++++++ packages/ooxml.js/src/util/base64.ts | 15 ++--- 2 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 packages/ooxml.js/src/util/base64.test.ts 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..f7dc179d6 100644 --- a/packages/ooxml.js/src/util/base64.ts +++ b/packages/ooxml.js/src/util/base64.ts @@ -26,12 +26,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 +38,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); } From 73f00ffc6c5eeba5c84af63ab57fd241e746942d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:54:15 +0100 Subject: [PATCH 002/102] test(ooxml.js): cover buildXml's node kinds and drop unobservable builder scaffolding Adds direct coverage for buildXml across every XmlNode variant (text, comment, cdata, pi, declaration, attribute-less and attributed elements, nested children, multiple root nodes) and for assertBuiltString's own throw, extracted from buildXml so the "did the builder return a string" guard is directly testable with a non-string literal rather than left uncovered forever (XMLBuilder, given this module's fixed options, never actually returns anything else). Simplifies two spots verified directly against fast-xml-parser to be unobservable: a processing instruction's and a declaration's own child array is never rendered by the builder under this configuration (`{ "?custom": [{ "#text": "x" }] }` and `{ "?custom": [] }` build to the byte-identical ``), so neither carries a value the builder ever reads; and an element's own `:@` attributes object is set unconditionally rather than gated on whether any attribute exists, since an empty `:@": {}` builds identically to the key being absent and parseAttributes already reads both back to the same empty array. --- packages/ooxml.js/src/xml/build.test.ts | 107 ++++++++++++++++++++++++ packages/ooxml.js/src/xml/build.ts | 26 +++--- 2 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 packages/ooxml.js/src/xml/build.test.ts 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..d596b13f4 100644 --- a/packages/ooxml.js/src/xml/build.ts +++ b/packages/ooxml.js/src/xml/build.ts @@ -13,14 +13,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 +45,17 @@ 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}`]: [] }; + // 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": [], ":@": 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; - } } } From aefe3f5c0e73466ccb60441ad6cf987be95d2373 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:54:30 +0100 Subject: [PATCH 003/102] test(ooxml.js): cover parseXml's internal validation helpers directly Exports and directly unit-tests every one of parseXml's own structural guards and error paths (isRecord, isUnknownArray, asString, parseNodes, parseNode, parseAttributes, scalarText) against synthetic fast-xml-parser-shaped input: a node that is not an object, a node with no tag key or more than one, an attribute value or scalar-text wrapper of the wrong shape. Real fast-xml-parser output never produces these malformed shapes, so none of these branches was ever exercised through parseXml's own public entry point alone. --- packages/ooxml.js/src/xml/parse.test.ts | 230 ++++++++++++++++++++++++ packages/ooxml.js/src/xml/parse.ts | 15 +- 2 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 packages/ooxml.js/src/xml/parse.test.ts 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"); } From 41b97a0c415224a1028be1a40c4d042d997749d4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:54:40 +0100 Subject: [PATCH 004/102] test(ooxml.js): cover isXmlNode's full truth table across every node variant Adds direct coverage for isXmlNode's own structural guard across non-record inputs (null, an array, a primitive -- each a distinct branch of typeof/null/Array.isArray that real Zod-validated input never separately exercises), every XmlNode variant's own required fields, malformed attribute entries, and a recursive check that a child element's own children are validated the same way rather than only its own direct fields. --- packages/ooxml.js/src/model/node.test.ts | 180 +++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/ooxml.js/src/model/node.test.ts 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..8b76072bd --- /dev/null +++ b/packages/ooxml.js/src/model/node.test.ts @@ -0,0 +1,180 @@ +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); + }); +}); + +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); + }); +}); From 7d00eafa4b044c4878dcee153057320de66ab9e6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:54:52 +0100 Subject: [PATCH 005/102] test(ooxml.js): cover looksLikeXml's BOM/whitespace skip and drop a redundant bounds check Adds direct coverage, via packageFromEntries's own xml/binary classification, for a UTF-8 BOM prefix (alone and combined with leading whitespace), every individual whitespace byte the format permits, a run of several in a row, an all-whitespace part with no non-whitespace byte at all, and a part whose first three bytes only partially match the BOM (isolating each of the three signature bytes' own necessity) -- none of which any existing test exercised. Drops looksLikeXml's own `bytes.length >= 3` BOM guard: it is provably redundant given how out-of-range Uint8Array indexing behaves -- an index at or past a real array's own length always reads `undefined`, which can never equal a real BOM byte, so a short array already fails the byte-by-byte comparison on its own. The main scan loop is likewise rebounded on `bytes[i] !== undefined` rather than a separately tracked `i < bytes.length`, for the identical reason. --- packages/ooxml.js/src/package-io/read.test.ts | 86 +++++++++++++++++++ packages/ooxml.js/src/package-io/read.ts | 11 +-- 2 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 packages/ooxml.js/src/package-io/read.test.ts 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; From bfb4200ce1f388401c474cc9d1a80634c6ec27ef Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:55:30 +0100 Subject: [PATCH 006/102] test(ooxml.js): cover every sniffed image signature and drop a redundant bounds check Adds direct coverage for sniffImageFormat across every recognised signature (PNG, JPEG, both GIF header versions), near-miss prefixes that diverge partway through or on the final byte, and SVG detection by its own XML-prolog and bare-root-tag spellings, leading whitespace before either, and the 1024-byte sniff window's own boundary (a real ' { - 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..253a11117 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; From 6d2942fee70bb506403c04273de7421a8db631e3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:18:46 +0100 Subject: [PATCH 007/102] test(ooxml.js): cover relsPathFor/resolveRelTarget's path arithmetic Adds direct unit coverage for relsPathFor (a slash-free part path, and a nested one where only the LAST slash may split it) and resolveRelTarget (a package-rooted target, a relative target against both an empty and a real subject directory, a '../' segment popping the enclosing directory, a '.' segment, and a doubled-slash empty segment) -- neither function was reachable from any existing test except through a much larger relationship-resolution fixture that never varied these specific shapes. --- packages/ooxml.js/src/typed/util.test.ts | 64 ++++++++++++++++++++++++ packages/ooxml.js/src/typed/util.ts | 8 +-- 2 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 packages/ooxml.js/src/typed/util.test.ts 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..081376546 --- /dev/null +++ b/packages/ooxml.js/src/typed/util.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { relsPathFor, resolveRelTarget } from "./util"; + +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..c859e82f8 100644 --- a/packages/ooxml.js/src/typed/util.ts +++ b/packages/ooxml.js/src/typed/util.ts @@ -95,16 +95,16 @@ 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); 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); } From 53804419d8a58c8e6b8d3be4b564fa9fc312213d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:01 +0100 Subject: [PATCH 008/102] test(ooxml.js): cover serial.ts's date/time boundaries and remove two redundant date checks Adds direct coverage for serialToIsoTime/serialToIsoDateTime's own non-finite and negative-serial rejections, and for utcMsOfCalendarDate's own year/month rollover rejections -- including a day value large enough to roll a whole leap year forward, the one shape that makes the year check's own necessity observable (the public isoDateToSerial entry point never passes a day outside 0-99, which alone never triggers it). isoDateOfDayCount now switches on the sign of the offset from the phantom leap day rather than pairing an equality check (excluding day 60 itself) with a separate `<` comparison against the identical threshold: with 60 excluded by the `0` case, the remaining two cases are Math.sign's only other outputs, leaving no inequality boundary for a mutation to hide behind. utcMsOfCalendarDate drops its own third, day-level equality check: Date.UTC(year, month-1, day) maps onto exactly one real calendar date, so whenever a re-read year and month both already match what was asked for, day is necessarily inside that month's own valid range and is therefore already forced to match too (confirmed by exhaustive search over every realistic year/month/day combination) -- a third check here could only ever restate a fact the first two already guarantee. --- .../ooxml.js/src/typed/xlsx/serial.test.ts | 41 +++++++++++++++++++ packages/ooxml.js/src/typed/xlsx/serial.ts | 28 +++++++------ 2 files changed, 57 insertions(+), 12 deletions(-) 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..8dd092a10 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; } From ebd477fdd151ff2d347b8937c4af56843a6f529b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:14 +0100 Subject: [PATCH 009/102] test(ooxml.js): cover sqref parsing/formatting and simplify its whitespace split Adds direct coverage for parseSqref (absent/empty input, a single bare cell, a real span, several ranges, a malformed token skipped among well-formed ones), formatSqrefRange (bare cell vs. row-only vs. column-only vs. full spans), and formatSqref's own join -- none of which this shared helper had a dedicated test file for at all. Simplifies the token split from `/\s+/` to `/\s/`: splitting on each individual whitespace character rather than a run of them only ever inserts extra empty strings between adjacent whitespace characters, which the loop's own `token === ""` skip already discards, so both forms produce the identical final token list regardless of how many consecutive whitespace characters separate two ranges. --- .../ooxml.js/src/typed/xlsx/sqref.test.ts | 108 ++++++++++++++++++ packages/ooxml.js/src/typed/xlsx/sqref.ts | 3 +- 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/ooxml.js/src/typed/xlsx/sqref.test.ts 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..af39b65c8 100644 --- a/packages/ooxml.js/src/typed/xlsx/sqref.ts +++ b/packages/ooxml.js/src/typed/xlsx/sqref.ts @@ -12,8 +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 the loop's own `token === ""` skip below already discards -- so the two split forms produce the identical final token list regardless of how many consecutive whitespace characters separate two ranges. const ranges: ContentSheetRange[] = []; - for (const token of sqref.split(/\s+/)) { + for (const token of sqref.split(/\s/)) { if (token === "") { continue; } From 360a4961a2426e510f78a0ece7ec6139bf49b91a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:24 +0100 Subject: [PATCH 010/102] test(ooxml.js): cover captureResidualAttributes/residualAttributesFor directly Adds a dedicated test file for the xlsx rule-residue helpers: capturing zero, some, and every attribute as unmanaged, and reading residue back for an absent source, a wrong-format source, a source that fails to parse as exactly one element, and one whose tag mismatches the expected rule kind -- none of which had direct coverage before. --- .../src/typed/xlsx/rule-residue.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts 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..b16311e85 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts @@ -0,0 +1,87 @@ +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("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}" }); + }); +}); From bac112cfb68da6849e0965c3396dbfe17d7daaaf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:33 +0100 Subject: [PATCH 011/102] test(ooxml.js): cover loadSharedStrings and SharedStringTable directly Adds a dedicated test file: an absent sharedStrings part reads back as exactly an empty array (not a placeholder value), multi-run entries concatenate in document order, and SharedStringTable assigns sequential indices while deduplicating a value interned twice. --- .../src/typed/xlsx/shared-strings.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/shared-strings.test.ts 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); + }); +}); From b4280b312db0536b2f588a1cbddde4134b2baaaa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:42 +0100 Subject: [PATCH 012/102] test(ooxml.js): prove readXlsx omits the definitions key when there are no tables A plain property read cannot distinguish a genuinely absent key from one spread on with an explicit undefined value -- both read back as undefined. Adds an Object.hasOwn check alongside the existing toBeUndefined() assertion so readXlsx's own conditional spread is actually exercised, not just its value. --- packages/ooxml.js/src/typed/document-tree.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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", From 58cffd24fdd338bd15475f6537361b20dc711040 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:57 +0100 Subject: [PATCH 013/102] test(ooxml.js): cover readWorkbookDefinitions' relationship filtering directly Adds a dedicated test file: a sheet with no table relationship at all reads no definitions, a non-table relationship among several is skipped in favour of the genuine table one, and a table part missing its own name or ref attribute is skipped rather than promoted with a missing field. --- .../src/typed/xlsx/definitions.test.ts | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/definitions.test.ts 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..244463c52 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/definitions.test.ts @@ -0,0 +1,132 @@ +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 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(); + }); +}); From 2cf61430cf2de77bfe235c6f5eb85c0aff8976b4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:20:11 +0100 Subject: [PATCH 014/102] test(ooxml.js): cover consecutive images with no candidate paragraph at all Adds a case where neither of an image's own neighbours is a paragraph (two more images either side), which no existing fixture in this file exercised -- every prior case had at least one paragraph candidate, matching or not. --- packages/ooxml.js/src/typed/docx/figure-captions.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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..88bb2b350 100644 --- a/packages/ooxml.js/src/typed/docx/figure-captions.test.ts +++ b/packages/ooxml.js/src/typed/docx/figure-captions.test.ts @@ -86,6 +86,14 @@ 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("preserves the block count and order, which the extent indices depend on", () => { const blocks = [ paragraph("A"), From 36411b7a3ca79a0fec636b633e6cd1431dddda6e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:20:31 +0100 Subject: [PATCH 015/102] test(ooxml.js): cover shading's "none" colour tokens and single-colour patterns' own absent key Adds a "none" w:fill and a "none" w:color case (only "auto" was previously exercised for either), and strengthens the existing single-colour pattern tests with an Object.hasOwn check: a plain toEqual cannot distinguish an omitted foregroundColor/backgroundColor key from one spread on with an explicit undefined value, so a genuinely one-sided pattern read needs the stricter check to prove the other key is truly absent. --- .../ooxml.js/src/typed/docx/shading.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/docx/shading.test.ts b/packages/ooxml.js/src/typed/docx/shading.test.ts index b5540155a..7c4331ac0 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", () => { From 9ad53c98071b0461adc5de5b8426ab92fe5af581 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:20:55 +0100 Subject: [PATCH 016/102] test(ooxml.js): cover threaded-comment id formatting, counter increment, and reply linkage Adds a dedicated test file: threadedCommentId's own uppercase-hex formatting (a counter of 10 exercises the digit-vs-letter distinction 0-9 alone cannot), sequential ids increasing across two separately commented cells (not just within one thread), a reply immediately following its own root with the root's real id as parentId while the root itself carries none, and the threaded-comments root's own declared namespace. Exports threadedCommentId, previously module-private, purely for this direct coverage. --- .../src/typed/xlsx/comments-write.test.ts | 126 ++++++++++++++++++ .../ooxml.js/src/typed/xlsx/comments-write.ts | 4 +- 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 packages/ooxml.js/src/typed/xlsx/comments-write.test.ts 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..ce2ba5cd0 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts @@ -0,0 +1,126 @@ +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, + }; +} + +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([{ row: 0, column: 0, value: { kind: "number", value: 1 } }]), + ), + ).toBe(false); + }); + + it("is true when any cell carries a comment", () => { + expect( + sheetHasComments( + sheet([ + { + row: 0, + column: 0, + value: { kind: "number", value: 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([ + { + row: 0, + column: 0, + value: { kind: "number", value: 1 }, + comment: { text: "first" }, + }, + { + row: 1, + column: 0, + value: { kind: "number", value: 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([ + { + row: 0, + column: 0, + value: { kind: "number", value: 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, + ); + }); +}); + +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()}}`; } From 2544b3b7bf9057c6394ca3544a0a081673637444 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:21:08 +0100 Subject: [PATCH 017/102] test(ooxml.js): cover page-size tolerance's exact boundary and remove a dead type-narrowing check Adds a test at exactly the half-point tolerance boundary (not just comfortably inside it), and four tests each isolating one dimension's own necessity in pageSizeToPaperSizeCode's Letter/A4 checks (a width match with a mismatched height, and vice versa, for both page sizes) -- none of which any existing test distinguished from the other. parseUniversalMeasureToPt no longer runs an `amountRaw === undefined || unit === undefined` check after a successful regex match: neither of UNIVERSAL_MEASURE_RE's two capture groups is optional (neither has a trailing `?`), so a successful match always populates both -- TypeScript's own RegExpExecArray typing just cannot express that a specific pattern's own groups are mandatory. Non-null assertions state that directly instead of a runtime check no real regex match can ever fail. --- packages/ooxml.js/src/typed/xlsx/util.test.ts | 45 +++++++++++++++++++ packages/ooxml.js/src/typed/xlsx/util.ts | 8 ++-- 2 files changed, 48 insertions(+), 5 deletions(-) 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": From 418f3942069a0da2eab77260ecfe9378b5097899 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:21:17 +0100 Subject: [PATCH 018/102] test(ooxml.js): cover numbering's overridden-level guard, namespace, and numeric level ordering Adds a w:startOverride whose own ilvl names a level the base abstractNum never defined (must be skipped, not fabricated), a declared-namespace assertion for the built w:numbering root, and a level ordering case proving ilvl sorts numerically ('10' after '2'), none of which the existing round-trip-only fixtures distinguished from a passing but coincidentally-correct result. --- .../ooxml.js/src/typed/docx/numbering.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/numbering.test.ts b/packages/ooxml.js/src/typed/docx/numbering.test.ts index c4cb9e6a4..8f013f504 100644 --- a/packages/ooxml.js/src/typed/docx/numbering.test.ts +++ b/packages/ooxml.js/src/typed/docx/numbering.test.ts @@ -135,6 +135,22 @@ 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"]); + }); }); describe("buildNumberingElement", () => { @@ -165,4 +181,40 @@ 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"]); + }); }); From 9fad6913b4e6c1c8c80187efac50602f3e5c73be Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:21:27 +0100 Subject: [PATCH 019/102] test(ooxml.js): cover flavour detection's own precondition directly readEmbeddedOoxmlPayload's outer catch swallows a wrongly-detected flavour's own read failure exactly as gracefully as a genuinely undetected one, so testing hasDocxBody/detectFlavour only through that public entry point cannot tell "correctly found no flavour" apart from "wrongly matched one, then threw reading it" -- both produce the same undefined result. Exports both functions and adds direct coverage: a w:body present/absent, and each of the three entry-part flavours detected (or none) independent of the read that would follow. --- packages/ooxml.js/src/typed/embedded.test.ts | 54 +++++++++++++++++++- packages/ooxml.js/src/typed/embedded.ts | 6 +-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/ooxml.js/src/typed/embedded.test.ts b/packages/ooxml.js/src/typed/embedded.test.ts index 3af549d1b..3284f5390 100644 --- a/packages/ooxml.js/src/typed/embedded.test.ts +++ b/packages/ooxml.js/src/typed/embedded.test.ts @@ -7,7 +7,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). @@ -136,3 +142,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..e95795308 100644 --- a/packages/ooxml.js/src/typed/embedded.ts +++ b/packages/ooxml.js/src/typed/embedded.ts @@ -34,8 +34,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 +54,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 ( From d5f6bffe4e698c8c708a00b46f6337a9eed50793 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:22:58 +0100 Subject: [PATCH 020/102] fix(ooxml.js): populate the required displayText field on every test cell ContentSheetCellSchema requires displayText, absent from the plain number-cell literals comments-write.test.ts built by hand -- caught by tsconfig.node.json's own typecheck (which includes test files, unlike the base tsconfig.json a plain tsc run checks). Introduces a numberCell helper that always sets it alongside the numeric value. --- .../src/typed/xlsx/comments-write.test.ts | 56 ++++++++----------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts b/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts index ce2ba5cd0..bb48a60f2 100644 --- a/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts @@ -26,6 +26,21 @@ function sheet(cells: ContentSheetCell[]): ContentSheet { }; } +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}"); @@ -38,24 +53,13 @@ describe("threadedCommentId", () => { describe("sheetHasComments", () => { it("is false for a sheet with no cell comments at all", () => { - expect( - sheetHasComments( - sheet([{ row: 0, column: 0, value: { kind: "number", value: 1 } }]), - ), - ).toBe(false); + expect(sheetHasComments(sheet([numberCell(0, 0, 1)]))).toBe(false); }); it("is true when any cell carries a comment", () => { expect( sheetHasComments( - sheet([ - { - row: 0, - column: 0, - value: { kind: "number", value: 1 }, - comment: { text: "note" }, - }, - ]), + sheet([numberCell(0, 0, 1, { comment: { text: "note" } })]), ), ).toBe(true); }); @@ -64,18 +68,8 @@ describe("sheetHasComments", () => { describe("buildThreadedCommentElements", () => { it("assigns sequential, increasing ids across two separately-commented cells, not just within one thread", () => { const s = sheet([ - { - row: 0, - column: 0, - value: { kind: "number", value: 1 }, - comment: { text: "first" }, - }, - { - row: 1, - column: 0, - value: { kind: "number", value: 2 }, - comment: { text: "second" }, - }, + numberCell(0, 0, 1, { comment: { text: "first" } }), + numberCell(1, 0, 2, { comment: { text: "second" } }), ]); const elements = buildThreadedCommentElements(s); expect( @@ -88,15 +82,9 @@ describe("buildThreadedCommentElements", () => { it("writes a reply immediately after its own root, carrying the root's own id as parentId", () => { const s = sheet([ - { - row: 0, - column: 0, - value: { kind: "number", value: 1 }, - comment: { - text: "root", - replies: [{ text: "reply" }], - }, - }, + numberCell(0, 0, 1, { + comment: { text: "root", replies: [{ text: "reply" }] }, + }), ]); const elements = buildThreadedCommentElements(s); expect(elements).toHaveLength(2); From 9a0bba0bdb3d474f5267eaaf22dcc3bbd7ceeb5b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:23:27 +0100 Subject: [PATCH 021/102] refactor(ooxml.js): drop looksLikeSvg's redundant Math.min against bytes.length Uint8Array.prototype.subarray already clamps its end argument to the array's own length, so requesting SVG_SNIFF_WINDOW bytes from a shorter buffer already yields exactly the bytes that exist -- the Math.min was never observably different from omitting it. --- packages/ooxml.js/src/image/sniff.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/image/sniff.ts b/packages/ooxml.js/src/image/sniff.ts index 253a11117..3190d1824 100644 --- a/packages/ooxml.js/src/image/sniff.ts +++ b/packages/ooxml.js/src/image/sniff.ts @@ -29,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); From 4e12e9cd503f34a1e73976dc96cd509fe9fe1309 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:23:36 +0100 Subject: [PATCH 022/102] test(ooxml.js): prove isXmlNode's element branch gates on type, not shape A value shaped exactly like a valid element (tag/attributes/children all present) under an unrecognised type name must still fall through to the final `return false` -- nothing previously drove the value into the "element" arm by an unrelated type name alone. --- packages/ooxml.js/src/model/node.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ooxml.js/src/model/node.test.ts b/packages/ooxml.js/src/model/node.test.ts index 8b76072bd..79e9d86ac 100644 --- a/packages/ooxml.js/src/model/node.test.ts +++ b/packages/ooxml.js/src/model/node.test.ts @@ -21,6 +21,13 @@ describe("isXmlNode: non-record inputs", () => { 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", () => { From d8392e21a2f99fc96d6a16d999c1bba9c474a66e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:23:47 +0100 Subject: [PATCH 023/102] test(ooxml.js): prove a reply's own counter increment never runs backwards A comment thread with one reply, followed by a second cell's own comment, needs the second root's id to continue at 2 -- a reply-loop increment that ran backwards would instead collide it with the first cell's own root id. --- .../src/typed/xlsx/comments-write.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts b/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts index bb48a60f2..180b15d4f 100644 --- a/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/comments-write.test.ts @@ -97,6 +97,24 @@ describe("buildThreadedCommentElements", () => { 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", () => { From d941286cc276deb3fe635a116b8252cfb15b1b67 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:23:59 +0100 Subject: [PATCH 024/102] test(ooxml.js): prove a table relationship is filtered by its own type A distractor relationship whose type is not the table relationship type, but whose target happens to be a genuinely well-formed table element (name and ref both present), must still be skipped -- the existing distractor test's target failed the name/ref check anyway, so it could not by itself distinguish the type guard from an absent one. --- .../ooxml.js/src/typed/xlsx/definitions.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/definitions.test.ts b/packages/ooxml.js/src/typed/xlsx/definitions.test.ts index 244463c52..1da208424 100644 --- a/packages/ooxml.js/src/typed/xlsx/definitions.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/definitions.test.ts @@ -106,6 +106,22 @@ describe("readWorkbookDefinitions", () => { }); }); + 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", { From 0dd78101c9910f526fbed0823fca39986f24584e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:12 +0100 Subject: [PATCH 025/102] refactor(ooxml.js): drop isoDateTimeToSerial's redundant no-separator guard When indexOf finds no 'T', the date half slices to length iso.length - 1 and the time half to the whole iso.length characters. ISO_DATE_PATTERN and ISO_TIME_PATTERN are anchored to exactly 10 and 8 characters respectively, so matching both at once would need iso.length to be both 11 and 8 -- impossible. With no separator, at least one half always fails to parse, so the existing undefined fallthrough already covers it with no separate check needed. --- packages/ooxml.js/src/typed/xlsx/serial.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/serial.ts b/packages/ooxml.js/src/typed/xlsx/serial.ts index 8dd092a10..d1f7db8a7 100644 --- a/packages/ooxml.js/src/typed/xlsx/serial.ts +++ b/packages/ooxml.js/src/typed/xlsx/serial.ts @@ -197,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 From b623556183c83c18e8542010777e5e720eb79f6f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:23 +0100 Subject: [PATCH 026/102] refactor(ooxml.js): drop parseSqref's redundant empty-token skip parseRangeReference("") always returns undefined -- its own parseCellReference requires at least one letter and one digit, which an empty string can never supply -- so the loop's existing `range !== undefined` check already discards an empty token with no separate skip needed. --- packages/ooxml.js/src/typed/xlsx/sqref.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/sqref.ts b/packages/ooxml.js/src/typed/xlsx/sqref.ts index af39b65c8..e5eb5d560 100644 --- a/packages/ooxml.js/src/typed/xlsx/sqref.ts +++ b/packages/ooxml.js/src/typed/xlsx/sqref.ts @@ -12,12 +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 the loop's own `token === ""` skip below already discards -- so the two split forms produce the identical final token list regardless of how many consecutive whitespace characters separate two ranges. + // 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; - } const range = parseRangeReference(token); if (range !== undefined) { ranges.push(range); From b4260c67dcb5d77cb112088ad6d6224295ddf369 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:35 +0100 Subject: [PATCH 027/102] refactor(ooxml.js): hoist buildXml's ignored pi/declaration child array fast-xml-parser's own builder ignores the array's content entirely for both the "pi" and "declaration" ordered-node shapes (verified directly against the library), so a fresh 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 mutant instead, which the workspace's shared Stryker config already excludes from the valid-mutant count for exactly this reason. --- packages/ooxml.js/src/xml/build.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/xml/build.ts b/packages/ooxml.js/src/xml/build.ts index d596b13f4..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: "@_", @@ -47,10 +50,13 @@ function toOrderedNode(node: XmlNode): Record { 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}`]: [] }; + 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": [], ":@": attrsObject(node.attributes) }; + 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 { From cecf4426ad80ef3b4aada8f87a7441e94f850d53 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:44 +0100 Subject: [PATCH 028/102] test(ooxml.js): cover textContent's cdata concatenation, simplify relsPathFor textContent's own cdata half was never exercised by any existing fixture (every one used only nodes); adds a mixed text+cdata element proving both node kinds concatenate into one string. relsPathFor's fileName ternary is redundant in the same way its own sibling functions elsewhere in this package already are: slice(-1 + 1) is slice(0), which returns the whole string unchanged -- exactly what a slash-free path needs -- so partPath.slice(lastSlash + 1) alone already covers both cases correctly. --- packages/ooxml.js/src/typed/util.test.ts | 13 ++++++++++++- packages/ooxml.js/src/typed/util.ts | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/typed/util.test.ts b/packages/ooxml.js/src/typed/util.test.ts index 081376546..e88864b8c 100644 --- a/packages/ooxml.js/src/typed/util.test.ts +++ b/packages/ooxml.js/src/typed/util.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from "vitest"; -import { relsPathFor, resolveRelTarget } from "./util"; +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", () => { diff --git a/packages/ooxml.js/src/typed/util.ts b/packages/ooxml.js/src/typed/util.ts index c859e82f8..844b70ab0 100644 --- a/packages/ooxml.js/src/typed/util.ts +++ b/packages/ooxml.js/src/typed/util.ts @@ -99,7 +99,8 @@ export interface Relationship { 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`; } From 85188bf2270a0d6f63b0d7a51363a568472a831b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:56 +0100 Subject: [PATCH 029/102] test(ooxml.js): cover buildCellShading's unrecognised-kind default branch ContentCellFillSchema only ever produces 'solid' or 'pattern' through normal validated input, so the writer's own defensive default branch naming the actual kind was never exercised. Passes a fill shaped like neither, past the type system, and checks the thrown message names it. --- packages/ooxml.js/src/typed/docx/shading.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/shading.test.ts b/packages/ooxml.js/src/typed/docx/shading.test.ts index 7c4331ac0..25802d737 100644 --- a/packages/ooxml.js/src/typed/docx/shading.test.ts +++ b/packages/ooxml.js/src/typed/docx/shading.test.ts @@ -151,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/); + }); }); From 394a0f62771d77ed1da1409a56136c745e3203ae Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:25:07 +0100 Subject: [PATCH 030/102] test(ooxml.js): cover figure-captions' image gate, join separator Adds: a non-image block sitting beside a genuine Caption-styled paragraph must never gain a caption property of its own (proves the "is this an image" guard actually runs, not just that its outcome happens to match); and a caption with multiple runs must join them with no separator between, which every existing fixture's own single-run captions could never distinguish from any other join string. --- .../src/typed/docx/figure-captions.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 88bb2b350..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( @@ -94,6 +103,19 @@ describe("associateFigureCaptions", () => { ]); }); + 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"), From bee91f78dce8321f8ce7100973e4d4a196cbeb74 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:25:18 +0100 Subject: [PATCH 031/102] test(ooxml.js): cover numbering's non-canonical ilvl/numId sort, undefined level Object property enumeration hoists canonical non-negative-integer string keys ('2', '10', ...) into ascending numeric order on its own, with no sort needed at all -- which is exactly why the existing '10'/'2' ordering test cannot, by itself, distinguish a real numeric sort from no sort, or from a broken comparator. Adds a non-canonical ilvl ('00') and numId ('00') to let a genuine comparator show through, plus a level whose own value is undefined despite carrying an own key, proving it is omitted rather than written as a hole in w:abstractNum's children. --- .../ooxml.js/src/typed/docx/numbering.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/numbering.test.ts b/packages/ooxml.js/src/typed/docx/numbering.test.ts index 8f013f504..c56b52d4d 100644 --- a/packages/ooxml.js/src/typed/docx/numbering.test.ts +++ b/packages/ooxml.js/src/typed/docx/numbering.test.ts @@ -217,4 +217,70 @@ describe("buildNumberingElement", () => { .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", + ); + }); }); From d35a1a1e3174108da1f73ad266e551fecc16fb13 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:25:28 +0100 Subject: [PATCH 032/102] refactor(ooxml.js): drop bytesToBase64's redundant remainder-byte guards bytes[i + 1]/bytes[i + 2] already read back undefined past the array's own end, and the one use of each not already guarded by its own boundary ternary (the b1 >> 4 and b2 >> 6 shifts) coerces undefined to 0 via JS's own bitwise-operator ToInt32 conversion -- the same result the explicit ": 0" fallback gave. No input changes the output, only Uint8Array's own out-of-range-is-undefined semantics. --- packages/ooxml.js/src/util/base64.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/util/base64.ts b/packages/ooxml.js/src/util/base64.ts index f7dc179d6..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)) : "="; From 5bd2d87e61fc6bb823ae878181fccc5f44c7c747 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:25:40 +0100 Subject: [PATCH 033/102] test(ooxml.js): cover embedded-object root-entry precedence, Package lookup Adds three fixtures readEmbeddedOoxmlPayload's own decode had no direct coverage for: a nested archive's own same-named entry (ancestors.length > 0) must never overwrite the payload's genuine root-level part; the compound-file 'Package' stream must be found by its own name among several streams, not merely the first the directory tree visits (directory siblings are name-sorted, so a "Decoy" stream genuinely visits first); and bytes carrying neither the ZIP nor the compound-file magic must degrade to undefined. Also drops the function's own separate "is this even a ZIP or a compound file" gate: bytes matching neither magic still reach zipBytesOfPayload, fail isZipArchive, and then fail readCompoundFile's own equivalent magic check with a thrown CompoundFileFormatError -- caught by the same catch every other undecodable payload already degrades through. The gate changed which line produced undefined, never whether the caller saw it. --- packages/ooxml.js/src/typed/embedded.test.ts | 60 +++++++++++++++++++- packages/ooxml.js/src/typed/embedded.ts | 7 +-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/ooxml.js/src/typed/embedded.test.ts b/packages/ooxml.js/src/typed/embedded.test.ts index 3284f5390..3fa72d90a 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 { @@ -77,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( @@ -97,6 +125,13 @@ describe("readEmbeddedOoxmlPayload", () => { ).toBeUndefined(); }); + it("returns undefined immediately for bytes carrying neither the ZIP nor the compound-file magic at all, never entering the parse", () => { + // Neither isZipArchive nor isCompoundFile recognise this input -- the gate above must short-circuit to undefined itself, rather than only degrading via the catch block once a parse attempt throws. + 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([ @@ -129,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(); diff --git a/packages/ooxml.js/src/typed/embedded.ts b/packages/ooxml.js/src/typed/embedded.ts index e95795308..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. // @@ -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); From d908b8676f273e086b2137d2fa96709894d80c1c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:26:15 +0100 Subject: [PATCH 034/102] fix(ooxml.js): correct a stale comment about the removed magic-byte gate The previous commit removed readEmbeddedOoxmlPayload's own separate "neither ZIP nor compound file" early return, but this test's own name and comment still described that gate as the mechanism producing the undefined result. Both now describe how the catch block actually degrades this input. --- packages/ooxml.js/src/typed/embedded.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/typed/embedded.test.ts b/packages/ooxml.js/src/typed/embedded.test.ts index 3fa72d90a..ac70d78f8 100644 --- a/packages/ooxml.js/src/typed/embedded.test.ts +++ b/packages/ooxml.js/src/typed/embedded.test.ts @@ -125,8 +125,8 @@ describe("readEmbeddedOoxmlPayload", () => { ).toBeUndefined(); }); - it("returns undefined immediately for bytes carrying neither the ZIP nor the compound-file magic at all, never entering the parse", () => { - // Neither isZipArchive nor isCompoundFile recognise this input -- the gate above must short-circuit to undefined itself, rather than only degrading via the catch block once a parse attempt throws. + 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, ); From 262bd6a691ca1aedfeba9fa053eda60cff4dc8d3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:26:29 +0100 Subject: [PATCH 035/102] test(ooxml.js): add direct structural coverage for oleObjectBin Never published, but real code Stryker mutates all the same, and it had no test file of its own -- every existing use only exercised the default small/mini-stream shape indirectly through embedded.test.ts. Reads every fixture back through archive-codec's own independent readCompoundFile/readOlePackage, covering the custom stream-name option, a mini-stream payload spanning several sectors, and two differently-sized non-mini-stream (>= 4096 byte) payloads -- the large-stream code path no existing fixture ever reached. --- .../ooxml.js/src/test-support/cfb.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/ooxml.js/src/test-support/cfb.test.ts 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..163f519bb --- /dev/null +++ b/packages/ooxml.js/src/test-support/cfb.test.ts @@ -0,0 +1,61 @@ +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); + }); +}); From 07a2412591ab3d8965accb7cde248e46a3f88fb1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:26:43 +0100 Subject: [PATCH 036/102] test(ooxml.js): cover inherit's rel-type filter, placeholder fallback, style clamp Adds: a slide relationship filtered by its own type suffix rather than being the first one listed; an idx that names no shape falling back to type matching instead of returning early; a key naming neither idx nor type correctly refusing to match an equally-untyped shape; readRunPropertiesFromElement's own sizePt/bold/italic absence and explicit-false cases (no prior fixture omitted sz, or set b/i to anything but "1"); otherStyle as the fallback for a placeholder type that is neither title nor body; and level clamping at both the low (negative) and high (above 8) end, which the fixture's own single defined level (lvl1pPr) could only prove correct in one direction at a time. --- .../ooxml.js/src/typed/pptx/inherit.test.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/packages/ooxml.js/src/typed/pptx/inherit.test.ts b/packages/ooxml.js/src/typed/pptx/inherit.test.ts index 3e00b4616..a37fddf67 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,30 @@ describe("resolvePlaceholderXfrm", () => { }); }); +describe("readRunPropertiesFromElement", () => { + const context = { + layoutRoot: undefined, + masterRoot: undefined, + theme: EMPTY_THEME, + colorMap: new Map(), + }; + + it("leaves sizePt undefined for an element carrying no sz attribute at all", () => { + expect( + readRunPropertiesFromElement(el("a:rPr"), context).sizePt, + ).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 +439,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(); + }); }); From 617e777ca6713efc2d921a1160dc466aa0c71478 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:42:55 +0100 Subject: [PATCH 037/102] test(ooxml.js): assert italic is also undefined for an rPr with no attrs The sizePt/bold-absence test's own rPr carried no i attribute either, but only sizePt and bold were checked -- italic's own undefined branch went unasserted, leaving it indistinguishable from an outer guard forced to always take the "===\"1\"" arm. --- packages/ooxml.js/src/typed/pptx/inherit.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ooxml.js/src/typed/pptx/inherit.test.ts b/packages/ooxml.js/src/typed/pptx/inherit.test.ts index a37fddf67..681906022 100644 --- a/packages/ooxml.js/src/typed/pptx/inherit.test.ts +++ b/packages/ooxml.js/src/typed/pptx/inherit.test.ts @@ -383,10 +383,11 @@ describe("readRunPropertiesFromElement", () => { colorMap: new Map(), }; - it("leaves sizePt undefined for an element carrying no sz attribute at all", () => { - expect( - readRunPropertiesFromElement(el("a:rPr"), context).sizePt, - ).toBeUndefined(); + 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", () => { From bf4e275a53aa591f6754e54b4028e17c48ecb302 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:43:10 +0100 Subject: [PATCH 038/102] test(ooxml.js): prove residualAttributesFor rejects a matching-first-tag multi-element residue The existing two-cfRule-element fixture's own first element carried no attributes at all, so a bypassed node-count check would still return {} by coincidence. This one gives the first element real attributes, so only the count check itself can tell a genuine single-element residue apart from a multi-element one whose first entry happens to match. --- packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts b/packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts index b16311e85..2afd7d9f1 100644 --- a/packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/rule-residue.test.ts @@ -67,6 +67,16 @@ describe("residualAttributesFor", () => { ).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( From 98ff865bf48939e73e29768aa8d2a5b80ca7a456 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:43:30 +0100 Subject: [PATCH 039/102] refactor(ooxml.js): rewrite oleObjectBin's fixed-array copy loops as forEach The name-encoding loop (writeEntry) and the magic-byte loop both copy a known array's own elements into a buffer with no bounds arithmetic of their own to get wrong -- forEach's own iteration removes the hand-written index comparison as a mutation target entirely, the same technique this package already uses elsewhere for a manually-bounded copy loop. Also drops writeEntry's own high-32-bits-of-size write: entry is always a fresh 128-byte slice of a zero-initialised directory buffer, so that byte is already 0 there, and every size this builder ever writes fits in 32 bits regardless. --- packages/ooxml.js/src/test-support/cfb.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ooxml.js/src/test-support/cfb.ts b/packages/ooxml.js/src/test-support/cfb.ts index 993349d50..1b94e6d82 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,9 +92,9 @@ 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); From 04bb2d2257d5be412e9768c938583ea3d5d99230 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:43:53 +0100 Subject: [PATCH 040/102] test(ooxml.js): add byte-level coverage for oleObjectBin's remaining structure Adds five fixtures the round-trip-through-a-reader tests above cannot reach on their own: an exact-4096-byte packaged stream (the strict less-than boundary for "small"), direct inspection of every fixed [MS-CFB] header field this builder writes (several of which archive-codec's own reader deliberately never cross-checks -- its own header comments say so, for the directory's count fields and for the root entry's name specifically), the mini-FAT's own unused padding slot immediately past the real chain, and a fixture sized to the exact one-FAT-sector boundary this builder is structurally scoped to. The last two are not just belt-and-braces: manually verified against this exact suite, an off-by-one mutant on the mini-FAT loop's own bound writes into the byte-level fixture's padding slot with no other test able to observe it, and the boundary fixture is sized so a bypassed small-file guard elsewhere in this file provably raises RangeError against it while the real, guarded code still round-trips clean. --- .../ooxml.js/src/test-support/cfb.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/ooxml.js/src/test-support/cfb.test.ts b/packages/ooxml.js/src/test-support/cfb.test.ts index 163f519bb..f00d37751 100644 --- a/packages/ooxml.js/src/test-support/cfb.test.ts +++ b/packages/ooxml.js/src/test-support/cfb.test.ts @@ -58,4 +58,74 @@ describe("oleObjectBin", () => { 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); + }); }); From b52598cc1d65a67beafdeaa90fe3fbb1e1c3ffc7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:48:20 +0100 Subject: [PATCH 041/102] refactor(ooxml.js): drop oleObjectBin's three redundant zero-valued header writes 0x28, 0x48, and DIFAT[0] at 0x4c all write a literal 0 into file, a fresh Uint8Array that is already zero everywhere -- indistinguishable from leaving the default alone. Also rewrites the DIFAT[1..108] padding loop as an Array.from/forEach: its own last iteration is unobservable regardless of where the range ends, since the FAT sector's own bytes get (re)written immediately afterwards either way, so a hand-bounded comparison there was never provably correct by any test, only by inspection. --- packages/ooxml.js/src/test-support/cfb.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/ooxml.js/src/test-support/cfb.ts b/packages/ooxml.js/src/test-support/cfb.ts index 1b94e6d82..19f0b283d 100644 --- a/packages/ooxml.js/src/test-support/cfb.ts +++ b/packages/ooxml.js/src/test-support/cfb.ts @@ -100,18 +100,17 @@ export function oleObjectBin( 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); From a9ece7a4182f8ce9a9d0e513ea7fb39adcea30b4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:41:02 +0100 Subject: [PATCH 042/102] test(ooxml.js): cover defined-names' print-area/titles parse and build pair Direct unit coverage for readDefinedNamesBySheet, readWorkbookNames, parsePrintAreaValue, parsePrintTitlesValue, quoteSheetNameIfNeeded, buildPrintAreaValue, and buildPrintTitlesValue -- previously exercised only indirectly, if at all, through print-settings.ts and content.ts, leaving the localSheetId validation, sheet-name quoting, and reversed-range normalisation branches without any test proving their actual behaviour. --- .../src/typed/xlsx/defined-names.test.ts | 465 ++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/defined-names.test.ts 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..f64f76331 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/defined-names.test.ts @@ -0,0 +1,465 @@ +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("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); + }); +}); + +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 }, + }); + }); +}); From fd07e1df637a3a157f3d7b4b184d7309f0e235e9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:41:40 +0100 Subject: [PATCH 043/102] test(ooxml.js): cover data-validation's read/build attribute branches Direct coverage for readDataValidations and its buildDataValidationsElement write side: the unrecognised-type and no-range whole-element residue paths, the between/notBetween-only formula2 gate, the list/custom operator drop, the boolean-flag omit-when-false convention, the warning/information-only errorStyle promotion, and residue capture/restore through captureResidualAttributes and residualAttributesFor -- none of which had a test exercising this module directly before. --- .../src/typed/xlsx/data-validation.test.ts | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/data-validation.test.ts 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..75b49a9ad --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/data-validation.test.ts @@ -0,0 +1,379 @@ +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("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"); + }); +}); From 6ea7ad1cc2e498abd406a0b57c267a120e66c0a9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:41:54 +0100 Subject: [PATCH 044/102] test(ooxml.js): add direct coverage for the tree-walk/attr/rels helpers walk, elementsWithTag, childrenWithTag, attr, rootElement, and resolveRelationships had no test exercising them directly -- util.test.ts covers only relsPathFor/resolveRelTarget/textContent. Adds cases for depth-first descent order, direct-vs-descendant tag matching, a missing or binary part, External vs internal relationship targets, a Relationship element missing a required attribute, and entity-decoding both the Target and Type attributes before resolution. --- .../src/typed/util-structural.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 packages/ooxml.js/src/typed/util-structural.test.ts 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"); + }); +}); From cc85b327248499cf78a81e7b8a21073d8a6ed042 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:44:48 +0100 Subject: [PATCH 045/102] test(ooxml.js): cover print-settings' margins, breaks, and fit/scale gate Extends the existing page-size-only suite with the module's remaining branches: per-side margin fallback, pageOrder's default/overThenDown split, gridlines/headers booleans, row/col break index reading (including a non-numeric or negative id being skipped), the fitToPage/scale mutual exclusion, and readPrintSettings' own print-area/print-titles integration against defined-names.ts -- including the wrong-sheet-index and fails-to-parse cases that were previously untested. --- .../src/typed/xlsx/print-settings.test.ts | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) 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..b42385436 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,200 @@ 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, + }); + }); +}); + +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: [] }); + }); +}); + +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 absent or 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("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); + }); +}); From 9f80912c2dfd9c0091029ef870f24b3dabec6b8b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:01:45 +0100 Subject: [PATCH 046/102] refactor(ooxml.js): drop defined-names' redundant guards and regex reparse readDefinedNamesBySheet's own name-then-type check already excludes an absent name, making the separate name===undefined arm dead. stripSheetPrefix's ternary is a no-op in its own -1 branch, since slice(-1+1) is slice(0). parsePrintAreaValue's split-then-undefined-check is replaced by an indexOf/slice split that is never possibly undefined, dropping the now-redundant length guard too (an empty first segment already parses to no range on its own). The column half of parsePrintTitlesValue drops its letters regex in favour of trying columnLettersToIndex directly, which already rejects exactly the same inputs. buildPrintAreaValue now builds its dollared reference straight from the range's own row/column indices instead of formatting then re-parsing a plain reference with a regex, which also removes a genuinely equivalent quantifier mutant the regex approach could never have been made to fail on a multi-digit row. Adds the direct-unit coverage this uncovered was missing along the way: whitespace trimming around a printTitles segment, multi-digit row bands, prefix/suffix garbage rejected on both sides of a row band, a mixed digit/letter segment rejected as neither band, and a multi-letter column in buildPrintAreaValue. --- .../src/typed/xlsx/defined-names.test.ts | 45 +++++++++++++++++ .../ooxml.js/src/typed/xlsx/defined-names.ts | 50 ++++++++----------- 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/defined-names.test.ts b/packages/ooxml.js/src/typed/xlsx/defined-names.test.ts index f64f76331..524d865b9 100644 --- a/packages/ooxml.js/src/typed/xlsx/defined-names.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/defined-names.test.ts @@ -344,6 +344,39 @@ describe("parsePrintTitlesValue", () => { }); }); + 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 }, @@ -419,6 +452,18 @@ describe("buildPrintAreaValue", () => { 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", () => { 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). From 20f4930e9aeee4f4cb5f2835f0a2acd9d5347838 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:01:56 +0100 Subject: [PATCH 047/102] test(ooxml.js): cover every ST_DataValidationOperator vocabulary member isSheetRuleOperator's own OR chain only had a couple of its eight literal branches exercised, leaving the rest (notBetween, notEqual, greaterThanOrEqual, lessThan, lessThanOrEqual) unproven. Adds a parameterised test over every member, an explicit notBetween-with-formula2 case (formula2 is read for that operator too, not only between), and a case proving formula1 is genuinely omitted, not written as undefined, when the element carries no child at all. --- .../src/typed/xlsx/data-validation.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/data-validation.test.ts b/packages/ooxml.js/src/typed/xlsx/data-validation.test.ts index 75b49a9ad..8a7724528 100644 --- a/packages/ooxml.js/src/typed/xlsx/data-validation.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/data-validation.test.ts @@ -131,6 +131,43 @@ describe("readDataValidations", () => { 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", From 8f841091cdc16ea1adb49151537ed863122d1f0c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:02:08 +0100 Subject: [PATCH 048/102] refactor(ooxml.js): drop print-settings' redundant scale-presence guard Number(undefined) is NaN, and the isFinite check right below already rejects that exactly as it rejects any other non-numeric scale attribute, so the separate scaleRaw!==undefined guard around it was dead weight. Adds the margin/break/scale coverage this uncovered was missing: all four margin sides read from distinct values (proving multiplication, not division, and each attribute's own name), the top and left per-side defaults specifically, a break at index 0, manualBreaks reporting when only column breaks are present, and scalePercent staying omitted when the attribute is absent entirely. --- .../src/typed/xlsx/print-settings.test.ts | 54 ++++++++++++++++++- .../ooxml.js/src/typed/xlsx/print-settings.ts | 9 ++-- 2 files changed, 57 insertions(+), 6 deletions(-) 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 b42385436..fde27c86a 100644 --- a/packages/ooxml.js/src/typed/xlsx/print-settings.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/print-settings.test.ts @@ -94,6 +94,35 @@ describe("readPrintSettings: margins", () => { 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", () => { @@ -175,6 +204,23 @@ describe("readPrintSettings: manual breaks", () => { 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", () => { @@ -185,7 +231,7 @@ describe("readPrintSettings: fit-to-page vs scale", () => { expect(Object.hasOwn(settings, "fitToPages")).toBe(false); }); - it("omits scalePercent when scale is absent or non-numeric", () => { + it("omits scalePercent when scale is non-numeric", () => { const worksheet = el("worksheet", {}, [ el("pageSetup", { scale: "not-a-number" }), ]); @@ -193,6 +239,12 @@ describe("readPrintSettings: fit-to-page vs scale", () => { 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" })]), 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; } } From 9b59c323f793ab12dde0f0d8206013a49e70b71d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:08:33 +0100 Subject: [PATCH 049/102] test(ooxml.js): add direct structural coverage for chart cache reading readChartTable and readChartResidue had no unit test exercising them directly, only indirect coverage through a full xlsx round trip. Covers the no-chart/no-plotArea/no-series early returns, cached points read via c:numRef, a scatter series' c:xVal/c:yVal fallback and its precedence against c:cat/c:val, a series name from either an inline c:v or a cached string reference, the multi-level cached string reference's deepest-level selection, points sitting directly on the source with no ref wrapper, a c:pt missing idx or c:v being skipped, the numeric (not lexicographic) category ordering, a shared category index keeping its first series' label, and the chart residue cache's own per-element identity. --- .../ooxml.js/src/typed/pptx/chart.test.ts | 315 ++++++++++++++---- 1 file changed, 246 insertions(+), 69 deletions(-) diff --git a/packages/ooxml.js/src/typed/pptx/chart.test.ts b/packages/ooxml.js/src/typed/pptx/chart.test.ts index 715c71e5b..1c44e77df 100644 --- a/packages/ooxml.js/src/typed/pptx/chart.test.ts +++ b/packages/ooxml.js/src/typed/pptx/chart.test.ts @@ -1,87 +1,264 @@ 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 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", () => { + const chartRoot = chartRootWith( + ser( + el("c:cat", {}, [ + el("c:multiLvlStrRef", {}, [ + el("c:multiLvlStrCache", {}, [ + el("c:lvl", {}, [cPt("0", "outer")]), + el("c:lvl", {}, [cPt("0", "inner")]), ]), ]), ]), - ]), - ]), - ]); -} + 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: "inner" }] }], + }); + }); -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); }); }); From 57cf4dfe4464fb8481d445d942a91950020481fe Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:09:18 +0100 Subject: [PATCH 050/102] test(ooxml.js): add direct structural coverage for diagram text walking readDiagramText and readDiagramResidue had no unit test exercising them directly, only indirect coverage through a full pptx round trip. Covers the no-ptLst/no-doc-point early returns, node vs asst vs parTrans point-type filtering, a:r/a:fld/a:br run handling, a paragraph list being kept whole once any of its runs is non-empty (blank paragraphs included) and dropped entirely when none are, srcOrd-based sibling ordering with a missing srcOrd sorting as zero, depth-first traversal order, the parOf-only cxn filter, a cxn missing srcId/destId, the visited-set cycle guard, and the residue cache's own per-triple identity. --- .../ooxml.js/src/typed/pptx/diagram.test.ts | 352 +++++++++++++++--- 1 file changed, 301 insertions(+), 51 deletions(-) diff --git a/packages/ooxml.js/src/typed/pptx/diagram.test.ts b/packages/ooxml.js/src/typed/pptx/diagram.test.ts index 2cc18eb2f..b3d3dbac6 100644 --- a/packages/ooxml.js/src/typed/pptx/diagram.test.ts +++ b/packages/ooxml.js/src/typed/pptx/diagram.test.ts @@ -1,67 +1,317 @@ 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("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); }); }); From 7baa9b6caead8f61f4f10b1c1db8639bb2178359 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:09:28 +0100 Subject: [PATCH 051/102] test(ooxml.js): prove a startOverride with no w:val leaves startAt alone readLevelOverrides' startOverrideVal!==undefined guard had no test for its own false side while base was genuinely defined: every existing case either supplied a real w:val or targeted a level the abstractNum did not define at all, so a w:startOverride element present with no w:val attribute was never proven to leave the base level's startAt untouched. --- .../ooxml.js/src/typed/docx/numbering.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/numbering.test.ts b/packages/ooxml.js/src/typed/docx/numbering.test.ts index c56b52d4d..dd0baf52c 100644 --- a/packages/ooxml.js/src/typed/docx/numbering.test.ts +++ b/packages/ooxml.js/src/typed/docx/numbering.test.ts @@ -151,6 +151,24 @@ describe("readNumberingDefinitions", () => { ); 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", () => { From 521354edc399d7dbdaeb0c95af7fea6f3d825b7d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:14:39 +0100 Subject: [PATCH 052/102] test(ooxml.js): distinguish a multi-level cache's last level from its second .at(-1) and .at(+1) coincide on a two-level cache, so the earlier test proved nothing about which end readCachedPoints actually reads from. A three-level fixture makes the two genuinely differ. Also adds a case for a cached point whose c:v is present but genuinely empty, proving labelCell's own text===\"\" branch is exercised, not merely its text===undefined one. --- .../ooxml.js/src/typed/pptx/chart.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/ooxml.js/src/typed/pptx/chart.test.ts b/packages/ooxml.js/src/typed/pptx/chart.test.ts index 1c44e77df..c4e3bddad 100644 --- a/packages/ooxml.js/src/typed/pptx/chart.test.ts +++ b/packages/ooxml.js/src/typed/pptx/chart.test.ts @@ -169,6 +169,17 @@ describe("readChartTable", () => { }); }); + 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( @@ -180,14 +191,15 @@ describe("readChartTable", () => { expect(table?.rows[0]?.cells[1]).toEqual({ blocks: [] }); }); - it("reads the deepest (last) level of a multi-level cached string reference", () => { + 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", "outer")]), - el("c:lvl", {}, [cPt("0", "inner")]), + el("c:lvl", {}, [cPt("0", "level-0")]), + el("c:lvl", {}, [cPt("0", "level-1")]), + el("c:lvl", {}, [cPt("0", "level-2")]), ]), ]), ]), @@ -196,7 +208,7 @@ describe("readChartTable", () => { ); const table = readChartTable(chartRoot, FRAME); expect(table?.rows[1]?.cells[0]).toEqual({ - blocks: [{ kind: "paragraph", runs: [{ text: "inner" }] }], + blocks: [{ kind: "paragraph", runs: [{ text: "level-2" }] }], }); }); From 8d438838d3351f1560c7edb7d1ec64592d1457a4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:14:59 +0100 Subject: [PATCH 053/102] test(ooxml.js): prove an unrecognised paragraph child contributes no run The a:br test alone let its own condition mutate to an unconditional true survive undetected, since every other child in that fixture is a:r/a:fld and never reaches the elseif branch at all. Adds a paragraph carrying a genuinely unrecognised child tag between two real runs, proving it contributes neither text nor a stray newline. --- .../ooxml.js/src/typed/pptx/diagram.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/ooxml.js/src/typed/pptx/diagram.test.ts b/packages/ooxml.js/src/typed/pptx/diagram.test.ts index b3d3dbac6..c6843588f 100644 --- a/packages/ooxml.js/src/typed/pptx/diagram.test.ts +++ b/packages/ooxml.js/src/typed/pptx/diagram.test.ts @@ -126,6 +126,27 @@ describe("readDiagramText", () => { 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( [ From edc97329b27e8daf70cacff32277827039ff7f79 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:25:10 +0100 Subject: [PATCH 054/102] refactor(ooxml.js): drop readToggle's redundant absent-value guard When w:val is genuinely absent, each of the three inequality checks is already true on its own (undefined !== "0", etc.), so the combined check already reads absence as on -- the separate val===undefined arm changed nothing. --- packages/ooxml.js/src/typed/docx/styles.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. From 074e078603ee3e9e86278b9396d2b55ba96f01c9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:25:23 +0100 Subject: [PATCH 055/102] test(ooxml.js): close style-cascade gaps in type discrimination and merge findStyle/findDefaultStyle only ever ran against fixtures with one style type present, so a same-styleId style of the wrong type, or a default-style flag on the wrong type, was never proven to be rejected -- both checks in each function's AND could silently degrade to always-true without a test noticing. Adds: type-vs-styleId and type-vs-default discrimination, the default paragraph style's own w:pPr being merged in above docDefaults, strike's inheritance through a basedOn chain (mergeRunLayer's ?? fallback on strike specifically, not just the sibling fields other tests already cover), majorAscii/minorAscii alongside their HAnsi spellings, a bare w:u with no w:val, w:ind/@w:start as w:left's fallback, "distribute" alongside "both" for justify, atLeast alongside exact for the lineRule guard, and a themeTint byte with a stray character before or after its two hex digits. --- .../ooxml.js/src/typed/docx/styles.test.ts | 174 +++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/styles.test.ts b/packages/ooxml.js/src/typed/docx/styles.test.ts index f1578976e..b1729cd37 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,29 @@ describe("resolveRunProperties: fonts and size", () => { ).toBe("Minor Font"); }); + 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 +532,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 +678,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 +711,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 +760,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" })]), From a2a08e872b1c3ab373d36de10378b1ac6678e2b2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:31:04 +0100 Subject: [PATCH 056/102] test(ooxml.js): prove an unrecognised asciiTheme resolves to no font readRunFontFamily's minorHAnsi/minorAscii check had no test for a value matching neither branch, so its own condition could degrade to an unconditional true (always returning the minor theme font) without any existing test catching it. --- packages/ooxml.js/src/typed/docx/styles.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/ooxml.js/src/typed/docx/styles.test.ts b/packages/ooxml.js/src/typed/docx/styles.test.ts index b1729cd37..8a8ec2766 100644 --- a/packages/ooxml.js/src/typed/docx/styles.test.ts +++ b/packages/ooxml.js/src/typed/docx/styles.test.ts @@ -466,6 +466,19 @@ 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( [], From a54b472ec901eb353bf1b72a53139a477fd50427 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:51:52 +0100 Subject: [PATCH 057/102] refactor(ooxml.js): drop reading-order's provably redundant cut guards Two guards in cut() never change its observable output for any input: shapes.length<=1 short-circuits an early return, but with at most one shape splitOnGap always yields a single group and a zero widestGap on both axes, so both ratios are 0, neither 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 regardless. columns.groups.length>1 alongside the ratio comparison is implied by it: splitOnGap only raises widestGap above 0 by actually pushing a second group, so a positive ratio already guarantees at least two groups exist. --- packages/ooxml.js/src/typed/pptx/reading-order.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/ooxml.js/src/typed/pptx/reading-order.ts b/packages/ooxml.js/src/typed/pptx/reading-order.ts index a2f50541e..0ea42496d 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); } From ea49b67ce16bf0e9e4d6d4a0b5b7c90aa6d6a4ea Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:52:02 +0100 Subject: [PATCH 058/102] test(ooxml.js): cover reading-order's axis-tie, recursion, and extent math Adds cases the existing geometry fixtures never exercised: an exact tie between the two axes' relative gaps breaking to rows rather than columns, overlapping shapes sorted correctly when the primary (y) and secondary (x) keys point opposite ways, a genuine y-tie broken by x, a row needing its own internal column cut once split out (rather than the whole set's flat sort coincidentally landing on the same order), and extentAlong computing a true span rather than a start+end sum (exposed by shifting one axis's coordinates far from zero while leaving the other near it). --- .../src/typed/pptx/reading-order.test.ts | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) 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..a97b4fe07 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,6 +132,28 @@ 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"]); From a46e6ec73e2cae9ac963daf35622ea2ff338e709 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:00:45 +0100 Subject: [PATCH 059/102] test(ooxml.js): close reading-order's touching-boundary and gap-arithmetic gaps Proves two real behavioural distinctions splitOnGap's own boundary math depends on: a strictly-greater comparison is required so two shapes touching exactly at a shared edge are grouped together rather than wrongly split apart, and the gap itself must be a subtraction (distance) rather than a sum, since summing a large preceding reach into the gap can inflate the wrong axis's ratio and flip which axis wins the cut. Also drops two of the function's own remaining guards once their necessity is disproved: ratio's extent-zero branch, since extentAlong being exactly zero forces every gap on that axis to be zero too, so the unguarded division's NaN loses every comparison exactly as the guarded zero already did; and splitOnGap's trailing current-length guard, since a non-empty input always leaves current non-empty at that point regardless, and an empty input's resulting phantom group is never inspected by its only caller. --- .../src/typed/pptx/reading-order.test.ts | 18 ++++++++++++++++++ .../ooxml.js/src/typed/pptx/reading-order.ts | 10 ++++------ 2 files changed, 22 insertions(+), 6 deletions(-) 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 a97b4fe07..0eced7b6f 100644 --- a/packages/ooxml.js/src/typed/pptx/reading-order.test.ts +++ b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts @@ -159,6 +159,24 @@ describe("assignReadingOrder", () => { 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("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 0ea42496d..437bde000 100644 --- a/packages/ooxml.js/src/typed/pptx/reading-order.ts +++ b/packages/ooxml.js/src/typed/pptx/reading-order.ts @@ -85,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 @@ -116,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 }; } From ec2429e60d62c9a1e0070991281f06a66e3bccaf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:03:39 +0100 Subject: [PATCH 060/102] test(ooxml.js): add direct structural coverage for the embedded-fixture builders Unzips each of minimalXlsxBytes/minimalDocxBytes/minimalPptxBytes and decodes its content-types override and root relationship target back to text, asserting on the exact markup rather than relying on downstream readers -- every consuming suite tolerates a malformed embedded payload by falling back to the plain picture, so a mutant collapsing any of these strings to empty still passed every test that merely used the fixture rather than inspecting its own bytes. --- .../src/test-support/embedded.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/ooxml.js/src/test-support/embedded.test.ts 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"'); + }); +}); From 5ae46456845d27dc3e000dffd2bc122f249659a4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:13:54 +0100 Subject: [PATCH 061/102] test(ooxml.js): close metadata's blank-value, keyword-parsing, and per-field gaps Adds direct coverage for firstElementText's empty-text branch (a present but textless element must read back as undefined, not ""), readKeywords' blank-entry filtering (a doubled or trailing comma, or comma/whitespace-only text, must never leave an empty string in the array, and must collapse an all-blank result to undefined), removeChildrenWithTag's own selectivity (removing cp:keywords must leave every other element untouched), the no-root-element throw, and author/subject each being set independently of one another and of title. Also proves patchCoreProperties genuinely removes an emptied cp:keywords element from the XML rather than writing an empty one, asserting on the serialized markup directly rather than through the entity-decoding reader. Drops namespacePrefixOf's unreachable "no colon" branch: every real caller (ensureNamespaceDeclared, for one of the four always-prefixed tags this module ever creates) only ever passes a colon-qualified tag, so the branch handling its absence, and the caller's own dead check for an undefined prefix, can never actually run. --- .../src/typed/shared/metadata.test.ts | 67 +++++++++++++++++++ .../ooxml.js/src/typed/shared/metadata.ts | 10 +-- 2 files changed, 70 insertions(+), 7 deletions(-) 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; From 4236147a67c4079ff81ff1c92f9f02a65d1af81b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:23:09 +0100 Subject: [PATCH 062/102] test(ooxml.js): add direct structural coverage for xlsx table/name definitions writing Covers collectTableEntries' own filtering (a non-table entry is skipped without ever validating its fields) and per-field validation (each of name/ref/sheet/columns throws naming itself and the entry kind when absent, and a columns array is rejected the moment even one entry isn't a string, not only when none of them are), buildNameDefinedNameElements' own scopeSheetIndex encoding (a defined, truthy scope is carried as itself, an absent one falls back to an empty suffix, not a placeholder), and buildTablePart's exact CT_Table shape: its own required attributes, an autoFilter over the entry's ref, and one 1-based tableColumn per column in order. --- .../src/typed/xlsx/definitions-write.test.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/definitions-write.test.ts 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"]); + }); +}); From 42608901ed69639c96d414ee4d812488dfb0d555 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:44:24 +0100 Subject: [PATCH 063/102] test(ooxml.js): close color.ts's HSL boundary and gamma-threshold gaps Adds direct rgbToHsl/hslToRgb coverage: every max===r/g/b hue branch (with the g 1 subtract 1" guards, whose own boundary values (hue exactly 0 or 1) reach the identical result either way, and unlike the more familiar double-mod form it leaves an already-in-range value bit- exact, preserving the two piece boundaries (t === 1/6, t === 1/2) that are NOT equivalent for a real, floating-point-exact boundary test. The final "t < 2/3" piece and its "else return p" fallback are folded into one Math.max(0, 2/3 - t)-clamped expression, since (2/3 - t) is exactly 0 at their shared boundary regardless of which side "< 2/3" includes. --- .../ooxml.js/src/typed/shared/color.test.ts | 129 +++++++++++++++++- packages/ooxml.js/src/typed/shared/color.ts | 28 ++-- 2 files changed, 140 insertions(+), 17 deletions(-) diff --git a/packages/ooxml.js/src/typed/shared/color.test.ts b/packages/ooxml.js/src/typed/shared/color.test.ts index f7b827c93..1e516c887 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. + expect(hslToRgb({ h: 60, s: 0.8, l: 0.6 }).g).toBe(0.92); + }); + + 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 { From 755af5cf4c66ac6f12bce53cc5f914d88da56757 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:44:36 +0100 Subject: [PATCH 064/102] test(ooxml.js): close xlsx.ts's rels-correlation and sheet-ordering gaps Adds a workbook rels Target with no leading slash and one carrying a leading slash, each naming its sheet something other than the filename- derived Sheet fallback -- every existing fixture named its sheet "Sheet1", indistinguishable from what a completely broken rels correlation would fall back to on its own, so a bug in resolveRelTarget or relTargets could silently coincide with the right answer. Also proves worksheets are ordered by their own numeric suffix rather than the package's part insertion order, inserting sheet3/sheet1/sheet2 out of sequence and asserting the read-back order is 1, 2, 3. --- packages/ooxml.js/src/typed/xlsx.test.ts | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) 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"]); + }); }); From 105f306910bfbdfbd3862862756ca07af1317df2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:49:42 +0100 Subject: [PATCH 065/102] test(ooxml.js): pick a non-coincidental (l, s) pair for the 1/6 hue boundary The previous (s=0.8, l=0.6) pair happened to round-trip the low-piece formula back to q exactly at t === 1/6, coincidentally matching the correct (q-branch) result and leaving the boundary comparison unkilled. s=0.73/ l=0.29 is one of the pairs where that rounding measurably misses q instead. --- packages/ooxml.js/src/typed/shared/color.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/typed/shared/color.test.ts b/packages/ooxml.js/src/typed/shared/color.test.ts index 1e516c887..47ec5a939 100644 --- a/packages/ooxml.js/src/typed/shared/color.test.ts +++ b/packages/ooxml.js/src/typed/shared/color.test.ts @@ -181,8 +181,8 @@ describe("hslToRgb", () => { // 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. - expect(hslToRgb({ h: 60, s: 0.8, l: 0.6 }).g).toBe(0.92); + // 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", () => { From ffce1a99c3b0ce562f886d043014a543d28ced05 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:57:35 +0100 Subject: [PATCH 066/102] test(ooxml.js): close drawingml's per-field, theme-fallback, and transform gaps Adds per-attribute coverage for readXfrm/readGroupXfrm's required-field checks: each of x/y/cx/cy (and chOff/chExt's own cx/cy/ccx/ccy) missing on its own, isolating every OR clause from the others and from the earlier "element itself absent" guard, which the existing tests only ever exercise. Covers readThemeSlotColor/readClrScheme directly: a colour-scheme child that is neither a:srgbClr nor a:sysClr resolves to no colour at all, a non-element child (whitespace text) is skipped to find the real colour element, and a sysClr's lastClr is read over the windowText/window fallback even when they would otherwise coincide (every existing fixture's lastClr happened to already match its own fallback). Also proves a transform child with no val attribute is skipped rather than included. Covers canonicalizeGroupRotation's own flipH+flipV (cancels to a pure 180deg-shifted rotation, not a mirror) and lone-flipV (a 180deg-shifted mirror) cases via composeGroupTransform, and applyGroupTransform's own child-offset subtraction (previously only ever exercised with childOffXPt/ childOffYPt at zero, where addition and subtraction coincide) and its identity-shortcut boundary (a mirrored group with zero rotation must still take the centre-rotation path, not the unrotated shortcut). Extracts composeAngleDeg out of composeRotation so composeShapeRotationDeg can compute its own angle directly: the function only ever read the angleDeg half of composeRotation's result, so the `mirrored: false` it had to fabricate for the unused inner-mirrored input never affected anything composeShapeRotationDeg actually returned. --- .../src/typed/shared/drawingml.test.ts | 222 +++++++++++++++++- .../ooxml.js/src/typed/shared/drawingml.ts | 36 +-- 2 files changed, 242 insertions(+), 16 deletions(-) diff --git a/packages/ooxml.js/src/typed/shared/drawingml.test.ts b/packages/ooxml.js/src/typed/shared/drawingml.test.ts index 5379348a2..f8bda3a37 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", () => { @@ -502,6 +684,44 @@ describe("composeGroupTransform", () => { 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..425f32ae1 100644 --- a/packages/ooxml.js/src/typed/shared/drawingml.ts +++ b/packages/ooxml.js/src/typed/shared/drawingml.ts @@ -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, }; } @@ -477,11 +485,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, + ); } From c6c4fd0358d67e3b8f5b3414eb9ce8bde0b68ac3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 22:05:30 +0100 Subject: [PATCH 067/102] refactor(ooxml.js): drop localName's unreachable no-colon branch lastIndexOf returns -1 for an unprefixed tag, and tag.slice(-1 + 1) is tag.slice(0), the whole string unchanged -- exactly what the branch existed to return, for every possible tag rather than merely the ones this file happens to see. The ternary's own comparison is never actually reachable as a distinct outcome, so the unconditional slice already computes the same result on its own. --- packages/ooxml.js/src/typed/xlsx/comments.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/xlsx/comments.ts b/packages/ooxml.js/src/typed/xlsx/comments.ts index 570f2a08a..26a55322b 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( From 20b00032871828100d81e2b2208848738d787de8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 22:05:45 +0100 Subject: [PATCH 068/102] refactor(ooxml.js): drop applyGroupTransform's redundant identity shortcut With rotationDeg 0 and no mirror, Math.cos(0) and Math.sin(0) are exactly 1 and 0 (multiplying/dividing by zero introduces no floating-point error), so the general rotate/mirror path already reduces algebraically back to the plain canonical box the shortcut returned directly. The shortcut only ever skipped work that was going to produce the identical answer. Also merges canonicalizeGroupRotation's flipH-and-flipV and flipV-only branches into one: both add the identical 180deg shift, differing only in mirrored (exactly !flipH either way), so the same "+ 180" no longer needs to appear twice. Adds a negative-subtraction composeGroupTransform case (every existing mirrored-parent test lands on the positive side of normalizeDeg's own wraparound) and, for the removed shortcut, a mirrored/zero-rotation case proving the general path is exercised rather than short-circuited. --- .../src/typed/shared/drawingml.test.ts | 31 +++++++++++++++++++ .../ooxml.js/src/typed/shared/drawingml.ts | 10 ++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/ooxml.js/src/typed/shared/drawingml.test.ts b/packages/ooxml.js/src/typed/shared/drawingml.test.ts index f8bda3a37..d564da7a6 100644 --- a/packages/ooxml.js/src/typed/shared/drawingml.test.ts +++ b/packages/ooxml.js/src/typed/shared/drawingml.test.ts @@ -681,6 +681,37 @@ 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(); }); diff --git a/packages/ooxml.js/src/typed/shared/drawingml.ts b/packages/ooxml.js/src/typed/shared/drawingml.ts index 425f32ae1..65654cad5 100644 --- a/packages/ooxml.js/src/typed/shared/drawingml.ts +++ b/packages/ooxml.js/src/typed/shared/drawingml.ts @@ -344,11 +344,9 @@ 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. if (flipV) { - return { angleDeg: rotationDeg + 180, mirrored: true }; + return { angleDeg: rotationDeg + 180, mirrored: !flipH }; } if (flipH) { return { angleDeg: rotationDeg, mirrored: true }; @@ -452,9 +450,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; From 96690e50b614c3b3f6220f63518d465befe776b2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 22:07:08 +0100 Subject: [PATCH 069/102] docs(ooxml.js): document canonicalizeGroupRotation's irreducible +180 mutant Every caller normalises the returned angleDeg modulo 360 eventually, and (x + 180) mod 360 equals (x - 180) mod 360 for every x since the two differ by exactly 360 -- no test built on this function's own observable contract can ever tell the two apart here, for any input, not just the ones a test happens to try. Recorded explicitly rather than left unexplained. --- packages/ooxml.js/src/typed/shared/drawingml.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ooxml.js/src/typed/shared/drawingml.ts b/packages/ooxml.js/src/typed/shared/drawingml.ts index 65654cad5..ef48008e0 100644 --- a/packages/ooxml.js/src/typed/shared/drawingml.ts +++ b/packages/ooxml.js/src/typed/shared/drawingml.ts @@ -345,6 +345,8 @@ function canonicalizeGroupRotation( flipV: boolean, ): { readonly angleDeg: number; readonly mirrored: boolean } { // 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: !flipH }; } From 5098a3a7809c2bfad0bb469501e663d7a0ee3eea Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 03:16:46 +0100 Subject: [PATCH 070/102] test(ooxml.js): cover isCompactXmlNode's full type-code truth table directly CompactXmlNodeSchema was only ever exercised through round-trip package fixtures built from real docx/pptx XML, so every well-formed shape the guard accepts was covered but none of its rejection branches were: a malformed length, a wrong-typed slot, an unrecognised leading type code, or an element whose attr pairs or children fail their own nested check. Test CompactXmlNodeSchema.safeParse directly against the full positive and negative shape for every CompactXmlNode variant (text/cdata/comment, declaration, pi, element), including a code that satisfies the element shape by coincidence so the code===0 branch guard itself is exercised. Also close the remaining gaps in compact.ts's package-level codec: a round-trip through a cdata node and a processing-instruction node (never exercised via decodePackage/zipPackage's own XML sources), and the two error paths in fromCompact -- an out-of-range string-table index and an odd-length attribute index-pairs array -- via directly constructed CompactPackage fixtures rather than only ever-valid ones. --- packages/ooxml.js/src/compact.test.ts | 156 +++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/compact.test.ts b/packages/ooxml.js/src/compact.test.ts index 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", From f145b62cb989c26bc9fd7009a1c8f366ea93b689 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:31:38 +0100 Subject: [PATCH 071/102] refactor(ooxml.js): drop comments' redundant presence guards before assignment entry.author/createdAt/parentId and comment.author/createdAt/comment.replies' per-item author are optional fields; every consumer (ContentSheetCellCommentSchema, this codebase's toEqual-based tests, and JSON serialisation) treats an explicit undefined value identically to the key being absent altogether, so a presence guard before each assignment was only ever a no-op. Also simplify relatedPartPaths' accumulation loop to a filter/map chain and drop readThreadedComments' early return on an empty partPaths list, since the loop below already does nothing when there is nothing to iterate. --- packages/ooxml.js/src/typed/xlsx/comments.ts | 64 ++++++-------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/comments.ts b/packages/ooxml.js/src/typed/xlsx/comments.ts index 26a55322b..1fe71ca92 100644 --- a/packages/ooxml.js/src/typed/xlsx/comments.ts +++ b/packages/ooxml.js/src/typed/xlsx/comments.ts @@ -62,13 +62,9 @@ function relatedPartPaths( partPath: string, relType: string, ): string[] { - const paths: string[] = []; - for (const rel of resolveRelationships(pkg, partPath).values()) { - if (rel.type === relType) { - paths.push(rel.target); - } - } - return paths; + return Array.from(resolveRelationships(pkg, partPath).values()) + .filter((rel) => rel.type === relType) + .map((rel) => rel.target); } // --- legacy xl/comments{N}.xml ---------------------------------------------------------------------------------- @@ -119,9 +115,8 @@ function readLegacyComments( : Number.parseInt(authorIdRaw, 10); const author = authorIndex === undefined ? undefined : authors[authorIndex]; - if (author !== undefined) { - entry.author = author; - } + // Assigned unconditionally, even when author is undefined: entry.author is optional and every consumer (ContentSheetCellCommentSchema, this codebase's toEqual-based tests, JSON serialisation) treats an explicit undefined value identically to the key being absent altogether, so a presence guard here would only ever be a no-op. + entry.author = author; into.set(`${position.row}:${position.column}`, { row: position.row, column: position.column, @@ -181,10 +176,8 @@ function readThreadedCreatedAt(element: XmlElement): string | undefined { if (dT !== undefined) { return dT; } + // No "dCreation === undefined" guard: Number(undefined) is NaN (unlike Number(null), which is 0), so an absent dCreation already falls through Number.isFinite to the same undefined result this guard would have returned directly. const dCreation = attr(element, "dCreation"); - if (dCreation === undefined) { - return undefined; - } const ms = Number(dCreation); return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined; } @@ -195,10 +188,8 @@ function readThreadedComments( sheetPath: string, into: Map, ): void { + // No "partPaths.length === 0" early return: with no threaded-comment parts, the loop below simply never runs, and readPersons on a sheet with no person relationships either just returns an empty, unused map -- an early return here would only ever skip work whose absence is already unobservable. const partPaths = relatedPartPaths(pkg, sheetPath, REL_THREADED_COMMENTS); - if (partPaths.length === 0) { - return; - } const persons = readPersons(pkg, sheetPath); for (const path of partPaths) { const root = rootElement(pkg.parts[path]); @@ -218,18 +209,10 @@ function readThreadedComments( column: position.column, text: textContent(textEl), }; - const author = readThreadedAuthor(element, persons); - if (author !== undefined) { - entry.author = author; - } - const createdAt = readThreadedCreatedAt(element); - if (createdAt !== undefined) { - entry.createdAt = createdAt; - } - const parentId = attr(element, "parentId") ?? attr(element, "parent"); - if (parentId !== undefined) { - entry.parentId = parentId; - } + // author/createdAt/parentId are assigned unconditionally: each is an optional field on ThreadedCommentEntry, and every consumer below (the parentId===undefined root test, the toEqual-based tests, JSON serialisation) treats an explicit undefined value identically to the key being absent, so a presence guard here would only ever be a no-op. + entry.author = readThreadedAuthor(element, persons); + entry.createdAt = readThreadedCreatedAt(element); + entry.parentId = attr(element, "parentId") ?? attr(element, "parent"); const key = `${position.row}:${position.column}`; const group = groups.get(key); if (group === undefined) { @@ -245,24 +228,17 @@ function readThreadedComments( if (rootEntry === undefined) { continue; } - const comment: ContentSheetCellComment = { text: rootEntry.text }; - if (rootEntry.author !== undefined) { - comment.author = rootEntry.author; - } - if (rootEntry.createdAt !== undefined) { - comment.createdAt = rootEntry.createdAt; - } + const comment: ContentSheetCellComment = { + text: rootEntry.text, + author: rootEntry.author, + createdAt: rootEntry.createdAt, + }; const replies = group.filter((entry) => entry !== rootEntry); if (replies.length > 0) { - comment.replies = replies.map((reply) => { - const answer: { text: string; author?: string } = { - text: reply.text, - }; - if (reply.author !== undefined) { - answer.author = reply.author; - } - return answer; - }); + comment.replies = replies.map((reply) => ({ + text: reply.text, + author: reply.author, + })); } into.set(key, { row: rootEntry.row, column: rootEntry.column, comment }); } From 7da5c3ec515b7f6b1e64a010b2280d3fb2991f38 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:48:19 +0100 Subject: [PATCH 072/102] test(ooxml.js): close comments.ts's relationship-type, local-name, and thread-ordering gaps relatedPartPaths' relType filter had no test proving it actually excludes a wrong-typed relationship whose target happens to be a validly-shaped legacy comments part; childrenWithLocalName's own filter had no sibling of a different tag to exclude. readLegacyCommentText's -run concatenation had no case where it differs from the text element's own whole-subtree content (a stray text node outside any run). The empty authors-list fallback had no case where a comment references an authorId with no element at all. readThreadedComments' root-detection (find by parentId undefined, ?? group.at(0) fallback) had no case where a reply is written before its root in document order -- every existing thread fixture already had its root first, so document order alone happened to pick the right entry regardless of whether parentId was read correctly. Document normalizeGuid's toLowerCase as a genuinely irreducible equivalent mutation opportunity: its only observable effect anywhere in this file is guid equality, which folding to either case produces identically. --- .../ooxml.js/src/typed/xlsx/comments.test.ts | 199 ++++++++++++++++++ packages/ooxml.js/src/typed/xlsx/comments.ts | 2 +- 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/xlsx/comments.test.ts b/packages/ooxml.js/src/typed/xlsx/comments.test.ts index 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 1fe71ca92..d0fb0f721 100644 --- a/packages/ooxml.js/src/typed/xlsx/comments.ts +++ b/packages/ooxml.js/src/typed/xlsx/comments.ts @@ -51,7 +51,7 @@ function childrenWithLocalName( return out; } -// ST_Guid as written in these parts is braced and upper case, but the brace spelling varies across producers, so both sides of every guid comparison (personId -> person/@id) go through this normaliser. +// ST_Guid as written in these parts is braced and upper case, but the brace spelling varies across producers, so both sides of every guid comparison (personId -> person/@id) go through this normaliser. The specific choice of toLowerCase over toUpperCase here is a genuinely irreducible equivalent mutation opportunity, not merely an untested one: this normaliser's only observable effect anywhere in this file is whether two guid spellings compare equal (a Map key match in readPersons/readThreadedAuthor) -- and folding every input to the SAME case, in either direction, produces that identical equality relation for every possible pair of inputs. No test built on this function's own observable contract (guid equality, never the normalised string's own case) can ever tell toLowerCase and toUpperCase apart here, any more than a test could tell +180 from -180 apart in a value that is always later reduced modulo 360 (see canonicalizeGroupRotation's own doc comment in shared/drawingml.ts for the general shape of this argument). function normalizeGuid(value: string): string { return value.replaceAll("{", "").replaceAll("}", "").toLowerCase(); } From 18fea9bd361a03c6bf22951975e4176505b3b9af Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:55:32 +0100 Subject: [PATCH 073/102] test(ooxml.js): distinguish extentAlong's true earliest start from its latest A 2x2 heading/list grid tuned so the real (min-start) extent makes rows the winning axis, while substituting the latest start for the earliest one shrinks the vertical extent enough to flip the cut to columns -- proving extentAlong measures from the true earliest start rather than the latest. --- .../src/typed/pptx/reading-order.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/ooxml.js/src/typed/pptx/reading-order.test.ts b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts index 0eced7b6f..b92779f06 100644 --- a/packages/ooxml.js/src/typed/pptx/reading-order.test.ts +++ b/packages/ooxml.js/src/typed/pptx/reading-order.test.ts @@ -177,6 +177,23 @@ describe("assignReadingOrder", () => { expect(order(shapes)).toEqual(["a", "b"]); }); + it("measures an axis's extent from its true earliest start, not its latest one", () => { + // extentAlong spans from the EARLIEST start to the latest end; substituting the latest start for the earliest one shrinks the denominator of whichever ratio it feeds. Here the two columns sit only 50pt apart -- a modest gap next to the genuine 240pt-tall extent real code measures -- so the real vertical ratio (from the tall lists) beats the real horizontal one and rows win, reading each heading immediately before its own list. Using the latest start instead collapses the vertical extent down to the last shape's own 150pt height, inflating that ratio past the horizontal one and flipping the cut to columns, which would instead read both headings before either list. + const shapes = [ + shape("left-heading", 0, 0, 100, 40), + shape("right-heading", 150, 0, 100, 40), + shape("left-list", 0, 90, 100, 150), + shape("right-list", 150, 90, 100, 150), + ]; + + expect(order(shapes)).toEqual([ + "left-heading", + "right-heading", + "left-list", + "right-list", + ]); + }); + it("returns the array in document order, ranking rather than reordering", () => { // The point of the whole design: sourcePath is assigned as slides[N].shapes[N], so the array must // keep naming the positions it names. Only the ranks describe the reading order. From 26495bacebf5565093251cd50d4e0b7e9639a163 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:10:32 +0100 Subject: [PATCH 074/102] refactor(ooxml.js): drop constructs.ts's three redundant guards insertConstructMarkers's own "extents.length === 0" early return produces the same array content the main loop already builds for an empty extent list, and isBlockScopedHalf's trailing calc no longer needs its "lastContentIndex === -1" shortcut: position is guaranteed non-negative by the guard above it, so "position > lastContentIndex" already evaluates true on its own whenever lastContentIndex is -1. Both readCheckboxState and readOnOff drop the identical "val === undefined ||" shortcut for the same reason -- undefined already satisfies every one of the three !== checks that follow it. --- packages/ooxml.js/src/typed/docx/constructs.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/constructs.ts b/packages/ooxml.js/src/typed/docx/constructs.ts index 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. From dd248a729e770ec99951f8b7f00df58ae6fb1800 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:10:43 +0100 Subject: [PATCH 075/102] test(ooxml.js): close constructs.ts's paragraph-index, checkbox, and pairing gaps Adds direct unit coverage for indexParagraphContent's content-bearing classification, isBlockScopedHalf's leading/trailing edge cases via synthetic ParagraphContentIndex objects, runRangeMarkerExtents' malformed start/end pairings and out-of-order run positions, compareExtents' startIndex-over-order sort priority for crossing extents, and every w:/w14: spelling fallback across readContentControlDescriptor and readFormControlDescriptor's checkbox, dropdown, and gallery reading. --- .../src/typed/docx/constructs.test.ts | 384 +++++++++++++++++- 1 file changed, 382 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/constructs.test.ts b/packages/ooxml.js/src/typed/docx/constructs.test.ts index 95ba08217..bf263e7ca 100644 --- a/packages/ooxml.js/src/typed/docx/constructs.test.ts +++ b/packages/ooxml.js/src/typed/docx/constructs.test.ts @@ -2,10 +2,20 @@ import { describe, expect, it } from "vitest"; import type { ConstructDescriptor, ContentBlock } from "document-schema.js"; import { findConstructMarkerImbalance } from "document-schema.js"; import type { Package } from "../../model/package"; -import type { XmlNode } from "../../model/node"; +import type { XmlElement, XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { readDocxContent } from "./read"; -import { insertConstructMarkers } from "./constructs"; +import { + bookmarkAnchorDescriptor, + indexParagraphContent, + insertConstructMarkers, + readContentControlDescriptor, + readFormControlDescriptor, + runInstructionText, + runRangeMarkerExtents, + type ParagraphContentIndex, + type ParagraphRangeMarkerHalf, +} from "./constructs"; // The block-scope rule in action: which real docx spellings of a structured document tag, field, bookmark, or tracked change become a constructStart/constructEnd pair, and which ones (the run-level occurrences, and the pairs whose extents cross) are deliberately not representable. Every fixture here is a whole word/document.xml body, so each case is read exactly as readDocxContent would read a real file. @@ -50,6 +60,202 @@ function outline( }); } +describe("indexParagraphContent", () => { + it("indexes a non-run element as content-bearing unconditionally, and a run only when it carries non-inert content", () => { + // The hyperlink has no children at all, so it only counts as content-bearing via the "not a w:r" branch itself, never by inspecting children the way a run is inspected -- if that branch were skipped, an empty non-run element would wrongly fall through to the run-only children check and read as empty. The run mixes an inert w:rPr with a real w:t, which only reads as content-bearing under "some child is non-inert" (true here); "every child is non-inert" would read it as false, since w:rPr alone already fails that. + const paragraph = el("w:p", {}, [ + el("w:pPr", {}, []), + el("w:hyperlink", {}, []), + el("w:r", {}, [el("w:rPr", {}, []), el("w:t", {}, [txt("x")])]), + ]); + const index = indexParagraphContent(paragraph); + expect(index.firstContentIndex).toBe(1); + expect(index.lastContentIndex).toBe(2); + }); + + it("leaves both indices at -1 when a paragraph has no content-bearing children at all", () => { + const paragraph = el("w:p", {}, [ + el("w:pPr", {}, []), + el("w:bookmarkStart", { "w:id": "1" }, []), + ]); + const index = indexParagraphContent(paragraph); + expect(index.firstContentIndex).toBe(-1); + expect(index.lastContentIndex).toBe(-1); + }); +}); + +describe("runRangeMarkerExtents: isBlockScopedHalf", () => { + const half = ( + element: ParagraphRangeMarkerHalf["element"], + kind: "start" | "end", + runPosition: number, + ): ParagraphRangeMarkerHalf => ({ + element, + family: "bookmark", + id: "z", + name: kind === "start" ? "bm" : undefined, + kind, + runPosition, + }); + + it("treats a half nested inside a container -- not a direct paragraph child -- as run-scoped, not block-scoped", () => { + // Both halves sit inside the hyperlink rather than directly on the paragraph, so index.elements.indexOf never finds either: this is the "not found among the direct children" case the container comment describes, and it must resolve to run-scoped (kept) rather than silently falling through to the leading/trailing position math with a stray -1. + const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); + const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); + const paragraph = el("w:p", {}, [ + el("w:hyperlink", {}, [ + startEl, + el("w:r", {}, [el("w:t", {}, [txt("x")])]), + endEl, + ]), + ]); + const index = indexParagraphContent(paragraph); + const extents = runRangeMarkerExtents( + [half(startEl, "start", 0), half(endEl, "end", 1)], + index, + ); + expect(extents).toEqual([ + { descriptor: bookmarkAnchorDescriptor("bm"), startRun: 0, endRun: 1 }, + ]); + }); + + it("treats a found half with no content at all as leading regardless of its own position", () => { + // A synthetic index whose firstContentIndex is -1 (no content-bearing children) while lastContentIndex is a real, larger value: leading's own "-1 means everything is leading" shortcut must fire for ANY position here, not just one smaller than some real firstContentIndex, and trailing must stay false since neither half's position exceeds lastContentIndex. Both halves land on the block-scoped path only through that shortcut, so the pair is dropped. + const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); + const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); + const index: ParagraphContentIndex = { + elements: [startEl, endEl], + firstContentIndex: -1, + lastContentIndex: 100, + }; + const extents = runRangeMarkerExtents( + [half(startEl, "start", 0), half(endEl, "end", 5)], + index, + ); + expect(extents).toEqual([]); + }); + + it("treats a found half sitting exactly at the first content-bearing position as NOT leading", () => { + // firstContentIndex is a real index equal to this half's own position, so leading must be false (strictly less than, not less-than-or-equal) -- and trailing is pinned false by a lastContentIndex far beyond both halves' positions, so the pair is kept only if leading is computed correctly. + const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); + const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); + const index: ParagraphContentIndex = { + elements: [startEl, endEl], + firstContentIndex: 0, + lastContentIndex: 100, + }; + const extents = runRangeMarkerExtents( + [half(startEl, "start", 0), half(endEl, "end", 5)], + index, + ); + expect(extents).toEqual([ + { descriptor: bookmarkAnchorDescriptor("bm"), startRun: 0, endRun: 5 }, + ]); + }); +}); + +describe("runRangeMarkerExtents: malformed pairings", () => { + const flatIndex = (elements: XmlElement[]): ParagraphContentIndex => ({ + elements, + firstContentIndex: 0, + lastContentIndex: elements.length - 1, + }); + + it("drops an id with two starts and one end, rather than pairing the end with an arbitrary start", () => { + const startA = el("w:bookmarkStart", { "w:id": "z", "w:name": "a" }, []); + const startB = el("w:bookmarkStart", { "w:id": "z", "w:name": "b" }, []); + const end = el("w:bookmarkEnd", { "w:id": "z" }, []); + const halves: ParagraphRangeMarkerHalf[] = [ + { + element: startA, + family: "bookmark", + id: "z", + name: "a", + kind: "start", + runPosition: 0, + }, + { + element: startB, + family: "bookmark", + id: "z", + name: "b", + kind: "start", + runPosition: 1, + }, + { + element: end, + family: "bookmark", + id: "z", + name: undefined, + kind: "end", + runPosition: 2, + }, + ]; + expect( + runRangeMarkerExtents(halves, flatIndex([startA, startB, end])), + ).toEqual([]); + }); + + it("drops an id with one start and two ends, rather than pairing the start with an arbitrary end", () => { + const start = el("w:bookmarkStart", { "w:id": "z", "w:name": "a" }, []); + const endA = el("w:bookmarkEnd", { "w:id": "z" }, []); + const endB = el("w:bookmarkEnd", { "w:id": "z" }, []); + const halves: ParagraphRangeMarkerHalf[] = [ + { + element: start, + family: "bookmark", + id: "z", + name: "a", + kind: "start", + runPosition: 0, + }, + { + element: endA, + family: "bookmark", + id: "z", + name: undefined, + kind: "end", + runPosition: 1, + }, + { + element: endB, + family: "bookmark", + id: "z", + name: undefined, + kind: "end", + runPosition: 2, + }, + ]; + expect( + runRangeMarkerExtents(halves, flatIndex([start, endA, endB])), + ).toEqual([]); + }); + + it("drops a pair whose end precedes its own start rather than emitting a negative-length extent", () => { + const start = el("w:bookmarkStart", { "w:id": "z", "w:name": "a" }, []); + const end = el("w:bookmarkEnd", { "w:id": "z" }, []); + const halves: ParagraphRangeMarkerHalf[] = [ + { + element: start, + family: "bookmark", + id: "z", + name: "a", + kind: "start", + runPosition: 5, + }, + { + element: end, + family: "bookmark", + id: "z", + name: undefined, + kind: "end", + runPosition: 2, + }, + ]; + expect(runRangeMarkerExtents(halves, flatIndex([start, end]))).toEqual([]); + }); +}); + describe("docx constructs: structured document tags", () => { it("reads a block-level w:sdt as a contentControl construct bracketing its own content", () => { const sdt = el("w:sdt", {}, [ @@ -205,6 +411,171 @@ describe("docx constructs: structured document tags", () => { }); }); +describe("readContentControlDescriptor: internals", () => { + it("omits every optional field entirely, rather than setting it to undefined, when none of them apply", () => { + // toStrictEqual (unlike toEqual) fails on an extra key holding undefined, which is exactly what each of the four optional-field guards below would produce if its own "!== undefined" check were forced true regardless of the actual value. + const sdt = el("w:sdt", {}, [el("w:sdtPr", {}, [el("w:text")])]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "plainText", + }); + }); + + it("accepts a Table of Contents gallery spelled as w:docPartList, not only w:docPartObj", () => { + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w:docPartList", {}, [ + el("w:docPartGallery", { "w:val": "Table of Contents" }), + ]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "index", + }); + }); + + it("reads a comboBox's own listItem entries the same way a dropDownList's are read", () => { + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w:comboBox", {}, [ + el("w:listItem", { "w:displayText": "One", "w:value": "1" }), + ]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "comboBox", + options: ["One"], + }); + }); + + it("falls back to a listItem's own w:value when it carries no w:displayText", () => { + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w:dropDownList", {}, [el("w:listItem", { "w:value": "raw" })]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "dropDown", + options: ["raw"], + }); + }); + + it("reads a checkbox control from its plain w: spelling, not only the w14: forms", () => { + // w:checkbox (not w14:checkbox) and w:checked (not w14:checked): both fallbacks must actually be reachable, not merely declared. w14:val is used directly here so this stays independent of the w:val fallback, which gets its own test below. + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w:checkbox", {}, [el("w:checked", { "w14:val": "1" })]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: true, + }); + }); + + it("reads a checkbox's own checked value from its plain w:val, not only w14:val", () => { + // "0" rather than some other value: a checked state read via a broken w:val fallback would come back undefined, which this toggle's own convention reads as checked (true) -- indistinguishable from a genuine "1" unless the real answer is false. + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w14:checkbox", {}, [el("w14:checked", { "w:val": "0" })]), + ]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: false, + }); + }); + + it("treats a checkbox with no w:checked child at all as unchecked, not absent", () => { + const sdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [el("w14:checkbox", {}, [])]), + ]); + expect(readContentControlDescriptor(sdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: false, + }); + }); + + it("reads a checkbox's 'false' and 'off' values as unchecked, alongside '0'", () => { + const falseSdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w14:checkbox", {}, [el("w14:checked", { "w14:val": "false" })]), + ]), + ]); + const offSdt = el("w:sdt", {}, [ + el("w:sdtPr", {}, [ + el("w14:checkbox", {}, [el("w14:checked", { "w14:val": "off" })]), + ]), + ]); + expect(readContentControlDescriptor(falseSdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: false, + }); + expect(readContentControlDescriptor(offSdt)).toStrictEqual({ + kind: "contentControl", + controlType: "checkbox", + checked: false, + }); + }); +}); + +describe("readFormControlDescriptor: internals", () => { + it("reads a legacy checkbox field's own checked value across '0', 'false', and 'off'", () => { + const beginRun = (val: string): XmlElement => + el("w:r", {}, [ + el("w:ffData", {}, [ + el("w:checkBox", {}, [el("w:checked", { "w:val": val })]), + ]), + ]); + expect(readFormControlDescriptor(beginRun("0"))?.checked).toBe(false); + expect(readFormControlDescriptor(beginRun("false"))?.checked).toBe(false); + expect(readFormControlDescriptor(beginRun("off"))?.checked).toBe(false); + }); + + it("falls back to w:default when a legacy checkbox field carries no w:checked", () => { + const beginRun = el("w:r", {}, [ + el("w:ffData", {}, [ + el("w:checkBox", {}, [el("w:default", { "w:val": "0" })]), + ]), + ]); + expect(readFormControlDescriptor(beginRun)?.checked).toBe(false); + }); + + it("defaults a legacy checkbox field's checked state to false when neither w:checked nor w:default is present", () => { + const beginRun = el("w:r", {}, [ + el("w:ffData", {}, [el("w:checkBox", {}, [])]), + ]); + expect(readFormControlDescriptor(beginRun)?.checked).toBe(false); + }); + + it("never mistakes a legacy text field for a drop-down list", () => { + const beginRun = el("w:r", {}, [ + el("w:ffData", {}, [el("w:textInput", {}, [])]), + ]); + const descriptor = readFormControlDescriptor(beginRun); + expect(descriptor?.controlType).toBe("plainText"); + expect(descriptor?.source?.format).toBe("docx"); + expect(descriptor).not.toHaveProperty("options"); + }); +}); + +describe("runInstructionText", () => { + it("reads w:delInstrText the same way as w:instrText, and ignores unrelated run children", () => { + const run = el("w:r", {}, [ + el("w:t", {}, [txt("not instruction")]), + el("w:delInstrText", {}, [txt(" DATE ")]), + ]); + expect(runInstructionText(run)).toBe(" DATE "); + }); +}); + describe("docx constructs: tracked changes", () => { it("reads a whole paragraph whose every content child is a w:ins as an insertion construct", () => { const paragraph = el("w:p", {}, [ @@ -738,4 +1109,13 @@ describe("insertConstructMarkers", () => { it("keeps the block list unchanged when there are no extents at all", () => { expect(insertConstructMarkers(blocks, [])).toEqual(blocks); }); + + it("sorts crossing extents by their own startIndex, not by discovery order alone", () => { + // P starts before Q but ends before Q ends too -- a genuine crossing, which the extent-scope rule drops entirely (Q has no encoding). P and Q's `order` fields are deliberately the REVERSE of their startIndex order: if compareExtents fell back to comparing `order` alone without weighing startIndex first, it would process Q before P, and P (starting at 0, before Q's own already-open span) would then read as nested inside Q rather than the reverse -- both extents would wrongly survive instead of Q alone being dropped. + const marked = insertConstructMarkers(blocks, [ + { startIndex: 0, endIndex: 2, order: 1, descriptor: anchor("p") }, + { startIndex: 1, endIndex: 3, order: 0, descriptor: anchor("q") }, + ]); + expect(outline(marked)).toEqual([anchor("p"), "a", "b", ")", "c"]); + }); }); From af9f9adfbe5818050d65e54bd7b148b9320eb77d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:19:55 +0100 Subject: [PATCH 076/102] fix(ooxml.js): pin isBlockScopedHalf's own leading/trailing boundary tests The two boundary tests introduced in the prior commit compared a half's runPosition rather than its actual array position (index.elements.indexOf), so both silently exercised the wrong slots and left the { expect(extents).toEqual([]); }); + // A run of dummy filler elements, purely to occupy array slots: isBlockScopedHalf's "position" is index.elements.indexOf(half.element), not a half's own runPosition, so pinning a half to a specific array position means padding the array out to it. + const filler = (): XmlElement => el("w:r", {}, []); + it("treats a found half sitting exactly at the first content-bearing position as NOT leading", () => { - // firstContentIndex is a real index equal to this half's own position, so leading must be false (strictly less than, not less-than-or-equal) -- and trailing is pinned false by a lastContentIndex far beyond both halves' positions, so the pair is kept only if leading is computed correctly. + // The start half sits at array position 0, exactly firstContentIndex (0): leading must be false there (strictly less than, not less-than-or-equal), or the pair would be wrongly dropped. The end half sits at array position 5, past a lastContentIndex of 2 by a wide margin, pinning IT as block-scoped (via trailing) regardless of either boundary mutant here or in the sibling test below -- so the pair's own "both block-scoped" AND hinges entirely on the start half's own leading value. const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); const index: ParagraphContentIndex = { - elements: [startEl, endEl], + elements: [startEl, filler(), filler(), filler(), filler(), endEl], firstContentIndex: 0, - lastContentIndex: 100, + lastContentIndex: 2, }; const extents = runRangeMarkerExtents( [half(startEl, "start", 0), half(endEl, "end", 5)], @@ -152,6 +155,25 @@ describe("runRangeMarkerExtents: isBlockScopedHalf", () => { { descriptor: bookmarkAnchorDescriptor("bm"), startRun: 0, endRun: 5 }, ]); }); + + it("treats a found half sitting exactly at the last content-bearing position as NOT trailing", () => { + // The end half sits at array position 15, exactly lastContentIndex (15): trailing must be false there (strictly greater than, not greater-than-or-equal), or the pair would be wrongly dropped. The start half sits at array position 0, clearly below a firstContentIndex of 10, pinning IT as block-scoped (via leading) regardless of either boundary mutant -- so the AND hinges entirely on the end half's own trailing value. + const startEl = el("w:bookmarkStart", { "w:id": "z", "w:name": "bm" }, []); + const endEl = el("w:bookmarkEnd", { "w:id": "z" }, []); + const elements = [startEl, ...Array.from({ length: 14 }, filler), endEl]; + const index: ParagraphContentIndex = { + elements, + firstContentIndex: 10, + lastContentIndex: 15, + }; + const extents = runRangeMarkerExtents( + [half(startEl, "start", 0), half(endEl, "end", 15)], + index, + ); + expect(extents).toEqual([ + { descriptor: bookmarkAnchorDescriptor("bm"), startRun: 0, endRun: 15 }, + ]); + }); }); describe("runRangeMarkerExtents: malformed pairings", () => { From 62612ab30784724e8291aa6b7667a28277287f85 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:24:29 +0100 Subject: [PATCH 077/102] refactor(ooxml.js): drop drawings.ts's redundant column/row validity checks Number.isInteger(min)/(max)/(r) is always true or NaN given each value's own Number.parseInt provenance, and min >= 1 (or r >= 1) already rejects NaN unaided, so the isInteger guards were checking exactly what the numeric bounds already reject. A "max >= min" guard on a declared column range is equally unnecessary: columnWidthPt's own lookup only ever matches a range via "index >= min && index <= max", which an inverted range can never satisfy for any index, so admitting one unguarded is exactly as inert as rejecting it. Introduces parseIntAttr to read min/max/r directly as NaN-when-absent, replacing the "attr(..) ?? \"\"" placeholder Number.parseInt needed only to satisfy its own string parameter -- every string that could stand in for "absent" parses to NaN just the same, so the placeholder's own text was never an observable choice. --- packages/ooxml.js/src/typed/xlsx/drawings.ts | 23 +++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/drawings.ts b/packages/ooxml.js/src/typed/xlsx/drawings.ts index ba42a9702..78c1ddd4e 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings.ts @@ -34,6 +34,12 @@ const CHART_GRAPHIC_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"; const DRAWING_REL_SUFFIX = "/drawing"; +// A whole-number attribute read as ECMA-376's own min/max/row-index vocabulary spells it: absent becomes NaN directly, never routed through a placeholder string first -- attr's own "string | undefined" would otherwise force a "?? \"\"" just to satisfy Number.parseInt's signature, and every string that could stand in for the absent case parses to NaN just the same, making the placeholder's own text a distinction with no behavioural difference to test. +function parseIntAttr(element: XmlElement, name: string): number { + const raw = attr(element, name); + return raw === undefined ? Number.NaN : Number.parseInt(raw, 10); +} + // One declared range, kept as the RANGE the anchor geometry needs -- readColumns deliberately materialises only each element's starting index (the repeat-hazard policy), but a column in the middle of a min..max span has a real width a drawing placed against it must resolve through. interface DeclaredColumn { readonly min: number; @@ -51,19 +57,15 @@ class SheetGridGeometry { const cols = childrenWithTag(worksheet, "cols")[0]; if (cols !== undefined) { for (const col of childrenWithTag(cols, "col")) { - const min = Number.parseInt(attr(col, "min") ?? "", 10); - const max = Number.parseInt(attr(col, "max") ?? "", 10); + const min = parseIntAttr(col, "min"); + const max = parseIntAttr(col, "max"); const widthRaw = attr(col, "width"); const widthPt = widthRaw === undefined ? undefined : columnWidthCharsToPt(Number(widthRaw)); - if ( - Number.isInteger(min) && - Number.isInteger(max) && - min >= 1 && - max >= min - ) { + // No separate Number.isInteger(min)/(max) guard is needed: both are always the result of Number.parseInt just above, which can only ever return NaN or a genuine integer -- never a finite non-integer -- and min >= 1 already rejects NaN on its own (every comparison against NaN is false). A "max >= min" guard is equally unnecessary here, for a different reason: columnWidthPt's own lookup below only ever matches a range via "index >= column.min && index <= column.max", and an inverted range (max < min) can never satisfy both halves of that for any index at all -- pushing one through unguarded is exactly as inert as rejecting it, since nothing else ever reads `columns` besides that lookup. + if (min >= 1) { this.columns.push({ min: min - 1, max: max - 1, @@ -84,10 +86,11 @@ class SheetGridGeometry { const sheetData = childrenWithTag(worksheet, "sheetData")[0]; if (sheetData !== undefined) { for (const row of childrenWithTag(sheetData, "row")) { - const r = Number.parseInt(attr(row, "r") ?? "", 10); + const r = parseIntAttr(row, "r"); const htRaw = attr(row, "ht"); const ht = htRaw === undefined ? Number.NaN : Number(htRaw); - if (Number.isInteger(r) && r >= 1 && Number.isFinite(ht)) { + // Same redundant isInteger drop as the column read above: r is always Number.parseInt's own result (NaN or a genuine integer), and r >= 1 already rejects NaN unaided. + if (r >= 1 && Number.isFinite(ht)) { this.rowHeights.set(r - 1, ht); } } From 8f62f385fb644f95cca8f6a5edc76d5b6e95d919 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:27:04 +0100 Subject: [PATCH 078/102] refactor(ooxml.js): drop drawings.ts's remaining redundant NaN-fallback ternaries Number(undefined) is already NaN, and every one of these ternaries fed that NaN straight into an isFinite check that already degrades it to the same fallback (0, or DEFAULT_ROW_HEIGHT_PT) an explicit NaN branch would produce -- readAnchorChild's own "empty string" arm is the same story, since Number("") is 0, itself already finite and thus already the function's own fallback value. parseIntAttr's identical-shaped ternary stays: Number.parseInt requires a genuine string argument, so the "undefined" branch there is load-bearing for the type system even though it is provably behaviourally equivalent to the value parsing would already produce. --- packages/ooxml.js/src/typed/xlsx/drawings.ts | 22 +++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/drawings.ts b/packages/ooxml.js/src/typed/xlsx/drawings.ts index 78c1ddd4e..94223863f 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings.ts @@ -34,7 +34,7 @@ const CHART_GRAPHIC_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"; const DRAWING_REL_SUFFIX = "/drawing"; -// A whole-number attribute read as ECMA-376's own min/max/row-index vocabulary spells it: absent becomes NaN directly, never routed through a placeholder string first -- attr's own "string | undefined" would otherwise force a "?? \"\"" just to satisfy Number.parseInt's signature, and every string that could stand in for the absent case parses to NaN just the same, making the placeholder's own text a distinction with no behavioural difference to test. +// A whole-number attribute read as ECMA-376's own min/max/row-index vocabulary spells it. The "raw === undefined" branch is a genuinely irreducible equivalent mutation opportunity, not merely an untested one: Number.parseInt itself already returns NaN for undefined (it stringifies its argument first, and "undefined" starts with a non-digit), so the explicit NaN literal here produces exactly the value Number.parseInt(raw, 10) would already compute if TypeScript allowed passing raw (string | undefined) to a parameter typed string -- it exists only to satisfy that signature, not to change the outcome. No test built on this function's own observable contract (the returned number, never which branch computed it) can tell the two apart, any more than a test could tell +180 from -180 apart in a value always later reduced modulo 360 (see canonicalizeGroupRotation's own doc comment in shared/drawingml.ts for the general shape of this argument). function parseIntAttr(element: XmlElement, name: string): number { const raw = attr(element, name); return raw === undefined ? Number.NaN : Number.parseInt(raw, 10); @@ -59,11 +59,8 @@ class SheetGridGeometry { for (const col of childrenWithTag(cols, "col")) { const min = parseIntAttr(col, "min"); const max = parseIntAttr(col, "max"); - const widthRaw = attr(col, "width"); - const widthPt = - widthRaw === undefined - ? undefined - : columnWidthCharsToPt(Number(widthRaw)); + // No "widthRaw === undefined" guard is needed: Number(undefined) is already NaN, columnWidthCharsToPt propagates a NaN input straight through to a NaN result, and the isFinite check below already converts that to undefined -- an absent width attribute reaches the identical outcome whichever branch computes it. + const widthPt = columnWidthCharsToPt(Number(attr(col, "width"))); // No separate Number.isInteger(min)/(max) guard is needed: both are always the result of Number.parseInt just above, which can only ever return NaN or a genuine integer -- never a finite non-integer -- and min >= 1 already rejects NaN on its own (every comparison against NaN is false). A "max >= min" guard is equally unnecessary here, for a different reason: columnWidthPt's own lookup below only ever matches a range via "index >= column.min && index <= column.max", and an inverted range (max < min) can never satisfy both halves of that for any index at all -- pushing one through unguarded is exactly as inert as rejecting it, since nothing else ever reads `columns` besides that lookup. if (min >= 1) { this.columns.push({ @@ -75,11 +72,12 @@ class SheetGridGeometry { } } const sheetFormatPr = childrenWithTag(worksheet, "sheetFormatPr")[0]; + // No "sheetFormatPr === undefined" ternary is needed here: attr(undefined, ...) would be a type error (attr expects a real XmlElement), so the guard stays -- but the NUMBER side of it below drops the equivalent redundant ternary, since Number(undefined) is already NaN. const defaultRaw = sheetFormatPr === undefined ? undefined : attr(sheetFormatPr, "defaultRowHeight"); - const parsed = defaultRaw === undefined ? Number.NaN : Number(defaultRaw); + const parsed = Number(defaultRaw); this.defaultRowHeightPt = Number.isFinite(parsed) ? parsed : DEFAULT_ROW_HEIGHT_PT; @@ -87,8 +85,7 @@ class SheetGridGeometry { if (sheetData !== undefined) { for (const row of childrenWithTag(sheetData, "row")) { const r = parseIntAttr(row, "r"); - const htRaw = attr(row, "ht"); - const ht = htRaw === undefined ? Number.NaN : Number(htRaw); + const ht = Number(attr(row, "ht")); // Same redundant isInteger drop as the column read above: r is always Number.parseInt's own result (NaN or a genuine integer), and r >= 1 already rejects NaN unaided. if (r >= 1 && Number.isFinite(ht)) { this.rowHeights.set(r - 1, ht); @@ -183,14 +180,15 @@ function readAnchorChild(marker: XmlElement, tag: string): number { : child.children .map((node) => (node.type === "text" ? node.value : "")) .join(""); - const parsed = text === undefined || text === "" ? Number.NaN : Number(text); + // No "undefined or empty" guard is needed: Number(undefined) and Number("") are already NaN and 0 respectively, and the isFinite check below already maps BOTH of those through to the same 0 fallback this function returns for any other malformed text -- the explicit NaN this ternary substitutes for "" changes nothing downstream of it. + const parsed = Number(text); return Number.isFinite(parsed) ? parsed : 0; } // An anchor-level numeric attribute (xdr:ext's cx/cy): the same degrade-to-0 contract readAnchorChild gives a marker's child-text values, never a NaN frame. function numericAttr(element: XmlElement, name: string): number { - const raw = attr(element, name); - const parsed = raw === undefined ? Number.NaN : Number(raw); + // No "raw === undefined" guard is needed: Number(undefined) is already NaN, which the isFinite check below already degrades to 0, the same outcome the explicit NaN branch produces. + const parsed = Number(attr(element, name)); return Number.isFinite(parsed) ? parsed : 0; } From 4c13801a148f1d35cda4188a888a39814c7901a1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:21:10 +0100 Subject: [PATCH 079/102] test(ooxml.js): cover SheetGridGeometry's column/row lookups and editAs sizing Adds synthetic-package tests for xlsx drawing-anchor geometry: a malformed column range (min below 1) falling back to the default width, a covering range's own declared width winning over a wider range with no width at all, a real sheetFormatPr defaultRowHeight overriding the built-in default, a declared row's own height taking precedence over that default, a malformed row (r below 1, or an unparseable ht) falling back to the default height, and editAs defaulting to twoCell (to-marker sizing) versus reading an explicit oneCell (own transform-extent sizing). Also names the payload sheet from the graphic frame's own xdr:cNvPr/@name in the existing chart graphic frame test, rather than leaving it implicit. --- .../ooxml.js/src/typed/xlsx/content.test.ts | 233 +++++++++++++++++- 1 file changed, 232 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 00dfe3fd5..1b1bd4e8b 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -14,12 +14,13 @@ import type { ContentSheetDataValidation, } from "document-schema.js"; import type { Package } from "../../model/package"; +import type { XmlElement, XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { decodePackage, encodePackage } from "../../codec"; import { parsePackage } from "../../package-io/read"; import { attr, childrenWithTag, rootElement } from "../util"; import { buildXlsxPackageFromContent } from "./build"; -import { columnWidthCharsToPt } from "./units"; +import { columnWidthCharsToPt, DEFAULT_COLUMN_WIDTH_CHARS } from "./units"; import { readXlsxContent, resolveSheetEntries } from "./content"; // This suite reads real, unmodified LibreOffice-generated .xlsx fixtures (src/typed/xlsx/fixtures/*.xlsx). Both fixtures are genuine LibreOffice xlsx-exports (`soffice --headless --convert-to xlsx`) of odf.js's own src/typed/ods/fixtures/{kitchen-sink,minimal}.ods -- the same feature set that package's own readOds test suite already validates against ODF's equivalent mechanisms, run back through LibreOffice's real SpreadsheetML export filter so this suite exercises genuine, LibreOffice-authored xlsx markup (column-width character units, row heights, hidden rows/columns, every value-type LibreOffice's own xlsx exporter distinguishes, a real merged range, a real cross-sheet formula, and real print settings including Print_Area/Print_Titles defined names) rather than a hand-built approximation of what that markup might look like. A handful of narrow scope-boundary/error-path tests at the end use small, synthetic, hand-built packages instead (via el/txt), mirroring readOds's own established convention for the identical reason. @@ -1008,6 +1009,8 @@ describe("readXlsxContent: chart graphic frames", () => { chart?.document.kind === "spreadsheet" ? chart.document.sheets[0] : undefined; + // The graphic frame's own xdr:cNvPr/@name ("Chart 1"), not the "Chart" fallback -- the payload sheet is named after the shape that actually held it. + expect(sheet?.name).toBe("Chart 1"); expect(sheet?.cells).toEqual([ { row: 0, @@ -2038,6 +2041,234 @@ describe("readXlsxContent: drawing pictures (mixed anchor spellings)", () => { }); }); +// A drawing-bearing package for SheetGridGeometry and anchor-walk edge cases the fixtures above don't happen to exercise: the caller supplies the worksheet's own children (cols/sheetFormatPr/sheetData) and the drawing's own single anchor element directly, everything else (workbook, every relationship, the one media part) fixed to the same tiny PNG the picture fixtures above already use. +function customDrawingPackage( + worksheetChildren: XmlNode[], + anchor: XmlElement, +): Package { + const worksheet = el("worksheet", {}, [ + ...worksheetChildren, + el("drawing", { "r:id": "rIdDrawing" }), + ]); + const drawing = el("xdr:wsDr", {}, [anchor]); + const relationship = (id: string, type: string, target: string) => + el("Relationship", { Id: id, Type: type, Target: target }); + return { + parts: { + "xl/workbook.xml": { + kind: "xml", + nodes: [ + el("workbook", {}, [ + el("sheets", {}, [ + el("sheet", { name: "Data", sheetId: "1", "r:id": "rIdSheet" }), + ]), + ]), + ], + }, + "xl/_rels/workbook.xml.rels": { + kind: "xml", + nodes: [ + el("Relationships", {}, [ + relationship( + "rIdSheet", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + "worksheets/sheet1.xml", + ), + ]), + ], + }, + "xl/worksheets/sheet1.xml": { kind: "xml", nodes: [worksheet] }, + "xl/worksheets/_rels/sheet1.xml.rels": { + kind: "xml", + nodes: [ + el("Relationships", {}, [ + relationship( + "rIdDrawing", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing", + "../drawings/drawing1.xml", + ), + ]), + ], + }, + "xl/drawings/drawing1.xml": { kind: "xml", nodes: [drawing] }, + "xl/drawings/_rels/drawing1.xml.rels": { + kind: "xml", + nodes: [ + el("Relationships", {}, [ + relationship( + "rIdImage", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + "../media/image1.png", + ), + relationship( + "rIdChart", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart", + "../charts/chart1.xml", + ), + ]), + ], + }, + "xl/media/image1.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + "xl/charts/chart1.xml": { + kind: "xml", + nodes: [ + el("c:chartSpace", {}, [ + el("c:chart", {}, [el("c:plotArea", {}, [el("c:barChart", {})])]), + ]), + ], + }, + }, + }; +} + +// A twoCellAnchor carrying a single xdr:pic, from col0/row0 (offset 0) to col1/row1 (offset 0) unless overridden -- the minimal shape for exercising SheetGridGeometry's own column/row reading via the resulting frame size, independent of the anchor-placement arithmetic the fixtures above already cover. +function onePicTwoCellAnchor( + opts: { + toCol?: number; + toRow?: number; + editAs?: string; + } = {}, +): XmlElement { + const { toCol = 1, toRow = 1, editAs } = opts; + const picture = el("xdr:pic", {}, [ + el("xdr:nvPicPr", {}, [el("xdr:cNvPr", { id: "2", name: "Picture 1" })]), + el("xdr:blipFill", {}, [el("a:blip", { "r:embed": "rIdImage" })]), + el("xdr:spPr", {}, [ + el("a:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { cx: "914400", cy: "914400" }), + ]), + el("a:prstGeom", { prst: "rect" }, [el("a:avLst")]), + ]), + ]); + return el("xdr:twoCellAnchor", editAs === undefined ? {} : { editAs }, [ + el("xdr:from", {}, [ + el("xdr:col", {}, [txt("0")]), + el("xdr:colOff", {}, [txt("0")]), + el("xdr:row", {}, [txt("0")]), + el("xdr:rowOff", {}, [txt("0")]), + ]), + el("xdr:to", {}, [ + el("xdr:col", {}, [txt(String(toCol))]), + el("xdr:colOff", {}, [txt("0")]), + el("xdr:row", {}, [txt(String(toRow))]), + el("xdr:rowOff", {}, [txt("0")]), + ]), + picture, + el("xdr:clientData"), + ]); +} + +function imagesOf(pkg: Package): ContentSheet["images"] { + const document = readXlsxContent(pkg); + if (document.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + return document.sheets[0]?.images ?? []; +} + +describe("readXlsxContent: SheetGridGeometry (synthetic packages)", () => { + it("ignores a declared column range whose min is below 1, falling back to the default column width", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("cols", {}, [el("col", { min: "0", max: "1", width: "999" })]), + el("sheetData", {}, []), + ], + onePicTwoCellAnchor({ toCol: 1 }), + ), + ); + // Column 0 must fall back to the default width, not the malformed range's huge declared one. + expect(images[0]?.widthPt).toBeCloseTo( + columnWidthCharsToPt(DEFAULT_COLUMN_WIDTH_CHARS), + 5, + ); + }); + + it("prefers a covering column range's own declared width over a narrower range with no width at all", () => { + // Two declared ranges both cover column 0 -- an outer 1..5 range with no width (a real producer's habit for "these columns use the sheet default"), and an inner 1..1 range that actually states one. The inner range's real width must win, not the wider range's undefined one merely because .find() met it first. + const images = imagesOf( + customDrawingPackage( + [ + el("cols", {}, [ + el("col", { min: "1", max: "5" }), + el("col", { min: "1", max: "1", width: "40" }), + ]), + el("sheetData", {}, []), + ], + onePicTwoCellAnchor({ toCol: 1 }), + ), + ); + expect(images[0]?.widthPt).toBeCloseTo(columnWidthCharsToPt(40), 5); + }); + + it("reads a real sheetFormatPr defaultRowHeight rather than falling back to the built-in default", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("sheetFormatPr", { defaultRowHeight: "30" }), + el("sheetData", {}, []), + ], + onePicTwoCellAnchor({ toRow: 1 }), + ), + ); + expect(images[0]?.heightPt).toBeCloseTo(30, 5); + }); + + it("reads a declared row's own height, offset by one from its 1-based r, in preference to the default", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("sheetFormatPr", { defaultRowHeight: "15" }), + el("sheetData", {}, [el("row", { r: "1", ht: "50" })]), + ], + onePicTwoCellAnchor({ toRow: 1 }), + ), + ); + // r="1" names the FIRST row (0-based index 0) -- the very row this anchor spans, not the one after it. + expect(images[0]?.heightPt).toBeCloseTo(50, 5); + }); + + it("ignores a declared row whose r is below 1, or whose ht does not parse, falling back to the default height", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("sheetFormatPr", { defaultRowHeight: "15" }), + el("sheetData", {}, [ + el("row", { r: "0", ht: "999" }), + el("row", { r: "1", ht: "not a number" }), + ]), + ], + onePicTwoCellAnchor({ toRow: 1 }), + ), + ); + expect(images[0]?.heightPt).toBeCloseTo(15, 5); + }); + + it("defaults editAs to twoCell (sizing from the to-marker) when the attribute is absent, and reads it when present", () => { + const defaulted = imagesOf( + customDrawingPackage( + [el("sheetData", {}, [])], + onePicTwoCellAnchor({ toCol: 2 }), + ), + ); + // No editAs at all: sized from the to-marker difference (2 default-width columns), not the picture's own 1"x1" (72pt) xdr:ext. + expect(defaulted[0]?.widthPt).toBeCloseTo( + 2 * columnWidthCharsToPt(DEFAULT_COLUMN_WIDTH_CHARS), + 5, + ); + + const oneCell = imagesOf( + customDrawingPackage( + [el("sheetData", {}, [])], + onePicTwoCellAnchor({ toCol: 2, editAs: "oneCell" }), + ), + ); + // editAs="oneCell" on a twoCellAnchor (Excel's real spelling for "move but don't size with cells"): sized from the shape's own transform extent (1in = 72pt) instead, ignoring the to-marker entirely. + expect(oneCell[0]?.widthPt).toBeCloseTo(72, 5); + }); +}); + // dataValidation and conditionalFormatting rules, promoted to real vocabulary (ExaDev/documents.js#758) for every rule this package's schema names -- the two real-producer fixtures below exercise the structural read/write path; the synthetic packages further down exercise what is deliberately left un-promoted (an 'expression' cfRule, a dataValidation type this schema does not name) through the pre-existing anchor-cell residue mechanism. function worksheetOnlyPackage(worksheet: ReturnType): Package { const workbook = el("workbook", {}, [ From dbeee96e9600b15c76b000a5c0268458c936bb36 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:54:14 +0100 Subject: [PATCH 080/102] test(ooxml.js): close drawings.ts's remaining chart-frame and marker gaps Adds synthetic-package tests for the chart-graphic-frame reading path that the picture-anchor fixtures never exercised: a graphicData whose uri names something other than a chart, a graphic frame with no xdr:cNvPr at all, one whose cNvPr carries no name attribute, a worksheet whose rels list an unrelated relationship type before the real drawing one, and a picture-only drawing asserting embeddedObjects stays absent. Also adds marker-field tests distinguishing a genuinely nonzero rowOff from colOff and a numeric marker value from a non-text sibling node, a column-range test proving a range never applies below its own declared min, and an absoluteAnchor position landing exactly on a column boundary. Drops the now-provably-redundant "r >= 1" guard on declared row heights: rowHeightPt is a direct Map.get on the caller's own index, never a range test, so a malformed row lands at a key no legitimate query can ever reach, unlike the analogous column-range check this guard was modelled on. Reads editAs directly against "oneCell" rather than through an intermediate default, since twoCell and an absent attribute are already indistinguishable to that comparison. Documents emptyWorksheet's own tag as unobservable to its sole caller. Rewrites chartCells to read a table cell's single run directly instead of joining a general multi-block, multi-run shape neither this file's only producer (labelCell) nor any real chart cache ever populates with more than one of either. --- .../ooxml.js/src/typed/xlsx/content.test.ts | 224 +++++++++++++++++- packages/ooxml.js/src/typed/xlsx/drawings.ts | 26 +- 2 files changed, 230 insertions(+), 20 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 1b1bd4e8b..769d75244 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -1433,6 +1433,8 @@ describe("readXlsxContent: drawing pictures", () => { expect(image?.offsetYPt).toBe(0); expect(image?.widthPt).toBeCloseTo(col0 + col1 - offsetX, 5); expect(image?.heightPt).toBeCloseTo(45, 5); + // A drawing carrying only a picture, no chart graphic frame at all, leaves embeddedObjects absent rather than an empty array -- the same "undefined means none, [] means none for images specifically" split the module doc comment states. + expect(document.sheets[0]?.embeddedObjects).toBeUndefined(); }); it("leaves a picture whose media bytes do not sniff as PNG/JPEG unread rather than emitting an unsniffable image", () => { @@ -1617,7 +1619,12 @@ describe("readXlsxContent: drawing pictures (oneCellAnchor)", () => { }); // The absoluteAnchor spelling: xdr:pos (x/y EMU, page-absolute) plus xdr:ext sizing, no markers at all. ContentSheetImage's anchor vocabulary is cell-relative, so the landing #776 decides on is the nearest-cell re-basing -- the grid geometry's own inverse maps the absolute position onto a containing column/row plus the offset within it, exactly the fields a from-marker spells directly. The fixture grid: column 0 is 10 chars (52.5 pt), column 1 is 20 chars (105 pt), rows default 15 pt; pos 762000 x 190500 EMU is 60 x 15 pt, so column 1 offset 7.5 pt (52.5 + 7.5 = 60) and row 1 offset 0 (15 sits exactly on the row-1 boundary). -function absolutePicturePackage(extCx = "1828800", extCy = "914400"): Package { +function absolutePicturePackage( + extCx = "1828800", + extCy = "914400", + posX = "762000", + posY = "190500", +): Package { const picture = el("xdr:pic", {}, [ el("xdr:nvPicPr", {}, [el("xdr:cNvPr", { id: "2", name: "Picture 1" })]), el("xdr:blipFill", {}, [el("a:blip", { "r:embed": "rIdImage" })]), @@ -1631,7 +1638,7 @@ function absolutePicturePackage(extCx = "1828800", extCy = "914400"): Package { ]); const drawing = el("xdr:wsDr", {}, [ el("xdr:absoluteAnchor", {}, [ - el("xdr:pos", { x: "762000", y: "190500" }), + el("xdr:pos", { x: posX, y: posY }), el("xdr:ext", { cx: extCx, cy: extCy }), picture, el("xdr:clientData"), @@ -1732,6 +1739,21 @@ describe("readXlsxContent: drawing pictures (absoluteAnchor)", () => { expect(document.sheets[0]?.images).toEqual([]); }); + it("locates a position sitting exactly on a column boundary as the start of the next column, not an offset into the previous one", () => { + // Column 0 is 10 chars = columnWidthCharsToPt(10) pt exactly, i.e. that many EMU at 12700 EMU/pt -- pos x lands exactly on the column 0/1 boundary, pos y at 0 keeps the row/height math out of it entirely. + const boundaryEmu = Math.round(columnWidthCharsToPt(10) * 12700); + const document = readXlsxContent( + absolutePicturePackage("1828800", "914400", String(boundaryEmu), "0"), + ); + if (document.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + const image = document.sheets[0]?.images[0]; + // A position exactly at the boundary belongs to the column it starts (column 1, offset 0), not the tail end of column 0 (column 0, offset = the whole column width). + expect(image?.anchorColumn).toBe(1); + expect(image?.offsetXPt).toBeCloseTo(0, 5); + }); + it("round-trips the whole document through ContentDocumentSchema, so the absolute-anchored sheet image is schema-valid as read", () => { expect( ContentDocumentSchema.safeParse(readXlsxContent(absolutePicturePackage())) @@ -2127,9 +2149,19 @@ function onePicTwoCellAnchor( toCol?: number; toRow?: number; editAs?: string; + fromColOffEmu?: number; + fromRowOffEmu?: number; + fromColNodes?: XmlNode[]; } = {}, ): XmlElement { - const { toCol = 1, toRow = 1, editAs } = opts; + const { + toCol = 1, + toRow = 1, + editAs, + fromColOffEmu = 0, + fromRowOffEmu = 0, + fromColNodes, + } = opts; const picture = el("xdr:pic", {}, [ el("xdr:nvPicPr", {}, [el("xdr:cNvPr", { id: "2", name: "Picture 1" })]), el("xdr:blipFill", {}, [el("a:blip", { "r:embed": "rIdImage" })]), @@ -2143,10 +2175,10 @@ function onePicTwoCellAnchor( ]); return el("xdr:twoCellAnchor", editAs === undefined ? {} : { editAs }, [ el("xdr:from", {}, [ - el("xdr:col", {}, [txt("0")]), - el("xdr:colOff", {}, [txt("0")]), + el("xdr:col", {}, fromColNodes ?? [txt("0")]), + el("xdr:colOff", {}, [txt(String(fromColOffEmu))]), el("xdr:row", {}, [txt("0")]), - el("xdr:rowOff", {}, [txt("0")]), + el("xdr:rowOff", {}, [txt(String(fromRowOffEmu))]), ]), el("xdr:to", {}, [ el("xdr:col", {}, [txt(String(toCol))]), @@ -2267,6 +2299,186 @@ describe("readXlsxContent: SheetGridGeometry (synthetic packages)", () => { // editAs="oneCell" on a twoCellAnchor (Excel's real spelling for "move but don't size with cells"): sized from the shape's own transform extent (1in = 72pt) instead, ignoring the to-marker entirely. expect(oneCell[0]?.widthPt).toBeCloseTo(72, 5); }); + + it("never applies a declared column range to an index below its own min, even when that index is within the range's max", () => { + const images = imagesOf( + customDrawingPackage( + [ + el("cols", {}, [el("col", { min: "3", max: "5", width: "999" })]), + el("sheetData", {}, []), + ], + onePicTwoCellAnchor({ toCol: 1 }), + ), + ); + // Column 0 sits below the declared range's own min (2, 0-based) -- it must fall back to the default width, not the range's huge declared one merely because 0 <= the range's own max. + expect(images[0]?.widthPt).toBeCloseTo( + columnWidthCharsToPt(DEFAULT_COLUMN_WIDTH_CHARS), + 5, + ); + }); +}); + +describe("readXlsxContent: anchor marker fields (synthetic packages)", () => { + it("reads a marker's own rowOff distinctly from its colOff, rather than one child tag's value doing double duty for both", () => { + const images = imagesOf( + customDrawingPackage( + [el("sheetData", {}, [])], + // Small enough to stay well inside the default 15pt row height, so the anchor's own height stays positive (4pt = 50800 EMU). + onePicTwoCellAnchor({ fromRowOffEmu: 50_800 }), + ), + ); + // The row axis carries a real offset; the column axis stays at its own default (0). + expect(images[0]?.offsetXPt).toBe(0); + expect(images[0]?.offsetYPt).toBeCloseTo(4, 5); + }); + + it("extracts a marker child's numeric text past a non-text sibling node, rather than letting that sibling corrupt the joined value", () => { + const images = imagesOf( + customDrawingPackage( + [el("sheetData", {}, [])], + onePicTwoCellAnchor({ + fromColNodes: [{ type: "comment", value: "producer note" }, txt("5")], + toCol: 6, + }), + ), + ); + // The comment sibling contributes nothing to the joined text; the real numeric value is "5", not corrupted by whatever a non-text node's own placeholder text would join in as. + expect(images[0]?.anchorColumn).toBe(5); + }); +}); + +describe("readXlsxContent: chart graphic frame structural gaps (synthetic packages)", () => { + function chartGraphicFrame( + opts: { + withCNvPr?: boolean; + name?: string; + graphicUri?: string; + } = {}, + ): XmlElement { + const { + withCNvPr = true, + graphicUri = "http://schemas.openxmlformats.org/drawingml/2006/chart", + } = opts; + // "name" in opts (not a destructured default) distinguishes "caller omitted the option, use the real default" from "caller explicitly asked for no name attribute at all" -- a destructured default would treat {name: undefined} identically to {}, which defeats the one test below that needs a cNvPr with genuinely no name attribute. + const name = "name" in opts ? opts.name : "Chart 1"; + const nvGraphicFramePrChildren = withCNvPr + ? [ + el( + "xdr:cNvPr", + name === undefined ? { id: "2" } : { id: "2", name }, + [], + ), + ] + : []; + return el("xdr:graphicFrame", {}, [ + el("xdr:nvGraphicFramePr", {}, nvGraphicFramePrChildren), + el("a:graphic", {}, [ + el("a:graphicData", { uri: graphicUri }, [ + el("c:chart", { "r:id": "rIdChart" }), + ]), + ]), + ]); + } + + function chartFrameAnchor(frame: XmlElement): XmlElement { + return el("xdr:twoCellAnchor", {}, [ + el("xdr:from", {}, [ + el("xdr:col", {}, [txt("0")]), + el("xdr:colOff", {}, [txt("0")]), + el("xdr:row", {}, [txt("0")]), + el("xdr:rowOff", {}, [txt("0")]), + ]), + el("xdr:to", {}, [ + el("xdr:col", {}, [txt("1")]), + el("xdr:colOff", {}, [txt("0")]), + el("xdr:row", {}, [txt("1")]), + el("xdr:rowOff", {}, [txt("0")]), + ]), + frame, + el("xdr:clientData"), + ]); + } + + function embeddedChartOf(pkg: Package) { + const document = readXlsxContent(pkg); + if (document.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + return document.sheets[0]?.embeddedObjects; + } + + it("treats a graphicData whose uri names something other than a chart as carrying no embeddable content at all", () => { + const objects = embeddedChartOf( + customDrawingPackage( + [el("sheetData", {}, [])], + chartFrameAnchor( + chartGraphicFrame({ graphicUri: "http://example.com/not-a-chart" }), + ), + ), + ); + expect(objects).toBeUndefined(); + }); + + it("names the payload sheet 'Chart' when the graphic frame carries no xdr:cNvPr at all", () => { + const objects = embeddedChartOf( + customDrawingPackage( + [el("sheetData", {}, [])], + chartFrameAnchor(chartGraphicFrame({ withCNvPr: false })), + ), + ); + const sheet = + objects?.[0]?.document.kind === "spreadsheet" + ? objects[0].document.sheets[0] + : undefined; + expect(sheet?.name).toBe("Chart"); + }); + + it("names the payload sheet 'Chart' when xdr:cNvPr carries no name attribute", () => { + const objects = embeddedChartOf( + customDrawingPackage( + [el("sheetData", {}, [])], + chartFrameAnchor(chartGraphicFrame({ name: undefined })), + ), + ); + const sheet = + objects?.[0]?.document.kind === "spreadsheet" + ? objects[0].document.sheets[0] + : undefined; + expect(sheet?.name).toBe("Chart"); + }); + + it("never resolves an unrelated relationship type as the worksheet's own drawing part, even when it sorts before the real one", () => { + // A hyperlink relationship inserted before the genuine drawing relationship in the worksheet's own rels part -- resolveRelationships preserves declaration order, so a coverage-bearing loop that stops at the FIRST relationship regardless of type would resolve the hyperlink's own (nonsensical, non-drawing) target as if it were the drawing part. + const relationship = (id: string, type: string, target: string) => + el("Relationship", { Id: id, Type: type, Target: target }); + const pkg = customDrawingPackage( + [el("sheetData", {}, [])], + chartFrameAnchor(chartGraphicFrame()), + ); + const sheetRels = pkg.parts["xl/worksheets/_rels/sheet1.xml.rels"]; + if (sheetRels?.kind !== "xml") { + throw new Error("expected the worksheet rels part to be xml"); + } + const relationships = sheetRels.nodes[0]; + if (relationships?.type !== "element") { + throw new Error("expected a Relationships root element"); + } + pkg.parts["xl/worksheets/_rels/sheet1.xml.rels"] = { + kind: "xml", + nodes: [ + el("Relationships", {}, [ + relationship( + "rIdHyperlink", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + "https://example.com", + ), + ...relationships.children, + ]), + ], + }; + const objects = embeddedChartOf(pkg); + expect(objects).toHaveLength(1); + }); }); // dataValidation and conditionalFormatting rules, promoted to real vocabulary (ExaDev/documents.js#758) for every rule this package's schema names -- the two real-producer fixtures below exercise the structural read/write path; the synthetic packages further down exercise what is deliberately left un-promoted (an 'expression' cfRule, a dataValidation type this schema does not name) through the pre-existing anchor-cell residue mechanism. diff --git a/packages/ooxml.js/src/typed/xlsx/drawings.ts b/packages/ooxml.js/src/typed/xlsx/drawings.ts index 94223863f..021c3cba1 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings.ts @@ -86,8 +86,8 @@ class SheetGridGeometry { for (const row of childrenWithTag(sheetData, "row")) { const r = parseIntAttr(row, "r"); const ht = Number(attr(row, "ht")); - // Same redundant isInteger drop as the column read above: r is always Number.parseInt's own result (NaN or a genuine integer), and r >= 1 already rejects NaN unaided. - if (r >= 1 && Number.isFinite(ht)) { + // No "r >= 1" guard is needed, unlike the column read above's "min >= 1": rowHeightPt's own lookup is a direct Map.get(index) on the exact key a real anchor row supplies, never a range test, and every call site (xPt/yPt's own loops, locateRow) only ever queries a non-negative integer index. A malformed r below 1 (or the NaN parseIntAttr already returns for an unparseable one) still lands at some key <= -1 or NaN, which can never equal any index a legitimate query supplies -- so admitting it here is exactly as inert as rejecting it. + if (Number.isFinite(ht)) { this.rowHeights.set(r - 1, ht); } } @@ -257,10 +257,11 @@ function readAnchorPlacement( } const xPt = geometry.xPt(from.column, from.colOffEmu); const yPt = geometry.yPt(from.row, from.rowOffEmu); - // editAs governs which size statement is the semantic one: "oneCell" means move-but-not-size-with-cells, so the shape's own transform extent is the frame (the to-marker is Calc's spelling habit for it and disagrees with the character-unit column widths underneath -- verified against real producer output); "twoCell" (also ECMA's default) means the frame IS the to-marker difference, resizing with the grid, so the grid rules; "absolute" sizes independently of both. - const editAs = attr(anchor, "editAs") ?? "twoCell"; + // editAs governs which size statement is the semantic one: "oneCell" means move-but-not-size-with-cells, so the shape's own transform extent is the frame (the to-marker is Calc's spelling habit for it and disagrees with the character-unit column widths underneath -- verified against real producer output); an absent attribute or any other spelling ("twoCell", ECMA's own default, or "absolute") all fall to the same to-marker-difference sizing below, so the comparison reads the attribute directly rather than materialising a "twoCell" default nothing else ever observes. const childExt = - editAs === "oneCell" ? readChildTransformExtEmu(anchor) : undefined; + attr(anchor, "editAs") === "oneCell" + ? readChildTransformExtEmu(anchor) + : undefined; return { xPt, yPt, @@ -317,12 +318,12 @@ function readAnchorPlacement( }; } -// A minimal, childless worksheet element for the payload sheet's own print settings -- the same all-defaults ContentSheetPrintSettings readPrintSettings produces for an empty worksheet, which is the honest spelling for a synthesized sheet that never had a page setup of its own. +// A minimal, childless worksheet element for the payload sheet's own print settings -- the same all-defaults ContentSheetPrintSettings readPrintSettings produces for an empty worksheet, which is the honest spelling for a synthesized sheet that never had a page setup of its own. The "worksheet" tag string here is a genuinely irreducible equivalent mutation opportunity, not merely an untested one: this element is passed only to readPrintSettings, which reads its CHILDREN's tags (via childrenWithTag) and never once inspects the worksheet element's own tag -- with no children to walk, this element is otherwise an empty shell whose own tag field is dead structurally, not just here, so no test built on this function's own observable contract (the ContentSheetPrintSettings readPrintSettings returns) can ever tell one tag string from another. function emptyWorksheet(): XmlElement { return { type: "element", tag: "worksheet", attributes: [], children: [] }; } -// readChartTable's table laid out as the payload sheet's sparse cells: the header row's series names over the category column, one row per category, values verbatim c:v text -- chart caches carry no typed-cell concept to preserve beyond the string itself, which is why every populated cell is the string kind. +// readChartTable's table laid out as the payload sheet's sparse cells: the header row's series names over the category column, one row per category, values verbatim c:v text -- chart caches carry no typed-cell concept to preserve beyond the string itself, which is why every populated cell is the string kind. Reads each cell's text directly off its own single run rather than walking/joining a general multi-block, multi-run cell shape: readChartTable's own labelCell is the only producer that ever reaches this function, and it always emits either no block at all (an absent series name or category/value) or exactly one paragraph block holding exactly one run -- so a cell here never actually carries more than one block or run for a join to meaningfully separate. function chartCells( chartRoot: XmlElement, frame: ContentEmbeddedObject["frame"], @@ -334,13 +335,10 @@ function chartCells( const cells: ContentSheetCell[] = []; table.rows.forEach((row, rowIndex) => { row.cells.forEach((cell, columnIndex) => { - const text = cell.blocks - .map((block) => - block.kind === "paragraph" - ? block.runs.map((run) => run.text).join("") - : "", - ) - .join(""); + const block = cell.blocks[0]; + // block.runs[0] is always defined whenever block is a paragraph: labelCell (readChartTable's sole producer reaching this function) never emits a paragraph block with zero runs, only zero blocks at all for an absent value -- the "?? ''" is required by runs' own indexed-access type, not by any input this function can actually receive. + const text = + block?.kind === "paragraph" ? (block.runs[0]?.text ?? "") : ""; if (text !== "") { cells.push({ row: rowIndex, From 90a008f8b77803b8a9e31de48c1f507a6f57f5b5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 13:55:17 +0100 Subject: [PATCH 081/102] test(ooxml.js): add direct structural coverage for buildDrawing and fixed package-scaffolding XML buildDrawing's zero offsets, rect preset, and distT/B/L/R attributes, and the fixed _rels/.rels relationships, [Content_Types].xml Default/Override entries, and styles.xml docDefaults/Normal scaffolding were never asserted against their literal values: readDocxContent doesn't read most of them back, so a round-trip assertion alone can't catch a mutated literal. These tests parse the written XML directly and check every fixed attribute value. --- .../ooxml.js/src/typed/docx/write.test.ts | 316 +++++++++++++++++- 1 file changed, 315 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/docx/write.test.ts b/packages/ooxml.js/src/typed/docx/write.test.ts index 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", {}, [ From aec479f46cbb77fd36ea3ecaf5b4c0fd896bbd53 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:05:54 +0100 Subject: [PATCH 082/102] test(ooxml.js): close content.ts's row/column, span, and residue mutation gaps Adds direct synthetic-package coverage for sheetFormatDefaultRowHeightPt, readColumns, and readRows: default row height fallback, 1-based min/r lower bounds, the 1-based-to-0-based index subtraction, and hidden flags. Covers deriveDisplayText/resolveNumericValue's exact per-kind displayText output (dateTime, percentage, false boolean, symbol-only currency), readCellValue's boolean parsing and NaN handling, and merged-range colSpan/rowSpan arithmetic anchored away from row/column 0 so subtraction and addition mutants actually diverge. Adds a hasOwn() helper and uses it wherever a test needs to prove a key is genuinely absent from a ContentSheetCell/ContentSheet, since toBeUndefined() cannot distinguish an absent key from one explicitly assigned undefined. Removes redundant guards whose branches the surrounding NaN-fallback arithmetic already collapses to the identical result (sheetFormatDefaultRowHeightPt's raw-undefined check, readColumns' widthRaw-defined check, readRows' htRaw-undefined check), and the two early-return size checks in applyCellComments/applyCellResidueRules, whose absence only skips pointless work over an empty collection rather than changing any observable output. Documents two remaining genuinely irreducible equivalent mutants (the sqref-split regex's + quantifier, and fallbackEmptyWorksheet's own tag string, matching drawings.ts's identically-shaped case) with the exact reasoning that makes them unobservable through this module's own contract. --- .../ooxml.js/src/typed/xlsx/content.test.ts | 361 +++++++++++++++++- packages/ooxml.js/src/typed/xlsx/content.ts | 34 +- 2 files changed, 374 insertions(+), 21 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 769d75244..116f36ac4 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -20,9 +20,18 @@ import { decodePackage, encodePackage } from "../../codec"; import { parsePackage } from "../../package-io/read"; import { attr, childrenWithTag, rootElement } from "../util"; import { buildXlsxPackageFromContent } from "./build"; -import { columnWidthCharsToPt, DEFAULT_COLUMN_WIDTH_CHARS } from "./units"; +import { + columnWidthCharsToPt, + DEFAULT_COLUMN_WIDTH_CHARS, + DEFAULT_ROW_HEIGHT_PT, +} from "./units"; import { readXlsxContent, resolveSheetEntries } from "./content"; +// True precisely when `key` is an own property of `obj`, regardless of whether its value is `undefined` -- unlike `toBeUndefined()`, which is satisfied identically by a key holding `undefined` and by the key's own absence, and so cannot distinguish "never assigned" from "assigned undefined". Several of readCell's own optional-field copies (font/background/borders/alignment/verticalAlignment/numberFormatCode) are guarded by a presence check specifically to avoid ever assigning the key at all when the source has nothing to offer, and only a key-existence assertion can prove that guard is doing real work rather than being a no-op the object shape would be identical without. +function hasOwn(obj: object, key: string): boolean { + return Object.hasOwn(obj, key); +} + // This suite reads real, unmodified LibreOffice-generated .xlsx fixtures (src/typed/xlsx/fixtures/*.xlsx). Both fixtures are genuine LibreOffice xlsx-exports (`soffice --headless --convert-to xlsx`) of odf.js's own src/typed/ods/fixtures/{kitchen-sink,minimal}.ods -- the same feature set that package's own readOds test suite already validates against ODF's equivalent mechanisms, run back through LibreOffice's real SpreadsheetML export filter so this suite exercises genuine, LibreOffice-authored xlsx markup (column-width character units, row heights, hidden rows/columns, every value-type LibreOffice's own xlsx exporter distinguishes, a real merged range, a real cross-sheet formula, and real print settings including Print_Area/Print_Titles defined names) rather than a hand-built approximation of what that markup might look like. A handful of narrow scope-boundary/error-path tests at the end use small, synthetic, hand-built packages instead (via el/txt), mirroring readOds's own established convention for the identical reason. const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); @@ -846,6 +855,331 @@ describe("readXlsxContent: cell decoration (background/borders/alignment/vertica )?.background, ).toBeUndefined(); }); + + it("omits the font/background/borders/alignment/verticalAlignment keys entirely on a cell whose s index carries none of them -- not merely assigned undefined", () => { + const cell = readDecoratedCell( + styledSheet, + el("c", { r: "A1", s: "0" }, [el("v", {}, [txt("42")])]), + ); + expect(cell).toBeDefined(); + if (cell === undefined) { + throw new Error("expected a cell"); + } + expect(hasOwn(cell, "font")).toBe(false); + expect(hasOwn(cell, "background")).toBe(false); + expect(hasOwn(cell, "borders")).toBe(false); + expect(hasOwn(cell, "alignment")).toBe(false); + expect(hasOwn(cell, "verticalAlignment")).toBe(false); + }); + + it("sets the numberFormatCode key when the cell's style resolves one, verbatim", () => { + const cell = readDecoratedCell( + styledSheet, + el("c", { r: "A1", s: "1" }, [el("v", {}, [txt("42")])]), + ); + expect(hasOwn(cell ?? {}, "numberFormatCode")).toBe(true); + }); + + it("omits numberFormatCode entirely (not merely as undefined) for an out-of-range style index that resolves to no entry at all", () => { + const cell = readDecoratedCell( + styledSheet, + el("c", { r: "A1", s: "99" }, [el("v", {}, [txt("42")])]), + ); + expect(hasOwn(cell ?? {}, "numberFormatCode")).toBe(false); + }); + + it("omits numberFormatCode entirely (not merely as undefined) for a resolvable style entry whose own numFmtId names no code anywhere", () => { + const noCodeSheet = el("styleSheet", {}, [ + el("cellXfs", {}, [ + el("xf", { numFmtId: "0" }), + el("xf", { numFmtId: "999" }), + ]), + ]); + const cell = readDecoratedCell( + noCodeSheet, + el("c", { r: "A1", s: "1" }, [el("v", {}, [txt("42")])]), + ); + expect(hasOwn(cell ?? {}, "numberFormatCode")).toBe(false); + }); +}); + +// Every one of readColumns/readRows/sheetFormatDefaultRowHeightPt's own conditional branches and index arithmetic, exercised directly against small synthetic worksheets -- the kitchen-sink fixture's own real rows/columns don't happen to visit every boundary (a 0-based min, a non-numeric width, a row number exactly at its own lower bound) these functions guard against. +function readSheetFromWorksheet( + worksheet: ReturnType, +): ContentSheet { + const result = readXlsxContent(buildMinimalPackage(worksheet)); + if (result.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + const sheet = result.sheets[0]; + if (sheet === undefined) { + throw new Error("expected a sheet"); + } + return sheet; +} + +describe("readXlsxContent: row/column geometry edge cases (synthetic packages)", () => { + it("falls back to DEFAULT_ROW_HEIGHT_PT for a row with no ht attribute when the worksheet carries no sheetFormatPr at all", () => { + const worksheet = el("worksheet", {}, [ + el("sheetData", {}, [el("row", { r: "1" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows).toEqual([ + { index: 0, heightPt: DEFAULT_ROW_HEIGHT_PT }, + ]); + }); + + it("falls back to the sheetFormatPr's own declared defaultRowHeight, not the package-wide default, for a row with no ht of its own", () => { + const worksheet = el("worksheet", {}, [ + el("sheetFormatPr", { defaultRowHeight: "22.5" }), + el("sheetData", {}, [el("row", { r: "1" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows).toEqual([ + { index: 0, heightPt: 22.5 }, + ]); + }); + + it("prefers a row's own ht over the sheetFormatPr default", () => { + const worksheet = el("worksheet", {}, [ + el("sheetFormatPr", { defaultRowHeight: "22.5" }), + el("sheetData", {}, [el("row", { r: "1", ht: "30" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows).toEqual([ + { index: 0, heightPt: 30 }, + ]); + }); + + it("drops a row whose own r is 0 (below CT_Row/@r's 1-based lower bound) but keeps one whose r is exactly 1", () => { + const worksheet = el("worksheet", {}, [ + el("sheetData", {}, [el("row", { r: "0" }), el("row", { r: "1" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows).toEqual([ + { index: 0, heightPt: DEFAULT_ROW_HEIGHT_PT }, + ]); + }); + + it('recovers row index 4 -- not 6 -- from r="5", proving the 1-based-to-0-based conversion subtracts rather than adds', () => { + const worksheet = el("worksheet", {}, [ + el("sheetData", {}, [el("row", { r: "5" })]), + ]); + expect(readSheetFromWorksheet(worksheet).rows[0]?.index).toBe(4); + }); + + it("marks a row hidden only when its own hidden attribute reads true, never as a side effect of any other attribute", () => { + const worksheet = el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1", hidden: "true" }), + el("row", { r: "2" }), + ]), + ]); + const rows = readSheetFromWorksheet(worksheet).rows; + expect(rows[0]).toEqual({ + index: 0, + heightPt: DEFAULT_ROW_HEIGHT_PT, + hidden: true, + }); + expect(hasOwn(rows[1] ?? {}, "hidden")).toBe(false); + }); + + it("drops a whose min is 0 (below CT_Col/@min's 1-based lower bound) but keeps one whose min is exactly 1", () => { + const worksheet = el("worksheet", {}, [ + el("cols", {}, [ + el("col", { min: "0", max: "0" }), + el("col", { min: "1", max: "1" }), + ]), + el("sheetData", {}), + ]); + expect(readSheetFromWorksheet(worksheet).columns).toEqual([{ index: 0 }]); + }); + + it("sets widthPt from a numeric width attribute, and omits the key entirely when width is absent", () => { + const worksheet = el("worksheet", {}, [ + el("cols", {}, [ + el("col", { min: "1", max: "1", width: "20" }), + el("col", { min: "2", max: "2" }), + ]), + el("sheetData", {}), + ]); + const columns = readSheetFromWorksheet(worksheet).columns; + expect(columns[0]?.widthPt).toBeCloseTo(columnWidthCharsToPt(20), 10); + expect(hasOwn(columns[1] ?? {}, "widthPt")).toBe(false); + }); + + it("omits widthPt for a non-numeric width attribute, rather than reporting a NaN width", () => { + const worksheet = el("worksheet", {}, [ + el("cols", {}, [ + el("col", { min: "1", max: "1", width: "not-a-number" }), + ]), + el("sheetData", {}), + ]); + expect( + hasOwn(readSheetFromWorksheet(worksheet).columns[0] ?? {}, "widthPt"), + ).toBe(false); + }); + + it("marks a column hidden only when its own hidden attribute reads true", () => { + const worksheet = el("worksheet", {}, [ + el("cols", {}, [ + el("col", { min: "1", max: "1", hidden: "true" }), + el("col", { min: "2", max: "2" }), + ]), + el("sheetData", {}), + ]); + const columns = readSheetFromWorksheet(worksheet).columns; + expect(columns[0]).toEqual({ index: 0, hidden: true }); + expect(hasOwn(columns[1] ?? {}, "hidden")).toBe(false); + }); +}); + +describe("readXlsxContent: readSheet's own optional-field keys are absent, not undefined, when a sheet carries none of them", () => { + it("omits embeddedObjects/dataValidations/conditionalFormats entirely from a sheet with no drawing, validation, or conditional format at all", () => { + const sheet = readSheetFromWorksheet( + el("worksheet", {}, [el("sheetData", {})]), + ); + expect(hasOwn(sheet, "embeddedObjects")).toBe(false); + expect(hasOwn(sheet, "dataValidations")).toBe(false); + expect(hasOwn(sheet, "conditionalFormats")).toBe(false); + }); +}); + +describe("readXlsxContent: deriveDisplayText/resolveNumericValue exact per-kind coverage (synthetic packages)", () => { + it("renders a numeric-format dateTime cell's displayText as its ISO spelling, not the boolean-branch TRUE/FALSE fallthrough text", () => { + const cell = readStyledCell( + "yyyy-mm-dd hh:mm:ss", + numericCell("46234.604166666666667"), + ); + expect(cell?.displayText).toBe("2026-07-31T14:30:00"); + }); + + it("renders a percentage cell's displayText as the raw stored fraction, not TRUE", () => { + const cell = readStyledCell("0.00%", numericCell("0.4256")); + expect(cell?.displayText).toBe("0.4256"); + }); + + it("omits the currency key entirely (not merely as undefined) when the format names money by symbol alone", () => { + const cell = readStyledCell("[$£-809]#,##0.00", numericCell("99.99")); + expect(cell?.value.kind).toBe("currency"); + expect(hasOwn(cell?.value ?? {}, "currency")).toBe(false); + }); + + it('renders FALSE, not just "not TRUE", for a false boolean cell', () => { + expect( + readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1", t: "b" }, [el("v", {}, [txt("0")])]), + ]), + ]), + ]), + ).cells[0], + ).toMatchObject({ + value: { kind: "boolean", value: false }, + displayText: "FALSE", + }); + }); +}); + +describe("readXlsxContent: readCellValue's boolean/numeric branch precision (synthetic packages)", () => { + it('reads t="b" true from an upper-, lower-, or mixed-case spelling of "true", not just the literal "1"', () => { + for (const raw of ["TRUE", "True", "true"]) { + const { cells } = readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1", t: "b" }, [el("v", {}, [txt(raw)])]), + ]), + ]), + ]), + ); + expect(cells[0]?.value).toEqual({ kind: "boolean", value: true }); + } + }); + + it('reads t="b" as false for any raw text that is neither "1" nor a case-insensitive "true"', () => { + const { cells } = readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1", t: "b" }, [el("v", {}, [txt("false")])]), + ]), + ]), + ]), + ); + expect(cells[0]?.value).toEqual({ kind: "boolean", value: false }); + }); + + it("drops an untyped cell whose text is not a parseable number at all, rather than reporting NaN", () => { + const { cells } = readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1" }, [el("v", {}, [txt("not-a-number")])]), + ]), + ]), + ]), + ); + expect(cells).toEqual([]); + }); +}); + +describe("readXlsxContent: readCell's formula key presence (synthetic packages)", () => { + it("omits the formula key entirely for a plain value cell with no child", () => { + const { cells } = readFirstCell( + el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "1" }, [ + el("c", { r: "A1" }, [el("v", {}, [txt("42")])]), + ]), + ]), + ]), + ); + expect(hasOwn(cells[0] ?? {}, "formula")).toBe(false); + }); +}); + +describe("readXlsxContent: merged-range span arithmetic (synthetic packages)", () => { + // Anchored at B2, not A1: with a zero-valued start, endColumn-startColumn and endColumn+startColumn (the ArithmeticOperator mutant's own replacement) coincide, so a genuine test needs a nonzero start on both axes to actually distinguish subtraction from addition. + function mergedWorksheet( + ref: string, + anchorRef: string, + ): ReturnType { + return el("worksheet", {}, [ + el("sheetData", {}, [ + el("row", { r: "2" }, [ + el("c", { r: anchorRef }, [el("v", {}, [txt("1")])]), + ]), + ]), + el("mergeCells", {}, [el("mergeCell", { ref })]), + ]); + } + + it("computes colSpan and rowSpan from the true end-minus-start distance, not an end-plus-start sum, for a merge anchored away from row/column 0", () => { + const { cells } = readFirstCell(mergedWorksheet("B2:D4", "B2")); + const anchor = cells[0]; + expect(anchor?.colSpan).toBe(3); + expect(anchor?.rowSpan).toBe(3); + }); + + it("sets colSpan alone for a 1-row, multi-column merge, never fabricating a rowSpan", () => { + const { cells } = readFirstCell(mergedWorksheet("B2:D2", "B2")); + const anchor = cells[0]; + expect(anchor?.colSpan).toBe(3); + expect(hasOwn(anchor ?? {}, "rowSpan")).toBe(false); + }); + + it("sets rowSpan alone for a 1-column, multi-row merge, never fabricating a colSpan", () => { + const { cells } = readFirstCell(mergedWorksheet("B2:B4", "B2")); + const anchor = cells[0]; + expect(anchor?.rowSpan).toBe(3); + expect(hasOwn(anchor ?? {}, "colSpan")).toBe(false); + }); + + it("sets neither colSpan nor rowSpan for a single-cell 'merge' (B2:B2) -- a span of exactly 1 on both axes", () => { + const { cells } = readFirstCell(mergedWorksheet("B2:B2", "B2")); + const anchor = cells[0]; + expect(hasOwn(anchor ?? {}, "colSpan")).toBe(false); + expect(hasOwn(anchor ?? {}, "rowSpan")).toBe(false); + }); }); // A chart graphic frame reached the way a real workbook reaches one: the worksheet's own names a drawing part through the worksheet's relationships, the drawing's xdr:twoCellAnchor carries an xdr:graphicFrame whose a:graphicData names the chart part through the DRAWING's relationships. The anchor geometry resolves through the sheet's own declared column widths and row heights, exactly as a spreadsheet renderer would place it. @@ -2675,6 +3009,31 @@ describe("readXlsxContent: dataValidation and conditionalFormatting -- what is N ).toBeUndefined(); }); + it("leaves a rule whose sqref is the empty string unattached, the same as one that does not parse at all", () => { + const cells = readFirstCellOf( + el("worksheet", {}, [ + el("sheetData", {}, []), + el("dataValidations", { count: "1" }, [ + el("dataValidation", { type: "none", sqref: "" }), + ]), + ]), + ); + expect(cells).toEqual([]); + }); + + it("materialises a residue-only anchor cell as kind empty with an empty displayText, not a placeholder marker string", () => { + const cells = readFirstCellOf( + el("worksheet", {}, [ + el("sheetData", {}, []), + el("dataValidations", { count: "1" }, [ + el("dataValidation", { type: "none", sqref: "F6" }), + ]), + ]), + ); + const anchor = cells.find((cell) => cell.row === 5 && cell.column === 5); + expect(anchor).toMatchObject({ value: { kind: "empty" }, displayText: "" }); + }); + it("keeps the first residue-eligible rule when two anchor at the same cell -- one residue slot per cell -- and leaves a rule whose sqref does not parse unattached", () => { const cells = readFirstCellOf( el("worksheet", {}, [ diff --git a/packages/ooxml.js/src/typed/xlsx/content.ts b/packages/ooxml.js/src/typed/xlsx/content.ts index 22e45d3f1..7945e2e75 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.ts @@ -84,9 +84,7 @@ function sheetFormatDefaultRowHeightPt(worksheet: XmlElement): number { sheetFormatPr === undefined ? undefined : attr(sheetFormatPr, "defaultRowHeight"); - if (raw === undefined) { - return DEFAULT_ROW_HEIGHT_PT; - } + // No "raw === undefined" guard: Number(undefined) is NaN (unlike Number(null), which is 0), so an absent defaultRowHeight already falls through Number.isFinite to the same DEFAULT_ROW_HEIGHT_PT result this guard would have returned directly. const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : DEFAULT_ROW_HEIGHT_PT; } @@ -105,13 +103,12 @@ function readColumns(worksheet: XmlElement): ContentSheetColumn[] { continue; } const column: ContentSheetColumn = { index: min - 1 }; + // No "widthRaw !== undefined" guard: Number(undefined) is NaN, and columnWidthCharsToPt's own arithmetic propagates a NaN input straight through to a NaN result, so an absent width already falls through the Number.isFinite check below to the same "no widthPt" outcome this guard would have skipped to directly. const widthRaw = attr(col, "width"); - if (widthRaw !== undefined) { - const widthPt = columnWidthCharsToPt(Number(widthRaw)); - // widthPt is optional -- absent means "no declared width, use the application default" (document-schema.js's own ContentSheetColumn doc comment), not a fabricated 0; a element with no width attribute at all (e.g. one that exists purely to declare `hidden`) must not report a zero-width column. - if (Number.isFinite(widthPt)) { - column.widthPt = widthPt; - } + const widthPt = columnWidthCharsToPt(Number(widthRaw)); + // widthPt is optional -- absent means "no declared width, use the application default" (document-schema.js's own ContentSheetColumn doc comment), not a fabricated 0; a element with no width attribute at all (e.g. one that exists purely to declare `hidden`) must not report a zero-width column. + if (Number.isFinite(widthPt)) { + column.widthPt = widthPt; } if (readXmlBool(attr(col, "hidden"))) { column.hidden = true; @@ -140,8 +137,9 @@ function readRows(worksheet: XmlElement): ContentSheetRow[] { ) { continue; } + // No "htRaw === undefined" guard: Number(undefined) is NaN, so an absent ht already falls through the Number.isFinite check below to the same fallbackHeightPt result this guard would have selected directly. const htRaw = attr(row, "ht"); - const heightPt = htRaw === undefined ? fallbackHeightPt : Number(htRaw); + const heightPt = Number(htRaw); const contentRow: ContentSheetRow = { index: rowNumber - 1, heightPt: Number.isFinite(heightPt) ? heightPt : fallbackHeightPt, @@ -260,9 +258,8 @@ function resolveNumericValue( ? { kind: "number", value: num } : { kind: "dateTime", value: iso }; } + // elapsedTime/text/number are grouped in one case list, not three separate returns of the identical literal, deliberately: an elapsed-time format ([h]:mm:ss) is a DURATION, which may legitimately exceed 24 hours -- ContentCellValue's own 'time' variant is explicitly a wall-clock time of day and has no duration sibling to carry this instead, so the raw day-fraction number is kept rather than folded into a wrong-kind time; 'text' and 'number' formats carry no reclassification information at all. Because all three produce the exact same {kind:"number", value:num} object, any mutation that moves 'elapsedTime' between this group and the one above (or duplicates/reorders the case labels) is genuinely unobservable through this function's own return value for every possible input -- not a gap a differently-shaped test could close, so the three are stated once rather than left as separate case blocks Stryker could find spurious "move this label" mutations between. case "elapsedTime": - // An elapsed-time format ([h]:mm:ss) is a DURATION, which may legitimately exceed 24 hours -- ContentCellValue's own 'time' variant is explicitly a wall-clock time of day and has no duration sibling to carry this instead, so the raw day-fraction number is kept rather than folded into a wrong-kind time. - return { kind: "number", value: num }; case "text": case "number": return { kind: "number", value: num }; @@ -446,9 +443,7 @@ function applyCellComments( comments: ReadonlyMap, cells: ContentSheetCell[], ): void { - if (comments.size === 0) { - return; - } + // No "comments.size === 0" early return: with no comments, the two loops below simply never do anything (building an unused, empty byPosition map, then iterating a genuinely empty comments Map) -- `cells` comes back byte-for-byte unchanged either way, so an early return here would only ever skip work whose absence is already unobservable. const byPosition = new Map(); for (const cell of cells) { byPosition.set(`${cell.row}:${cell.column}`, cell); @@ -467,7 +462,7 @@ function applyCellComments( comment, }; cells.push(materialised); - byPosition.set(key, materialised); + // No `byPosition.set(key, materialised)` here (unlike applyCellResidueRules' own identically-shaped materialise branch below): `comments`'s keys are already unique (it is a Map), so no later iteration of this same loop can ever look up `key` again -- recording it would only ever be read by nothing. } } @@ -476,15 +471,14 @@ function applyCellResidueRules( cells: ContentSheetCell[], rules: readonly XmlElement[], ): void { - if (rules.length === 0) { - return; - } + // No "rules.length === 0" early return: with no rules, the two loops below simply never do anything (building an unused, empty byPosition map, then iterating a genuinely empty rules array) -- `cells` comes back byte-for-byte unchanged either way, so an early return here would only ever skip work whose absence is already unobservable. const byPosition = new Map(); for (const cell of cells) { byPosition.set(`${cell.row}:${cell.column}`, cell); } for (const rule of rules) { const sqref = attr(rule, "sqref"); + // The regex's own "+" (one-or-more, versus a single whitespace character) is a genuinely irreducible equivalent mutation opportunity here, not merely an untested one: only index [0] of the split result is ever read, and the substring BEFORE the first regex match is identical regardless of how many whitespace characters that first match itself consumes -- \s and \s+ always start matching at the same position, so [0] can never differ between them for any input, only the LATER elements of the split array (never read here) can. const firstToken = sqref === undefined ? undefined : sqref.split(/\s+/)[0]; const range = firstToken === undefined || firstToken === "" @@ -566,7 +560,7 @@ function readSheet( }; } -// A minimal, childless element, used only as readPrintSettings' own input when a in xl/workbook.xml points at a part the package doesn't actually have (a malformed package) -- gives the same all-defaults ContentSheetPrintSettings a genuinely empty worksheet would produce, without readPrintSettings itself needing an `undefined`-worksheet branch. +// A minimal, childless element, used only as readPrintSettings' own input when a in xl/workbook.xml points at a part the package doesn't actually have (a malformed package) -- gives the same all-defaults ContentSheetPrintSettings a genuinely empty worksheet would produce, without readPrintSettings itself needing an `undefined`-worksheet branch. The "worksheet" tag string itself is a genuinely irreducible equivalent mutation opportunity, not merely an untested one, matching drawings.ts's own identically-shaped emptyWorksheet: readPrintSettings only ever reads this element's CHILDREN's tags (via childrenWithTag), never its own tag, so with no children to walk it is an otherwise-empty shell whose own tag field is dead structurally -- no test built on readPrintSettings' own observable output can ever tell one tag string from another here. function fallbackEmptyWorksheet(): XmlElement { return { type: "element", tag: "worksheet", attributes: [], children: [] }; } From 0b870699897b6461a42672a727f00a8e78237792 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:11:24 +0100 Subject: [PATCH 083/102] test(ooxml.js): reach content.ts's genuine mutation ceiling Removes applyCellResidueRules' own empty-firstToken disjunct: parseRangeReference('') already returns undefined rather than throwing, so the check was a redundant special case of the undefined branch beside it, which stays load-bearing on its own. Asserts displayText, not just value, for a true boolean cell, closing the one real remaining gap in deriveDisplayText. Documents deriveDisplayText's own "empty" case as a structurally required but genuinely unreachable switch arm: ContentCellValue's type still includes "empty" as a member, so the case must stay for the function to type-check as returning string unconditionally, even though neither of its two real call sites can ever pass one. --- packages/ooxml.js/src/typed/xlsx/content.test.ts | 1 + packages/ooxml.js/src/typed/xlsx/content.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 116f36ac4..ca9bd076e 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -1092,6 +1092,7 @@ describe("readXlsxContent: readCellValue's boolean/numeric branch precision (syn ]), ); expect(cells[0]?.value).toEqual({ kind: "boolean", value: true }); + expect(cells[0]?.displayText).toBe("TRUE"); } }); diff --git a/packages/ooxml.js/src/typed/xlsx/content.ts b/packages/ooxml.js/src/typed/xlsx/content.ts index 7945e2e75..9403eda7e 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.ts @@ -181,6 +181,7 @@ function deriveDisplayText(value: ContentCellValue): string { case "time": case "dateTime": return value.value; + // This branch is genuinely unreachable through either of this function's own two call sites (both below): the boolean case always passes a value of kind "boolean", and the numeric case always passes whatever resolveNumericValue itself returns, which is one of number/percentage/currency/date/time/dateTime/elapsedTime -- never "empty". It stays here, and its own return value stays untestable, purely because ContentCellValue's declared type still includes "empty" as a member: removing this case would make the switch non-exhaustive over that type and this function would no longer type-check as returning `string` unconditionally. This is the same shape of irreducible gap as localName's own "no colon" branch (comments.ts) -- a case the type system requires but no real call site can ever actually reach. case "empty": return ""; } @@ -480,10 +481,9 @@ function applyCellResidueRules( const sqref = attr(rule, "sqref"); // The regex's own "+" (one-or-more, versus a single whitespace character) is a genuinely irreducible equivalent mutation opportunity here, not merely an untested one: only index [0] of the split result is ever read, and the substring BEFORE the first regex match is identical regardless of how many whitespace characters that first match itself consumes -- \s and \s+ always start matching at the same position, so [0] can never differ between them for any input, only the LATER elements of the split array (never read here) can. const firstToken = sqref === undefined ? undefined : sqref.split(/\s+/)[0]; + // No "firstToken === ''" disjunct: parseRangeReference('') already returns undefined rather than throwing (verified directly against document-schema.js's own implementation), so an empty firstToken already falls through to the identical `range === undefined` outcome this disjunct would have short-circuited to. The `undefined` check alone stays load-bearing: parseRangeReference(undefined) throws, unlike the empty-string case. const range = - firstToken === undefined || firstToken === "" - ? undefined - : parseRangeReference(firstToken); + firstToken === undefined ? undefined : parseRangeReference(firstToken); if (range === undefined) { continue; } From 077247c6b85aea4cf1cc3ba1c07e5c013162c0f3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:23:24 +0100 Subject: [PATCH 084/102] test(ooxml.js): close styles.ts's decoration key-presence and signature mutation gaps Adds direct unit coverage for every optional-field presence check in the read side (font/fill/border/alignment key absence, not just value, proven with hasOwn rather than toBeUndefined), for the exact val-string behaviour of readFontToggle/readFontUnderline, for a non-integer numFmtId/sizePt leaving the code/sizePt unresolvable, and for colorFromElement's own validation (invalid hex, a too-short rgb attribute). Adds write-side tests proving every one of CellFormatTable's own signature segments (each font/fill/border/alignment flag) actually distinguishes two otherwise-identical entries, rather than trusting that the signature strings alone don't collapse two different inputs onto the same interned index; and that a font/fill/border interned twice under different number formats still caches to a single declared entry. Removes readFontTableEntry's own redundant szVal-undefined guard: Number(undefined) is NaN, so an absent already falls through the Number.isFinite check below to the same "no sizePt" result this guard would have selected directly. Documents two remaining genuinely irreducible equivalent mutants in colorFromElement (the raw.length >= 6 vs > 6 boundary, and the hex regex's own anchors) with the exact reasoning that makes them unobservable given hex's own fixed construction. --- .../ooxml.js/src/typed/xlsx/styles.test.ts | 641 ++++++++++++++++++ packages/ooxml.js/src/typed/xlsx/styles.ts | 8 +- 2 files changed, 646 insertions(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index 737cc41e1..a3c68e10f 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.test.ts @@ -9,8 +9,10 @@ import { CellFormatTable, DEFAULT_CELL_FORMAT_INDEX, GENERAL_NUM_FMT_ID, + colorFromElement, readCellFormatCodes, readCellStyles, + readColorRgb, } from "./styles"; const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); @@ -19,6 +21,11 @@ function stylesPackage(styleSheet: ReturnType): Package { return { parts: { "xl/styles.xml": { kind: "xml", nodes: [styleSheet] } } }; } +// True precisely when `key` is an own property of `obj`, regardless of whether its value is `undefined` -- unlike `toBeUndefined()`, which is satisfied identically by a key holding `undefined` and by the key's own absence, and so cannot distinguish "never assigned" from "assigned undefined". Several of this module's own optional-field copies are guarded by a presence check specifically to avoid ever assigning the key at all when the source has nothing to offer, and only a key-existence assertion can prove that guard is doing real work. +function hasOwn(obj: object, key: string): boolean { + return Object.hasOwn(obj, key); +} + describe("readCellFormatCodes: real LibreOffice output (kitchen-sink.xlsx)", () => { const pkg = parsePackage( new Uint8Array(readFileSync(join(FIXTURES_DIR, "kitchen-sink.xlsx"))), @@ -624,3 +631,637 @@ describe("CellFormatTable: interning the cell font alongside the number format", }); }); }); + +describe("readNumberFormatCodesById: a non-integer numFmtId registers no code", () => { + it("skips a whose numFmtId is not a parseable integer, leaving that id unresolvable", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("numFmts", {}, [ + el("numFmt", { numFmtId: "not-a-number", formatCode: "0.00" }), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "not-a-number" })]), + ]), + ); + expect(hasOwn(readCellStyles(pkg)[0] ?? {}, "numberFormatCode")).toBe( + false, + ); + }); +}); + +describe("readFontToggle/readFontUnderline: exact val-string behaviour", () => { + // Diffs a single font against a plain Calibri baseline with NO toggles at all, so bare presence (no val) and val="1" show up as an explicit `true` difference. A `val="0"`/`val="false"` toggle reads as `false`, which is indistinguishable from this baseline via a diff (false against false is no difference) -- those two cases use offToggleFont below instead, against an ALL-toggles-on baseline, so turning one off is what shows up as the difference. + function diffedToggleFont(toggle: ReturnType) { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("name", { val: "Calibri" })]), + el("font", {}, [toggle, el("name", { val: "Calibri" })]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + return readCellStyles(pkg)[0]?.font ?? {}; + } + + // Diffs a single font, WITH b/i/strike all on, against a baseline that ALSO has them all on -- so replacing one of the baseline's own toggles with an explicit val="0"/"false" version is what shows up as that one property's own false in the diff. + function offToggleFont(toggle: ReturnType) { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("b"), + el("i"), + el("strike"), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [toggle, el("name", { val: "Calibri" })]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + return readCellStyles(pkg)[0]?.font ?? {}; + } + + it("reads a bare with no val attribute as bold: true", () => { + expect(diffedToggleFont(el("b"))).toEqual({ bold: true }); + }); + + it('reads (anything other than "0"/"false") as bold: true', () => { + expect(diffedToggleFont(el("b", { val: "1" }))).toEqual({ bold: true }); + }); + + it('reads as bold: false, distinguishing the val attribute from a bare element', () => { + expect(offToggleFont(el("b", { val: "0" }, []))).toMatchObject({ + bold: false, + }); + }); + + it('reads as bold: false too, the alternate xsd:boolean spelling', () => { + expect(offToggleFont(el("b", { val: "false" }))).toMatchObject({ + bold: false, + }); + }); + + it('reads as italic: false, proving the "0" check is not bold-specific', () => { + expect(offToggleFont(el("i", { val: "0" }))).toMatchObject({ + italic: false, + }); + }); + + it('reads as strike: false', () => { + expect(offToggleFont(el("strike", { val: "false" }))).toMatchObject({ + strike: false, + }); + }); +}); + +describe("readFontTableEntry: sizePt on a non-numeric ", () => { + it("states no sizePt for a that does not parse as a number, rather than reporting NaN", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("name", { val: "Calibri" })]), + el("font", {}, [ + el("sz", { val: "not-a-number" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + expect(hasOwn(readCellStyles(pkg)[0]?.font ?? {}, "sizePt")).toBe(false); + }); +}); + +describe("contentFontOf: omits fontFamily/sizePt/color entirely (not merely as undefined) when they match the baseline", () => { + it("omits fontFamily when the entry's own name equals the baseline's, but still states bold", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("sz", { val: "11" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [ + el("b"), + el("sz", { val: "11" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + const font = readCellStyles(pkg)[0]?.font ?? {}; + expect(font).toMatchObject({ bold: true }); + expect(hasOwn(font, "fontFamily")).toBe(false); + expect(hasOwn(font, "sizePt")).toBe(false); + }); + + it("states a colour equal to the baseline's own resolved colour as absent, not restated", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("color", { rgb: "FFFF0000" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [ + el("b"), + el("color", { rgb: "FFFF0000" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + const font = readCellStyles(pkg)[0]?.font ?? {}; + expect(font).toEqual({ bold: true }); + expect(hasOwn(font, "color")).toBe(false); + }); +}); + +describe("colorFromElement/readColorRgb: hex length boundary and validation", () => { + it("returns undefined -- not a garbage colour -- for a 6-character rgb that is not valid hex", () => { + expect(colorFromElement(el("color", { rgb: "ZZZZZZ" }))).toBeUndefined(); + }); + + it("returns undefined for an rgb attribute shorter than 6 characters", () => { + expect(colorFromElement(el("color", { rgb: "FF00" }))).toBeUndefined(); + }); + + it("resolves an 8-digit AARRGGBB rgb by its last 6 (real) digits, dropping the alpha prefix", () => { + expect( + readColorRgb(el("x", {}, [el("color", { rgb: "80112233" })]), "color"), + ).toEqual({ r: 0x11 / 255, g: 0x22 / 255, b: 0x33 / 255 }); + }); + + it("returns undefined when the element carries no rgb attribute at all", () => { + expect( + readColorRgb(el("x", {}, [el("color", {})]), "color"), + ).toBeUndefined(); + }); + + // The regex's own "^"/"$" anchors are a genuinely irreducible equivalent mutation opportunity here, not merely an untested one: `hex` is constructed immediately above as either exactly 6 characters (raw.slice(-6), whenever raw.length >= 6) or fewer than 6 (raw itself, otherwise) -- never more. A {6}-quantified pattern can only ever match a 6-character string across its ENTIRE length regardless of anchors (there is no room for a partial match either before or after), and can never match a shorter one at all, so no input this function can ever construct `hex` from can tell an anchored and an unanchored match apart. The same reasoning makes the raw.length ">= 6" vs "> 6" boundary equivalent too: at raw.length exactly 6, slice(-6) returns the whole (unchanged) string, identical to what the ">" branch's bare `raw` would have returned directly. +}); + +describe("readFillBackground: fgColor/bgColor tag names and presence", () => { + it("falls back to bgColor for a solid fill whose fgColor is absent", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fills", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "solid" }, [ + el("bgColor", { rgb: "FF00FF00" }), + ]), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fillId: "0" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.background).toEqual({ + kind: "solid", + color: { r: 0, g: 1, b: 0 }, + }); + }); + + it("carries only foregroundColor (never a phantom backgroundColor) for a pattern fill with fgColor alone", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fills", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "darkGrid" }, [ + el("fgColor", { rgb: "FFFF0000" }), + ]), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fillId: "0" })]), + ]), + ); + const background = readCellStyles(pkg)[0]?.background ?? {}; + expect(hasOwn(background, "foregroundColor")).toBe(true); + expect(hasOwn(background, "backgroundColor")).toBe(false); + }); + + it("carries only backgroundColor (never a phantom foregroundColor) for a pattern fill with bgColor alone", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fills", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "darkGrid" }, [ + el("bgColor", { rgb: "FF0000FF" }), + ]), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fillId: "0" })]), + ]), + ); + const background = readCellStyles(pkg)[0]?.background ?? {}; + expect(hasOwn(background, "foregroundColor")).toBe(false); + expect(hasOwn(background, "backgroundColor")).toBe(true); + }); +}); + +describe('readBorderEdge: style="none" means no border, distinct from an absent style', () => { + it('reads undefined for an edge whose style is explicitly "none"', () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [ + el("left", { style: "none" }, [el("color", { rgb: "FF000000" })]), + el("right"), + el("top"), + el("bottom"), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.borders).toBeUndefined(); + }); +}); + +describe("readBorders: each edge's own presence is independent", () => { + it("returns undefined for a whose every edge resolves to no border at all", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [el("left"), el("right"), el("top"), el("bottom")]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.borders).toBeUndefined(); + }); + + it("carries exactly the right edge -- none of left/top/bottom -- for a border naming only right", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [ + el("left"), + el("right", { style: "thin" }, [el("color", { rgb: "FF000000" })]), + el("top"), + el("bottom"), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + const borders = readCellStyles(pkg)[0]?.borders ?? {}; + expect(hasOwn(borders, "left")).toBe(false); + expect(hasOwn(borders, "right")).toBe(true); + expect(hasOwn(borders, "top")).toBe(false); + expect(hasOwn(borders, "bottom")).toBe(false); + }); + + it("carries exactly the top edge for a border naming only top", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [ + el("left"), + el("right"), + el("top", { style: "thin" }, [el("color", { rgb: "FF000000" })]), + el("bottom"), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + const borders = readCellStyles(pkg)[0]?.borders ?? {}; + expect(hasOwn(borders, "top")).toBe(true); + expect(hasOwn(borders, "left")).toBe(false); + expect(hasOwn(borders, "right")).toBe(false); + expect(hasOwn(borders, "bottom")).toBe(false); + }); + + it("carries exactly the bottom edge for a border naming only bottom", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("borders", {}, [ + el("border", {}, [ + el("left"), + el("right"), + el("top"), + el("bottom", { style: "thin" }, [el("color", { rgb: "FF000000" })]), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", borderId: "0" })]), + ]), + ); + const borders = readCellStyles(pkg)[0]?.borders ?? {}; + expect(hasOwn(borders, "bottom")).toBe(true); + expect(hasOwn(borders, "left")).toBe(false); + expect(hasOwn(borders, "right")).toBe(false); + expect(hasOwn(borders, "top")).toBe(false); + }); +}); + +describe("readHorizontalAlignment: every recognised member, not just center/right", () => { + function alignedEntry(horizontal: string) { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("cellXfs", {}, [ + el("xf", { numFmtId: "0" }, [el("alignment", { horizontal })]), + ]), + ]), + ); + return readCellStyles(pkg)[0]; + } + + it('reads horizontal="left"', () => { + expect(alignedEntry("left")?.alignment).toBe("left"); + }); + + it('reads horizontal="justify"', () => { + expect(alignedEntry("justify")?.alignment).toBe("justify"); + }); +}); + +describe("readCellStyles: numFmtId/numberFormatCode/alignment key presence", () => { + it("leaves numberFormatCode absent for a non-integer numFmtId on the xf itself", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("cellXfs", {}, [el("xf", { numFmtId: "not-a-number" })]), + ]), + ); + expect(hasOwn(readCellStyles(pkg)[0] ?? {}, "numberFormatCode")).toBe( + false, + ); + }); + + it("leaves alignment absent (not undefined) when the xf's own states no recognised horizontal value, but still states verticalAlignment", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("cellXfs", {}, [ + el("xf", { numFmtId: "0" }, [ + el("alignment", { horizontal: "fill", vertical: "top" }), + ]), + ]), + ]), + ); + const entry = readCellStyles(pkg)[0] ?? {}; + expect(hasOwn(entry, "alignment")).toBe(false); + expect(entry.verticalAlignment).toBe("top"); + }); + + it("leaves verticalAlignment absent when the xf's own states no recognised vertical value, but still states alignment", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("cellXfs", {}, [ + el("xf", { numFmtId: "0" }, [ + el("alignment", { horizontal: "center", vertical: "bottom" }), + ]), + ]), + ]), + ); + const entry = readCellStyles(pkg)[0] ?? {}; + expect(hasOwn(entry, "verticalAlignment")).toBe(false); + expect(entry.alignment).toBe("center"); + }); +}); + +describe("CellFormatTable: font signature isolates every one of its own segments", () => { + // Interns two fonts differing in exactly ONE property and asserts they mint DISTINCT font entries -- if a signature segment were ever dropped (a template literal collapsed, a boolean-to-string comparison broken), the two would wrongly collide onto the same fontId instead. + function internedFontIds( + fontA: { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strike?: boolean; + color?: { r: number; g: number; b: number }; + }, + fontB: typeof fontA, + ): [number, number] { + const table = new CellFormatTable(); + const a = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: fontA }, + ); + const b = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: fontB }, + ); + return [a, b]; + } + + it("bold alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ bold: true }, { bold: false }); + expect(a).not.toBe(b); + }); + + it("italic alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ italic: true }, { italic: false }); + expect(a).not.toBe(b); + }); + + it("underline alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ underline: true }, { underline: false }); + expect(a).not.toBe(b); + }); + + it("strike alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ strike: true }, { strike: false }); + expect(a).not.toBe(b); + }); + + it("colour alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds( + { color: { r: 1, g: 0, b: 0 } }, + { color: { r: 0, g: 0, b: 1 } }, + ); + expect(a).not.toBe(b); + }); + + it("declares underline as undefined, not false, for a ContentFont whose own underline is explicitly false", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: { underline: false, bold: true } }, + ); + expect(table.fontDeclarations()[1]?.underline).toBeUndefined(); + }); + + it("caches a font interned twice under DIFFERENT number formats to the same fontId, minting only one entry", () => { + const table = new CellFormatTable(); + const first = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: { bold: true } }, + ); + const second = table.intern( + { kind: "builtin", id: 9 }, + { font: { bold: true } }, + ); + expect(table.cellFormatRecords()[first]?.fontId).toBe( + table.cellFormatRecords()[second]?.fontId, + ); + // Exactly one real font entry beyond the default: had the font-level cache write been skipped, this second, differently-outer-keyed intern() would have missed the cache and minted a duplicate. + expect(table.fontDeclarations()).toHaveLength(2); + }); +}); + +describe("CellFormatTable: fill signature isolates colour, and caches across different outer formats", () => { + it("two different solid colours mint two distinct fill entries, not one shared by signature collapse", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + table.intern( + { kind: "builtin", id: 9 }, + { background: { kind: "solid", color: { r: 0, g: 0, b: 1 } } }, + ); + expect(table.fillDeclarations()).toEqual([ + { kind: "none" }, + { kind: "gray125" }, + { kind: "solid", rgb: "ff0000" }, + { kind: "solid", rgb: "0000ff" }, + ]); + }); + + it("two pattern fills differing only in backgroundColor mint two distinct entries", () => { + const table = new CellFormatTable(); + const shared = { + kind: "pattern" as const, + patternType: "darkGrid" as const, + foregroundColor: { r: 1, g: 0, b: 0 }, + }; + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { ...shared, backgroundColor: { r: 0, g: 0, b: 1 } } }, + ); + table.intern( + { kind: "builtin", id: 9 }, + { background: { ...shared, backgroundColor: { r: 0, g: 1, b: 0 } } }, + ); + expect(table.fillDeclarations()).toHaveLength(4); + }); + + it("caches a fill interned twice under different number formats to the same fillId, minting only one real entry", () => { + const table = new CellFormatTable(); + const first = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + const second = table.intern( + { kind: "builtin", id: 9 }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + expect(table.cellFormatRecords()[first]?.fillId).toBe( + table.cellFormatRecords()[second]?.fillId, + ); + expect(table.fillDeclarations()).toHaveLength(3); + }); +}); + +describe("CellFormatTable: border signature and caching across different outer formats", () => { + it("caches a border interned twice under different number formats to the same borderId, minting only one real entry", () => { + const table = new CellFormatTable(); + const border = { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } }; + const first = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { borders: border }, + ); + const second = table.intern( + { kind: "builtin", id: 9 }, + { borders: border }, + ); + expect(table.cellFormatRecords()[first]?.borderId).toBe( + table.cellFormatRecords()[second]?.borderId, + ); + expect(table.borderDeclarations()).toHaveLength(2); + }); + + it("writes a dashed border at thin weight as plain dashed, not mediumDashed -- the medium check is not a no-op", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + borders: { + left: { + color: { r: 0, g: 0, b: 0 }, + widthPt: 0.75, + style: "dashed", + }, + }, + }, + ); + expect(table.borderDeclarations()[1]).toEqual({ + edges: { left: { style: "dashed", rgb: "000000" } }, + }); + }); +}); + +describe("CellFormatTable: intern's own alignment-presence OR, not AND", () => { + it("still creates a record.alignment when only horizontal is given, with no vertical at all", () => { + const table = new CellFormatTable(); + const index = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { alignment: "center" }, + ); + expect(table.cellFormatRecords()[index]?.alignment).toEqual({ + horizontal: "center", + vertical: undefined, + }); + }); + + it("still creates a record.alignment when only vertical is given, with no horizontal at all", () => { + const table = new CellFormatTable(); + const index = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { verticalAlignment: "middle" }, + ); + expect(table.cellFormatRecords()[index]?.alignment).toEqual({ + horizontal: undefined, + vertical: "middle", + }); + }); + + it("a decoration with only alignment set does not collide with one that also sets a fill", () => { + const table = new CellFormatTable(); + const alignedOnly = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { alignment: "left" }, + ); + const alignedAndFilled = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + alignment: "left", + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }, + ); + expect(alignedOnly).not.toBe(alignedAndFilled); + }); + + it("a decoration with alignment set does not collide with an otherwise-identical one with no alignment at all", () => { + const table = new CellFormatTable(); + const noAlignment = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + const withAlignment = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + alignment: "left", + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }, + ); + expect(noAlignment).not.toBe(withAlignment); + }); + + it("a decoration with verticalAlignment set does not collide with an otherwise-identical one with no verticalAlignment at all", () => { + const table = new CellFormatTable(); + const noVertical = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: { kind: "solid", color: { r: 1, g: 0, b: 0 } } }, + ); + const withVertical = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + verticalAlignment: "top", + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }, + ); + expect(noVertical).not.toBe(withVertical); + }); +}); diff --git a/packages/ooxml.js/src/typed/xlsx/styles.ts b/packages/ooxml.js/src/typed/xlsx/styles.ts index 9e6bbcf9d..703e53555 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.ts @@ -98,14 +98,15 @@ function readFontTableEntry(font: XmlElement): FontTableEntry { const name = childrenWithTag(font, "name")[0]; const sz = childrenWithTag(font, "sz")[0]; const szVal = sz === undefined ? undefined : attr(sz, "val"); - const szNum = szVal === undefined ? undefined : Number(szVal); + // No "szVal === undefined" guard: Number(undefined) is NaN, so an absent already falls through the Number.isFinite check below to the same "no sizePt" outcome this guard would have selected directly. + const szNum = Number(szVal); return { bold: readFontToggle(childrenWithTag(font, "b")[0]), italic: readFontToggle(childrenWithTag(font, "i")[0]), underline: readFontUnderline(childrenWithTag(font, "u")[0]), strike: readFontToggle(childrenWithTag(font, "strike")[0]), fontFamily: name === undefined ? undefined : attr(name, "val"), - sizePt: szNum !== undefined && Number.isFinite(szNum) ? szNum : undefined, + sizePt: Number.isFinite(szNum) ? szNum : undefined, color: readColorRgb(font, "color"), }; } @@ -237,8 +238,9 @@ export function colorFromElement( if (raw === undefined) { return undefined; } - // Excel writes "FFRRGGBB" (alpha + RGB); a 6-digit "RRGGBB" is also spec-legal. Take the LAST six hex digits in both cases, since the alpha channel has no ContentSheetCell.background representation and a leading "FF" is the only prefix real producers emit. + // Excel writes "FFRRGGBB" (alpha + RGB); a 6-digit "RRGGBB" is also spec-legal. Take the LAST six hex digits in both cases, since the alpha channel has no ContentSheetCell.background representation and a leading "FF" is the only prefix real producers emit. The boundary here (">=" rather than ">") is a genuinely irreducible equivalent mutation opportunity: at raw.length exactly 6, slice(-6) returns the whole, unchanged string -- identical to what the ">" branch's bare `raw` would have returned directly -- so the two operators can never be told apart by this result for any input. const hex = raw.length >= 6 ? raw.slice(-6) : raw; + // The regex's own "^"/"$" anchors are equally irreducible: `hex` is always either exactly 6 characters (the slice above) or fewer (raw itself, when shorter) -- never more. A {6}-quantified pattern can only ever match a 6-character string across its entire length regardless of anchors, and can never match a shorter one at all, so no possible `hex` value can tell an anchored and an unanchored match apart here. if (!/^[0-9a-fA-F]{6}$/.test(hex)) { return undefined; } From 94b56b5175129b3be51a653d09a07b5fbf31aca8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:31:54 +0100 Subject: [PATCH 085/102] test(ooxml.js): reach styles.ts's genuine mutation ceiling Adds a real regression test for the border style ?? "solid" fallback: an edge with no style stated and one explicitly styled "solid" now assert to the same borderId, proving the fallback actually merges two representations of the identical visible border rather than only avoiding a crash. Adds tests for fontFamily/sizePt/colour genuinely differing from or absent against the baseline, size/fontFamily alone distinguishing two interned fonts, an empty borders object not colliding with a real one, and internFill's own default branch throwing for a fill kind this discriminated union has no member for. Removes readBorderEdge's own redundant "none" special case: "none" is not a key XLSX_BORDER_STYLE declares, so it already falls through the resolved-undefined check below to the identical result this check would have returned directly. Documents the remaining genuinely irreducible equivalent mutants in the read-side and write-side non-integer-numFmtId guards (each redundant with a sibling guard on the only real call path) and in every internal-only, never-exposed signature-building segment (font/fill/border/alignment dedup keys), where no consistent relabelling or placeholder substitution can ever create a real collision given the actual domain of values each field carries. --- .../ooxml.js/src/typed/xlsx/styles.test.ts | 120 ++++++++++++++++++ packages/ooxml.js/src/typed/xlsx/styles.ts | 10 +- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index a3c68e10f..c01e93e26 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; +import type { ContentCellFill } from "document-schema.js"; import type { Package } from "../../model/package"; import { el } from "../../xml/fragment"; import { parsePackage } from "../../package-io/read"; @@ -778,6 +779,62 @@ describe("contentFontOf: omits fontFamily/sizePt/color entirely (not merely as u expect(font).toEqual({ bold: true }); expect(hasOwn(font, "color")).toBe(false); }); + + it("omits fontFamily entirely when the entry states no at all, even though the baseline has one", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("name", { val: "Calibri" })]), + el("font", {}, [el("b")]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + const font = readCellStyles(pkg)[0]?.font ?? {}; + expect(font).toEqual({ bold: true }); + expect(hasOwn(font, "fontFamily")).toBe(false); + }); + + it("omits sizePt entirely when the entry states no at all, even though the baseline has one", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("sz", { val: "11" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [el("b"), el("name", { val: "Calibri" })]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + const font = readCellStyles(pkg)[0]?.font ?? {}; + expect(font).toEqual({ bold: true }); + expect(hasOwn(font, "sizePt")).toBe(false); + }); + + it("states an entry's colour when it genuinely differs from the baseline's own resolved colour", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("color", { rgb: "FFFF0000" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [ + el("color", { rgb: "FF0000FF" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.font?.color).toEqual({ + r: 0, + g: 0, + b: 1, + }); + }); }); describe("colorFromElement/readColorRgb: hex length boundary and validation", () => { @@ -1030,6 +1087,8 @@ describe("CellFormatTable: font signature isolates every one of its own segments underline?: boolean; strike?: boolean; color?: { r: number; g: number; b: number }; + sizePt?: number; + fontFamily?: string; }, fontB: typeof fontA, ): [number, number] { @@ -1073,6 +1132,19 @@ describe("CellFormatTable: font signature isolates every one of its own segments expect(a).not.toBe(b); }); + it("size alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds({ sizePt: 11 }, { sizePt: 14 }); + expect(a).not.toBe(b); + }); + + it("fontFamily alone distinguishes two otherwise-identical fonts", () => { + const [a, b] = internedFontIds( + { fontFamily: "Arial" }, + { fontFamily: "Courier New" }, + ); + expect(a).not.toBe(b); + }); + it("declares underline as undefined, not false, for a ContentFont whose own underline is explicitly false", () => { const table = new CellFormatTable(); table.intern( @@ -1190,6 +1262,54 @@ describe("CellFormatTable: border signature and caching across different outer f edges: { left: { style: "dashed", rgb: "000000" } }, }); }); + + it("dedupes a border edge with no style stated against one explicitly styled 'solid' -- both are the same visible border", () => { + const table = new CellFormatTable(); + const implicit = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, + ); + const explicit = table.intern( + { kind: "builtin", id: 9 }, + { + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75, style: "solid" }, + }, + }, + ); + expect(table.cellFormatRecords()[implicit]?.borderId).toBe( + table.cellFormatRecords()[explicit]?.borderId, + ); + expect(table.borderDeclarations()).toHaveLength(2); + }); + + it("a real edge segment distinguishes a border from an entirely empty one, not just an empty-vs-empty collision", () => { + const table = new CellFormatTable(); + const empty = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { borders: {} }, + ); + const real = table.intern( + { kind: "builtin", id: 9 }, + { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, + ); + expect(table.cellFormatRecords()[empty]?.borderId).not.toBe( + table.cellFormatRecords()[real]?.borderId, + ); + }); +}); + +describe("CellFormatTable: internFill's own default branch for a wholly unrecognised fill kind", () => { + it("throws naming the unrecognised kind, for a fill this discriminated union genuinely has no member for", () => { + const table = new CellFormatTable(); + const bogus = { kind: "gradient" } as unknown as ContentCellFill; + expect(() => + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { background: bogus }, + ), + ).toThrow(/gradient/); + }); }); describe("CellFormatTable: intern's own alignment-presence OR, not AND", () => { diff --git a/packages/ooxml.js/src/typed/xlsx/styles.ts b/packages/ooxml.js/src/typed/xlsx/styles.ts index 703e53555..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)); @@ -305,7 +306,8 @@ function readBorderEdge( return undefined; } const styleToken = attr(edgeEl, "style"); - if (styleToken === undefined || styleToken === "none") { + // No "styleToken === 'none'" disjunct: "none" is not a key XLSX_BORDER_STYLE declares, so it already falls through the resolved-undefined check below to the identical undefined result this disjunct would have short-circuited to. The `undefined` check alone stays load-bearing, since XLSX_BORDER_STYLE[undefined as never] would be a type error this reader never actually triggers, not a graceful undefined. + if (styleToken === undefined) { return undefined; } const resolved = XLSX_BORDER_STYLE[styleToken]; @@ -426,6 +428,7 @@ export function readCellStyles(pkg: Package): readonly CellStyleEntry[] { ? GENERAL_NUM_FMT_ID : Number.parseInt(numFmtRaw, 10); const entry: CellStyleEntry = {}; + // Genuinely irreducible, not merely untested: readNumberFormatCodesById above applies this identical guard before ever writing a Map entry, so `codes` can never actually hold a NaN key -- codes.get(NaN) already returns undefined on its own (a Map lookup miss, not a throw), the same outcome this guard would have skipped to directly for a non-integer numFmtId. if (Number.isInteger(numFmtId)) { const code = codes.get(numFmtId); if (code !== undefined) { @@ -559,6 +562,7 @@ function normalisedFontOf(font: ContentFont | undefined): DeclaredFont { }; } +// Every "=== true" comparison and the "?? ''" colour fallback below are genuinely irreducible equivalent mutation opportunities, not merely untested ones: this signature is consumed ONLY as an internal Map key (fontIndexBySignature), never exposed, so what matters is solely whether two DIFFERENT DeclaredFont values ever produce equal strings (a wrong collision) or two IDENTICAL values ever produce different ones (a wrong split) -- never which literal characters a given input maps to. Flipping "=== true" to "!== true" for one boolean field relabels that field's two segment values (swapping which string means "on" and which means "off") but stays a bijection over {true, non-true}, so it still correctly distinguishes every bold=true font from every bold=false one and still collides every bold=true font with every other bold=true font -- the equivalence classes this signature partitions inputs into are unchanged. The colour fallback is the same shape: no valid 6-hex-digit colorRgb string can ever equal the empty string (or any other fixed placeholder a mutant substitutes), so the "no colour" case can never collide with a real one regardless of which placeholder marks it. function signatureOfFont(font: ContentFont | undefined): string { const declared = normalisedFontOf(font); let sig = `b:${declared.bold === true}`; @@ -571,7 +575,7 @@ function signatureOfFont(font: ContentFont | undefined): string { return sig; } -// A deterministic signature for one ContentCellFill, shared by signatureOfDecoration (the cellXfs interning key) and CellFormatTable.internFill (the table's own dedup key) so the two can never disagree about which fills count as identical. +// A deterministic signature for one ContentCellFill, shared by signatureOfDecoration (the cellXfs interning key) and CellFormatTable.internFill (the table's own dedup key) so the two can never disagree about which fills count as identical. Each "? '' :" fallback below is a genuinely irreducible equivalent mutation opportunity for the identical reason signatureOfFont's own colour fallback is: no valid colorToRgbHex output can ever equal a mutant's substituted placeholder, so an absent foreground/background colour can never collide with a real one regardless of which fixed string marks its absence. function fillSignature(fill: ContentCellFill): string { return fill.kind === "solid" ? `solid:${colorToRgbHex(fill.color)}` @@ -594,6 +598,7 @@ function signatureOfDecoration(decoration: CellFormatDecoration): string { } } } + // Both presence guards below are genuinely irreducible equivalent mutation opportunities, not merely untested ones: Alignment and its vertical counterpart are closed string-literal unions (left/center/right/justify, top/middle/bottom) that can never hold the literal string "undefined" a forced-true mutant would interpolate here for an actually-absent value -- so an alignment-less decoration can never collide with one genuinely stating a real alignment value, regardless of whether this guard runs. if (decoration.alignment !== undefined) { sig += `|h:${decoration.alignment}`; } @@ -817,6 +822,7 @@ export class CellFormatTable { private internBorder(borders: ContentCellBorders): number { const edges: DeclaredBorder["edges"] = {}; + // The initial value here is a genuinely irreducible equivalent mutation opportunity, not merely an untested one: borderIndexBySignature starts genuinely empty (no pre-seeded entry, unlike fontIndexBySignature's own DEFAULT_FONT seed), so this string is never compared against a fixed external constant -- only ever against itself, built the identical way, on a later call. Any fixed starting string works identically as a dedup key, as long as it is used consistently, which it is. let signature = ""; for (const edge of ["left", "right", "top", "bottom"] as const) { const border = borders[edge]; From c43c1d5a9b980eb6ea8527378e25019e7a9641f3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:37:15 +0100 Subject: [PATCH 086/102] test(ooxml.js): fix two border-signature tests that could not actually observe their own mutant Both replacement tests compared an empty-borders {} decoration against a real one, but an empty {} decoration's own outer signature already coincides with EMPTY_DECORATION's (the loop over its zero edges appends nothing), so it hits the seeded cellFormat-level cache before internFormat/internBorder is ever called at all -- neither test could ever have observed a change to internBorder's own per-edge signature building or to the outer alignment-presence check, regardless of mutation. Verified directly, per this project's own equivalence-claim convention: applying each mutation by hand and running the affected test confirmed it passed unchanged either way, before rewriting it. The style ?? "solid" fallback now compares two SAME-number-format interns (an implicit-style edge against an explicit "solid" one), which genuinely forces reuse of the outer cellFormat cache and so exercises signatureOfDecoration's own fallback, not internBorder's separately-correct borderToXlsxStyle handling of the same case. The edge-segment test now compares two distinct REAL borders (both of which genuinely reach internBorder) rather than an empty one against a real one, so a collapsed per-edge segment is observable as a wrongly-shared borderId. --- .../ooxml.js/src/typed/xlsx/styles.test.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index c01e93e26..5a43917ec 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.test.ts @@ -1263,39 +1263,40 @@ describe("CellFormatTable: border signature and caching across different outer f }); }); - it("dedupes a border edge with no style stated against one explicitly styled 'solid' -- both are the same visible border", () => { + it("dedupes a whole cellXfs entry across an implicit-vs-explicit-'solid' border, at the outer decoration-signature level", () => { + // Deliberately the SAME number format on both calls, so the outer cellFormat-level cache (signatureOfDecoration, not internBorder's own separate borderIndexBySignature) is what is actually exercised here: a second intern() with a different numFmtId would call internBorder again regardless of the outer signature, proving nothing about this specific "?? 'solid'" fallback. const table = new CellFormatTable(); const implicit = table.intern( { kind: "builtin", id: GENERAL_NUM_FMT_ID }, { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, ); const explicit = table.intern( - { kind: "builtin", id: 9 }, + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75, style: "solid" }, }, }, ); - expect(table.cellFormatRecords()[implicit]?.borderId).toBe( - table.cellFormatRecords()[explicit]?.borderId, - ); - expect(table.borderDeclarations()).toHaveLength(2); + expect(explicit).toBe(implicit); + expect(table.cellFormatRecords()).toHaveLength(2); }); - it("a real edge segment distinguishes a border from an entirely empty one, not just an empty-vs-empty collision", () => { + it("two genuinely different real borders mint two distinct entries, not one shared by an edge-segment collapse", () => { + // Deliberately two REAL, non-empty borders (not an empty-vs-real pair): an empty `{}` decoration hits the outer cellFormat-level default seed before internBorder is ever called at all (its own signature already coincides with EMPTY_DECORATION's), so it can never exercise internBorder's own per-edge signature segment either way. Two distinct real borders, by contrast, both genuinely reach internBorder, so only a real per-edge signature can tell them apart. const table = new CellFormatTable(); - const empty = table.intern( + const thin = table.intern( { kind: "builtin", id: GENERAL_NUM_FMT_ID }, - { borders: {} }, + { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, ); - const real = table.intern( + const thick = table.intern( { kind: "builtin", id: 9 }, - { borders: { left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75 } } }, + { borders: { left: { color: { r: 1, g: 0, b: 0 }, widthPt: 1.5 } } }, ); - expect(table.cellFormatRecords()[empty]?.borderId).not.toBe( - table.cellFormatRecords()[real]?.borderId, + expect(table.cellFormatRecords()[thin]?.borderId).not.toBe( + table.cellFormatRecords()[thick]?.borderId, ); + expect(table.borderDeclarations()).toHaveLength(3); }); }); From 10f853d24d39f44b8cf2d0a8f4f8429544687728 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:40:48 +0100 Subject: [PATCH 087/102] test(ooxml.js): cover borderToXlsxStyle's double/dotted tokens Both cases were entirely unreached by any existing test -- every border test so far exercised only the dashed/solid weight-bucketing branches, leaving the two fixed-token cases genuinely uncovered rather than merely untested for a specific input. --- .../ooxml.js/src/typed/xlsx/styles.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index 5a43917ec..979e730f8 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.test.ts @@ -1244,6 +1244,36 @@ describe("CellFormatTable: border signature and caching across different outer f expect(table.borderDeclarations()).toHaveLength(2); }); + it("writes a double-style border as the double token verbatim, ignoring widthPt entirely", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75, style: "double" }, + }, + }, + ); + expect(table.borderDeclarations()[1]).toEqual({ + edges: { left: { style: "double", rgb: "000000" } }, + }); + }); + + it("writes a dotted-style border as the dotted token verbatim, ignoring widthPt entirely", () => { + const table = new CellFormatTable(); + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.75, style: "dotted" }, + }, + }, + ); + expect(table.borderDeclarations()[1]).toEqual({ + edges: { left: { style: "dotted", rgb: "000000" } }, + }); + }); + it("writes a dashed border at thin weight as plain dashed, not mediumDashed -- the medium check is not a no-op", () => { const table = new CellFormatTable(); table.intern( From f8df73ceddb56724bb7a8b86d26c0b1d63d6f891 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:45:33 +0100 Subject: [PATCH 088/102] test(ooxml.js): add drawings-write.ts's own direct unit suite This module had no dedicated test file at all: its only coverage came from content.test.ts/build.test.ts round trips through readXlsxContent, whose reader never inspects an OOXML element's exact tag or attribute spelling, only its structural shape -- so a round trip could never tell a real element name from a mutated one apart. Asserts the full xdr:oneCellAnchor/xdr:pic/xdr:graphicFrame shape for both a picture and a chart anchor, the drawing/chart namespace declarations, the relationship part's own Id/Type/Target triples, the chart XML declaration and c:chartSpace root, every c:ser/ c:barChart/c:catAx/c:valAx element and its fixed axis ids, the series/category range arithmetic against a non-trivial category count (so the +1 in each range's own upper bound, and the +1 from column index to letters, are both observable), object-id and relationship-id counters advancing across multiple images, media/ chart numbering advancing across two calls sharing one counters instance (the shared-across-sheets contract DrawingCounters' own doc comment states), and every one of this module's five thrown error paths (svg image, non-chart object, a missing anchor field, a non-spreadsheet document, a spreadsheet document with no sheet). --- .../src/typed/xlsx/drawings-write.test.ts | 660 ++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts diff --git a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts new file mode 100644 index 000000000..985833254 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts @@ -0,0 +1,660 @@ +import { describe, expect, it } from "vitest"; +import type { + ContentDocument, + ContentEmbeddedObject, + ContentSheet, + ContentSheetImage, +} from "document-schema.js"; +import { el, txt } from "../../xml/fragment"; +import { ptToEmu } from "../shared/units"; +import { + CT_CHART, + CT_DRAWING, + buildSheetDrawing, + newDrawingCounters, +} from "./drawings-write"; + +// This module has no round-trip read side of its own to lean on for coverage (unlike most of this package's write-side modules): typed/xlsx/drawings.ts's own reader never inspects an OOXML element's exact tag/attribute spelling, only its structural shape, so a content.test.ts round trip through readXlsxContent(buildXlsxPackageFromContent(x)) cannot tell "xdr:pic" from "xdr:foo" apart. Every constant here -- namespace URIs, element/attribute names, the fixed axis IDs -- is therefore asserted directly against buildSheetDrawing's own output, which is the only way any of them are ever actually exercised. + +const PRINT_SETTINGS: ContentSheet["printSettings"] = { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + gridlines: false, + headers: false, + pageOrder: "downThenOver", +}; + +function chartDocument( + sheetName: string, + seriesName: string, + categoryLabel: string, + value: string, +): ContentDocument { + return { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: sheetName, + cells: [ + { + row: 0, + column: 1, + value: { kind: "string", value: seriesName }, + displayText: seriesName, + }, + { + row: 1, + column: 0, + value: { kind: "string", value: categoryLabel }, + displayText: categoryLabel, + }, + { + row: 1, + column: 1, + value: { kind: "string", value }, + displayText: value, + }, + ], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + }, + ], + }; +} + +function pngImage( + overrides: Partial = {}, +): ContentSheetImage { + return { + kind: "image", + format: "png", + base64: "aGVsbG8=", + widthPt: 100, + heightPt: 50, + anchorRow: 2, + anchorColumn: 3, + offsetXPt: 5, + offsetYPt: 10, + ...overrides, + }; +} + +function chartObject( + overrides: Partial = {}, +): ContentEmbeddedObject { + return { + objectKind: "chart", + document: chartDocument("Data", "Sales", "Q1", "100"), + frame: { xPt: 0, yPt: 0, widthPt: 200, heightPt: 150 }, + anchorRow: 5, + anchorColumn: 1, + offsetXPt: 0, + offsetYPt: 0, + ...overrides, + }; +} + +function sheet(overrides: Partial = {}): ContentSheet { + return { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + ...overrides, + }; +} + +describe("buildSheetDrawing: undefined for a sheet with neither images nor embedded objects", () => { + it("returns undefined, minting no drawing part at all", () => { + expect(buildSheetDrawing(sheet(), newDrawingCounters())).toBeUndefined(); + }); +}); + +describe("buildSheetDrawing: one image and one chart, every element and attribute exactly", () => { + const result = buildSheetDrawing( + sheet({ images: [pngImage()], embeddedObjects: [chartObject()] }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + + it("builds the picture anchor with its own xdr:from/xdr:ext/xdr:pic/xdr:clientData shape", () => { + const picAnchor = el("xdr:oneCellAnchor", {}, [ + el("xdr:from", {}, [ + el("xdr:col", {}, [txt("3")]), + el("xdr:colOff", {}, [txt(String(ptToEmu(5)))]), + el("xdr:row", {}, [txt("2")]), + el("xdr:rowOff", {}, [txt(String(ptToEmu(10)))]), + ]), + el("xdr:ext", { cx: String(ptToEmu(100)), cy: String(ptToEmu(50)) }), + el("xdr:pic", {}, [ + el("xdr:nvPicPr", {}, [ + el("xdr:cNvPr", { id: "2", name: "Picture 2" }), + el("xdr:cNvPicPr", {}, [el("a:picLocks", { noChangeAspect: "1" })]), + ]), + el("xdr:blipFill", {}, [ + el("a:blip", { "r:embed": "rId1" }), + el("a:stretch", {}, [el("a:fillRect")]), + ]), + el("xdr:spPr", {}, [ + el("a:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { + cx: String(ptToEmu(100)), + cy: String(ptToEmu(50)), + }), + ]), + el("a:prstGeom", { prst: "rect" }, [el("a:avLst")]), + ]), + ]), + el("xdr:clientData"), + ]); + expect(result.drawingRoot.children[0]).toEqual(picAnchor); + }); + + it("builds the chart anchor with its own xdr:from/xdr:ext/xdr:graphicFrame shape", () => { + const chartAnchor = el("xdr:oneCellAnchor", {}, [ + el("xdr:from", {}, [ + el("xdr:col", {}, [txt("1")]), + el("xdr:colOff", {}, [txt(String(ptToEmu(0)))]), + el("xdr:row", {}, [txt("5")]), + el("xdr:rowOff", {}, [txt(String(ptToEmu(0)))]), + ]), + el("xdr:ext", { cx: String(ptToEmu(200)), cy: String(ptToEmu(150)) }), + el("xdr:graphicFrame", {}, [ + el("xdr:nvGraphicFramePr", {}, [ + el("xdr:cNvPr", { id: "3", name: "Chart 3" }), + el("xdr:cNvGraphicFramePr"), + ]), + el("xdr:xfrm", {}, [ + el("a:off", { x: "0", y: "0" }), + el("a:ext", { + cx: String(ptToEmu(200)), + cy: String(ptToEmu(150)), + }), + ]), + el("a:graphic", {}, [ + el( + "a:graphicData", + { uri: "http://schemas.openxmlformats.org/drawingml/2006/chart" }, + [el("c:chart", { "r:id": "rId2" })], + ), + ]), + ]), + el("xdr:clientData"), + ]); + expect(result.drawingRoot.children[1]).toEqual(chartAnchor); + }); + + it("wraps both anchors in xdr:wsDr with the three drawingml namespace declarations", () => { + expect(result.drawingRoot.tag).toBe("xdr:wsDr"); + expect(result.drawingRoot.attributes).toEqual([ + { + name: "xmlns:xdr", + value: + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + }, + { + name: "xmlns:a", + value: "http://schemas.openxmlformats.org/drawingml/2006/main", + }, + { + name: "xmlns:r", + value: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + }, + ]); + }); + + it("declares one image and one chart relationship, in order, each with its own real target path", () => { + expect(result.drawingRelsRoot).toEqual( + el( + "Relationships", + { + xmlns: "http://schemas.openxmlformats.org/package/2006/relationships", + }, + [ + el("Relationship", { + Id: "rId1", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + Target: "../media/image1.png", + }), + el("Relationship", { + Id: "rId2", + Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart", + Target: "../charts/chart1.xml", + }), + ], + ), + ); + }); + + it("writes the image bytes verbatim under xl/media/image1.png, and reports png as a used format", () => { + expect(result.extraParts["xl/media/image1.png"]).toEqual({ + kind: "binary", + base64: "aGVsbG8=", + }); + expect(result.usedImageFormats).toEqual(new Set(["png"])); + }); + + it("names the chart part xl/charts/chart1.xml and lists it in chartPartNames", () => { + expect(result.chartPartNames).toEqual(["xl/charts/chart1.xml"]); + expect(result.extraParts["xl/charts/chart1.xml"]).toBeDefined(); + }); + + it("builds the chart XML declaration and c:chartSpace root with its own three namespace declarations", () => { + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + expect(chartPart.nodes[0]).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + { name: "standalone", value: "yes" }, + ], + }); + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + expect(root.tag).toBe("c:chartSpace"); + expect(root.attributes).toEqual([ + { + name: "xmlns:c", + value: "http://schemas.openxmlformats.org/drawingml/2006/chart", + }, + { + name: "xmlns:a", + value: "http://schemas.openxmlformats.org/drawingml/2006/main", + }, + { + name: "xmlns:r", + value: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + }, + ]); + }); + + it("builds one c:ser per column, with its own idx/order, tx, and a real sheet-qualified cache range for both cat and val", () => { + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + const chart = root.children.find( + (n) => n.type === "element" && n.tag === "c:chart", + ); + if (chart?.type !== "element") { + throw new Error("expected c:chart"); + } + const plotArea = chart.children.find( + (n) => n.type === "element" && n.tag === "c:plotArea", + ); + if (plotArea?.type !== "element") { + throw new Error("expected c:plotArea"); + } + const barChart = plotArea.children.find( + (n) => n.type === "element" && n.tag === "c:barChart", + ); + if (barChart?.type !== "element") { + throw new Error("expected c:barChart"); + } + const ser = barChart.children.find( + (n) => n.type === "element" && n.tag === "c:ser", + ); + expect(ser).toEqual( + el("c:ser", {}, [ + el("c:idx", { val: "0" }), + el("c:order", { val: "0" }), + el("c:tx", {}, [el("c:v", {}, [txt("Sales")])]), + el("c:cat", {}, [ + el("c:strRef", {}, [ + el("c:f", {}, [txt("Data!$A$2:$A$2")]), + el("c:strCache", {}, [ + el("c:ptCount", { val: "1" }), + el("c:pt", { idx: "0" }, [el("c:v", {}, [txt("Q1")])]), + ]), + ]), + ]), + el("c:val", {}, [ + el("c:numRef", {}, [ + el("c:f", {}, [txt("Data!$B$2:$B$2")]), + el("c:numCache", {}, [ + el("c:ptCount", { val: "1" }), + el("c:pt", { idx: "0" }, [el("c:v", {}, [txt("100")])]), + ]), + ]), + ]), + ]), + ); + }); + + it("builds c:barChart/c:catAx/c:valAx with fixed axis ids, bar direction, and grouping", () => { + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + const chart = root.children.find( + (n) => n.type === "element" && n.tag === "c:chart", + ); + if (chart?.type !== "element") { + throw new Error("expected c:chart"); + } + const plotArea = chart.children.find( + (n) => n.type === "element" && n.tag === "c:plotArea", + ); + if (plotArea?.type !== "element") { + throw new Error("expected c:plotArea"); + } + expect(plotArea.children[0]).toEqual(el("c:layout")); + const barChart = plotArea.children.find( + (n) => n.type === "element" && n.tag === "c:barChart", + ); + if (barChart?.type !== "element") { + throw new Error("expected c:barChart"); + } + expect(barChart.children[0]).toEqual(el("c:barDir", { val: "col" })); + expect(barChart.children[1]).toEqual( + el("c:grouping", { val: "clustered" }), + ); + expect(barChart.children[barChart.children.length - 2]).toEqual( + el("c:axId", { val: "111111111" }), + ); + expect(barChart.children[barChart.children.length - 1]).toEqual( + el("c:axId", { val: "222222222" }), + ); + expect( + plotArea.children.find( + (n) => n.type === "element" && n.tag === "c:catAx", + ), + ).toEqual( + el("c:catAx", {}, [ + el("c:axId", { val: "111111111" }), + el("c:scaling", {}, [el("c:orientation", { val: "minMax" })]), + el("c:delete", { val: "0" }), + el("c:axPos", { val: "b" }), + el("c:crossAx", { val: "222222222" }), + ]), + ); + expect( + plotArea.children.find( + (n) => n.type === "element" && n.tag === "c:valAx", + ), + ).toEqual( + el("c:valAx", {}, [ + el("c:axId", { val: "222222222" }), + el("c:scaling", {}, [el("c:orientation", { val: "minMax" })]), + el("c:delete", { val: "0" }), + el("c:axPos", { val: "l" }), + el("c:crossAx", { val: "111111111" }), + ]), + ); + }); +}); + +describe("buildSheetDrawing: series/category range arithmetic against a second, non-trivial category count", () => { + it("closes the cache range at categories.length + 1, and derives each column's own letters from index + 1", () => { + const document: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Data", + cells: [ + { + row: 0, + column: 1, + value: { kind: "string", value: "A" }, + displayText: "A", + }, + { + row: 0, + column: 2, + value: { kind: "string", value: "B" }, + displayText: "B", + }, + { + row: 1, + column: 0, + value: { kind: "string", value: "Cat1" }, + displayText: "Cat1", + }, + { + row: 2, + column: 0, + value: { kind: "string", value: "Cat2" }, + displayText: "Cat2", + }, + { + row: 1, + column: 1, + value: { kind: "string", value: "1" }, + displayText: "1", + }, + { + row: 2, + column: 1, + value: { kind: "string", value: "2" }, + displayText: "2", + }, + { + row: 1, + column: 2, + value: { kind: "string", value: "3" }, + displayText: "3", + }, + { + row: 2, + column: 2, + value: { kind: "string", value: "4" }, + displayText: "4", + }, + ], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + }, + ], + }; + const result = buildSheetDrawing( + sheet({ + embeddedObjects: [chartObject({ document })], + }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + const findAll = (tag: string, node: typeof root): (typeof root)[] => { + const out: (typeof root)[] = []; + const walk = (n: typeof root) => { + if (n.tag === tag) { + out.push(n); + } + for (const child of n.children) { + if (child.type === "element") { + walk(child); + } + } + }; + walk(node); + return out; + }; + const fRanges = findAll("c:f", root).map((n) => { + const first = n.children[0]; + return first?.type === "text" ? first.value : undefined; + }); + // Two categories -> the cache range closes at row 3 (2 + 1), not row 1 (2 - 1); the second column's own letters are "C" (index 1 + 1), not "A" (index 1 - 1). + expect(fRanges).toEqual([ + "Data!$A$2:$A$3", + "Data!$B$2:$B$3", + "Data!$A$2:$A$3", + "Data!$C$2:$C$3", + ]); + }); +}); + +describe("buildSheetDrawing: object-id and relationship-id counters advance forward, not backward", () => { + it("assigns rId1/rId2 and cNvPr id 2/3 to two images in document order", () => { + const result = buildSheetDrawing( + sheet({ + images: [pngImage({ anchorColumn: 0 }), pngImage({ anchorColumn: 1 })], + }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + const ids = result.drawingRelsRoot.children.map((n) => + n.type === "element" + ? n.attributes.find((a) => a.name === "Id")?.value + : undefined, + ); + expect(ids).toEqual(["rId1", "rId2"]); + const secondAnchor = result.drawingRoot.children[1]; + if (secondAnchor?.type !== "element") { + throw new Error("expected an element"); + } + const pic = secondAnchor.children.find( + (n) => n.type === "element" && n.tag === "xdr:pic", + ); + if (pic?.type !== "element") { + throw new Error("expected xdr:pic"); + } + const nvPicPr = pic.children.find( + (n) => n.type === "element" && n.tag === "xdr:nvPicPr", + ); + if (nvPicPr?.type !== "element") { + throw new Error("expected xdr:nvPicPr"); + } + const cNvPr = nvPicPr.children[0]; + expect(cNvPr?.type === "element" && cNvPr.attributes).toContainEqual({ + name: "id", + value: "3", + }); + }); + + it("keeps media/chart numbering advancing across TWO separate buildSheetDrawing calls sharing one counters instance", () => { + const counters = newDrawingCounters(); + const first = buildSheetDrawing( + sheet({ name: "Sheet1", images: [pngImage()] }), + counters, + ); + const second = buildSheetDrawing( + sheet({ name: "Sheet2", images: [pngImage()] }), + counters, + ); + expect(first?.extraParts["xl/media/image1.png"]).toBeDefined(); + expect(second?.extraParts["xl/media/image2.png"]).toBeDefined(); + + const chartCounters = newDrawingCounters(); + const firstChart = buildSheetDrawing( + sheet({ name: "Sheet1", embeddedObjects: [chartObject()] }), + chartCounters, + ); + const secondChart = buildSheetDrawing( + sheet({ name: "Sheet2", embeddedObjects: [chartObject()] }), + chartCounters, + ); + expect(firstChart?.chartPartNames).toEqual(["xl/charts/chart1.xml"]); + expect(secondChart?.chartPartNames).toEqual(["xl/charts/chart2.xml"]); + }); +}); + +describe("buildSheetDrawing: error paths", () => { + it("throws for an svg image, naming the reason no raster blip exists", () => { + expect(() => + buildSheetDrawing( + sheet({ images: [pngImage({ format: "svg" })] }), + newDrawingCounters(), + ), + ).toThrow(/svg/); + }); + + it("throws for a non-chart embedded object, naming its actual objectKind", () => { + expect(() => + buildSheetDrawing( + sheet({ + embeddedObjects: [chartObject({ objectKind: "oleObject" as never })], + }), + newDrawingCounters(), + ), + ).toThrow(/oleObject/); + }); + + it("throws for a chart embedded object missing any one of its four anchor fields", () => { + expect(() => + buildSheetDrawing( + sheet({ + embeddedObjects: [chartObject({ anchorRow: undefined })], + }), + newDrawingCounters(), + ), + ).toThrow(/anchorRow/); + }); + + it("throws for a chart embedded object whose document is not a spreadsheet ContentDocument", () => { + expect(() => + buildSheetDrawing( + sheet({ + embeddedObjects: [ + chartObject({ + document: { kind: "wordprocessing", metadata: {}, sections: [] }, + }), + ], + }), + newDrawingCounters(), + ), + ).toThrow(/wordprocessing/); + }); + + it("throws for a chart embedded object whose spreadsheet document carries no sheet at all", () => { + expect(() => + buildSheetDrawing( + sheet({ + embeddedObjects: [ + chartObject({ + document: { kind: "spreadsheet", metadata: {}, sheets: [] }, + }), + ], + }), + newDrawingCounters(), + ), + ).toThrow(/exactly one sheet/); + }); +}); + +describe("CT_DRAWING/CT_CHART content-type constants", () => { + it("names the real ECMA-376 drawing and chart content types build.ts registers", () => { + expect(CT_DRAWING).toBe( + "application/vnd.openxmlformats-officedocument.drawing+xml", + ); + expect(CT_CHART).toBe( + "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", + ); + }); +}); From 7092fd59120706c0a9ff65ea50a4ecd5f412a936 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:49:24 +0100 Subject: [PATCH 089/102] test(ooxml.js): fix drawings-write test coverage attribution Moved the shared picture+chart buildSheetDrawing() call from the describe body into beforeEach. Stryker's per-test coverage instrumentation attributes a line's execution to whichever test is running when that line executes, and a describe body runs during test collection, before any it() has started -- a call made there is invisible to that attribution, so Stryker silently ran some other, less precise test against these mutants instead of this file's own assertions. Verified directly: an L62 StringLiteral mutant on "xdr:col" showed Survived in a real scoped run despite the exact assertion in this file catching it when the same mutation was applied by hand and run locally, until the call moved into beforeEach. --- .../src/typed/xlsx/drawings-write.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts index 985833254..10a746fdb 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { ContentDocument, ContentEmbeddedObject, @@ -116,13 +116,18 @@ describe("buildSheetDrawing: undefined for a sheet with neither images nor embed }); describe("buildSheetDrawing: one image and one chart, every element and attribute exactly", () => { - const result = buildSheetDrawing( - sheet({ images: [pngImage()], embeddedObjects: [chartObject()] }), - newDrawingCounters(), - ); - if (result === undefined) { - throw new Error("expected a SheetDrawingWrite"); - } + // Computed fresh inside beforeEach, not once at describe-body level: Stryker's own per-test coverage instrumentation attributes a line's execution to whichever test is "currently running" at the moment it executes, and a describe body runs during test COLLECTION, before any it() has started -- a call made there is invisible to that attribution, so Stryker silently falls back to running some OTHER, less precise test against a mutant on this line instead of this file's own (confirmed directly: an L62 mutant survived under a real scoped run despite this exact assertion catching it when applied by hand, until this call moved into beforeEach). + let result: NonNullable>; + beforeEach(() => { + const built = buildSheetDrawing( + sheet({ images: [pngImage()], embeddedObjects: [chartObject()] }), + newDrawingCounters(), + ); + if (built === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + result = built; + }); it("builds the picture anchor with its own xdr:from/xdr:ext/xdr:pic/xdr:clientData shape", () => { const picAnchor = el("xdr:oneCellAnchor", {}, [ From 3e9f28a0aa34b3ca2b2cb105fca3dab45a7e97f5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 05:52:52 +0100 Subject: [PATCH 090/102] test(ooxml.js): close drawings-write's remaining chart-counter and sparse-cell gaps Adds a two-chart test proving the object-id counter genuinely advances (2 then 3) for charts, not just for the two-image case already covered, and a sparse-chart-cells test proving a missing series-name/value cell reads back through chartSeriesFromDocument's own cellAt fallback as an empty string, matching the empty label a missing point already gets on the read side. --- .../src/typed/xlsx/drawings-write.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts index 10a746fdb..04eb901c3 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings-write.test.ts @@ -412,6 +412,78 @@ describe("buildSheetDrawing: one image and one chart, every element and attribut }); }); +describe("buildSheetDrawing: chartSeriesFromDocument's own sparse-cell fallback", () => { + it("reads a missing cell (one chartCells never materialised, e.g. an absent point) back as an empty string, not undefined", () => { + const document: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Data", + // No (0,1) series-name cell at all, and no (1,1) value cell -- both genuinely absent from the sparse array, the same shape chartCells leaves for a missing point. (2,1) forces maxColumn to 1 so a series column genuinely exists to read the missing (0,1)/(1,1) cells back through. + cells: [ + { + row: 1, + column: 0, + value: { kind: "string", value: "Q1" }, + displayText: "Q1", + }, + { + row: 2, + column: 1, + value: { kind: "string", value: "42" }, + displayText: "42", + }, + ], + columns: [], + rows: [], + images: [], + printSettings: PRINT_SETTINGS, + }, + ], + }; + const result = buildSheetDrawing( + sheet({ embeddedObjects: [chartObject({ document })] }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + const chartPart = result.extraParts["xl/charts/chart1.xml"]; + if (chartPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const root = chartPart.nodes[1]; + if (root?.type !== "element") { + throw new Error("expected an element"); + } + const findFirst = ( + tag: string, + node: typeof root, + ): typeof root | undefined => { + if (node.tag === tag) { + return node; + } + for (const child of node.children) { + if (child.type === "element") { + const found = findFirst(tag, child); + if (found !== undefined) { + return found; + } + } + } + return undefined; + }; + const tx = findFirst("c:tx", root); + const seriesNameValue = tx?.children[0]; + if (seriesNameValue?.type !== "element") { + throw new Error("expected c:v"); + } + const seriesNameText = seriesNameValue.children[0]; + expect(seriesNameText?.type === "text" && seriesNameText.value).toBe(""); + }); +}); + describe("buildSheetDrawing: series/category range arithmetic against a second, non-trivial category count", () => { it("closes the cache range at categories.length + 1, and derives each column's own letters from index + 1", () => { const document: ContentDocument = { @@ -563,6 +635,39 @@ describe("buildSheetDrawing: object-id and relationship-id counters advance forw }); }); + it("assigns cNvPr id 2 then 3 to two charts in document order -- the object-id counter advances for charts too, not just images", () => { + const result = buildSheetDrawing( + sheet({ + embeddedObjects: [chartObject(), chartObject({ anchorColumn: 5 })], + }), + newDrawingCounters(), + ); + if (result === undefined) { + throw new Error("expected a SheetDrawingWrite"); + } + const secondAnchor = result.drawingRoot.children[1]; + if (secondAnchor?.type !== "element") { + throw new Error("expected an element"); + } + const frame = secondAnchor.children.find( + (n) => n.type === "element" && n.tag === "xdr:graphicFrame", + ); + if (frame?.type !== "element") { + throw new Error("expected xdr:graphicFrame"); + } + const nvPr = frame.children.find( + (n) => n.type === "element" && n.tag === "xdr:nvGraphicFramePr", + ); + if (nvPr?.type !== "element") { + throw new Error("expected xdr:nvGraphicFramePr"); + } + const cNvPr = nvPr.children[0]; + expect(cNvPr?.type === "element" && cNvPr.attributes).toContainEqual({ + name: "id", + value: "3", + }); + }); + it("keeps media/chart numbering advancing across TWO separate buildSheetDrawing calls sharing one counters instance", () => { const counters = newDrawingCounters(); const first = buildSheetDrawing( From 59642da93595bd28f3b86d50ada0e6dc2a8df283 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 06:00:56 +0100 Subject: [PATCH 091/102] test(ooxml.js): add conditional-format.ts's own direct unit suite This module's only prior coverage came from two real-producer fixtures (cellIs, colorScale) and a round-trip suite covering every rule family's own value shape, neither of which exercised the module's own exact XML vocabulary directly: an attribute name mutation on the write side and the matching read-side lookup can cancel each other out in a round trip, and toEqual-based value checks cannot distinguish a key genuinely absent from one assigned undefined. Covers, on the read side: the wrapper-level sqref gate, priority/ stopIfTrue/source residue capture, every cfvo type token including formula/percentile (previously entirely uncovered), the colorScale cfvo/color count-mismatch rejection, dataBar/iconSet's own true-default showValue convention and reverse flag, every dxf residue passthrough branch (font/fill/numFmt/alignment/border/ protection, including a font or fill kept whole when no colour was captured from it), cellIs formula2 restricted to between/ notBetween, top10's rank<=0 rejection and percent/bottom presence, aboveAverage's own true-default and stdDev<=0 rejection, and the colorScale/iconSet type discriminants. Covers, on the write side: formula/formula2 element count, top10/ aboveAverage/dataBar/iconSet's own attribute presence (each written only when explicitly set, never restating a default), the colorScale element's cfvo-before-color ordering, and buildConditionalFormattingElements' own range-based grouping and gap-filling priority assignment (an unpriorised rule never reuses an already-claimed explicit priority). --- .../src/typed/xlsx/conditional-format.test.ts | 758 ++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts diff --git a/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts b/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts new file mode 100644 index 000000000..2f3f2c106 --- /dev/null +++ b/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts @@ -0,0 +1,758 @@ +import { describe, expect, it } from "vitest"; +import type { ContentSheetConditionalFormat } from "document-schema.js"; +import { el, txt } from "../../xml/fragment"; +import { childrenWithTag } from "../util"; +import { + DxfTable, + buildConditionalFormattingElements, + readConditionalFormats, +} from "./conditional-format"; + +function hasOwn(obj: object, key: string): boolean { + return Object.hasOwn(obj, key); +} + +// A worksheet carrying exactly one wrapper with exactly one child, so every test below can build just the cfRule's own attributes/children and get back formats[0]/residueElements[0] directly. +function worksheetWithRule( + sqref: string, + cfRule: ReturnType, + dxfs: ReturnType[] = [], +): { + formats: ContentSheetConditionalFormat[]; + residueElements: ReturnType[]; +} { + const worksheet = el("worksheet", {}, [ + el("conditionalFormatting", { sqref }, [cfRule]), + ]); + return readConditionalFormats(worksheet, dxfs); +} + +describe("readConditionalFormats: the wrapper's own sqref gates every rule inside it", () => { + it("quarantines every cfRule as residue when the wrapper's own sqref parses to no range at all", () => { + const { formats, residueElements } = worksheetWithRule( + "not a ref", + el("cfRule", { type: "containsBlanks", dxfId: "0", priority: "1" }), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + +describe("readCommonFields: priority and stopIfTrue", () => { + it("states no priority for a non-integer priority attribute", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { + type: "containsBlanks", + priority: "not-a-number", + }), + ); + expect(hasOwn(formats[0] ?? {}, "priority")).toBe(false); + }); + + it("states stopIfTrue: true only for an explicit true value, and omits the key entirely otherwise", () => { + const { formats: withStop } = worksheetWithRule( + "A1", + el("cfRule", { + type: "containsBlanks", + priority: "1", + stopIfTrue: "1", + }), + ); + expect(withStop[0]?.stopIfTrue).toBe(true); + const { formats: withoutStop } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1" }), + ); + expect(hasOwn(withoutStop[0] ?? {}, "stopIfTrue")).toBe(false); + }); + + it("captures a genuinely unrecognised cfRule attribute as source residue, and states no source when every attribute is a managed one", () => { + const { formats: withResidue } = worksheetWithRule( + "A1", + el("cfRule", { + type: "containsBlanks", + priority: "1", + "x14ac:extraAttr": "value", + }), + ); + expect(withResidue[0]?.source).toBeDefined(); + const { formats: withoutResidue } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1" }), + ); + expect(hasOwn(withoutResidue[0] ?? {}, "source")).toBe(false); + }); +}); + +describe("readCfvo: exact type-token membership", () => { + function cfvoType(type: string): string | undefined { + const { formats } = worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, [ + el("cfvo", { type, val: "0" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF0000FF" }), + ]), + ]), + ); + const format = formats[0]; + return format?.type === "colorScale" + ? format.stops[0]?.value.type + : undefined; + } + + it('recognises "num"', () => { + expect(cfvoType("num")).toBe("num"); + }); + + it('recognises "formula"', () => { + expect(cfvoType("formula")).toBe("formula"); + }); + + it('recognises "percentile"', () => { + expect(cfvoType("percentile")).toBe("percentile"); + }); + + it("rejects an unrecognised type token, dropping the whole colorScale rule to residue", () => { + const { formats, residueElements } = worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, [ + el("cfvo", { type: "bogus", val: "0" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF0000FF" }), + ]), + ]), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + +describe("readColorScaleStops: min/max cfvo/color pair count mismatch", () => { + it("rejects a colorScale whose cfvo/color counts genuinely mismatch, dropping the rule to residue", () => { + const { formats, residueElements } = worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, [ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + ]), + ]), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + +describe("readDataBar: showValue's own default-is-true convention", () => { + function dataBarShowValue(showValue?: string): boolean | undefined { + const attrs: Record = {}; + if (showValue !== undefined) { + attrs.showValue = showValue; + } + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "dataBar", priority: "1" }, [ + el("dataBar", attrs, [ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + ]), + ]), + ); + const format = formats[0]; + return format?.type === "dataBar" ? format.showValue : undefined; + } + + it("states no showValue key at all when the attribute is absent (the true default)", () => { + expect(hasOwn({ v: dataBarShowValue(undefined) }, "v")).toBe(true); + expect(dataBarShowValue(undefined)).toBeUndefined(); + }); + + it("states showValue: false only for an explicit false value", () => { + expect(dataBarShowValue("0")).toBe(false); + }); + + it("states no showValue at all for an explicit true value (matching the default, nothing to record)", () => { + expect(dataBarShowValue("1")).toBeUndefined(); + }); +}); + +describe("readIconSet: reverse, showValue, and the empty-thresholds rejection", () => { + it("rejects an iconSet with no cfvo thresholds at all, dropping the rule to residue", () => { + const { formats, residueElements } = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [el("iconSet", {})]), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("states reverse: true only for an explicit true value", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [ + el("iconSet", { reverse: "1" }, [ + el("cfvo", { type: "percent", val: "33" }), + ]), + ]), + ); + const format = formats[0]; + expect(format?.type === "iconSet" ? format.reverse : undefined).toBe(true); + }); + + it("states showValue: false only for an explicit false value, and nothing for an explicit true", () => { + const falseCase = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [ + el("iconSet", { showValue: "0" }, [ + el("cfvo", { type: "percent", val: "33" }), + ]), + ]), + ).formats[0]; + expect( + falseCase?.type === "iconSet" ? falseCase.showValue : undefined, + ).toBe(false); + const trueCase = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [ + el("iconSet", { showValue: "1" }, [ + el("cfvo", { type: "percent", val: "33" }), + ]), + ]), + ).formats[0]; + expect( + trueCase?.type === "iconSet" ? trueCase.showValue : undefined, + ).toBeUndefined(); + }); +}); + +describe("styleFromDxf/dxfResidueChildren: residue passthrough for font/fill/numFmt/alignment/border/protection", () => { + function styleOf(dxf: ReturnType) { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1", dxfId: "0" }), + [dxf], + ); + return formats[0]?.type === "containsBlanks" ? formats[0].style : undefined; + } + + it("states no style at all for a dxf carrying neither a resolvable colour nor any residue", () => { + expect(styleOf(el("dxf", {}, []))).toBeUndefined(); + }); + + it("keeps a font's other children (e.g. b/i toggles) as residue alongside a captured textColor", () => { + const style = styleOf( + el("dxf", {}, [ + el("font", {}, [el("b"), el("color", { rgb: "FFFF0000" })]), + ]), + ); + expect(style?.textColor).toEqual({ r: 1, g: 0, b: 0 }); + expect(style?.source?.xml).toContain(" { + const style = styleOf(el("dxf", {}, [el("font", {}, [el("b")])])); + expect(style?.textColor).toBeUndefined(); + expect(style?.source?.xml).toContain(" { + const style = styleOf( + el("dxf", {}, [el("numFmt", { numFmtId: "1", formatCode: "0.00" })]), + ); + expect(style?.source?.xml).toContain("numFmt"); + }); + + it("keeps other patternFill children and other fill children alongside a captured background", () => { + const style = styleOf( + el("dxf", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "solid" }, [ + el("fgColor", { rgb: "FF00FF00" }), + el("bgColor", { rgb: "FFFF0000" }), + ]), + ]), + ]), + ); + expect(style?.background).toEqual({ r: 1, g: 0, b: 0 }); + expect(style?.source?.xml).toContain("fgColor"); + }); + + it("keeps a whole fill element as residue when it carries no bgColor at all (no background captured)", () => { + const style = styleOf( + el("dxf", {}, [ + el("fill", {}, [ + el("patternFill", { patternType: "solid" }, [ + el("fgColor", { rgb: "FF00FF00" }), + ]), + ]), + ]), + ); + expect(style?.background).toBeUndefined(); + expect(style?.source?.xml).toContain("fgColor"); + }); + + it("keeps alignment/border/protection residue elements verbatim, in document order", () => { + const style = styleOf( + el("dxf", {}, [ + el("alignment", { horizontal: "center" }), + el("border", {}, [el("left", { style: "thin" })]), + el("protection", { locked: "0" }), + ]), + ); + expect(style?.source?.xml).toBe( + '', + ); + }); + + it("resolves style from an out-of-range dxfId as no style at all, rather than throwing", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1", dxfId: "99" }), + [], + ); + expect(hasOwn(formats[0] ?? {}, "style")).toBe(false); + }); +}); + +describe("readCfRule: cellIs formula2 only for between/notBetween", () => { + it("carries formula2 for a between operator", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator: "between", priority: "1" }, [ + el("formula", {}, [txt("1")]), + el("formula", {}, [txt("10")]), + ]), + ); + const format = formats[0]; + expect(format?.type === "cellIs" ? format.formula2 : undefined).toBe("10"); + }); + + it("omits formula2 entirely for a non-between/notBetween operator, even when a second exists", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator: "greaterThan", priority: "1" }, [ + el("formula", {}, [txt("1")]), + el("formula", {}, [txt("10")]), + ]), + ); + expect(hasOwn(formats[0] ?? {}, "formula2")).toBe(false); + }); +}); + +describe("readCfRule: top10's rank boundary", () => { + it("rejects rank 0 and negative rank, dropping the rule to residue", () => { + for (const rank of ["0", "-1"]) { + const { formats, residueElements } = worksheetWithRule( + "A1", + el("cfRule", { type: "top10", rank, priority: "1" }), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + } + }); + + it("accepts rank 1 (the boundary itself) and states percent/bottom only when explicitly true", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { + type: "top10", + rank: "1", + percent: "1", + bottom: "1", + priority: "1", + }), + ); + const format = formats[0]; + expect(format?.type === "top10" ? format.rank : undefined).toBe(1); + expect(format?.type === "top10" ? format.percent : undefined).toBe(true); + expect(format?.type === "top10" ? format.bottom : undefined).toBe(true); + }); + + it("omits percent/bottom entirely when neither attribute is set", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "top10", rank: "5", priority: "1" }), + ); + expect(hasOwn(formats[0] ?? {}, "percent")).toBe(false); + expect(hasOwn(formats[0] ?? {}, "bottom")).toBe(false); + }); +}); + +describe("readCfRule: aboveAverage's own true-default and stdDev boundary", () => { + it("states aboveAverage: false only for an explicit false value, and nothing for an absent or true value", () => { + const explicit = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", aboveAverage: "0", priority: "1" }), + ).formats[0]; + expect( + explicit?.type === "aboveAverage" ? explicit.aboveAverage : undefined, + ).toBe(false); + const absent = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", priority: "1" }), + ).formats[0]; + expect(hasOwn(absent ?? {}, "aboveAverage")).toBe(false); + }); + + it("states equalAverage: true only for an explicit true value", () => { + const format = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", equalAverage: "1", priority: "1" }), + ).formats[0]; + expect( + format?.type === "aboveAverage" ? format.equalAverage : undefined, + ).toBe(true); + }); + + it("rejects stdDev 0, keeping the rule but omitting the stdDev key", () => { + const format = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", stdDev: "0", priority: "1" }), + ).formats[0]; + expect(hasOwn(format ?? {}, "stdDev")).toBe(false); + }); + + it("accepts stdDev 1 (the boundary itself)", () => { + const format = worksheetWithRule( + "A1", + el("cfRule", { type: "aboveAverage", stdDev: "1", priority: "1" }), + ).formats[0]; + expect(format?.type === "aboveAverage" ? format.stdDev : undefined).toBe(1); + }); +}); + +describe("readCfRule: colorScale/iconSet type discrimination", () => { + it('reads type "colorScale" as the colorScale kind, not falling through to residue', () => { + const { formats } = worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, [ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF0000FF" }), + ]), + ]), + ); + expect(formats[0]?.type).toBe("colorScale"); + }); + + it('reads type "iconSet" as the iconSet kind, not falling through to residue', () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "iconSet", priority: "1" }, [ + el("iconSet", {}, [el("cfvo", { type: "percent", val: "33" })]), + ]), + ); + expect(formats[0]?.type).toBe("iconSet"); + }); +}); + +// --- the write side --------------------------------------------------------------------------------------------- + +function buildOneRule(format: ContentSheetConditionalFormat): { + conditionalFormatting: ReturnType; + dxfTable: DxfTable; +} { + const dxfTable = new DxfTable(); + const [conditionalFormatting] = buildConditionalFormattingElements( + [format], + dxfTable, + ); + if (conditionalFormatting === undefined) { + throw new Error("expected one conditionalFormatting element"); + } + return { conditionalFormatting, dxfTable }; +} + +function firstCfRule(conditionalFormatting: ReturnType) { + const rule = childrenWithTag(conditionalFormatting, "cfRule")[0]; + if (rule === undefined) { + throw new Error("expected a cfRule"); + } + return rule; +} + +describe("buildCfRuleElement: cellIs formula/formula2 elements", () => { + it("writes exactly one for a formula1-only rule", () => { + const { conditionalFormatting } = buildOneRule({ + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + operator: "greaterThan", + formula1: "5", + }); + const rule = firstCfRule(conditionalFormatting); + expect(childrenWithTag(rule, "formula")).toHaveLength(1); + }); + + it("writes two elements, in order, for a formula1+formula2 rule", () => { + const { conditionalFormatting } = buildOneRule({ + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + operator: "between", + formula1: "1", + formula2: "10", + }); + const rule = firstCfRule(conditionalFormatting); + const formulas = childrenWithTag(rule, "formula").map((f) => { + const t = f.children[0]; + return t?.type === "text" ? t.value : undefined; + }); + expect(formulas).toEqual(["1", "10"]); + }); +}); + +describe("buildCfRuleElement: top10's percent/bottom attribute presence", () => { + it("writes bottom='1' only when bottom is true, and omits it entirely otherwise", () => { + const withBottom = firstCfRule( + buildOneRule({ + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 5, + bottom: true, + }).conditionalFormatting, + ); + expect(childrenWithTag).toBeDefined(); + const bottomAttr = withBottom.attributes.find((a) => a.name === "bottom"); + expect(bottomAttr?.value).toBe("true"); + + const withoutBottom = firstCfRule( + buildOneRule({ + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 5, + }).conditionalFormatting, + ); + expect(withoutBottom.attributes.some((a) => a.name === "bottom")).toBe( + false, + ); + }); + + it("writes percent='1' only when percent is true", () => { + const withPercent = firstCfRule( + buildOneRule({ + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 5, + percent: true, + }).conditionalFormatting, + ); + expect( + withPercent.attributes.find((a) => a.name === "percent")?.value, + ).toBe("true"); + }); +}); + +describe("buildCfRuleElement: aboveAverage's own three independent flags", () => { + it("writes aboveAverage='0' only when aboveAverage is explicitly false", () => { + const rule = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + aboveAverage: false, + }).conditionalFormatting, + ); + expect(rule.attributes.find((a) => a.name === "aboveAverage")?.value).toBe( + "false", + ); + }); + + it("writes equalAverage='1' only when equalAverage is explicitly true", () => { + const rule = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + equalAverage: true, + }).conditionalFormatting, + ); + expect(rule.attributes.find((a) => a.name === "equalAverage")?.value).toBe( + "true", + ); + }); + + it("writes stdDev only when it is genuinely present", () => { + const rule = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + stdDev: 2, + }).conditionalFormatting, + ); + expect(rule.attributes.find((a) => a.name === "stdDev")?.value).toBe("2"); + }); +}); + +describe("buildCfRuleElement: colorScale/dataBar/iconSet element shape", () => { + it("writes one with every cfvo before every color, in stop order", () => { + const rule = firstCfRule( + buildOneRule({ + type: "colorScale", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + stops: [ + { value: { type: "min" }, color: { r: 1, g: 0, b: 0 } }, + { value: { type: "max" }, color: { r: 0, g: 0, b: 1 } }, + ], + }).conditionalFormatting, + ); + const colorScale = childrenWithTag(rule, "colorScale")[0]; + if (colorScale === undefined) { + throw new Error("expected colorScale"); + } + const tags = colorScale.children + .filter((c) => c.type === "element") + .map((c) => c.tag); + expect(tags).toEqual(["cfvo", "cfvo", "color", "color"]); + const colors = childrenWithTag(colorScale, "color").map( + (c) => c.attributes.find((a) => a.name === "rgb")?.value, + ); + expect(colors).toEqual(["FFff0000", "FF0000ff"]); + }); + + it("writes dataBar's showValue on the element itself, not the ", () => { + const rule = firstCfRule( + buildOneRule({ + type: "dataBar", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + min: { type: "min" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + showValue: false, + }).conditionalFormatting, + ); + expect(rule.attributes.some((a) => a.name === "showValue")).toBe(false); + const dataBar = childrenWithTag(rule, "dataBar")[0]; + expect(dataBar?.attributes.find((a) => a.name === "showValue")?.value).toBe( + "false", + ); + }); + + it("writes iconSet's iconSet attribute only for a non-default iconSetType", () => { + const defaultType = firstCfRule( + buildOneRule({ + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + iconSetType: "3TrafficLights1", + thresholds: [{ type: "percent", value: "33" }], + }).conditionalFormatting, + ); + const defaultIconSet = childrenWithTag(defaultType, "iconSet")[0]; + expect(defaultIconSet?.attributes.some((a) => a.name === "iconSet")).toBe( + false, + ); + + const customType = firstCfRule( + buildOneRule({ + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + iconSetType: "3Arrows", + thresholds: [{ type: "percent", value: "33" }], + }).conditionalFormatting, + ); + const customIconSet = childrenWithTag(customType, "iconSet")[0]; + expect( + customIconSet?.attributes.find((a) => a.name === "iconSet")?.value, + ).toBe("3Arrows"); + }); + + it("writes iconSet's reverse and showValue only when explicitly set", () => { + const rule = firstCfRule( + buildOneRule({ + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + iconSetType: "3TrafficLights1", + thresholds: [{ type: "percent", value: "33" }], + reverse: true, + showValue: false, + }).conditionalFormatting, + ); + const iconSet = childrenWithTag(rule, "iconSet")[0]; + expect(iconSet?.attributes.find((a) => a.name === "reverse")?.value).toBe( + "true", + ); + expect(iconSet?.attributes.find((a) => a.name === "showValue")?.value).toBe( + "false", + ); + }); +}); + +describe("buildConditionalFormattingElements: range grouping and priority assignment", () => { + it("groups two rules sharing the identical range set into one conditionalFormatting wrapper", () => { + const range = { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }; + const elements = buildConditionalFormattingElements( + [ + { type: "containsBlanks", ranges: [range] }, + { type: "containsErrors", ranges: [range] }, + ], + new DxfTable(), + ); + expect(elements).toHaveLength(1); + expect(childrenWithTag(elements[0] ?? el("x"), "cfRule")).toHaveLength(2); + }); + + it("splits two rules with genuinely different range sets into two separate wrappers", () => { + const elements = buildConditionalFormattingElements( + [ + { + type: "containsBlanks", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + { + type: "containsErrors", + ranges: [{ startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 }], + }, + ], + new DxfTable(), + ); + expect(elements).toHaveLength(2); + }); + + it("assigns explicit priorities verbatim, and fills the gap for an unpriorised rule rather than colliding with it", () => { + const elements = buildConditionalFormattingElements( + [ + { + type: "containsBlanks", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + priority: 1, + }, + { + type: "containsErrors", + ranges: [{ startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 }], + }, + ], + new DxfTable(), + ); + const priorities = elements.flatMap((wrapper) => + childrenWithTag(wrapper, "cfRule").map( + (rule) => rule.attributes.find((a) => a.name === "priority")?.value, + ), + ); + // The unpriorised rule must NOT reuse "1" (already explicitly claimed) -- it gets the next free integer, "2". + expect( + [...priorities].sort((a, b) => (a ?? "").localeCompare(b ?? "")), + ).toEqual(["1", "2"]); + }); + + it("assigns sequential priorities to two unpriorised rules sharing one range, in document order", () => { + const range = { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }; + const elements = buildConditionalFormattingElements( + [ + { type: "containsBlanks", ranges: [range] }, + { type: "containsErrors", ranges: [range] }, + ], + new DxfTable(), + ); + const priorities = childrenWithTag(elements[0] ?? el("x"), "cfRule").map( + (rule) => rule.attributes.find((a) => a.name === "priority")?.value, + ); + expect(priorities).toEqual(["1", "2"]); + }); +}); From 9f262686b69204c98e04f9a51e5dc1c0a7ea58ea Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 06:07:58 +0100 Subject: [PATCH 092/102] test(ooxml.js): close conditional-format's operator, boundary, and residue gaps Covers every isSheetRuleOperator member distinctly (not just between/greaterThan), cellIs formula2 for notBetween alongside between, the absent-timePeriod rejection, the colorScale cfvo/ color count boundary at exactly 2 and exactly 3 stops on both sides, residualAttributesFor's own expectedTag gate (an unmanaged cfRule attribute genuinely restored on write), rangeSetKey's field separator (two ranges that would collide under naive concatenation without it), and a full round trip of every dxf residue kind at once (font+color, fill+patternFill+bgColor, numFmt, alignment, border, protection) through DxfTable.intern. Adds the missing "omitted entirely" half of several write-side attribute-presence tests (top10 percent, aboveAverage/equalAverage/ stdDev, dataBar showValue, iconSet reverse/showValue) that only asserted the explicit-true/false case, never that the attribute is genuinely absent -- not merely unasserted -- when nothing was set. --- .../src/typed/xlsx/conditional-format.test.ts | 312 +++++++++++++++++- 1 file changed, 298 insertions(+), 14 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts b/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts index 2f3f2c106..27e7e36fe 100644 --- a/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/conditional-format.test.ts @@ -85,6 +85,70 @@ describe("readCommonFields: priority and stopIfTrue", () => { }); }); +describe("isSheetRuleOperator: every accepted member, distinctly", () => { + function operatorOf(operator: string): string | undefined { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator, priority: "1" }, [ + el("formula", {}, [txt("1")]), + ]), + ); + const format = formats[0]; + return format?.type === "cellIs" ? format.operator : undefined; + } + + for (const operator of [ + "between", + "notBetween", + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + ]) { + it(`accepts "${operator}"`, () => { + expect(operatorOf(operator)).toBe(operator); + }); + } + + it("rejects an unrecognised operator token, dropping the rule to residue", () => { + const { formats, residueElements } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator: "bogus", priority: "1" }, [ + el("formula", {}, [txt("1")]), + ]), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + +describe("readCfRule: cellIs formula2 for notBetween too, not just between", () => { + it("carries formula2 for a notBetween operator", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "cellIs", operator: "notBetween", priority: "1" }, [ + el("formula", {}, [txt("1")]), + el("formula", {}, [txt("10")]), + ]), + ); + const format = formats[0]; + expect(format?.type === "cellIs" ? format.formula2 : undefined).toBe("10"); + }); +}); + +describe("isTimePeriod: rejects an absent timePeriod attribute, dropping the rule to residue", () => { + it("drops a timePeriod rule with no timePeriod attribute at all", () => { + const { formats, residueElements } = worksheetWithRule( + "A1", + el("cfRule", { type: "timePeriod", priority: "1" }), + ); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); +}); + describe("readCfvo: exact type-token membership", () => { function cfvoType(type: string): string | undefined { const { formats } = worksheetWithRule( @@ -133,18 +197,68 @@ describe("readCfvo: exact type-token membership", () => { }); }); -describe("readColorScaleStops: min/max cfvo/color pair count mismatch", () => { +function colorScaleFormats(cfvoAndColor: ReturnType[]) { + return worksheetWithRule( + "A1:B2", + el("cfRule", { type: "colorScale", priority: "1" }, [ + el("colorScale", {}, cfvoAndColor), + ]), + ); +} + +describe("readColorScaleStops: the cfvo/color count boundary (2..3 stops, matched counts)", () => { it("rejects a colorScale whose cfvo/color counts genuinely mismatch, dropping the rule to residue", () => { - const { formats, residueElements } = worksheetWithRule( - "A1:B2", - el("cfRule", { type: "colorScale", priority: "1" }, [ - el("colorScale", {}, [ - el("cfvo", { type: "min" }), - el("cfvo", { type: "max" }), - el("color", { rgb: "FFFF0000" }), - ]), - ]), - ); + const { formats, residueElements } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + ]); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("rejects a single-stop colorScale (below the 2-stop minimum)", () => { + const { formats, residueElements } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("color", { rgb: "FFFF0000" }), + ]); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("accepts exactly 2 stops (the minimum boundary itself)", () => { + const { formats } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF0000FF" }), + ]); + expect(formats[0]?.type).toBe("colorScale"); + }); + + it("accepts exactly 3 stops (the maximum boundary itself)", () => { + const { formats } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("cfvo", { type: "percentile", val: "50" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF00FF00" }), + el("color", { rgb: "FF0000FF" }), + ]); + expect(formats[0]?.type).toBe("colorScale"); + }); + + it("rejects a 4-stop colorScale (above the 3-stop maximum), even though the counts still match", () => { + const { formats, residueElements } = colorScaleFormats([ + el("cfvo", { type: "min" }), + el("cfvo", { type: "percentile", val: "25" }), + el("cfvo", { type: "percentile", val: "75" }), + el("cfvo", { type: "max" }), + el("color", { rgb: "FFFF0000" }), + el("color", { rgb: "FF00FF00" }), + el("color", { rgb: "FF00FFFF" }), + el("color", { rgb: "FF0000FF" }), + ]); expect(formats).toEqual([]); expect(residueElements).toHaveLength(1); }); @@ -311,6 +425,71 @@ describe("styleFromDxf/dxfResidueChildren: residue passthrough for font/fill/num expect(style?.source?.xml).toBe( '', ); + expect(hasOwn(style ?? {}, "textColor")).toBe(false); + expect(hasOwn(style ?? {}, "background")).toBe(false); + }); + + it("round-trips a dxf carrying every residue kind at once (font+color, fill+patternFill+bgColor, numFmt, alignment, border, protection) back through DxfTable.intern", () => { + const { formats } = worksheetWithRule( + "A1", + el("cfRule", { type: "containsBlanks", priority: "1", dxfId: "0" }), + [ + el("dxf", {}, [ + el("font", {}, [el("b"), el("color", { rgb: "FFFF0000" })]), + el("numFmt", { numFmtId: "1", formatCode: "0.00" }), + el("fill", {}, [ + el("patternFill", { patternType: "solid" }, [ + el("fgColor", { rgb: "FF00FF00" }), + el("bgColor", { rgb: "FF0000FF" }), + ]), + ]), + el("alignment", { horizontal: "center" }), + el("border", {}, [el("left", { style: "thin" })]), + el("protection", { locked: "0" }), + ]), + ], + ); + const style = + formats[0]?.type === "containsBlanks" ? formats[0].style : undefined; + if (style === undefined) { + throw new Error("expected a style"); + } + const dxfTable = new DxfTable(); + dxfTable.intern(style); + const rebuilt = dxfTable.dxfElements()[0]; + if (rebuilt === undefined) { + throw new Error("expected a rebuilt dxf element"); + } + const tags = rebuilt.children + .filter((c) => c.type === "element") + .map((c) => c.tag); + expect(tags).toEqual([ + "font", + "numFmt", + "fill", + "alignment", + "border", + "protection", + ]); + const font = childrenWithTag(rebuilt, "font")[0]; + expect(childrenWithTag(font ?? el("x"), "b")).toHaveLength(1); + expect( + childrenWithTag(font ?? el("x"), "color")[0]?.attributes.find( + (a) => a.name === "rgb", + )?.value, + ).toBe("FFff0000"); + const fill = childrenWithTag(rebuilt, "fill")[0]; + const patternFill = childrenWithTag(fill ?? el("x"), "patternFill")[0]; + expect( + childrenWithTag(patternFill ?? el("x"), "fgColor")[0]?.attributes.find( + (a) => a.name === "rgb", + )?.value, + ).toBe("FF00FF00"); + expect( + childrenWithTag(patternFill ?? el("x"), "bgColor")[0]?.attributes.find( + (a) => a.name === "rgb", + )?.value, + ).toBe("FF0000ff"); }); it("resolves style from an out-of-range dxfId as no style at all, rather than throwing", () => { @@ -511,6 +690,44 @@ describe("buildCfRuleElement: cellIs formula/formula2 elements", () => { }); }); +describe("buildCfRuleElement: residualAttributesFor's own expectedTag gate", () => { + it("restores an unmanaged residual attribute (a real one this schema does not model) back onto the built cfRule", () => { + const rule = firstCfRule( + buildOneRule({ + type: "containsBlanks", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + source: { + format: "xlsx", + xml: '', + }, + }).conditionalFormatting, + ); + expect(rule.attributes.find((a) => a.name === "pivot")?.value).toBe("1"); + }); +}); + +describe("rangeSetKey: distinguishes ranges by the separator between fields, not just concatenation", () => { + it("groups a single 10:0-1:1 range separately from two adjacent 1:0-1:1/0:1-1:1 ranges, even though naive concatenation without a separator would collide", () => { + const elements = buildConditionalFormattingElements( + [ + { + type: "containsBlanks", + ranges: [{ startRow: 10, startColumn: 0, endRow: 1, endColumn: 1 }], + }, + { + type: "containsErrors", + ranges: [ + { startRow: 1, startColumn: 0, endRow: 1, endColumn: 1 }, + { startRow: 0, startColumn: 1, endRow: 1, endColumn: 1 }, + ], + }, + ], + new DxfTable(), + ); + expect(elements).toHaveLength(2); + }); +}); + describe("buildCfRuleElement: top10's percent/bottom attribute presence", () => { it("writes bottom='1' only when bottom is true, and omits it entirely otherwise", () => { const withBottom = firstCfRule( @@ -537,7 +754,7 @@ describe("buildCfRuleElement: top10's percent/bottom attribute presence", () => ); }); - it("writes percent='1' only when percent is true", () => { + it("writes percent='true' only when percent is true, and omits it entirely otherwise", () => { const withPercent = firstCfRule( buildOneRule({ type: "top10", @@ -549,6 +766,16 @@ describe("buildCfRuleElement: top10's percent/bottom attribute presence", () => expect( withPercent.attributes.find((a) => a.name === "percent")?.value, ).toBe("true"); + const withoutPercent = firstCfRule( + buildOneRule({ + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 5, + }).conditionalFormatting, + ); + expect(withoutPercent.attributes.some((a) => a.name === "percent")).toBe( + false, + ); }); }); @@ -564,9 +791,18 @@ describe("buildCfRuleElement: aboveAverage's own three independent flags", () => expect(rule.attributes.find((a) => a.name === "aboveAverage")?.value).toBe( "false", ); + const defaultRule = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }).conditionalFormatting, + ); + expect(defaultRule.attributes.some((a) => a.name === "aboveAverage")).toBe( + false, + ); }); - it("writes equalAverage='1' only when equalAverage is explicitly true", () => { + it("writes equalAverage='true' only when equalAverage is explicitly true, and omits it otherwise", () => { const rule = firstCfRule( buildOneRule({ type: "aboveAverage", @@ -577,9 +813,18 @@ describe("buildCfRuleElement: aboveAverage's own three independent flags", () => expect(rule.attributes.find((a) => a.name === "equalAverage")?.value).toBe( "true", ); + const withoutEqualAverage = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }).conditionalFormatting, + ); + expect( + withoutEqualAverage.attributes.some((a) => a.name === "equalAverage"), + ).toBe(false); }); - it("writes stdDev only when it is genuinely present", () => { + it("writes stdDev only when it is genuinely present, never a phantom stdDev attribute", () => { const rule = firstCfRule( buildOneRule({ type: "aboveAverage", @@ -588,6 +833,15 @@ describe("buildCfRuleElement: aboveAverage's own three independent flags", () => }).conditionalFormatting, ); expect(rule.attributes.find((a) => a.name === "stdDev")?.value).toBe("2"); + const withoutStdDev = firstCfRule( + buildOneRule({ + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }).conditionalFormatting, + ); + expect(withoutStdDev.attributes.some((a) => a.name === "stdDev")).toBe( + false, + ); }); }); @@ -633,6 +887,19 @@ describe("buildCfRuleElement: colorScale/dataBar/iconSet element shape", () => { expect(dataBar?.attributes.find((a) => a.name === "showValue")?.value).toBe( "false", ); + const withoutShowValue = firstCfRule( + buildOneRule({ + type: "dataBar", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + min: { type: "min" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + }).conditionalFormatting, + ); + const defaultDataBar = childrenWithTag(withoutShowValue, "dataBar")[0]; + expect(defaultDataBar?.attributes.some((a) => a.name === "showValue")).toBe( + false, + ); }); it("writes iconSet's iconSet attribute only for a non-default iconSetType", () => { @@ -681,6 +948,23 @@ describe("buildCfRuleElement: colorScale/dataBar/iconSet element shape", () => { expect(iconSet?.attributes.find((a) => a.name === "showValue")?.value).toBe( "false", ); + const withoutFlags = childrenWithTag( + firstCfRule( + buildOneRule({ + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + iconSetType: "3TrafficLights1", + thresholds: [{ type: "percent", value: "33" }], + }).conditionalFormatting, + ), + "iconSet", + )[0]; + expect(withoutFlags?.attributes.some((a) => a.name === "reverse")).toBe( + false, + ); + expect(withoutFlags?.attributes.some((a) => a.name === "showValue")).toBe( + false, + ); }); }); From 1c6cfdbec961058d12cf980675e471a360d7b08a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 06:18:47 +0100 Subject: [PATCH 093/102] test(ooxml.js): cover pptx read's slide-size fallback, alignment, and underline/strike tokens Adds a minimal, layout/master-free slide package builder for isolating a single shape's own paragraph/run properties, and uses it to cover: the widescreen-default fallback when p:sldSz carries no cx (previously untested against a real, differently-sized sldSz, so the default and a genuine explicit size were indistinguishable), every algn token (l/ctr/r/just/justLow, plus an unrecognised token falling through to no alignment), and the exact none/noStrike tokens for underline and strikethrough alongside their positive and absent-attribute cases. --- packages/ooxml.js/src/typed/pptx/read.test.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/packages/ooxml.js/src/typed/pptx/read.test.ts b/packages/ooxml.js/src/typed/pptx/read.test.ts index 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(); + }); +}); From e59d7269921f80c650cc32b74c432f551f0501b6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 06:50:40 +0100 Subject: [PATCH 094/102] docs(ooxml.js): raise the mutation break threshold to the re-measured floor Re-measures the package-wide score after closing the mutation gaps in content.ts, styles.ts, drawings-write.ts (now a genuine 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, up from the original 64.55% baseline the threshold of 63 reflected. States plainly which modules remain the real next targets (xlsx/build.ts, docx/write.ts, docx/read.ts), so the number reads as a measured floor rather than a ceiling. --- packages/ooxml.js/stryker.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index 10fa317f4..d0560cd5a 100644 --- a/packages/ooxml.js/stryker.config.ts +++ b/packages/ooxml.js/stryker.config.ts @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // First CI-measured baseline: 64.55% of 6823 valid mutants, timeout share 0.4% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. - breakThreshold: 63, + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. The package's three largest modules (xlsx/build.ts, docx/write.ts, docx/read.ts) remain well short of 100% and are the next real targets for closing this gap further; this threshold reflects the genuinely measured floor today, not a ceiling to stop at. + breakThreshold: 83, }); From ee8e81c703ea2bef21b780275f790544fa69c7ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 07:56:28 +0100 Subject: [PATCH 095/102] test(ooxml.js): cover build.ts's exact package-scaffolding output Adds direct assertions for the XML declaration prolog, every [Content_Types].xml Override across a document exercising comments, images, charts, and tables, the fixed _rels/.rels and xl/_rels/workbook.xml.rels relationships, xl/workbook.xml's sheetId and r:id numbering, and xl/sharedStrings.xml's count/uniqueCount and xml:space attribute. Closes the Print_Titles derivation gap where repeatRows and repeatColumns were always set together, so mutating the || between them to && never changed the observable output; adds cases with each set alone and with neither set. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 414 ++++++++++++++++++ 1 file changed, 414 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 4915a1c8f..a9511966e 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -1957,3 +1957,417 @@ describe("buildXlsxPackageFromContent: the definitions option (Table objects) an expect(Object.keys(pkg.parts)).not.toContain("xl/tables/table1.xml"); }); }); + +// --- exact scaffolding: the XML declaration, [Content_Types].xml, package/workbook relationships ----------------- + +describe("buildXlsxPackageFromContent: every XML part carries the same declaration prolog", () => { + it('declares version="1.0" encoding="UTF-8" standalone="yes" on the [Content_Types].xml part', () => { + const part = buildXlsxPackageFromContent(singleSheetDocument([])).parts[ + "[Content_Types].xml" + ]; + if (part?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const declaration = part.nodes[0]; + if (declaration?.type !== "declaration") { + throw new Error("expected a declaration node first"); + } + const attrOf = (name: string): string | undefined => + declaration.attributes.find((a) => a.name === name)?.value; + expect(attrOf("version")).toBe("1.0"); + expect(attrOf("encoding")).toBe("UTF-8"); + expect(attrOf("standalone")).toBe("yes"); + }); +}); + +describe("buildXlsxPackageFromContent: [Content_Types].xml carries every part's exact Override, for a document exercising every content kind", () => { + function fullDocument(): ContentDocument { + const chart = chartEmbeddedObject(); + return { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + comment: { text: "note" }, + }, + ], + columns: [], + rows: [], + images: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + embeddedObjects: [chart], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + { + name: "Sheet2", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }; + } + + it("writes the fixed workbook/styles/sharedStrings overrides, one worksheet override per sheet, and the media/comments/drawing/chart/table overrides for the parts a fuller document actually carries", () => { + const pkg = buildXlsxPackageFromContent(fullDocument(), { + definitions: tableDefinitions(), + }); + const contentTypes = rootElement(pkg.parts["[Content_Types].xml"]); + if (contentTypes === undefined) { + throw new Error("expected [Content_Types].xml to have a root element"); + } + const defaults = childrenWithTag(contentTypes, "Default").map((el) => ({ + extension: attr(el, "Extension"), + contentType: attr(el, "ContentType"), + })); + expect(defaults).toContainEqual({ + extension: "rels", + contentType: "application/vnd.openxmlformats-package.relationships+xml", + }); + expect(defaults).toContainEqual({ + extension: "xml", + contentType: "application/xml", + }); + expect(defaults).toContainEqual({ + extension: "png", + contentType: "image/png", + }); + + const overrides = childrenWithTag(contentTypes, "Override").map((el) => ({ + partName: attr(el, "PartName"), + contentType: attr(el, "ContentType"), + })); + expect(overrides).toContainEqual({ + partName: "/xl/workbook.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", + }); + expect(overrides).toContainEqual({ + partName: "/xl/styles.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml", + }); + expect(overrides).toContainEqual({ + partName: "/xl/sharedStrings.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml", + }); + // One worksheet override per sheet, not one fewer or one more. + expect(overrides).toContainEqual({ + partName: "/xl/worksheets/sheet1.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", + }); + expect(overrides).toContainEqual({ + partName: "/xl/worksheets/sheet2.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", + }); + expect( + overrides.filter((o) => o.partName?.startsWith("/xl/worksheets/sheet")), + ).toHaveLength(2); + // Only sheet 1 carries a comment, a drawing, and a table -- indices must not leak onto sheet 2. + expect(overrides).toContainEqual({ + partName: "/xl/threadedComments/threadedComment1.xml", + contentType: "application/vnd.ms-excel.threadedcomments+xml", + }); + expect(overrides).not.toContainEqual( + expect.objectContaining({ + partName: "/xl/threadedComments/threadedComment2.xml", + }), + ); + expect(overrides).toContainEqual({ + partName: "/xl/drawings/drawing1.xml", + contentType: "application/vnd.openxmlformats-officedocument.drawing+xml", + }); + expect(overrides).not.toContainEqual( + expect.objectContaining({ partName: "/xl/drawings/drawing2.xml" }), + ); + expect(overrides).toContainEqual({ + partName: "/xl/charts/chart1.xml", + contentType: + "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", + }); + expect(overrides).toContainEqual({ + partName: "/xl/tables/table1.xml", + contentType: + "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml", + }); + expect(overrides).toContainEqual({ + partName: "/docProps/core.xml", + contentType: "application/vnd.openxmlformats-package.core-properties+xml", + }); + expect(overrides).toContainEqual({ + partName: "/docProps/app.xml", + contentType: + "application/vnd.openxmlformats-officedocument.extended-properties+xml", + }); + }); + + it("declares no jpeg/gif media default when only a png is actually used", () => { + const pkg = buildXlsxPackageFromContent(fullDocument()); + const contentTypes = rootElement(pkg.parts["[Content_Types].xml"]); + if (contentTypes === undefined) { + throw new Error("expected [Content_Types].xml to have a root element"); + } + const extensions = childrenWithTag(contentTypes, "Default").map((el) => + attr(el, "Extension"), + ); + expect(extensions).not.toContain("jpeg"); + expect(extensions).not.toContain("gif"); + }); +}); + +describe("buildXlsxPackageFromContent: _rels/.rels carries exactly the three fixed package relationships", () => { + it("writes rId1/rId2/rId3 pointing at the workbook, core properties, and extended properties, in that order", () => { + const pkg = buildXlsxPackageFromContent(singleSheetDocument([])); + const rels = rootElement(pkg.parts["_rels/.rels"]); + if (rels === undefined) { + throw new Error("expected _rels/.rels to have a root element"); + } + const relationships = childrenWithTag(rels, "Relationship").map((el) => ({ + id: attr(el, "Id"), + type: attr(el, "Type"), + target: attr(el, "Target"), + })); + expect(relationships).toEqual([ + { + id: "rId1", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", + target: "xl/workbook.xml", + }, + { + id: "rId2", + type: "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", + target: "docProps/core.xml", + }, + { + id: "rId3", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", + target: "docProps/app.xml", + }, + ]); + }); +}); + +describe("buildXlsxPackageFromContent: xl/_rels/workbook.xml.rels numbers worksheet relationships before styles/sharedStrings, exactly one id past the sheet count", () => { + it("writes one worksheet relationship per sheet (rId1..rIdN), then styles at rId(N+1) and sharedStrings at rId(N+2), for a 2-sheet workbook", () => { + const pkg = buildXlsxPackageFromContent(DOCUMENT); + const rels = rootElement(pkg.parts["xl/_rels/workbook.xml.rels"]); + if (rels === undefined) { + throw new Error( + "expected xl/_rels/workbook.xml.rels to have a root element", + ); + } + const relationships = childrenWithTag(rels, "Relationship").map((el) => ({ + id: attr(el, "Id"), + type: attr(el, "Type"), + target: attr(el, "Target"), + })); + expect(relationships).toEqual([ + { + id: "rId1", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + target: "worksheets/sheet1.xml", + }, + { + id: "rId2", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + target: "worksheets/sheet2.xml", + }, + { + id: "rId3", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + target: "styles.xml", + }, + { + id: "rId4", + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings", + target: "sharedStrings.xml", + }, + ]); + }); + + it("writes exactly one worksheet relationship, at rId1, for a single-sheet workbook -- proving the loop runs sheetCount times, not one more or fewer", () => { + const pkg = buildXlsxPackageFromContent(singleSheetDocument([])); + const rels = rootElement(pkg.parts["xl/_rels/workbook.xml.rels"]); + if (rels === undefined) { + throw new Error( + "expected xl/_rels/workbook.xml.rels to have a root element", + ); + } + const worksheetRels = childrenWithTag(rels, "Relationship").filter( + (el) => + attr(el, "Type") === + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + ); + expect(worksheetRels).toHaveLength(1); + expect(attr(worksheetRels[0], "Id")).toBe("rId1"); + }); +}); + +describe("buildXlsxPackageFromContent: xl/workbook.xml sheet elements carry the correct sheetId and r:id per index", () => { + it("numbers sheetId from 1 and r:id via worksheetRelId, matching the sheet's own position, for a 2-sheet workbook", () => { + const pkg = buildXlsxPackageFromContent(DOCUMENT); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + expect(attr(workbook, "xmlns:r")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + ); + const sheetsEl = requireChild(workbook, "sheets"); + const sheetElements = elementsOf(sheetsEl, "sheet").map((el) => ({ + name: attributeOf(el, "name"), + sheetId: attributeOf(el, "sheetId"), + rId: attributeOf(el, "r:id"), + })); + expect(sheetElements).toEqual([ + { name: "Data", sheetId: "1", rId: "rId1" }, + { name: "Summary", sheetId: "2", rId: "rId2" }, + ]); + }); +}); + +describe("buildXlsxPackageFromContent: derives _xlnm.Print_Titles from EITHER repeatRows or repeatColumns alone, not only when both are present", () => { + function documentWithRepeat( + repeat: Partial< + Pick + >, + ): ContentDocument { + return { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { ...DEFAULT_PRINT_SETTINGS, ...repeat }, + }, + ], + }; + } + + it("derives Print_Titles from repeatRows alone, with no repeatColumns set", () => { + const pkg = buildXlsxPackageFromContent( + documentWithRepeat({ repeatRows: { start: 0, end: 1 } }), + ); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + const definedNames = requireChild(workbook, "definedNames"); + const printTitles = elementsOf(definedNames, "definedName").find( + (el) => attributeOf(el, "name") === "_xlnm.Print_Titles", + ); + expect(printTitles).toBeDefined(); + }); + + it("derives Print_Titles from repeatColumns alone, with no repeatRows set", () => { + const pkg = buildXlsxPackageFromContent( + documentWithRepeat({ repeatColumns: { start: 0, end: 1 } }), + ); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + const definedNames = requireChild(workbook, "definedNames"); + const printTitles = elementsOf(definedNames, "definedName").find( + (el) => attributeOf(el, "name") === "_xlnm.Print_Titles", + ); + expect(printTitles).toBeDefined(); + }); + + it("derives no Print_Titles at all when neither repeatRows nor repeatColumns is set", () => { + const pkg = buildXlsxPackageFromContent(documentWithRepeat({})); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + expect(childrenWithTag(workbook, "definedNames")).toHaveLength(0); + }); + + it("does not duplicate Print_Titles when the names array already carries it verbatim for that sheet", () => { + const wide = documentWithRepeat({ repeatRows: { start: 0, end: 1 } }); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + wide.names = [ + { + name: "_xlnm.Print_Titles", + refersTo: "Sheet1!$1:$1", + scopeSheetIndex: 0, + }, + ]; + const pkg = buildXlsxPackageFromContent(wide); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + const definedNames = requireChild(workbook, "definedNames"); + const printTitlesEntries = elementsOf(definedNames, "definedName").filter( + (el) => attributeOf(el, "name") === "_xlnm.Print_Titles", + ); + expect(printTitlesEntries).toHaveLength(1); + expect(textContent(printTitlesEntries[0]!)).toBe("Sheet1!$1:$1"); + }); +}); + +describe("buildXlsxPackageFromContent: xl/sharedStrings.xml carries the exact count/uniqueCount and per-entry xml:space", () => { + it('writes count and uniqueCount equal to the number of distinct strings, and xml:space="preserve" on every ', () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "Alpha" }, + displayText: "Alpha", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "Beta" }, + displayText: "Beta", + }, + ]), + ); + const sharedStrings = rootElement(pkg.parts["xl/sharedStrings.xml"]); + if (sharedStrings === undefined) { + throw new Error("expected xl/sharedStrings.xml to have a root element"); + } + expect(attr(sharedStrings, "count")).toBe("2"); + expect(attr(sharedStrings, "uniqueCount")).toBe("2"); + const tElements = childrenWithTag(sharedStrings, "si").map( + (si) => childrenWithTag(si, "t")[0], + ); + for (const t of tElements) { + expect(t === undefined ? undefined : attr(t, "xml:space")).toBe( + "preserve", + ); + } + expect(textContent(childrenWithTag(sharedStrings, "si")[0]!)).toBe("Alpha"); + }); +}); From 01c3cf29c0372e50410945a3cde63d56624bf5fe Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 07:58:30 +0100 Subject: [PATCH 096/102] test(ooxml.js): cover build.ts's styles-part and docProps output exactly Asserts the exact fixed scaffolding buildStylesPart writes for a document needing no number formats: the single default font, the two reserved fills, the one reserved border, and the default cellStyleXfs/cellXfs/cellStyles entries, none apply*-flagged. Adds a font carrying bold, italic, strike, and underline together, proving each toggle writes its own element independently; a border with only its top edge set, proving the per-edge branch runs independently for each of the four edges rather than uniformly; a cell with verticalAlignment 'top', the one branch neither 'middle' nor the default omission exercises; and pattern fills with only a foreground or only a background colour. Asserts every docProps/core.xml and docProps/app.xml field, including subject, modifiedIso, and creator, and the case where metadata carries none of them and keywords is an empty array. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index a9511966e..737a0c7ee 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -812,6 +812,334 @@ describe("buildXlsxPackageFromContent: a workbook needing no number formats writ ), ).toEqual([undefined]); }); + + it("writes the exact fixed scaffolding: one default font, the two reserved fills, the one reserved border, and a single default cellStyleXfs/cellXfs/cellStyles entry, none of them apply*-flagged", () => { + const styles = styleSheetOf(pkg); + + const fontsEl = requireChild(styles, "fonts"); + expect(attributeOf(fontsEl, "count")).toBe("1"); + const fonts = elementsOf(fontsEl, "font"); + expect(fonts).toHaveLength(1); + const defaultFont = fonts[0]; + if (defaultFont === undefined) { + throw new Error("expected a default "); + } + expect(attributeOf(requireChild(defaultFont, "sz"), "val")).toBe("11"); + expect(attributeOf(requireChild(defaultFont, "name"), "val")).toBe( + "Calibri", + ); + expect(elementsOf(defaultFont, "color")).toHaveLength(0); + expect(elementsOf(defaultFont, "b")).toHaveLength(0); + expect(elementsOf(defaultFont, "i")).toHaveLength(0); + expect(elementsOf(defaultFont, "strike")).toHaveLength(0); + expect(elementsOf(defaultFont, "u")).toHaveLength(0); + + const fillsEl = requireChild(styles, "fills"); + expect(attributeOf(fillsEl, "count")).toBe("2"); + const fills = elementsOf(fillsEl, "fill"); + expect( + fills.map((fill) => + attributeOf(requireChild(fill, "patternFill"), "patternType"), + ), + ).toEqual(["none", "gray125"]); + + const bordersEl = requireChild(styles, "borders"); + expect(attributeOf(bordersEl, "count")).toBe("1"); + const borders = elementsOf(bordersEl, "border"); + expect(borders).toHaveLength(1); + const reserved = borders[0]; + if (reserved === undefined) { + throw new Error("expected the reserved "); + } + expect(reserved.tag).toBe("border"); + for (const edge of ["left", "right", "top", "bottom", "diagonal"]) { + const edgeEl = requireChild(reserved, edge); + expect(attributeOf(edgeEl, "style")).toBeUndefined(); + expect(elementsOf(edgeEl, "color")).toHaveLength(0); + } + + const cellStyleXfsEl = requireChild(styles, "cellStyleXfs"); + expect(attributeOf(cellStyleXfsEl, "count")).toBe("1"); + const cellStyleXf = elementsOf(cellStyleXfsEl, "xf")[0]; + if (cellStyleXf === undefined) { + throw new Error("expected a inside "); + } + expect(attributeOf(cellStyleXf, "numFmtId")).toBe("0"); + expect(attributeOf(cellStyleXf, "fontId")).toBe("0"); + expect(attributeOf(cellStyleXf, "fillId")).toBe("0"); + expect(attributeOf(cellStyleXf, "borderId")).toBe("0"); + + const cellXfs = requireChild(styles, "cellXfs"); + const xf = elementsOf(cellXfs, "xf")[0]; + if (xf === undefined) { + throw new Error("expected the default "); + } + expect(attributeOf(xf, "fontId")).toBe("0"); + expect(attributeOf(xf, "fillId")).toBe("0"); + expect(attributeOf(xf, "borderId")).toBe("0"); + expect(attributeOf(xf, "xfId")).toBe("0"); + for (const flag of [ + "applyFont", + "applyFill", + "applyBorder", + "applyAlignment", + ]) { + expect(xf.attributes.map((a) => a.name)).not.toContain(flag); + } + + const cellStylesEl = requireChild(styles, "cellStyles"); + expect(attributeOf(cellStylesEl, "count")).toBe("1"); + const cellStyle = elementsOf(cellStylesEl, "cellStyle")[0]; + if (cellStyle === undefined) { + throw new Error("expected a "); + } + expect(attributeOf(cellStyle, "name")).toBe("Normal"); + expect(attributeOf(cellStyle, "xfId")).toBe("0"); + expect(attributeOf(cellStyle, "builtinId")).toBe("0"); + + expect(childrenWithTag(styles, "dxfs")).toHaveLength(0); + expect(styles.tag).toBe("styleSheet"); + expect(attr(styles, "xmlns")).toBe( + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + ); + }); + + it("writes numFmts with the exact declared numFmtId/formatCode and count, for a document needing a custom format", () => { + const withCustomFormat = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "boolean", value: true }, + displayText: "TRUE", + }, + ]), + ); + const styles = styleSheetOf(withCustomFormat); + const numFmts = requireChild(styles, "numFmts"); + expect(attributeOf(numFmts, "count")).toBe("1"); + const declared = elementsOf(numFmts, "numFmt"); + expect(declared).toHaveLength(1); + expect(attributeOf(declared[0]!, "numFmtId")).toBe("164"); + }); +}); + +describe("buildXlsxPackageFromContent: xl/styles.xml carries every font toggle, per-edge border mixing, and a one-sided pattern fill exactly", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + // A font using EVERY toggle at once, to prove each one writes its own element independently of the others. + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + font: { bold: true, italic: true, strike: true, underline: true }, + }, + // A border carrying only its top edge, so left/right/bottom must fall back to the bare, style-less branch while top alone carries real data. + { + row: 1, + column: 0, + value: { kind: "string", value: "y" }, + displayText: "y", + borders: { top: { color: { r: 0, g: 1, b: 0 }, widthPt: 1.5 } }, + }, + // A cell whose alignment.vertical is 'top', the one branch neither 'middle' nor the default omission exercises. + { + row: 2, + column: 0, + value: { kind: "string", value: "z" }, + displayText: "z", + alignment: "left", + verticalAlignment: "top", + }, + // A pattern fill with only its foreground colour set. + { + row: 3, + column: 0, + value: { kind: "string", value: "fg" }, + displayText: "fg", + background: { + kind: "pattern", + patternType: "lightGray", + foregroundColor: { r: 1, g: 0, b: 1 }, + }, + }, + // A pattern fill with only its background colour set. + { + row: 4, + column: 0, + value: { kind: "string", value: "bg" }, + displayText: "bg", + background: { + kind: "pattern", + patternType: "lightGray", + backgroundColor: { r: 0, g: 1, b: 1 }, + }, + }, + ]), + ); + const styles = styleSheetOf(pkg); + + it("writes bold/italic/strike/underline as four independent elements on the same ", () => { + const font = elementsOf(requireChild(styles, "fonts"), "font")[1]; + if (font === undefined) { + throw new Error("expected the all-toggles at index 1"); + } + expect(elementsOf(font, "b")).toHaveLength(1); + expect(elementsOf(font, "i")).toHaveLength(1); + expect(elementsOf(font, "strike")).toHaveLength(1); + const underline = elementsOf(font, "u")[0]; + expect(underline).toBeDefined(); + expect(attributeOf(underline!, "val")).toBe("single"); + }); + + it("writes only the top edge with real style/colour data, leaving left/right/bottom bare and the diagonal always empty", () => { + const border = elementsOf(requireChild(styles, "borders"), "border")[1]; + if (border === undefined) { + throw new Error("expected the top-only at index 1"); + } + expect(border.tag).toBe("border"); + const top = requireChild(border, "top"); + expect(attributeOf(top, "style")).toBe("medium"); + expect(attributeOf(requireChild(top, "color"), "rgb")).toBe("FF00ff00"); + for (const edge of ["left", "right", "bottom"]) { + const edgeEl = requireChild(border, edge); + expect(attributeOf(edgeEl, "style")).toBeUndefined(); + expect(elementsOf(edgeEl, "color")).toHaveLength(0); + } + expect(elementsOf(requireChild(border, "diagonal"), "color")).toHaveLength( + 0, + ); + }); + + it("writes verticalAlignment 'top' as alignment vertical=\"top\", distinct from 'middle' and the default omission", () => { + const cellXfs = requireChild(styles, "cellXfs"); + const topStyleIndex = attributeOf(writtenCell(pkg, "A3"), "s"); + const xf = elementsOf(cellXfs, "xf")[Number(topStyleIndex)]; + if (xf === undefined) { + throw new Error("expected an for the top-aligned cell"); + } + const alignment = requireChild(xf, "alignment"); + expect(attributeOf(alignment, "vertical")).toBe("top"); + }); + + it("writes a foreground-only pattern fill with fgColor and no bgColor", () => { + const fills = elementsOf(requireChild(styles, "fills"), "fill"); + const fgOnly = fills.find((fill) => { + const patternFill = childElement(fill, "patternFill"); + return ( + patternFill !== undefined && + attributeOf(patternFill, "patternType") === "lightGray" && + elementsOf(patternFill, "fgColor").length > 0 && + elementsOf(patternFill, "bgColor").length === 0 + ); + }); + expect(fgOnly).toBeDefined(); + const patternFill = requireChild(fgOnly!, "patternFill"); + expect(attributeOf(requireChild(patternFill, "fgColor"), "rgb")).toBe( + "FFff00ff", + ); + }); + + it("writes a background-only pattern fill with bgColor and no fgColor", () => { + const fills = elementsOf(requireChild(styles, "fills"), "fill"); + const bgOnly = fills.find((fill) => { + const patternFill = childElement(fill, "patternFill"); + return ( + patternFill !== undefined && + attributeOf(patternFill, "patternType") === "lightGray" && + elementsOf(patternFill, "bgColor").length > 0 && + elementsOf(patternFill, "fgColor").length === 0 + ); + }); + expect(bgOnly).toBeDefined(); + const patternFill = requireChild(bgOnly!, "patternFill"); + expect(attributeOf(requireChild(patternFill, "bgColor"), "rgb")).toBe( + "FF00ffff", + ); + }); +}); + +describe("buildXlsxPackageFromContent: docProps/core.xml and docProps/app.xml carry every metadata field", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: { + title: "T", + author: "A", + subject: "S", + keywords: ["k1", "k2"], + creator: "C", + createdIso: "2026-01-01T00:00:00Z", + modifiedIso: "2026-02-02T00:00:00Z", + }, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + + it("writes every core-properties field, including subject and modified date, into docProps/core.xml with the correct namespaces", () => { + const core = rootElement(pkg.parts["docProps/core.xml"]); + if (core === undefined) { + throw new Error("expected docProps/core.xml to have a root element"); + } + expect(core.tag).toBe("cp:coreProperties"); + expect(attr(core, "xmlns:cp")).toBe( + "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + ); + expect(attr(core, "xmlns:dc")).toBe("http://purl.org/dc/elements/1.1/"); + expect(attr(core, "xmlns:dcterms")).toBe("http://purl.org/dc/terms/"); + expect(attr(core, "xmlns:xsi")).toBe( + "http://www.w3.org/2001/XMLSchema-instance", + ); + expect(textContent(requireChild(core, "dc:subject"))).toBe("S"); + const modified = requireChild(core, "dcterms:modified"); + expect(attr(modified, "xsi:type")).toBe("dcterms:W3CDTF"); + expect(textContent(modified)).toBe("2026-02-02T00:00:00Z"); + }); + + it("writes the creator into docProps/app.xml's ", () => { + const app = rootElement(pkg.parts["docProps/app.xml"]); + if (app === undefined) { + throw new Error("expected docProps/app.xml to have a root element"); + } + expect(app.tag).toBe("Properties"); + expect(textContent(requireChild(app, "Application"))).toBe("C"); + }); + + it("writes no dc:subject, no cp:keywords, and no at all when those fields are absent, keywords is an empty array", () => { + const bare = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: { keywords: [] }, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + const core = rootElement(bare.parts["docProps/core.xml"]); + if (core === undefined) { + throw new Error("expected docProps/core.xml to have a root element"); + } + expect(childrenWithTag(core, "dc:subject")).toHaveLength(0); + expect(childrenWithTag(core, "cp:keywords")).toHaveLength(0); + const app = rootElement(bare.parts["docProps/app.xml"]); + if (app === undefined) { + throw new Error("expected docProps/app.xml to have a root element"); + } + expect(childrenWithTag(app, "Application")).toHaveLength(0); + }); }); describe('buildXlsxPackageFromContent: a formula cell with a cached STRING result writes t="str" literally, never shared-string-indexed', () => { From dbca8081477a47c8d9f91aecc13c69afc26e51fc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:00:55 +0100 Subject: [PATCH 097/102] test(ooxml.js): cover build.ts's dimension, cols, row/cell assembly, and merge output exactly Adds cases where a sheet's dimension is extended solely by its columns array or solely by its rows array (no cells at all), and a case where a column/row entry reaches past the last cell, proving computeDimension takes the genuine max across all three sources rather than letting one silently dominate. Asserts buildColsElement writes hidden with no width attribute, and width/customWidth with no hidden attribute, as two independent column declarations rather than always pairing the two. Proves buildSheetDataElement sorts both rows and, within a row, cells into ascending order regardless of input order, and that a row with no matching ContentSheetRow entry carries only its own r attribute. Proves buildMergeCellsElement treats colSpan and rowSpan as independent merge triggers, and writes no at all when every cell's span is 1 or absent. Covers buildCellElement's decoration ternary for alignment-only cells, the exact / child order and missing t attribute for a formula's numeric result, and the no-formula case writing no at all; renderString's formula-result branch for an unparseable temporal value; and buildSheetPrElement's fitToPage reflecting whether the sheet actually declares fitToPages. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 737a0c7ee..a0e735d08 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -2699,3 +2699,392 @@ describe("buildXlsxPackageFromContent: xl/sharedStrings.xml carries the exact co expect(textContent(childrenWithTag(sharedStrings, "si")[0]!)).toBe("Alpha"); }); }); + +// --- computeDimension, buildColsElement, cell/row assembly --------------------------------------------------------- + +describe("computeDimension: each of cells, columns, and rows independently extends the dimension, never overwriting a larger extent with a smaller one", () => { + function sheetOf( + overrides: Partial>, + ): ContentDocument { + return { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + ...overrides, + }, + ], + }; + } + + function dimensionRefOf(pkg: Package): string | undefined { + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + return attr(requireChild(worksheet, "dimension"), "ref"); + } + + it("extends the dimension from columns alone, with no cells or rows, down to row 1 only", () => { + const pkg = buildXlsxPackageFromContent( + sheetOf({ columns: [{ index: 4 }] }), + ); + expect(dimensionRefOf(pkg)).toBe("A1:E1"); + }); + + it("extends the dimension from rows alone, with no cells or columns, out to column A only", () => { + const pkg = buildXlsxPackageFromContent(sheetOf({ rows: [{ index: 4 }] })); + expect(dimensionRefOf(pkg)).toBe("A1:A5"); + }); + + it("takes the larger of cells' and rows'/columns' own extents, not the smaller -- a column/row entry past the last cell still widens the dimension", () => { + const pkg = buildXlsxPackageFromContent( + sheetOf({ + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + ], + columns: [{ index: 9 }], + rows: [{ index: 9 }], + }), + ); + expect(dimensionRefOf(pkg)).toBe("A1:J10"); + }); +}); + +describe("buildColsElement: width and hidden are independent, either can be written alone", () => { + it("writes a hidden column with no width attribute at all, when only `hidden` is declared", () => { + const hiddenOnly = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [{ index: 0, hidden: true }], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + const worksheet = rootElement(hiddenOnly.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const col = requireChild(requireChild(worksheet, "cols"), "col"); + expect(attr(col, "hidden")).toBe("true"); + expect(attr(col, "width")).toBeUndefined(); + expect(attr(col, "customWidth")).toBeUndefined(); + expect(attr(col, "min")).toBe("1"); + expect(attr(col, "max")).toBe("1"); + }); + + it("writes a visible column with width/customWidth and no hidden attribute at all, when only `widthPt` is declared", () => { + const widthOnly = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [{ index: 2, widthPt: 80 }], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + const worksheet = rootElement(widthOnly.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const col = requireChild(requireChild(worksheet, "cols"), "col"); + expect(attr(col, "customWidth")).toBe("true"); + expect(attr(col, "hidden")).toBeUndefined(); + expect(attr(col, "min")).toBe("3"); + expect(attr(col, "max")).toBe("3"); + }); +}); + +describe("buildSheetDataElement: rows and cells are written in ascending order regardless of input order, and a row with no ContentSheetRow entry carries only its own r attribute", () => { + it("writes rows in ascending row-index order and, within a row, cells in ascending column order, even when supplied in reverse", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 5, + column: 2, + value: { kind: "string", value: "e" }, + displayText: "e", + }, + { + row: 2, + column: 0, + value: { kind: "string", value: "b" }, + displayText: "b", + }, + { + row: 2, + column: 3, + value: { kind: "string", value: "d" }, + displayText: "d", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "a" }, + displayText: "a", + }, + ]), + ); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const sheetData = requireChild(worksheet, "sheetData"); + const rows = elementsOf(sheetData, "row"); + expect(rows.map((row) => attr(row, "r"))).toEqual(["1", "3", "6"]); + const middleRow = rows[1]; + if (middleRow === undefined) { + throw new Error("expected the row at index 1 (row 3)"); + } + expect(elementsOf(middleRow, "c").map((cell) => attr(cell, "r"))).toEqual([ + "A3", + "D3", + ]); + }); + + it("writes a row's own r attribute alone, with no ht/customHeight/hidden, when the sheet declares no matching ContentSheetRow", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 3, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + ]), + ); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const row = requireChild(requireChild(worksheet, "sheetData"), "row"); + expect(attr(row, "r")).toBe("4"); + expect(attr(row, "ht")).toBeUndefined(); + expect(attr(row, "customHeight")).toBeUndefined(); + expect(attr(row, "hidden")).toBeUndefined(); + }); +}); + +describe("buildMergeCellsElement: colSpan and rowSpan trigger a merge independently of each other", () => { + function pkgWith(cells: ContentSheet["cells"]): Package { + return buildXlsxPackageFromContent(singleSheetDocument(cells)); + } + + it("treats colSpan alone (rowSpan defaulting to 1) as a merge", () => { + const pkg = pkgWith([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + colSpan: 3, + }, + ]); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const mergeCells = requireChild(worksheet, "mergeCells"); + expect(attr(mergeCells, "count")).toBe("1"); + const mergeCell = requireChild(mergeCells, "mergeCell"); + expect(attr(mergeCell, "ref")).toBe("A1:C1"); + }); + + it("treats rowSpan alone (colSpan defaulting to 1) as a merge", () => { + const pkg = pkgWith([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + rowSpan: 3, + }, + ]); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const mergeCell = requireChild( + requireChild(worksheet, "mergeCells"), + "mergeCell", + ); + expect(attr(mergeCell, "ref")).toBe("A1:A3"); + }); + + it("writes no element at all when every cell's colSpan/rowSpan is exactly 1 or absent", () => { + const pkg = pkgWith([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + colSpan: 1, + rowSpan: 1, + }, + ]); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(worksheet, "mergeCells")).toHaveLength(0); + }); +}); + +describe("buildCellElement: the decoration/format branches that decide styleIndex, and the exact t/f/v children written", () => { + it("writes a cell carrying alignment alone (no font/background/borders/verticalAlignment) as decorated, not left at the default style index", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "left" }, + displayText: "left", + alignment: "left", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "plain" }, + displayText: "plain", + }, + ]), + ); + const leftIndex = attr(writtenCell(pkg, "A1"), "s"); + const plainIndex = attr(writtenCell(pkg, "B1"), "s"); + expect(leftIndex).not.toBe(plainIndex); + expect(plainIndex).toBe("0"); + }); + + it("writes both and for a formula cell, in that order, and no t attribute for its numeric cached result", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "number", value: 5 }, + formula: "2+3", + displayText: "5", + }, + ]), + ); + const cell = writtenCell(pkg, "A1"); + expect( + cell.children.map((c) => (c.type === "element" ? c.tag : c.type)), + ).toEqual(["f", "v"]); + expect(textContent(requireChild(cell, "f"))).toBe("2+3"); + expect(attr(cell, "t")).toBeUndefined(); + }); + + it("writes no element at all for a cell with no formula", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "number", value: 5 }, + displayText: "5", + }, + ]), + ); + expect(childrenWithTag(writtenCell(pkg, "A1"), "f")).toHaveLength(0); + }); +}); + +describe("renderString/renderTemporal: the formula-result and undefined-serial branches", () => { + it('writes a formula\'s own cached STRING result inline as t="str", never shared-string-indexed, even for a repeated value', () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "string", value: "same" }, + formula: '"same"', + displayText: "same", + }, + { + row: 0, + column: 1, + value: { kind: "string", value: "same" }, + displayText: "same", + }, + ]), + ); + expect(attr(writtenCell(pkg, "A1"), "t")).toBe("str"); + expect(attr(writtenCell(pkg, "B1"), "t")).toBe("s"); + // Only the literal cell interned into sharedStrings -- the formula's own cached text did not. + const sharedStrings = rootElement(pkg.parts["xl/sharedStrings.xml"]); + if (sharedStrings === undefined) { + throw new Error("expected xl/sharedStrings.xml to have a root element"); + } + expect(childrenWithTag(sharedStrings, "si")).toHaveLength(1); + }); + + it("degrades an unparseable date to text via renderString's OWN formula-result branch, writing t=\"str\" when the temporal value is itself a formula's cached result", () => { + const pkg = buildXlsxPackageFromContent( + singleSheetDocument([ + { + row: 0, + column: 0, + value: { kind: "date", value: "not-a-real-date" }, + formula: "TODAY()", + displayText: "not-a-real-date", + }, + ]), + ); + const cell = writtenCell(pkg, "A1"); + expect(attr(cell, "t")).toBe("str"); + expect(textContent(requireChild(cell, "v"))).toBe("not-a-real-date"); + }); +}); + +describe("buildSheetPrElement: fitToPage reflects whether fitToPages is actually present", () => { + it('writes pageSetUpPr fitToPage="true" when the sheet declares fitToPages', () => { + const pkg = buildXlsxPackageFromContent(SUMMARY_ONLY_DOCUMENT()); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const sheetPr = requireChild(worksheet, "sheetPr"); + expect(attr(requireChild(sheetPr, "pageSetUpPr"), "fitToPage")).toBe( + "true", + ); + }); + + it('writes pageSetUpPr fitToPage="false" when the sheet declares no fitToPages', () => { + const pkg = buildXlsxPackageFromContent(singleSheetDocument([])); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const sheetPr = requireChild(worksheet, "sheetPr"); + expect(attr(requireChild(sheetPr, "pageSetUpPr"), "fitToPage")).toBe( + "false", + ); + }); +}); + +function SUMMARY_ONLY_DOCUMENT(): ContentDocument { + return { kind: "spreadsheet", metadata: {}, sheets: [SUMMARY_SHEET] }; +} From b4ae2a85824227d63caa0a55479dccb014dec569 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:02:56 +0100 Subject: [PATCH 098/102] test(ooxml.js): cover build.ts's page margins, page setup, and manual breaks exactly Asserts ptToInches's genuine points-to-inches conversion for both the common 72pt case and non-72pt margins, and the fixed 0.3in header/footer margin. Covers buildPageSetupElement's paperSize/paperWidth-paperHeight branch for a standard versus a custom page size, the landscape and portrait orientation branches, the default scale/fitToWidth/ fitToHeight when neither scalePercent nor fitToPages is declared, and their declared values when present. Covers buildBreaksElements writing row breaks and column breaks independently of each other, with the exact id/min/max/man attributes, and writing neither element when manualBreaks is undefined or both its arrays are empty. Asserts buildWorksheetPart writes cols/mergeCells/drawing/ tableParts together for a sheet carrying every optional feature and none of them for a plain sheet, plus the worksheet root's own xmlns/xmlns:r and buildWorksheetRelsPart's Relationships root. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index a0e735d08..7ac36fa81 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -3088,3 +3088,303 @@ describe("buildSheetPrElement: fitToPage reflects whether fitToPages is actually function SUMMARY_ONLY_DOCUMENT(): ContentDocument { return { kind: "spreadsheet", metadata: {}, sheets: [SUMMARY_SHEET] }; } + +// --- print settings: margins, page setup, and manual breaks -------------------------------------------------------- + +describe("buildPageMarginsElement/ptToInches: writes the genuine points-to-inches conversion, not a fabricated one", () => { + it("converts 72pt margins to exactly 1 inch on every side, and the fixed 0.5in header/footer margin", () => { + const pkg = buildXlsxPackageFromContent(singleSheetDocument([])); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const margins = requireChild(worksheet, "pageMargins"); + expect(attr(margins, "left")).toBe("1"); + expect(attr(margins, "right")).toBe("1"); + expect(attr(margins, "top")).toBe("1"); + expect(attr(margins, "bottom")).toBe("1"); + expect(attr(margins, "header")).toBe("0.3"); + expect(attr(margins, "footer")).toBe("0.3"); + }); + + it("converts non-72pt margins proportionally, not with a fixed or fabricated ratio", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + margins: { topPt: 36, rightPt: 18, bottomPt: 144, leftPt: 9 }, + }, + }, + ], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const margins = requireChild(worksheet, "pageMargins"); + expect(attr(margins, "top")).toBe("0.5"); + expect(attr(margins, "right")).toBe("0.25"); + expect(attr(margins, "bottom")).toBe("2"); + expect(attr(margins, "left")).toBe("0.125"); + }); +}); + +describe("buildPageSetupElement: paperSize vs paperWidth/paperHeight, orientation, and scale/fitToWidth/fitToHeight defaults", () => { + function pageSetupOf(pageSize: { + widthPt: number; + heightPt: number; + }): XmlElement { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { ...DEFAULT_PRINT_SETTINGS, pageSize }, + }, + ], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + return requireChild(worksheet, "pageSetup"); + } + + it('writes paperSize (the recognised code), no paperWidth/paperHeight, and orientation="portrait" for a standard, taller-than-wide page', () => { + const pageSetup = pageSetupOf({ widthPt: 612, heightPt: 792 }); // US Letter + expect(attr(pageSetup, "paperSize")).toBe("1"); + expect(attr(pageSetup, "paperWidth")).toBeUndefined(); + expect(attr(pageSetup, "paperHeight")).toBeUndefined(); + expect(attr(pageSetup, "orientation")).toBe("portrait"); + }); + + it('writes paperWidth/paperHeight, no paperSize, and orientation="landscape" for a custom, wider-than-tall page', () => { + const pageSetup = pageSetupOf({ widthPt: 500, heightPt: 300 }); + expect(attr(pageSetup, "paperSize")).toBeUndefined(); + expect(attr(pageSetup, "paperWidth")).toBeDefined(); + expect(attr(pageSetup, "paperHeight")).toBeDefined(); + expect(attr(pageSetup, "orientation")).toBe("landscape"); + }); + + it('writes scale="100", fitToWidth="1", fitToHeight="1" as the genuine defaults when neither scalePercent nor fitToPages is declared', () => { + const pageSetup = pageSetupOf({ widthPt: 612, heightPt: 792 }); + expect(attr(pageSetup, "scale")).toBe("100"); + expect(attr(pageSetup, "fitToWidth")).toBe("1"); + expect(attr(pageSetup, "fitToHeight")).toBe("1"); + }); + + it("writes the declared scalePercent and fitToPages verbatim when they are present, not the defaults", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + scalePercent: 80, + fitToPages: { width: 2, height: 5 }, + }, + }, + ], + }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + const pageSetup = requireChild(worksheet, "pageSetup"); + expect(attr(pageSetup, "scale")).toBe("80"); + expect(attr(pageSetup, "fitToWidth")).toBe("2"); + expect(attr(pageSetup, "fitToHeight")).toBe("5"); + }); +}); + +describe("buildBreaksElements: manual row and column breaks are written independently of each other", () => { + function pkgWithBreaks(manualBreaks: { + rows: readonly number[]; + columns: readonly number[]; + }): Package { + return buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: { ...DEFAULT_PRINT_SETTINGS, manualBreaks }, + }, + ], + }); + } + + it("writes rowBreaks with the exact id/min/max/man attributes and count/manualBreakCount, no colBreaks at all, for row breaks alone", () => { + const pkg = pkgWithBreaks({ rows: [3, 7], columns: [] }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(worksheet, "colBreaks")).toHaveLength(0); + const rowBreaks = requireChild(worksheet, "rowBreaks"); + expect(attr(rowBreaks, "count")).toBe("2"); + expect(attr(rowBreaks, "manualBreakCount")).toBe("2"); + const brks = elementsOf(rowBreaks, "brk"); + expect(brks.map((brk) => attributeOf(brk, "id"))).toEqual(["3", "7"]); + const first = brks[0]; + if (first === undefined) { + throw new Error("expected the first "); + } + expect(attributeOf(first, "min")).toBe("0"); + expect(attributeOf(first, "max")).toBe("16383"); + expect(attributeOf(first, "man")).toBe("1"); + }); + + it("writes colBreaks with the exact id/min/max/man attributes and count/manualBreakCount, no rowBreaks at all, for column breaks alone", () => { + const pkg = pkgWithBreaks({ rows: [], columns: [2] }); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(worksheet, "rowBreaks")).toHaveLength(0); + const colBreaks = requireChild(worksheet, "colBreaks"); + expect(attr(colBreaks, "count")).toBe("1"); + expect(attr(colBreaks, "manualBreakCount")).toBe("1"); + const brk = elementsOf(colBreaks, "brk")[0]; + if (brk === undefined) { + throw new Error("expected a "); + } + expect(attributeOf(brk, "id")).toBe("2"); + expect(attributeOf(brk, "min")).toBe("0"); + expect(attributeOf(brk, "max")).toBe("1048575"); + expect(attributeOf(brk, "man")).toBe("1"); + }); + + it("writes neither rowBreaks nor colBreaks when manualBreaks is undefined, and neither when both arrays are empty", () => { + const noBreaks = rootElement( + buildXlsxPackageFromContent(singleSheetDocument([])).parts[ + "xl/worksheets/sheet1.xml" + ], + ); + if (noBreaks === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(noBreaks, "rowBreaks")).toHaveLength(0); + expect(childrenWithTag(noBreaks, "colBreaks")).toHaveLength(0); + + const emptyBreaks = rootElement( + pkgWithBreaks({ rows: [], columns: [] }).parts[ + "xl/worksheets/sheet1.xml" + ], + ); + if (emptyBreaks === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(emptyBreaks, "rowBreaks")).toHaveLength(0); + expect(childrenWithTag(emptyBreaks, "colBreaks")).toHaveLength(0); + }); +}); + +describe("buildWorksheetPart: element presence for cols, mergeCells, drawing, and tableParts, and buildWorksheetRelsPart's own root", () => { + it("writes cols, mergeCells, drawing, and tableParts all together, and no more than one of each, for a sheet carrying every optional feature", () => { + const pkg = buildXlsxPackageFromContent( + { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + colSpan: 2, + }, + ], + columns: [{ index: 0, widthPt: 50 }], + rows: [], + images: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 1, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }, + { definitions: tableDefinitions() }, + ); + const worksheet = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(worksheet.tag).toBe("worksheet"); + expect(attr(worksheet, "xmlns")).toBe( + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + ); + expect(attr(worksheet, "xmlns:r")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + ); + expect(childrenWithTag(worksheet, "cols")).toHaveLength(1); + expect(childrenWithTag(worksheet, "mergeCells")).toHaveLength(1); + const drawing = requireChild(worksheet, "drawing"); + expect(attr(drawing, "r:id")).toBeDefined(); + const tableParts = requireChild(worksheet, "tableParts"); + expect(attr(tableParts, "count")).toBe("1"); + expect(attr(requireChild(tableParts, "tablePart"), "r:id")).toBeDefined(); + + const rels = rootElement(pkg.parts["xl/worksheets/_rels/sheet1.xml.rels"]); + if (rels === undefined) { + throw new Error( + "expected the worksheet rels part to have a root element", + ); + } + expect(rels.tag).toBe("Relationships"); + expect(attr(rels, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/relationships", + ); + }); + + it("writes no cols, mergeCells, drawing, or tableParts at all for a plain sheet with none of those features", () => { + const worksheet = rootElement( + buildXlsxPackageFromContent(singleSheetDocument([])).parts[ + "xl/worksheets/sheet1.xml" + ], + ); + if (worksheet === undefined) { + throw new Error("expected a worksheet root element"); + } + expect(childrenWithTag(worksheet, "cols")).toHaveLength(0); + expect(childrenWithTag(worksheet, "mergeCells")).toHaveLength(0); + expect(childrenWithTag(worksheet, "drawing")).toHaveLength(0); + expect(childrenWithTag(worksheet, "tableParts")).toHaveLength(0); + }); +}); From dc0552fdfd22ad3ab19e0c0e1f6e4323f1e172a1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:26:07 +0100 Subject: [PATCH 099/102] test(ooxml.js): cover build.ts's per-sheet table filtering and relationship numbering exactly Proves a table definitions entry attaches tableParts and its own xl/tables/tableN.xml only to the sheet it names, and that an unrelated second sheet gets neither the table nor its own rels part at all, closing the gap where the 'table.sheet !== sheet.name' skip was never exercised by a genuinely non-matching sheet. Asserts worksheet relationship ids are assigned sequentially (rId1/rId2/rId3) across comments, drawing, and table relationships on the same sheet, in that order. Proves usedImageFormats collects every distinct format a sheet's images actually use (png and jpeg together), not just the first, and declares no Default extension for a format never used. Adds a negative assertion that a plain document with neither a chart nor a table writes no /xl/charts/ or /xl/tables/ Override at all, closing the gap where an initial-empty-array mutant seeding a bogus part name went undetected by toContainEqual-only assertions. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 7ac36fa81..12cc5f218 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -3388,3 +3388,176 @@ describe("buildWorksheetPart: element presence for cols, mergeCells, drawing, an expect(childrenWithTag(worksheet, "tableParts")).toHaveLength(0); }); }); + +// --- entry point: per-sheet table filtering, sequential relationship ids, and multi-format image usage ------------ + +describe("buildXlsxPackageFromContent: a table definitions entry attaches only to its own named sheet, never to any other", () => { + it("writes tableParts and xl/tables/table1.xml for the sheet the table names, and neither for a second, unrelated sheet", () => { + const pkg = buildXlsxPackageFromContent( + { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + { + name: "Other", + cells: [], + columns: [], + rows: [], + images: [], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }, + { definitions: tableDefinitions() }, + ); + const sheet1 = rootElement(pkg.parts["xl/worksheets/sheet1.xml"]); + const sheet2 = rootElement(pkg.parts["xl/worksheets/sheet2.xml"]); + if (sheet1 === undefined || sheet2 === undefined) { + throw new Error("expected both worksheet root elements"); + } + expect(childrenWithTag(sheet1, "tableParts")).toHaveLength(1); + expect(childrenWithTag(sheet2, "tableParts")).toHaveLength(0); + expect(Object.keys(pkg.parts)).not.toContain( + "xl/worksheets/_rels/sheet2.xml.rels", + ); + }); +}); + +describe("buildXlsxPackageFromContent: worksheet relationships are numbered sequentially across comments, drawing, and tables on the same sheet", () => { + it("assigns rId1/rId2/rId3 in the order comments, drawing, and table relationships are added, with no gap or repeat", () => { + const pkg = buildXlsxPackageFromContent( + { + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + comment: { text: "note" }, + }, + ], + columns: [], + rows: [], + images: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }, + { definitions: tableDefinitions() }, + ); + const rels = rootElement(pkg.parts["xl/worksheets/_rels/sheet1.xml.rels"]); + if (rels === undefined) { + throw new Error( + "expected the worksheet rels part to have a root element", + ); + } + const relationships = childrenWithTag(rels, "Relationship"); + expect(relationships.map((el) => attr(el, "Id"))).toEqual([ + "rId1", + "rId2", + "rId3", + ]); + const types = relationships.map((el) => attr(el, "Type")); + expect(types[0]).toContain("threadedComment"); + expect(types[1]).toContain("/drawing"); + expect(types[2]).toContain("/table"); + }); +}); + +describe("buildXlsxPackageFromContent: usedImageFormats collects every distinct image format actually used, and only those", () => { + it("declares a Default entry for both png and jpeg when a sheet carries one image of each, and none for gif", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: {}, + sheets: [ + { + name: "Sheet1", + cells: [], + columns: [], + rows: [], + images: [ + { + kind: "image", + format: "png", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + { + kind: "image", + format: "jpeg", + base64: TINY_PNG_BASE64, + widthPt: 10, + heightPt: 10, + anchorRow: 1, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + printSettings: DEFAULT_PRINT_SETTINGS, + }, + ], + }); + const contentTypes = rootElement(pkg.parts["[Content_Types].xml"]); + if (contentTypes === undefined) { + throw new Error("expected [Content_Types].xml to have a root element"); + } + const extensions = childrenWithTag(contentTypes, "Default").map((el) => + attr(el, "Extension"), + ); + expect(extensions).toContain("png"); + expect(extensions).toContain("jpeg"); + expect(extensions).not.toContain("gif"); + expect(Object.keys(pkg.parts)).toContain("xl/media/image1.png"); + expect(Object.keys(pkg.parts)).toContain("xl/media/image2.jpeg"); + }); +}); + +describe("buildXlsxPackageFromContent: [Content_Types].xml carries no chart/table overrides at all for a document with neither", () => { + it("writes no /xl/charts/ or /xl/tables/ Override, and no chart/table Default extensions, for a plain document", () => { + const pkg = buildXlsxPackageFromContent(DOCUMENT); + const contentTypes = rootElement(pkg.parts["[Content_Types].xml"]); + if (contentTypes === undefined) { + throw new Error("expected [Content_Types].xml to have a root element"); + } + const overrides = childrenWithTag(contentTypes, "Override").map((el) => + attr(el, "PartName"), + ); + expect(overrides.some((name) => name?.startsWith("/xl/charts/"))).toBe( + false, + ); + expect(overrides.some((name) => name?.startsWith("/xl/tables/"))).toBe( + false, + ); + }); +}); From 4eea4b6cdec3b207234aaccc2f311d946d73529d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:05:16 +0100 Subject: [PATCH 100/102] test(ooxml.js): assert the derived Print_Area definedName's own text content The existing "derives the reserved print names" test checked the definedName's name and localSheetId but never its own text, so a mutant dropping the range text entirely went unnoticed. --- packages/ooxml.js/src/typed/xlsx/build.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 12cc5f218..ad2661f24 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -2273,6 +2273,7 @@ describe("buildXlsxPackageFromContent: the definitions option (Table objects) an } expect(attr(printArea, "name")).toBe("_xlnm.Print_Area"); expect(attr(printArea, "localSheetId")).toBe("0"); + expect(textContent(printArea)).toBe("Sheet1!$A$1:$B$10"); }); it("writes no container and no xl/tables part at all when no definitions are supplied and the document carries no names", () => { From f8dcdf09c59a1d16744ea403e71e06f7095ad4e7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:25:10 +0100 Subject: [PATCH 101/102] docs(ooxml.js): record build.ts's re-measured mutation score and a Stryker reporting anomaly xlsx/build.ts now measures 69.3-69.5% of its own valid mutants under Stryker's scoped mutation run, up from 32.83% before the structural coverage added in this session's earlier commits, confirmed reproducible across three independent runs including one at concurrency 1. Documents a genuine tool-measurement anomaly found while verifying that improvement: several mutants Stryker's own reporter marks survived were directly disproven as equivalent by manually applying the exact mutation and running the identical vitest configuration Stryker's own runner uses, which fails the relevant tests every time. The package breakThreshold is left unchanged, since raising it needs a fresh full-package measurement once docx/write.ts and docx/read.ts, the package's two remaining large modules, have also been closed. --- packages/ooxml.js/stryker.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/stryker.config.ts b/packages/ooxml.js/stryker.config.ts index d0560cd5a..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", - // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. The package's three largest modules (xlsx/build.ts, docx/write.ts, docx/read.ts) remain well short of 100% and are the next real targets for closing this gap further; this threshold reflects the genuinely measured floor today, not a ceiling to stop at. + // Re-measured after closing the gaps in content.ts, styles.ts, drawings-write.ts (now 100%), conditional-format.ts, and pptx/read.ts: 84.44% of 6891 valid mutants, timeout share 0.4%; break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. xlsx/build.ts has since had extensive direct structural coverage added (exact scaffolding, styles-part, page setup, per-sheet assembly); scoped-mutating it alone now measures 69.3-69.5% of its own roughly 550 valid mutants, up from an original 32.83%, reproducibly across three separate runs including one at concurrency 1. The package's other two largest modules, docx/write.ts and docx/read.ts, remain essentially untouched and are still well short of 100%, so this threshold stays at its prior, conservative, genuinely-measured value rather than being bumped on an estimate: raising it needs a fresh full-package run (the CI mutation.yml pipeline, not a local single-file scope) to measure the real new floor once those two modules have also been closed. + // + // A genuine tool-measurement anomaly, not a test gap: several xlsx/build.ts mutants Stryker's own clear-text reporter marks [Survived] with "Ran all tests for this mutant" were directly disproven as equivalent. Manually applying the exact same mutation (e.g. build.ts:403's `declarations.length > 0` changed to `true`) and running `pnpm exec vitest run --config vitest.mutation.config.ts src/typed/xlsx/build.test.ts`, the identical runner config Stryker's own vitest-runner uses, fails two tests every time. Confirmed reproducible across two independent full scoped runs (differing survivor counts by 1, differing error and valid mutant counts by 21 between otherwise-identical build.ts source, pointing at nondeterminism in the TypeScript-checker phase's own mutant classification) and a third run at concurrency 1, which rules out a worker-pool race: the survivor trajectory tracked the concurrency-4 runs almost exactly at every checkpoint. Do not treat a [Survived] verdict on this package's mutation runs as proof a test is missing without first checking whether the equivalent manual-mutation-plus-vitest-run reproduces the failure; it may not. breakThreshold: 83, }); From 75093a6b4fc341fd076439eea6f4144603b2f64c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 08:29:19 +0100 Subject: [PATCH 102/102] test(ooxml.js): fix build.test.ts's own type errors under Node typecheck Narrow worksheetRels[0] with an explicit undefined check before passing it to attr(), instead of an unsound index access typed as XmlElement | undefined, and drop the readonly modifier from pkgWithBreaks's manualBreaks parameter to match ContentSheetPrintSettingsSchema's own mutable array type, which readonly arrays cannot satisfy. --- packages/ooxml.js/src/typed/xlsx/build.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index ad2661f24..aa84d479d 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -2551,7 +2551,11 @@ describe("buildXlsxPackageFromContent: xl/_rels/workbook.xml.rels numbers worksh "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", ); expect(worksheetRels).toHaveLength(1); - expect(attr(worksheetRels[0], "Id")).toBe("rId1"); + const [worksheetRel] = worksheetRels; + if (worksheetRel === undefined) { + throw new Error("expected exactly one worksheet relationship"); + } + expect(attr(worksheetRel, "Id")).toBe("rId1"); }); }); @@ -3219,8 +3223,8 @@ describe("buildPageSetupElement: paperSize vs paperWidth/paperHeight, orientatio describe("buildBreaksElements: manual row and column breaks are written independently of each other", () => { function pkgWithBreaks(manualBreaks: { - rows: readonly number[]; - columns: readonly number[]; + rows: number[]; + columns: number[]; }): Package { return buildXlsxPackageFromContent({ kind: "spreadsheet",