From 840b71be4f816030e4980e5dadcb60e559bceb9e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:53:58 +0100 Subject: [PATCH 01/81] 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 2cbe812af70df93cdad905b170bf82315a260745 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:54:15 +0100 Subject: [PATCH 02/81] 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 16c12caaf8ab54de49f1e3540febe58d75eedcf0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:54:30 +0100 Subject: [PATCH 03/81] 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 79f714b5a60e30d8412dd95aa70a3be79a93439c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:54:40 +0100 Subject: [PATCH 04/81] 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 8c73b6c2cf872ac3997fbfba6861819097027cd9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:54:52 +0100 Subject: [PATCH 05/81] 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 a5ba62aaa97228ec8352f965785bedd74054cd16 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 19:55:30 +0100 Subject: [PATCH 06/81] 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 f6243b07295c383701a0e656baa71e7aa2f74dac Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:18:46 +0100 Subject: [PATCH 07/81] 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 137e973404c0cdd03a1ca5cd35991f166a8d0761 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:01 +0100 Subject: [PATCH 08/81] 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 5c72ba10acbbfff8e9f9848718ddfcd2a9665d24 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:14 +0100 Subject: [PATCH 09/81] 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 5522911dacaf92726068594f6b916f058f29e264 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:24 +0100 Subject: [PATCH 10/81] 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 510a8ac244a76c4a5d45db5b1b93feb1eb24a243 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:33 +0100 Subject: [PATCH 11/81] 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 ebd1bfb90f62e0db9f8c73a7c7c5b926b73bf14a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:42 +0100 Subject: [PATCH 12/81] 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 278a07b6f9b3aebc58f1861cb43d3858a5aa52c1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:19:57 +0100 Subject: [PATCH 13/81] 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 0ebb6d99aa09b7b28008270ba7fb420183ccab26 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:20:11 +0100 Subject: [PATCH 14/81] 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 aca0d07129c95f2b08d074fbff3f7f6cd7eb85d4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:20:31 +0100 Subject: [PATCH 15/81] 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 a9e95bdac0401aba7876031ef74912ae25c2ca0f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:20:55 +0100 Subject: [PATCH 16/81] 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 c353c9c03dc688f6bff3160cc867260d88204d2d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:21:08 +0100 Subject: [PATCH 17/81] 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 3241e387bcfb564bdff61c98bc8ce301eb385d65 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:21:17 +0100 Subject: [PATCH 18/81] 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 c98e5a8e2704c082e45a755f1bc2baa4a17f33f1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:21:27 +0100 Subject: [PATCH 19/81] 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 4fca859bce2174cccc6080ce4f28946559c594d8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:22:58 +0100 Subject: [PATCH 20/81] 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 c15c71139aeabb3de0c8aaa3577641399b4186f1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:23:27 +0100 Subject: [PATCH 21/81] 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 b001ae1b1921ba4a480f79d1c2e7ab3cef72cbe2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:23:36 +0100 Subject: [PATCH 22/81] 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 2c48742af8c4bbe421ea111abc8ac1c154443634 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:23:47 +0100 Subject: [PATCH 23/81] 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 6c39bde88f28b09445f97ea066b613628214aa5c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:23:59 +0100 Subject: [PATCH 24/81] 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 3af2738fe920ce13cd9adb813f243168b739c592 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:12 +0100 Subject: [PATCH 25/81] 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 6745c51fb31b9d816aef681beba2d3fa2a958582 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:23 +0100 Subject: [PATCH 26/81] 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 792b2854d9f6360891f253497b2a0a6f52c0a227 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:35 +0100 Subject: [PATCH 27/81] 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 e30376ef29b8aec7195b686913b30f7ba74b18e1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:44 +0100 Subject: [PATCH 28/81] 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 9520aa7ffc6507bb6482c79e66e248561629e007 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:56 +0100 Subject: [PATCH 29/81] 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 4051f5fd36c94922555f3d0d47ac633da308f480 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:25:07 +0100 Subject: [PATCH 30/81] 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 be2e633a8ef0c87d7a01ace502c22fea46e4f188 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:25:18 +0100 Subject: [PATCH 31/81] 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 75cbf0c65e45aa3b020af4221ff0fc2ece6c6a59 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:25:28 +0100 Subject: [PATCH 32/81] 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 a085060b83f2a8b3153dcac54d59f93ad790ad2e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:25:40 +0100 Subject: [PATCH 33/81] 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 cfb811c415b0e6677ea88abfef4e9f0f2f9867f6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:26:15 +0100 Subject: [PATCH 34/81] 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 d77beb1dcff8a7be73fc663a2709e9e0c4df71aa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:26:29 +0100 Subject: [PATCH 35/81] 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 b8481a4cf58168d7f8bb07c50a797697e59d2aec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:26:43 +0100 Subject: [PATCH 36/81] 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 5ecee8b48f6a96c4fd7cb89588378616f6ee9fcd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:42:55 +0100 Subject: [PATCH 37/81] 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 98ac25bf82d894b450c76064916fcfd7b9748e25 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:43:10 +0100 Subject: [PATCH 38/81] 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 f2456291dbea9b9a4fbfc59c4f7236e637ac5bdd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:43:30 +0100 Subject: [PATCH 39/81] 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 54618d8f6e96722baecd1f0d8976322c23f05c58 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:43:53 +0100 Subject: [PATCH 40/81] 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 7198d8e0f3c248a07f4de46491f1467740cabb42 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:48:20 +0100 Subject: [PATCH 41/81] 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 8a9e53d2db029c60bdbbefa035c78c465fb86e5a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:41:02 +0100 Subject: [PATCH 42/81] 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 7ea2f15fe29ab3f3e44f192f03c1507560ef0e29 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:41:40 +0100 Subject: [PATCH 43/81] 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 60b098be914d1f62c1d7b5f898bdeeb1310d90fa Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:41:54 +0100 Subject: [PATCH 44/81] 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 92de84e6951f5427fab8f30f0e89514e9d3fc9f5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 18:44:48 +0100 Subject: [PATCH 45/81] 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 8e978f1e98571358d05f2c7a9ba8511a78596411 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:01:45 +0100 Subject: [PATCH 46/81] 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 f4ca53e415a36e0a58231cb1679f276b29fc4db2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:01:56 +0100 Subject: [PATCH 47/81] 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 5740d3c00ac05b58756c90350f360dd30da1dcd8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:02:08 +0100 Subject: [PATCH 48/81] 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 4c53ec22b495e4fa79fb8b2c199ae3388121bf25 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:08:33 +0100 Subject: [PATCH 49/81] 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 45fd09f19dcc800e75065743c0eedc5353e8bc14 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:09:18 +0100 Subject: [PATCH 50/81] 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 be6601ead0200af2515749d19a1f71452c87d4be Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:09:28 +0100 Subject: [PATCH 51/81] 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 da234f0b0569f819561b79d75f95dd660995cf34 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:14:39 +0100 Subject: [PATCH 52/81] 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 15db04876aea84f25b40967dee28821c4327c9bc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:14:59 +0100 Subject: [PATCH 53/81] 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 3c6113edeebabb356cccfafc42afc8060d190f7f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:25:10 +0100 Subject: [PATCH 54/81] 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 f36c6a9173fded626d4dd66215f4c10f589d2f06 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:25:23 +0100 Subject: [PATCH 55/81] 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 5aa96cad05e68193e34bf10c4763c317768e50d1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:31:04 +0100 Subject: [PATCH 56/81] 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 78df127f0498957e297f812dd945f50fdf65f3fc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:51:52 +0100 Subject: [PATCH 57/81] 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 7ef4b8fbf2dec4395302c0c0699ff4121a3fee45 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 19:52:02 +0100 Subject: [PATCH 58/81] 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 3902f9b12e0d6190b50c27d3a5fcdba2ae0c6375 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:00:45 +0100 Subject: [PATCH 59/81] 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 d942ff573ca8455fb3a092aa99c021237506a12b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:03:39 +0100 Subject: [PATCH 60/81] 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 6660ccbbef2f4c87c79d638312f9e36f4ac2f1ca Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:13:54 +0100 Subject: [PATCH 61/81] 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 cd883bcb255939714a67801644e88c8d89cb2ef0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:23:09 +0100 Subject: [PATCH 62/81] 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 5ecc2dfcaddcdb277a403566f313ee93e01493a6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:44:24 +0100 Subject: [PATCH 63/81] 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 ef78cb090fbc1819d3ebd24ba8720f384bd0b9cf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:44:36 +0100 Subject: [PATCH 64/81] 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 fd9a6f8179c3b768eddb22fba61a733aaeb18f51 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:49:42 +0100 Subject: [PATCH 65/81] 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 e90c50412392659326ab1fd1d72224a9429ef6de Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:57:35 +0100 Subject: [PATCH 66/81] 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 5fa061f1d5eb6b13d7dd6db819d7526adf31a5e6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 22:05:30 +0100 Subject: [PATCH 67/81] 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 8ead76caefa871af9a180fcc6d53a71482d721b5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 22:05:45 +0100 Subject: [PATCH 68/81] 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 979e08a0877d51305a89ebc487491b5d86b338c3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 22:07:08 +0100 Subject: [PATCH 69/81] 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 d0f280930e84c539bebea5c8a43ec037e6d4284c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 03:16:46 +0100 Subject: [PATCH 70/81] 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 60a4c8fd95eb3b3e08e7fc0f56f891841c7310cf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:31:38 +0100 Subject: [PATCH 71/81] 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 13fc751af71bb905fcb5677f21de05702ecf08c8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 05:48:19 +0100 Subject: [PATCH 72/81] 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 e67fdfee7f0736efab5b9b257625596e086e4464 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:55:32 +0100 Subject: [PATCH 73/81] 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 abd8ac258988079b24b93e8310178f9fc72e90b2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:10:32 +0100 Subject: [PATCH 74/81] 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 8e29a3a0e6b01b675bec3cd292009ea7c34794cf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:10:43 +0100 Subject: [PATCH 75/81] 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 f35672567daf3fea7d0087c70b5c82e227a3d793 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:19:55 +0100 Subject: [PATCH 76/81] 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 ba7dacf69bee26372b6d21c64593c94d67ab1b8a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:24:29 +0100 Subject: [PATCH 77/81] 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 e60fb6b697dfd7cebb39b0226e0ef82769c515c9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:27:04 +0100 Subject: [PATCH 78/81] 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 fefba10a588211961d17152a60c644fde723a2ca Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:21:10 +0100 Subject: [PATCH 79/81] 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 1885aee8369829b2db3056463022e610e00888a3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:54:14 +0100 Subject: [PATCH 80/81] 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 856c7dc6a74a64b683e0a0f6bf9f5bad40a814b5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 13:55:17 +0100 Subject: [PATCH 81/81] 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", {}, [