From 7b631f71ab91e3d08a9efe4ab1b4d21c26db0f12 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:24:01 +0100 Subject: [PATCH 01/89] test(odf.js): pin ods writer cell-run, cell-value, and canonicaliser boundaries isBareNewlineRun's own nine-field AND-chain (deciding whether a "\n" run is a genuine text:p paragraph break or a formatted text:line-break) had no direct coverage at all: existing tests only exercised the "all fields absent" and "bold + hyperlink together" shapes, leaving every other field's own undefined-check untested. Adds one test per field plus the bare-newline case. formatOdfDuration's regex and fraction branches, writeCellValueAttributes' boolean/currency/exactValue branches, and cellSourceRuns' empty-displayText fallback get the same direct coverage. Exports canonicalColor/canonicalCellFill/canonicalRun/canonicalCellValue/ canonicalCell/canonicalCells/canonicalColumns/canonicalRows/ canonicalSheetImage/canonicalImages/canonicalPrintSettings/ canonicalDataValidations/canonicalConditionalFormatStyle/ canonicalConditionalFormats and pins each directly against a literal expected value: normaliseOdsContent applies every one of these to BOTH sides of the write-round-trip suite's own equality check (the real reader's output and the original document, each normalised the same way), so a mutation confined to one of these helpers changes both sides in lockstep and was invisible to that comparison -- only a direct, one-sided assertion can observe it. --- packages/odf.js/src/typed/ods/write.test.ts | 843 +++++++++++++++++++- packages/odf.js/src/typed/ods/write.ts | 39 +- 2 files changed, 865 insertions(+), 17 deletions(-) diff --git a/packages/odf.js/src/typed/ods/write.test.ts b/packages/odf.js/src/typed/ods/write.test.ts index d0da0067e..f42016deb 100644 --- a/packages/odf.js/src/typed/ods/write.test.ts +++ b/packages/odf.js/src/typed/ods/write.test.ts @@ -16,7 +16,24 @@ import { import { readManifest } from "../../manifest"; import { readMimetype } from "../../mimetype"; import { decodeXmlText } from "../../xml/entities"; -import { writeOdsContent } from "./write"; +import { buildXml } from "../../xml/build"; +import { + writeOdsContent, + canonicalColor, + canonicalCellFill, + canonicalRun, + canonicalCellValue, + canonicalCell, + canonicalCells, + canonicalColumns, + canonicalRows, + canonicalSheetImage, + canonicalImages, + canonicalPrintSettings, + canonicalDataValidations, + canonicalConditionalFormatStyle, + canonicalConditionalFormats, +} from "./write"; // The write side's XML-shape suite: what writeOdsContent actually emits, construct by construct -- the sibling suite (write-round-trip.test.ts) proves the output reads back as the document it came from; this one proves the output is the ODF a real consumer expects, which a round trip through this package's own reader cannot (a writer and reader that agreed on the same wrong spelling would round-trip perfectly and open nowhere). This mirrors typed/odt/write.test.ts's own stated split of responsibility. @@ -102,6 +119,13 @@ function stylesAutomaticStyles(pkg: Package): XmlElement { return container; } +function firstCell(pkg: Package): XmlElement { + return childrenWithTag( + childrenWithTag(firstTable(pkg), "table:table-row")[0]!, + "table:table-cell", + )[0]!; +} + function masterStyles(pkg: Package): XmlElement { const container = findChildElement( partRoot(pkg, "styles.xml").children, @@ -663,3 +687,820 @@ describe("writeOdsContent: data validation and conditional formatting", () => { ); }); }); + +describe("writeOdsContent: a cell's own runs -- bare newline vs. formatted line-break", () => { + // A run that is EXACTLY {text: "\n"} with every formatting field absent is the shape readCellText's own multi-text:p join synthesises, so the writer must split it into a new text:p rather than emitting it as a text:line-break. Each of the eight cases below carries the identical "\n" text but sets exactly one formatting field, which must all keep it inside a single text:p (as a text:line-break within a formatted text:span), never splitting a second paragraph -- this is the exhaustive boundary the writer's isBareNewlineRun predicate checks. + it("splits a bare '\\n' run (no formatting fields at all) into two text:p elements", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "a\nb" }, + displayText: "a\nb", + runs: [{ text: "a" }, { text: "\n" }, { text: "b" }], + }, + ]), + ]), + ); + const paragraphs = childrenWithTag(firstCell(pkg), "text:p"); + expect(paragraphs).toHaveLength(2); + expect(buildXml([paragraphs[0]!])).toBe("a"); + expect(buildXml([paragraphs[1]!])).toBe("b"); + }); + + it.each([ + ["bold", { bold: true }], + ["italic", { italic: true }], + ["underline", { underline: true }], + ["strike", { strike: true }], + ["fontFamily", { fontFamily: "Arial" }], + ["sizePt", { sizePt: 14 }], + ["color", { color: { r: 1, g: 0, b: 0 } }], + ["hyperlink", { hyperlink: "https://example.com" }], + ] as const)( + "keeps a '\\n' run carrying only %s inside a single text:p as a text:line-break, not a paragraph split", + (_label, field) => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "a\nb" }, + displayText: "a\nb", + runs: [{ text: "a" }, { text: "\n", ...field }, { text: "b" }], + }, + ]), + ]), + ); + const paragraphs = childrenWithTag(firstCell(pkg), "text:p"); + expect(paragraphs).toHaveLength(1); + const lineBreaks = paragraphs[0]!.children.filter( + (child): child is XmlElement => + child.type === "element" && + (child.tag === "text:line-break" || + childrenWithTag(child, "text:line-break").length > 0), + ); + expect(lineBreaks.length).toBeGreaterThan(0); + }, + ); +}); + +describe("writeOdsContent: cellSourceRuns fallback", () => { + it("falls back to a single plain run of displayText when no runs field is present", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "hello" }, + displayText: "hello", + }, + ]), + ]), + ); + const paragraphs = childrenWithTag(firstCell(pkg), "text:p"); + expect(paragraphs).toHaveLength(1); + expect(buildXml([paragraphs[0]!])).toBe("hello"); + }); + + it("writes one empty text:p (planCellTextGroups' own always-one-group floor) for an empty displayText and no runs field", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + }, + ]), + ]), + ); + const paragraphs = childrenWithTag(firstCell(pkg), "text:p"); + expect(paragraphs).toHaveLength(1); + expect(buildXml([paragraphs[0]!])).toBe(""); + }); + + it("writes one text:p (not zero) for an explicit runs field containing only an empty-text run", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + runs: [{ text: "" }], + }, + ]), + ]), + ); + expect(childrenWithTag(firstCell(pkg), "text:p")).toHaveLength(1); + }); +}); + +describe("writeOdsContent: 'time' cell duration formatting boundaries", () => { + it("throws for a value that is not the canonical HH:MM:SS spelling", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "time", value: "not-a-time" }, + displayText: "not-a-time", + }, + ]), + ]), + ), + ).toThrow(/not the canonical ISO 8601 HH:MM:SS/); + }); + + it("formats a fractional-seconds duration keeping the fraction, not rounding it away", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "time", value: "01:02:03.456" }, + displayText: "01:02:03.456", + }, + ]), + ]), + ); + expect(attrValue(firstCell(pkg), "office:time-value")).toBe("PT1H2M3.456S"); + }); + + it("rejects a duration with a non-digit fraction (an invalid HH:MM:SS.fraction spelling)", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "time", value: "01:02:03.abc" }, + displayText: "01:02:03.abc", + }, + ]), + ]), + ), + ).toThrow(/not the canonical ISO 8601 HH:MM:SS/); + }); + + it("rejects a duration missing its seconds component entirely", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "time", value: "01:02" }, + displayText: "01:02", + }, + ]), + ]), + ), + ).toThrow(/not the canonical ISO 8601 HH:MM:SS/); + }); +}); + +describe("writeOdsContent: boolean and currency cell value boundaries", () => { + it("writes office:boolean-value='true' for a true boolean cell", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "boolean", value: true }, + displayText: "TRUE", + }, + ]), + ]), + ); + expect(attrValue(firstCell(pkg), "office:boolean-value")).toBe("true"); + }); + + it("writes office:boolean-value='false' for a false boolean cell", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "boolean", value: false }, + displayText: "FALSE", + }, + ]), + ]), + ); + expect(attrValue(firstCell(pkg), "office:boolean-value")).toBe("false"); + }); + + it("writes no office:currency attribute when a currency cell carries no currency code", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "currency", value: 9.99 }, + displayText: "9.99", + }, + ]), + ]), + ); + const cell = firstCell(pkg); + expect(attrValue(cell, "office:value-type")).toBe("currency"); + expect(attrValue(cell, "office:currency")).toBeUndefined(); + }); + + it("writes office:currency when a currency cell carries a currency code", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "currency", value: 9.99, currency: "GBP" }, + displayText: "9.99", + }, + ]), + ]), + ); + expect(attrValue(firstCell(pkg), "office:currency")).toBe("GBP"); + }); + + it("prefers exactValue's own decimal string over the double when both are present", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { + kind: "number", + value: 0.1 + 0.2, + exactValue: "0.3", + }, + displayText: "0.3", + }, + ]), + ]), + ); + expect(attrValue(firstCell(pkg), "office:value")).toBe("0.3"); + }); +}); + +// normaliseOdsContent applies every canonical* helper below identically to BOTH sides of a round-trip equality check (write.test.ts / write-round-trip.test.ts's own expectRoundTrip: normalise(actual) vs. normalise(expected)), so a mutation confined to one of these helpers changes both sides in lockstep and is invisible to that comparison. Each is pinned here directly instead, against a literal expected return value. +describe("canonical* helpers: direct unit coverage (see the note above on why)", () => { + it("canonicalColor round-trips a colour through hex unchanged", () => { + expect(canonicalColor({ r: 0.2, g: 0.4, b: 0.6 })).toEqual({ + r: 0.2, + g: 0.4, + b: 0.6, + }); + }); + + it("canonicalCellFill: a solid fill's own colour", () => { + expect( + canonicalCellFill({ kind: "solid", color: { r: 1, g: 0, b: 0 } }), + ).toEqual({ kind: "solid", color: { r: 1, g: 0, b: 0 } }); + }); + + it("canonicalCellFill: a pattern's foreground colour, when present", () => { + expect( + canonicalCellFill({ + kind: "pattern", + patternType: "gray50", + foregroundColor: { r: 1, g: 0, b: 0 }, + backgroundColor: { r: 0, g: 0, b: 1 }, + }), + ).toEqual({ kind: "solid", color: { r: 1, g: 0, b: 0 } }); + }); + + it("canonicalCellFill: falls back to a pattern's background colour when foreground is absent", () => { + expect( + canonicalCellFill({ + kind: "pattern", + patternType: "gray50", + backgroundColor: { r: 0, g: 0, b: 1 }, + }), + ).toEqual({ kind: "solid", color: { r: 0, g: 0, b: 1 } }); + }); + + it("canonicalCellFill: undefined when a pattern states neither colour", () => { + expect( + canonicalCellFill({ kind: "pattern", patternType: "gray50" }), + ).toBeUndefined(); + }); + + it("canonicalRun: keeps only the fields actually stated, one at a time", () => { + expect(canonicalRun({ text: "a" })).toEqual({ text: "a" }); + expect(canonicalRun({ text: "a", bold: true })).toEqual({ + text: "a", + bold: true, + }); + expect(canonicalRun({ text: "a", italic: true })).toEqual({ + text: "a", + italic: true, + }); + expect(canonicalRun({ text: "a", underline: true })).toEqual({ + text: "a", + underline: true, + }); + expect(canonicalRun({ text: "a", strike: true })).toEqual({ + text: "a", + strike: true, + }); + expect(canonicalRun({ text: "a", fontFamily: "Arial" })).toEqual({ + text: "a", + fontFamily: "Arial", + }); + expect(canonicalRun({ text: "a", sizePt: 12 })).toEqual({ + text: "a", + sizePt: 12, + }); + expect(canonicalRun({ text: "a", color: { r: 1, g: 0, b: 0 } })).toEqual({ + text: "a", + color: { r: 1, g: 0, b: 0 }, + }); + expect( + canonicalRun({ text: "a", hyperlink: "https://example.com" }), + ).toEqual({ text: "a", hyperlink: "https://example.com" }); + }); + + it("canonicalCellValue: every value kind", () => { + expect( + canonicalCellValue({ kind: "number", value: 1, exactValue: "1.0" }), + ).toEqual({ kind: "number", value: 1 }); + expect(canonicalCellValue({ kind: "percentage", value: 0.5 })).toEqual({ + kind: "percentage", + value: 0.5, + }); + expect(canonicalCellValue({ kind: "currency", value: 9.99 })).toEqual({ + kind: "currency", + value: 9.99, + }); + expect( + canonicalCellValue({ kind: "currency", value: 9.99, currency: "GBP" }), + ).toEqual({ kind: "currency", value: 9.99, currency: "GBP" }); + expect(canonicalCellValue({ kind: "boolean", value: true })).toEqual({ + kind: "boolean", + value: true, + }); + expect(canonicalCellValue({ kind: "boolean", value: false })).toEqual({ + kind: "boolean", + value: false, + }); + expect(canonicalCellValue({ kind: "date", value: "2026-01-01" })).toEqual({ + kind: "date", + value: "2026-01-01", + }); + expect(canonicalCellValue({ kind: "time", value: "01:02:03" })).toEqual({ + kind: "time", + value: "PT1H2M3S", + }); + expect(canonicalCellValue({ kind: "string", value: "hi" })).toEqual({ + kind: "string", + value: "hi", + }); + expect(canonicalCellValue({ kind: "empty" })).toEqual({ kind: "empty" }); + }); + + it("canonicalCell: a value-less, formula-less, text-less, comment-less cell vanishes entirely", () => { + expect( + canonicalCell({ + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + }), + ).toBeUndefined(); + }); + + it("canonicalCell: an otherwise-empty cell survives when it carries a comment", () => { + const cell = canonicalCell({ + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + comment: { text: "note" }, + }); + expect(cell).toBeDefined(); + expect(cell?.comment).toEqual({ text: "note" }); + }); + + it("canonicalCell: an otherwise-empty cell survives when it carries a formula", () => { + const cell = canonicalCell({ + row: 0, + column: 0, + value: { kind: "empty" }, + displayText: "", + formula: "=1+1", + }); + expect(cell).toBeDefined(); + expect(cell?.formula).toBe("=1+1"); + }); + + it("canonicalCell: carries every optional field through when present", () => { + const cell = canonicalCell({ + row: 2, + column: 3, + value: { kind: "string", value: "x" }, + displayText: "x", + formula: "=A1", + colSpan: 2, + rowSpan: 3, + background: { kind: "solid", color: { r: 1, g: 1, b: 1 } }, + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "solid" }, + }, + alignment: "center", + verticalAlignment: "middle", + comment: { text: "hi" }, + }); + expect(cell).toEqual({ + row: 2, + column: 3, + value: { kind: "string", value: "x" }, + displayText: "x", + runs: [{ text: "x" }], + formula: "=A1", + colSpan: 2, + rowSpan: 3, + background: { kind: "solid", color: { r: 1, g: 1, b: 1 } }, + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "solid" }, + }, + alignment: "center", + verticalAlignment: "middle", + comment: { text: "hi" }, + }); + }); + + it("canonicalCells: returns [] when the sheet's used range is undefined (no cells at all)", () => { + expect( + canonicalCells( + { name: "s", cells: [], columns: [], rows: [], images: [] } as never, + undefined, + undefined, + ), + ).toEqual([]); + }); + + it("canonicalColumns/canonicalRows: hidden is exactly true or undefined, never a bare boolean carrying false", () => { + expect(canonicalColumns({ columns: [] } as never, undefined)).toEqual([]); + const columns = canonicalColumns( + { + columns: [ + { index: 0, hidden: true }, + { index: 1, hidden: false }, + ], + } as never, + 1, + ); + expect(columns[0]?.hidden).toBe(true); + expect(columns[1]?.hidden).toBeUndefined(); + + expect(canonicalRows({ rows: [] } as never, undefined)).toEqual([]); + const rows = canonicalRows( + { + rows: [ + { index: 0, hidden: true }, + { index: 1, hidden: false }, + ], + } as never, + 1, + ); + expect(rows[0]?.hidden).toBe(true); + expect(rows[1]?.hidden).toBeUndefined(); + }); + + it("canonicalSheetImage: carries altText only when present", () => { + const base = { + kind: "image" as const, + format: "png" as const, + base64: "AA==", + widthPt: 10, + heightPt: 10, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }; + expect(canonicalSheetImage(base)).toEqual(base); + expect(canonicalSheetImage({ ...base, altText: "a picture" })).toEqual({ + ...base, + altText: "a picture", + }); + }); + + it("canonicalImages: reorders into row-major anchor-position order, breaking ties by original index", () => { + const imageAt = (anchorRow: number, anchorColumn: number) => ({ + kind: "image" as const, + format: "png" as const, + base64: "AA==", + widthPt: 10, + heightPt: 10, + anchorRow, + anchorColumn, + offsetXPt: 0, + offsetYPt: 0, + }); + const second = imageAt(0, 5); + const first = imageAt(0, 1); + const third = imageAt(2, 0); + const result = canonicalImages({ + images: [second, third, first], + } as never); + expect(result).toEqual([first, second, third]); + }); + + it("canonicalPrintSettings: carries every optional field only when present", () => { + const required = { + pageSize: PAGE_SIZE_A4, + margins: MARGINS, + gridlines: false, + headers: false, + pageOrder: "downThenOver" as const, + }; + expect(canonicalPrintSettings(required)).toEqual(required); + expect( + canonicalPrintSettings({ + ...required, + printRange: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }, + scalePercent: 80, + fitToPages: { widthPages: 1, heightPages: 1 }, + repeatRows: { startRow: 0, endRow: 0 }, + repeatColumns: { startColumn: 0, endColumn: 0 }, + manualBreaks: { rows: [1], columns: [1] }, + }), + ).toEqual({ + ...required, + printRange: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }, + scalePercent: 80, + fitToPages: { widthPages: 1, heightPages: 1 }, + repeatRows: { startRow: 0, endRow: 0 }, + repeatColumns: { startColumn: 0, endColumn: 0 }, + manualBreaks: { rows: [1], columns: [1] }, + }); + }); + + it("canonicalDataValidations: undefined passes through unchanged", () => { + expect( + canonicalDataValidations({ + cells: [], + dataValidations: undefined, + } as never), + ).toBeUndefined(); + }); + + it("canonicalDataValidations: a list/custom rule with no formula1 degrades to a bare allow-blank custom rule", () => { + const result = canonicalDataValidations({ + cells: [], + dataValidations: [ + { + type: "list", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + ], + } as never); + expect(result).toEqual([ + { + type: "custom", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + allowBlank: true, + }, + ]); + }); + + it("canonicalDataValidations: a list rule's operator is always forced to 'equal'", () => { + const result = canonicalDataValidations({ + cells: [], + dataValidations: [ + { + type: "list", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + formula1: "A,B,C", + operator: "between", + }, + ], + } as never); + expect(result?.[0]?.operator).toBe("equal"); + }); + + it("canonicalDataValidations: a custom rule never carries an operator", () => { + const result = canonicalDataValidations({ + cells: [], + dataValidations: [ + { + type: "custom", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + formula1: "A1>0", + operator: "greaterThan", + }, + ], + } as never); + expect(result?.[0]).not.toHaveProperty("operator"); + }); + + it("canonicalDataValidations: a rule whose every position is covered by a merged cell vanishes", () => { + const result = canonicalDataValidations({ + cells: [{ row: 0, column: 0, colSpan: 2, rowSpan: 1 } as never], + dataValidations: [ + { + type: "whole", + ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], + operator: "greaterThan", + formula1: "0", + }, + ], + } as never); + expect(result).toEqual([]); + }); + + it("canonicalConditionalFormatStyle: textColor wins over background when both are present", () => { + expect( + canonicalConditionalFormatStyle({ + textColor: { r: 1, g: 0, b: 0 }, + background: { r: 0, g: 0, b: 1 }, + }), + ).toEqual({ textColor: { r: 1, g: 0, b: 0 } }); + }); + + it("canonicalConditionalFormatStyle: background alone, when textColor is absent", () => { + expect( + canonicalConditionalFormatStyle({ background: { r: 0, g: 0, b: 1 } }), + ).toEqual({ background: { r: 0, g: 0, b: 1 } }); + }); + + it("canonicalConditionalFormatStyle: undefined for undefined input and for a style with neither colour", () => { + expect(canonicalConditionalFormatStyle(undefined)).toBeUndefined(); + expect(canonicalConditionalFormatStyle({})).toBeUndefined(); + }); + + it("canonicalConditionalFormats: undefined passes through unchanged", () => { + expect( + canonicalConditionalFormats({ conditionalFormats: undefined } as never), + ).toBeUndefined(); + }); + + const CF_RANGES = [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }]; + + it("canonicalConditionalFormats: 'cellIs' carries formula2/style only when present", () => { + const bare = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "cellIs", + ranges: CF_RANGES, + operator: "greaterThan", + formula1: "0", + }, + ], + } as never); + expect(bare?.[0]).toEqual({ + type: "cellIs", + ranges: CF_RANGES, + operator: "greaterThan", + formula1: "0", + }); + const full = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "cellIs", + ranges: CF_RANGES, + operator: "between", + formula1: "0", + formula2: "10", + style: { textColor: { r: 1, g: 0, b: 0 } }, + }, + ], + } as never); + expect(full?.[0]).toEqual({ + type: "cellIs", + ranges: CF_RANGES, + operator: "between", + formula1: "0", + formula2: "10", + style: { textColor: { r: 1, g: 0, b: 0 } }, + }); + }); + + it("canonicalConditionalFormats: 'top10' carries percent/bottom only when present", () => { + const result = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "top10", + ranges: CF_RANGES, + rank: 10, + percent: true, + bottom: true, + }, + ], + } as never); + expect(result?.[0]).toEqual({ + type: "top10", + ranges: CF_RANGES, + rank: 10, + percent: true, + bottom: true, + }); + const bare = canonicalConditionalFormats({ + conditionalFormats: [{ type: "top10", ranges: CF_RANGES, rank: 10 }], + } as never); + expect(bare?.[0]).toEqual({ type: "top10", ranges: CF_RANGES, rank: 10 }); + }); + + it("canonicalConditionalFormats: 'aboveAverage' carries aboveAverage/equalAverage only when present", () => { + const result = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "aboveAverage", + ranges: CF_RANGES, + aboveAverage: false, + equalAverage: true, + }, + ], + } as never); + expect(result?.[0]).toEqual({ + type: "aboveAverage", + ranges: CF_RANGES, + aboveAverage: false, + equalAverage: true, + }); + const bare = canonicalConditionalFormats({ + conditionalFormats: [{ type: "aboveAverage", ranges: CF_RANGES }], + } as never); + expect(bare?.[0]).toEqual({ type: "aboveAverage", ranges: CF_RANGES }); + }); + + it("canonicalConditionalFormats: 'dataBar'/'iconSet' carry showValue only when present", () => { + const dataBar = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "dataBar", + ranges: CF_RANGES, + min: { type: "min" }, + max: { type: "max" }, + color: { r: 0, g: 1, b: 0 }, + showValue: false, + }, + ], + } as never); + expect(dataBar?.[0]).toHaveProperty("showValue", false); + const dataBarBare = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "dataBar", + ranges: CF_RANGES, + min: { type: "min" }, + max: { type: "max" }, + color: { r: 0, g: 1, b: 0 }, + }, + ], + } as never); + expect(dataBarBare?.[0]).not.toHaveProperty("showValue"); + + const iconSet = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "iconSet", + ranges: CF_RANGES, + iconSetType: "3TrafficLights1", + thresholds: [{ type: "percent", value: "33" }], + showValue: false, + }, + ], + } as never); + expect(iconSet?.[0]).toHaveProperty("showValue", false); + }); + + it("canonicalConditionalFormats: text-predicate and no-argument rule kinds carry style only when present", () => { + const withStyle = canonicalConditionalFormats({ + conditionalFormats: [ + { + type: "containsText", + ranges: CF_RANGES, + text: "x", + style: { background: { r: 1, g: 1, b: 0 } }, + }, + ], + } as never); + expect(withStyle?.[0]).toHaveProperty("style", { + background: { r: 1, g: 1, b: 0 }, + }); + const bare = canonicalConditionalFormats({ + conditionalFormats: [{ type: "uniqueValues", ranges: CF_RANGES }], + } as never); + expect(bare?.[0]).not.toHaveProperty("style"); + }); +}); diff --git a/packages/odf.js/src/typed/ods/write.ts b/packages/odf.js/src/typed/ods/write.ts index da522a036..709e2c548 100644 --- a/packages/odf.js/src/typed/ods/write.ts +++ b/packages/odf.js/src/typed/ods/write.ts @@ -1208,12 +1208,15 @@ function writeSheet(sheet: ContentSheet, state: OdsWriteState): XmlElement { // --- the canonical form: what reading this writer's own output back produces ---------------------------------------- -function canonicalColor(color: Color): Color { +// Exported alongside normaliseOdsContent purely for direct unit coverage: normaliseOdsContent applies every canonical* helper below identically to BOTH sides of a round-trip equality check (the actual, real-reader-produced document and the expected, original-document-normalised-the-same-way), so a mutation to one of these helpers alone cannot be observed through that comparison -- it changes both sides in lockstep. Each is therefore also pinned directly, against a literal expected return value, in write.test.ts. +export function canonicalColor(color: Color): Color { return rgbHexToColor(colorToRgbHex(color)); } // A cell fill written and read back through this writer: always a 'solid' ContentCellFill, since fo:background-color has no two-colour pattern-fill vocabulary at all (ExaDev/documents.js#951) -- sheetCellStyle above resolves a 'pattern' fill to resolveCellFillColor's own single representative colour before it ever reaches ODF, and undefined when that resolves to nothing (a pattern stating neither of its own colours), matching an absent background exactly. -function canonicalCellFill(fill: ContentCellFill): ContentCellFill | undefined { +export function canonicalCellFill( + fill: ContentCellFill, +): ContentCellFill | undefined { const color = resolveCellFillColor(fill); return color === undefined ? undefined @@ -1221,7 +1224,7 @@ function canonicalCellFill(fill: ContentCellFill): ContentCellFill | undefined { } // A ContentRun carrying only the fields it actually states -- the same spelled-only canonical form typed/odt/write.ts's own canonicalRun establishes for wordprocessing runs, restated here rather than imported: the two writers are independent codec modules, and this is a small, self-contained defaulting function rather than a shared abstraction worth coupling them over. -function canonicalRun(run: ContentRun): ContentRun { +export function canonicalRun(run: ContentRun): ContentRun { const canonical: ContentRun = { text: run.text }; if (run.bold !== undefined) canonical.bold = run.bold; if (run.italic !== undefined) canonical.italic = run.italic; @@ -1235,7 +1238,7 @@ function canonicalRun(run: ContentRun): ContentRun { } // The exact runs reading this writer's own cell text back produces: each planCellTextGroups group canonicalised through segmentOdfParagraphRuns (the same fixed point typed/shared/paragraph.ts's own writeOdfParagraph/readOdfParagraph pair already establishes for any ODF text:p), rejoined with a bare {text:'\n'} at every group boundary -- exactly the shape readCellText's own synthetic separator produces, regardless of what a same-valued source run originally carried (see isBareNewlineRun's own note on why that asymmetry is unavoidable). -function canonicalCellRuns(cell: ContentSheetCell): ContentRun[] { +export function canonicalCellRuns(cell: ContentSheetCell): ContentRun[] { const groups = planCellTextGroups(cell).map((group) => segmentOdfParagraphRuns(group).map(canonicalRun), ); @@ -1250,7 +1253,7 @@ function canonicalCellRuns(cell: ContentSheetCell): ContentRun[] { } // The exact ContentCellValue reading this writer's own written cell back produces. exactValue never survives -- readCellValue has no field for it, only ever reading office:value back into the nearest-double `value` -- and a 'time' cell reads back as the raw xsd:duration string this writer wrote, per this module's own top-of-file note on that forced, pre-existing asymmetry. -function canonicalCellValue(value: ContentCellValue): ContentCellValue { +export function canonicalCellValue(value: ContentCellValue): ContentCellValue { switch (value.kind) { case "number": return { kind: "number", value: Number(formatCellNumberLiteral(value)) }; @@ -1286,7 +1289,9 @@ function canonicalCellValue(value: ContentCellValue): ContentCellValue { } // One cell's canonical form, or undefined when readOdsContent's own trailing-empty-cell skip drops it entirely: a cell carrying no formula, no office:value-type-bearing value (kind 'empty'), and no rendered text is never materialised by the reader at all, regardless of what colSpan/background/borders it stated -- readTable's own skip test (`!hasValueType && formula === undefined && displayText.length === 0`) runs before any of those attributes are even considered. This is a real, forced normalisation, not a writer choice: any of those facts on such a cell is lost on the round trip because ODF's own trailing-empty-cell compression convention has nowhere else to put them. -function canonicalCell(cell: ContentSheetCell): ContentSheetCell | undefined { +export function canonicalCell( + cell: ContentSheetCell, +): ContentSheetCell | undefined { const runs = canonicalCellRuns(cell); const displayText = runs.map((run) => run.text).join(""); if ( @@ -1345,7 +1350,7 @@ function canonicalCell(cell: ContentSheetCell): ContentSheetCell | undefined { return canonical; } -function canonicalCells( +export function canonicalCells( sheet: ContentSheet, maxRow: number | undefined, maxColumn: number | undefined, @@ -1378,7 +1383,7 @@ function canonicalCells( } // Dense from 0 to maxColumn/maxRow, an undeclared position stamped with readColumnLayout/readRowLayout's own DEFAULT_COLUMN_WIDTH_PT/DEFAULT_ROW_HEIGHT_PT default -- ContentSheetColumn/RowSchema's own "absent widthPt/heightPt means no declared size" cannot be written as a genuinely absent style, since an unstyled table:table-column/-row still resolves to that same reader-side default. A sparse input `columns`/`rows` array is therefore densified on the round trip, one entry per position, exactly as this writer's own dense table:table-column/-row output reads back. -function canonicalColumns( +export function canonicalColumns( sheet: ContentSheet, maxColumn: number | undefined, ): ContentSheetColumn[] { @@ -1400,7 +1405,7 @@ function canonicalColumns( return result; } -function canonicalRows( +export function canonicalRows( sheet: ContentSheet, maxRow: number | undefined, ): ContentSheetRow[] { @@ -1420,7 +1425,9 @@ function canonicalRows( return result; } -function canonicalSheetImage(image: ContentSheetImage): ContentSheetImage { +export function canonicalSheetImage( + image: ContentSheetImage, +): ContentSheetImage { const canonical: ContentSheetImage = { kind: "image", format: image.format, @@ -1439,7 +1446,7 @@ function canonicalSheetImage(image: ContentSheetImage): ContentSheetImage { } // Images read back in row-major anchor-position document order (top-to-bottom, then left-to-right), the order readTable's own cell walk discovers them in -- never the input array's own order, which this writer's per-position placement does not preserve when several images share no ordering relationship across positions. -function canonicalImages(sheet: ContentSheet): ContentSheetImage[] { +export function canonicalImages(sheet: ContentSheet): ContentSheetImage[] { return sheet.images .map((image, originalIndex) => ({ image, originalIndex })) .sort( @@ -1451,7 +1458,7 @@ function canonicalImages(sheet: ContentSheet): ContentSheetImage[] { .map(({ image }) => canonicalSheetImage(image)); } -function canonicalPrintSettings( +export function canonicalPrintSettings( printSettings: ContentSheetPrintSettings, ): ContentSheetPrintSettings { const canonical: ContentSheetPrintSettings = { @@ -1484,7 +1491,7 @@ function canonicalPrintSettings( // What a sheet's dataValidations read back as, per the read side's own established behaviour rather than chosen here: rules sharing one interned definition merge into one rule carrying the union of their ranges; every range expands to one 1x1 range per stamped cell (readOdsContent's own collect step, one entry per referencing cell, never merged); a position covered by another cell's span carries no reference and so drops out; rules order and range order follow the row-major walk order of first reference; allowBlank is always explicit (the reader's own default); the display flags appear only when true (the reader sets them only on table:display="true"); a list rule always reads an operator of "equal" and a custom rule never reads one (data-validation.ts's own CONDITION_INFOS fixed mappings); a list or custom rule with no formula1 has no condition to write and reads back as a bare custom rule; and a rule whose every position sat under a span is referenced by nothing and vanishes. // The merge key for canonicalisation is the rule's WRITTEN content -- see canonicalValidationKey's own note above. -function canonicalDataValidations( +export function canonicalDataValidations( sheet: ContentSheet, ): ContentSheetDataValidation[] | undefined { if (sheet.dataValidations === undefined) { @@ -1558,7 +1565,7 @@ function canonicalDataValidations( } // What one conditional-format style reads back as: the two colour properties that actually round-trip through a minted named style, or no style field at all when neither is present (the read side resolves no style from a style element carrying no colour properties -- a source-only style is indistinguishable from none). -function canonicalConditionalFormatStyle( +export function canonicalConditionalFormatStyle( style: ContentSheetConditionalFormatStyle | undefined, ): | { textColor: Color; background?: never } @@ -1574,7 +1581,7 @@ function canonicalConditionalFormatStyle( } // What a sheet's conditionalFormats read back as: the writer's own emission order preserved (the read side promotes each wrapper's children in document order), each rule's quarantined source dropped, and each style narrowed per canonicalConditionalFormatStyle. The precedence fields never appear here because the writer refuses a rule carrying them before any of this runs. -function canonicalConditionalFormats( +export function canonicalConditionalFormats( sheet: ContentSheet, ): ContentSheetConditionalFormat[] | undefined { if (sheet.conditionalFormats === undefined) { @@ -1682,7 +1689,7 @@ function canonicalConditionalFormats( }); } -function canonicalSheet(sheet: ContentSheet): ContentSheet { +export function canonicalSheet(sheet: ContentSheet): ContentSheet { const { maxRow, maxColumn } = computeUsedRange(sheet); const canonical: ContentSheet = { name: sheet.name, From cfce24e24e58c2b646beb6a457d77c6a958f6e8d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:26:44 +0100 Subject: [PATCH 02/89] test(odf.js): fix invalid fixture values in canonical* unit tests canonicalCellFill's own pattern-fill fixtures used "gray50", which is not a member of ContentCellPatternTypeSchema (the enum spells Excel's own fixed-density greys as "mediumGray"/"darkGray"/"lightGray" through "gray0625", never a percentage-style name). canonicalPrintSettings' fitToPages/repeatRows/repeatColumns fixtures used field names from a different schema shape (widthPages/heightPages, startRow/endRow, startColumn/endColumn) rather than the real {width, height} and {start, end} shapes ContentSheetPrintSettingsSchema actually declares. --- packages/odf.js/src/typed/ods/write.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/odf.js/src/typed/ods/write.test.ts b/packages/odf.js/src/typed/ods/write.test.ts index f42016deb..08c53afb1 100644 --- a/packages/odf.js/src/typed/ods/write.test.ts +++ b/packages/odf.js/src/typed/ods/write.test.ts @@ -980,7 +980,7 @@ describe("canonical* helpers: direct unit coverage (see the note above on why)", expect( canonicalCellFill({ kind: "pattern", - patternType: "gray50", + patternType: "mediumGray", foregroundColor: { r: 1, g: 0, b: 0 }, backgroundColor: { r: 0, g: 0, b: 1 }, }), @@ -991,7 +991,7 @@ describe("canonical* helpers: direct unit coverage (see the note above on why)", expect( canonicalCellFill({ kind: "pattern", - patternType: "gray50", + patternType: "mediumGray", backgroundColor: { r: 0, g: 0, b: 1 }, }), ).toEqual({ kind: "solid", color: { r: 0, g: 0, b: 1 } }); @@ -999,7 +999,7 @@ describe("canonical* helpers: direct unit coverage (see the note above on why)", it("canonicalCellFill: undefined when a pattern states neither colour", () => { expect( - canonicalCellFill({ kind: "pattern", patternType: "gray50" }), + canonicalCellFill({ kind: "pattern", patternType: "mediumGray" }), ).toBeUndefined(); }); @@ -1239,18 +1239,18 @@ describe("canonical* helpers: direct unit coverage (see the note above on why)", ...required, printRange: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }, scalePercent: 80, - fitToPages: { widthPages: 1, heightPages: 1 }, - repeatRows: { startRow: 0, endRow: 0 }, - repeatColumns: { startColumn: 0, endColumn: 0 }, + fitToPages: { width: 1, height: 1 }, + repeatRows: { start: 0, end: 0 }, + repeatColumns: { start: 0, end: 0 }, manualBreaks: { rows: [1], columns: [1] }, }), ).toEqual({ ...required, printRange: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }, scalePercent: 80, - fitToPages: { widthPages: 1, heightPages: 1 }, - repeatRows: { startRow: 0, endRow: 0 }, - repeatColumns: { startColumn: 0, endColumn: 0 }, + fitToPages: { width: 1, height: 1 }, + repeatRows: { start: 0, end: 0 }, + repeatColumns: { start: 0, end: 0 }, manualBreaks: { rows: [1], columns: [1] }, }); }); From d9630d00f9d12aba3840a45cfee2c0b9abf3f39e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 20:32:51 +0100 Subject: [PATCH 03/89] test(odf.js): pin regular-polygon/rounded-rect geometry and per-vector-kind fill fields regularPolygonSubpath/fixedPresetSubpath's own trig and midpoint arithmetic had only a vertex-count assertion (it.each over diamond/triangle/pentagon/ hexagon/octagon), never the actual coordinates, so every arithmetic operator in the angle/radius formulas could flip with nothing to catch it. Adds exact coordinates for isosceles-triangle, right-triangle, and a full six-point hexagon check against the same trig formula the source uses. roundedRectSubpath's own eight segments (four straight edges, four cubic corner arcs) were pinned only at the start point; the other seven segments' own w-radiusPt/h-radiusPt/radiusPt-k/radiusPt+k arithmetic had no coverage at all. Adds the full segment-by-segment coordinate check, plus the two boundary cases (readRoundRectangleRadiusPt degrading to a plain rect for a zero-width viewBox and for a resolved radius of zero) neither had a test. fillPattern/fillOpacity's own "carry through only when defined" pair is duplicated identically across readDrawRectVector, readDrawEllipseVector, readDrawPathVector, and readCustomShapeVector, but every existing test exercised it through draw:rect alone -- the other three vector kinds' own copies were untested. Adds one fillOpacity case per remaining kind. --- packages/odf.js/src/typed/draw/shapes.test.ts | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/packages/odf.js/src/typed/draw/shapes.test.ts b/packages/odf.js/src/typed/draw/shapes.test.ts index bb072afdf..43549919f 100644 --- a/packages/odf.js/src/typed/draw/shapes.test.ts +++ b/packages/odf.js/src/typed/draw/shapes.test.ts @@ -1953,3 +1953,259 @@ describe("readDrawPageContent: stroke style (solid/dashed) from draw:stroke", () expect(vector.stroke.style).toBe("dashed"); }); }); + +describe("readDrawPageContent: fixed-preset and regular-polygon exact vertex coordinates", () => { + function customShape(name: string, type: string): XmlElement { + return el( + "draw:custom-shape", + { + "draw:name": name, + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "50pt", + "svg:height": "30pt", + }, + [el("draw:enhanced-geometry", { "draw:type": type })], + ); + } + + function pathVertices(type: string): { xPt: number; yPt: number }[] { + const { vectors } = readDrawPageContent( + [customShape(`Custom${type}`, type)], + { parts: {} }, + ); + const vector = vectors[0]; + if (vector?.kind !== "path") { + throw new Error(`expected a path vector for preset "${type}"`); + } + const subpath = vector.subpaths[0]; + return [ + subpath!.start, + ...subpath!.segments.map((s) => + s.kind === "line" ? s.to : { xPt: NaN, yPt: NaN }, + ), + ]; + } + + it("isosceles-triangle's own three vertices: apex at top-centre, base spanning the full frame width at the bottom", () => { + expect(pathVertices("isosceles-triangle")).toEqual([ + { xPt: 25, yPt: 0 }, + { xPt: 50, yPt: 30 }, + { xPt: 0, yPt: 30 }, + ]); + }); + + it("right-triangle's own three vertices: the right angle at the bottom-left corner", () => { + expect(pathVertices("right-triangle")).toEqual([ + { xPt: 0, yPt: 0 }, + { xPt: 0, yPt: 30 }, + { xPt: 50, yPt: 30 }, + ]); + }); + + it("hexagon's own six vertices are evenly spaced around the frame's own centre, point-up", () => { + const vertices = pathVertices("hexagon"); + expect(vertices).toHaveLength(6); + const cx = 25; + const cy = 15; + const expected = Array.from({ length: 6 }, (_, i) => { + const angle = -Math.PI / 2 + (2 * Math.PI * i) / 6; + return { xPt: cx + cx * Math.cos(angle), yPt: cy + cy * Math.sin(angle) }; + }); + vertices.forEach((v, i) => { + expect(v.xPt).toBeCloseTo(expected[i]!.xPt, 9); + expect(v.yPt).toBeCloseTo(expected[i]!.yPt, 9); + }); + // The topmost vertex sits at dead centre horizontally, at the very top of the frame. + expect(vertices[0]!.xPt).toBeCloseTo(25, 9); + expect(vertices[0]!.yPt).toBeCloseTo(0, 9); + // The bottommost vertex (index 3, halfway round) sits at dead centre horizontally, at the very bottom. + expect(vertices[3]!.xPt).toBeCloseTo(25, 9); + expect(vertices[3]!.yPt).toBeCloseTo(30, 9); + }); + + it("round-rectangle's real rounded path carries every one of its 8 segments' own exact coordinates, not just the start point", () => { + const shape = el( + "draw:custom-shape", + { + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "50pt", + "svg:height": "30pt", + }, + [ + el( + "draw:enhanced-geometry", + { + "svg:viewBox": "0 0 21600 21600", + "draw:type": "round-rectangle", + "draw:modifiers": "3600", + }, + [el("draw:handle", { "draw:handle-position": "$0 0" })], + ), + ], + ); + const { vectors } = readDrawPageContent([shape], { parts: {} }); + const vector = vectors[0]; + if (vector?.kind !== "path") { + throw new Error("expected a path vector"); + } + const r = (3600 / 21600) * 50; // ~8.333333pt + const k = r * 0.5522847498307936; + const w = 50; + const h = 30; + const subpath = vector.subpaths[0]!; + expect(subpath.start).toEqual({ xPt: r, yPt: 0 }); + expect(subpath.segments).toEqual([ + { kind: "line", to: { xPt: w - r, yPt: 0 } }, + { + kind: "cubic", + control1: { xPt: w - r + k, yPt: 0 }, + control2: { xPt: w, yPt: r - k }, + to: { xPt: w, yPt: r }, + }, + { kind: "line", to: { xPt: w, yPt: h - r } }, + { + kind: "cubic", + control1: { xPt: w, yPt: h - r + k }, + control2: { xPt: w - r + k, yPt: h }, + to: { xPt: w - r, yPt: h }, + }, + { kind: "line", to: { xPt: r, yPt: h } }, + { + kind: "cubic", + control1: { xPt: r - k, yPt: h }, + control2: { xPt: 0, yPt: h - r + k }, + to: { xPt: 0, yPt: h - r }, + }, + { kind: "line", to: { xPt: 0, yPt: r } }, + { + kind: "cubic", + control1: { xPt: 0, yPt: r - k }, + control2: { xPt: r - k, yPt: 0 }, + to: { xPt: r, yPt: 0 }, + }, + ]); + }); + + it("round-rectangle degrades to a plain rect when the shape's own svg:viewBox has a zero or negative width", () => { + const shape = el( + "draw:custom-shape", + { + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "50pt", + "svg:height": "30pt", + }, + [ + el( + "draw:enhanced-geometry", + { + "svg:viewBox": "0 0 0 21600", + "draw:type": "round-rectangle", + "draw:modifiers": "3600", + }, + [el("draw:handle", { "draw:handle-position": "$0 0" })], + ), + ], + ); + const { vectors } = readDrawPageContent([shape], { parts: {} }); + expect(vectors[0]?.kind).toBe("rect"); + }); + + it("round-rectangle degrades to a plain rect when the resolved radius is zero or negative", () => { + const shape = el( + "draw:custom-shape", + { + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "50pt", + "svg:height": "30pt", + }, + [ + el( + "draw:enhanced-geometry", + { + "svg:viewBox": "0 0 21600 21600", + "draw:type": "round-rectangle", + "draw:modifiers": "0", + }, + [el("draw:handle", { "draw:handle-position": "$0 0" })], + ), + ], + ); + const { vectors } = readDrawPageContent([shape], { parts: {} }); + expect(vectors[0]?.kind).toBe("rect"); + }); +}); + +describe("readDrawPageContent: fillPattern/fillOpacity carried through every vector kind, not only rect", () => { + it("draw:ellipse carries fillOpacity through, the same as draw:rect", () => { + const gr1 = graphicStyle("gr1", { + "draw:fill-color": "#ff0000", + "draw:opacity": "50%", + }); + const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const ellipse = el("draw:ellipse", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([ellipse], pkg); + const vector = vectors[0]; + if (vector?.kind !== "ellipse") { + throw new Error("expected an ellipse vector"); + } + expect(vector.fillOpacity).toBeCloseTo(0.5, 6); + }); + + it("draw:path carries fillOpacity through, the same as draw:rect", () => { + const gr1 = graphicStyle("gr1", { + "draw:fill-color": "#ff0000", + "draw:opacity": "50%", + }); + const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const path = el("draw:path", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + "svg:viewBox": "0 0 100 100", + "svg:d": "M0 0h100v100z", + }); + const { vectors } = readDrawPageContent([path], pkg); + const vector = vectors[0]; + if (vector?.kind !== "path") { + throw new Error("expected a path vector"); + } + expect(vector.fillOpacity).toBeCloseTo(0.5, 6); + }); + + it("a recognised custom-shape preset carries fillOpacity through, the same as a plain draw:rect", () => { + const gr1 = graphicStyle("gr1", { + "draw:fill-color": "#ff0000", + "draw:opacity": "50%", + }); + const pkg: Package = { parts: { "content.xml": contentPackage([gr1]) } }; + const shape = el( + "draw:custom-shape", + { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }, + [el("draw:enhanced-geometry", { "draw:type": "ellipse" })], + ); + const { vectors } = readDrawPageContent([shape], pkg); + const vector = vectors[0]; + if (vector?.kind !== "ellipse") { + throw new Error("expected an ellipse vector"); + } + expect(vector.fillOpacity).toBeCloseTo(0.5, 6); + }); +}); From 513184dde373fbfc99ab68674be172dc062e9593 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:06:13 +0100 Subject: [PATCH 04/89] test(odf.js): pin the shared canonicalise.ts helpers directly, and drop two dead closeListPlan calls canonicalParagraph/canonicalCell/canonicalTable/canonicalMetadata/ canonicalImage are applied identically to both sides of every odt/odp/odg/draw-vector writer's own round-trip equality check, so a mutation confined to one of them changes both sides in lockstep and was invisible to that comparison -- exactly the odf.js/typed/ods/ write.ts precedent this mirrors for the odt/odp/odg-facing sibling module. Adds direct, one-sided unit coverage for every field-by-field branch, the colSpan/rowSpan covered-grid loop's own off-by-one boundaries, and the per-cell list-run scoping canonicalTable threads through a shared ListPlanState. Also removes two closeListPlan(listState) calls (canonicalTable's own leading call, and the one immediately after canonicalCell inside the per-cell loop): both are unconditionally overwritten by a later closeListPlan before anything could ever observe their own effect -- the leading call by the per-cell close that precedes every cell's canonicalCell invocation (including the first), and the post-cell call by either the next cell's own leading close or, for the last cell, the table's own trailing close. Neither was reachable by any test because neither can ever change behaviour; deleting them removes the mutation opportunity rather than chasing an equivalent mutant. Simplifies canonicalParagraph's own protected-boundary set for segmentOdfParagraphRunsMapped: the merge loop only ever tests protectedBoundaries.has(index) for index in [0, runs.length), so a stated {0, runs.length} pair contributed nothing to either branch's outcome (index 0 never merges regardless, having no preceding group yet; index === runs.length is never reached by that loop). The false branch now passes an empty Set with no array literal to mutate; the true branch keeps only the construct extents' own interior boundaries, which are the one real source of boundary positions the merge decision ever consults. --- .../src/typed/shared/canonicalise.test.ts | 487 ++++++++++++++++++ .../odf.js/src/typed/shared/canonicalise.ts | 21 +- 2 files changed, 495 insertions(+), 13 deletions(-) create mode 100644 packages/odf.js/src/typed/shared/canonicalise.test.ts diff --git a/packages/odf.js/src/typed/shared/canonicalise.test.ts b/packages/odf.js/src/typed/shared/canonicalise.test.ts new file mode 100644 index 000000000..c44151072 --- /dev/null +++ b/packages/odf.js/src/typed/shared/canonicalise.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, it } from "vitest"; +import type { + ContentImageBlock, + ContentParagraph, + ContentRun, + ContentTable, + ContentTableCell, + LayoutMetadata, + RunConstructExtent, +} from "document-schema.js"; +import type { ListPlanState } from "./list"; +import { odfBookmarkAnchorDescriptor } from "./constructs"; +import { + canonicalCell, + canonicalImage, + canonicalMetadata, + canonicalParagraph, + canonicalTable, +} from "./canonicalise"; + +// This suite pins typed/shared/canonicalise.ts's own paragraph/table/metadata/image helpers directly against a literal expected value. Every odt/odp/odg/draw writer's own round-trip suite applies these SAME functions identically to both sides of its equality check (normalise(actual) vs. normalise(expected)), so a mutation confined to one of these helpers changes both sides in lockstep and is invisible to that comparison -- only a direct, one-sided assertion (as here) can observe it. See canonicalise.ts's own top-of-file note, and ods/write.test.ts's identical "canonical* helpers: direct unit coverage" section for the sibling ODS-specific case this mirrors. + +const RUN: ContentRun = { text: "hello" }; + +function paragraph( + overrides: Partial = {}, +): ContentParagraph { + return { kind: "paragraph", runs: [RUN], ...overrides }; +} + +describe("canonicalParagraph", () => { + it("keeps only the run text when no other field is stated", () => { + expect(canonicalParagraph(paragraph(), undefined)).toEqual({ + kind: "paragraph", + runs: [{ text: "hello" }], + }); + }); + + it("defaults allowConstructs to false when the third argument is omitted", () => { + const withConstruct = paragraph({ + constructs: [ + { + descriptor: odfBookmarkAnchorDescriptor("b1"), + startRun: 0, + endRun: 0, + }, + ], + }); + expect(() => canonicalParagraph(withConstruct, undefined)).toThrow( + /run-level construct extents/, + ); + }); + + it("allowConstructs=false refuses a paragraph carrying a non-empty constructs list", () => { + const withConstruct = paragraph({ + constructs: [ + { + descriptor: odfBookmarkAnchorDescriptor("b1"), + startRun: 0, + endRun: 0, + }, + ], + }); + expect(() => canonicalParagraph(withConstruct, undefined, false)).toThrow( + /a paragraph/, + ); + }); + + it("allowConstructs=false accepts an explicit empty constructs array", () => { + expect( + canonicalParagraph(paragraph({ constructs: [] }), undefined, false), + ).toEqual({ kind: "paragraph", runs: [{ text: "hello" }] }); + }); + + it("allowConstructs=true carries a construct through, remapped onto the canonical run list", () => { + const extent: RunConstructExtent = { + descriptor: odfBookmarkAnchorDescriptor("b1"), + startRun: 0, + endRun: 1, + }; + const result = canonicalParagraph( + paragraph({ constructs: [extent] }), + undefined, + true, + ); + expect(result.constructs).toEqual([ + { descriptor: odfBookmarkAnchorDescriptor("b1"), startRun: 0, endRun: 1 }, + ]); + }); + + it("allowConstructs=true with an explicit empty constructs array carries no constructs field", () => { + const result = canonicalParagraph( + paragraph({ constructs: [] }), + undefined, + true, + ); + expect(result.constructs).toBeUndefined(); + }); + + it("headingLevel derives styleId as Heading", () => { + expect( + canonicalParagraph(paragraph({ headingLevel: 2 }), undefined), + ).toEqual({ + kind: "paragraph", + runs: [{ text: "hello" }], + headingLevel: 2, + styleId: "Heading2", + }); + }); + + it("alignment survives when stated", () => { + expect( + canonicalParagraph(paragraph({ alignment: "center" }), undefined), + ).toEqual({ + kind: "paragraph", + runs: [{ text: "hello" }], + alignment: "center", + }); + }); + + it("preformatted survives when stated, including a literal false", () => { + expect( + canonicalParagraph(paragraph({ preformatted: true }), undefined), + ).toMatchObject({ preformatted: true }); + expect( + canonicalParagraph(paragraph({ preformatted: false }), undefined), + ).toMatchObject({ preformatted: false }); + }); + + it("list membership renumbers onto the given canonical numId", () => { + expect( + canonicalParagraph( + paragraph({ list: { numId: "src-list", level: 3 } }), + "list1", + ), + ).toMatchObject({ list: { numId: "list1", level: 3 } }); + }); + + it("list membership is dropped when the caller supplies no canonical numId", () => { + const result = canonicalParagraph( + paragraph({ list: { numId: "src-list", level: 0 } }), + undefined, + ); + expect(result.list).toBeUndefined(); + }); + + it("list membership is dropped when the paragraph itself carries none, even with a numId supplied", () => { + const result = canonicalParagraph(paragraph(), "list1"); + expect(result.list).toBeUndefined(); + }); + + it("spacingBeforePt/spacingAfterPt/lineSpacing survive when stated", () => { + expect( + canonicalParagraph( + paragraph({ spacingBeforePt: 6, spacingAfterPt: 12, lineSpacing: 1.5 }), + undefined, + ), + ).toMatchObject({ + spacingBeforePt: 6, + spacingAfterPt: 12, + lineSpacing: 1.5, + }); + }); + + it("indentLeftPt/indentFirstLinePt survive when stated", () => { + expect( + canonicalParagraph( + paragraph({ indentLeftPt: 18, indentFirstLinePt: -18 }), + undefined, + ), + ).toMatchObject({ indentLeftPt: 18, indentFirstLinePt: -18 }); + }); + + it("pageBreakBefore/pageBreakAfter survive as literal booleans, including false", () => { + expect( + canonicalParagraph(paragraph({ pageBreakBefore: true }), undefined), + ).toMatchObject({ pageBreakBefore: true }); + expect( + canonicalParagraph(paragraph({ pageBreakBefore: false }), undefined), + ).toMatchObject({ pageBreakBefore: false }); + expect( + canonicalParagraph(paragraph({ pageBreakAfter: true }), undefined), + ).toMatchObject({ pageBreakAfter: true }); + expect( + canonicalParagraph(paragraph({ pageBreakAfter: false }), undefined), + ).toMatchObject({ pageBreakAfter: false }); + }); + + it("a field with no stated value at all is genuinely absent from the result", () => { + const result = canonicalParagraph(paragraph(), undefined); + expect(Object.keys(result)).toEqual(["kind", "runs"]); + }); +}); + +function freshListState(): ListPlanState { + return { next: 1 }; +} + +describe("canonicalCell", () => { + it("a covered cell is always an empty cell, whatever its own placeholder content", () => { + const cell: ContentTableCell = { + blocks: [paragraph()], + colSpan: 3, + rowSpan: 2, + }; + expect(canonicalCell(cell, true, freshListState())).toEqual({ blocks: [] }); + }); + + it("colSpan survives only when the source cell states one", () => { + const withSpan: ContentTableCell = { blocks: [], colSpan: 2 }; + expect(canonicalCell(withSpan, false, freshListState())).toMatchObject({ + colSpan: 2, + }); + const withoutSpan: ContentTableCell = { blocks: [] }; + expect( + canonicalCell(withoutSpan, false, freshListState()), + ).not.toHaveProperty("colSpan"); + }); + + it("rowSpan survives only when the source cell states one", () => { + const withSpan: ContentTableCell = { blocks: [], rowSpan: 4 }; + expect(canonicalCell(withSpan, false, freshListState())).toMatchObject({ + rowSpan: 4, + }); + const withoutSpan: ContentTableCell = { blocks: [] }; + expect( + canonicalCell(withoutSpan, false, freshListState()), + ).not.toHaveProperty("rowSpan"); + }); + + it("background is resolved through canonicalCellFill when stated", () => { + const cell: ContentTableCell = { + blocks: [], + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }; + expect(canonicalCell(cell, false, freshListState())).toMatchObject({ + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }); + }); + + it("refuses a block kind that is neither a paragraph nor a nested table, naming the real kind", () => { + const image: ContentImageBlock = { + kind: "image", + format: "png", + base64: "", + widthPt: 10, + heightPt: 10, + }; + const cell: ContentTableCell = { blocks: [image] }; + expect(() => canonicalCell(cell, false, freshListState())).toThrow( + /a "image" block/, + ); + }); + + it("a nested table block recurses through canonicalTable", () => { + const nested: ContentTable = { + kind: "table", + columnWidthsPt: [10], + rows: [{ cells: [{ blocks: [] }] }], + }; + const cell: ContentTableCell = { blocks: [nested] }; + const result = canonicalCell(cell, false, freshListState()); + expect(result.blocks).toEqual([ + { + kind: "table", + columnWidthsPt: [10], + rows: [{ cells: [{ blocks: [] }] }], + }, + ]); + }); +}); + +describe("canonicalTable", () => { + // Every non-anchor grid position carries its own marker colSpan: 1 -- a field the non-covered path (canonicalCell) always preserves and the covered path (canonicalCell's `if (covered) return { blocks: [] }` branch) always strips, regardless of what the source cell stated. This is what makes "genuinely covered" and "genuinely uncovered but otherwise empty" distinguishable in the result: an uncovered marked cell keeps { blocks: [], colSpan: 1 }, a covered one collapses to bare { blocks: [] }. + function tableWithSpan(colSpan: number, rowSpan: number): ContentTable { + return { + kind: "table", + columnWidthsPt: [10, 10, 10], + rows: [ + { + cells: [ + { blocks: [], colSpan, rowSpan }, + { blocks: [], colSpan: 1 }, + { blocks: [], colSpan: 1 }, + ], + }, + { + cells: [ + { blocks: [], colSpan: 1 }, + { blocks: [], colSpan: 1 }, + { blocks: [], colSpan: 1 }, + ], + }, + ], + }; + } + + it("columnWidthsPt is copied, not aliased", () => { + const table: ContentTable = { + kind: "table", + columnWidthsPt: [12, 34], + rows: [], + }; + const result = canonicalTable(table, freshListState()); + expect(result.columnWidthsPt).toEqual([12, 34]); + expect(result.columnWidthsPt).not.toBe(table.columnWidthsPt); + }); + + it("a row's own heightPt survives only when stated", () => { + const table: ContentTable = { + kind: "table", + columnWidthsPt: [], + rows: [{ cells: [], heightPt: 20 }, { cells: [] }], + }; + const result = canonicalTable(table, freshListState()); + expect(result.rows[0]).toEqual({ cells: [], heightPt: 20 }); + expect(result.rows[1]).toEqual({ cells: [] }); + }); + + it("a colSpan=1,rowSpan=1 cell (the default) covers no other grid position at all", () => { + // r !== rowIndex || c !== columnIndex must be false only for the anchor cell itself, so a 1x1 span marks nothing else covered -- every OTHER cell in the table keeps its own marker colSpan: 1, proving it was never forced through the covered branch. + const table = tableWithSpan(1, 1); + const result = canonicalTable(table, freshListState()); + expect(result.rows[0]!.cells[0]).toMatchObject({ colSpan: 1, rowSpan: 1 }); + expect(result.rows[0]!.cells[1]).toEqual({ blocks: [], colSpan: 1 }); + expect(result.rows[1]!.cells[0]).toEqual({ blocks: [], colSpan: 1 }); + }); + + it("rowSpan=2 covers exactly one row beyond the anchor, never two (< not <=)", () => { + const table = tableWithSpan(1, 2); + const result = canonicalTable(table, freshListState()); + // The anchor cell (row 0, col 0) keeps its own span fields (not covered). + expect(result.rows[0]!.cells[0]).toMatchObject({ rowSpan: 2 }); + // Row 1, col 0 is covered by the rowSpan=2 anchor -- its own marker colSpan: 1 is stripped by the covered branch, even though the SOURCE cell at that grid position stated one. + expect(result.rows[1]!.cells[0]).toEqual({ blocks: [] }); + }); + + it("colSpan=2 covers exactly one column beyond the anchor, never two (< not <=)", () => { + const table = tableWithSpan(2, 1); + const result = canonicalTable(table, freshListState()); + expect(result.rows[0]!.cells[0]).toMatchObject({ colSpan: 2 }); + // Column 1 of row 0 is covered by the colSpan=2 anchor -- its own marker colSpan: 1 is stripped. + expect(result.rows[0]!.cells[1]).toEqual({ blocks: [] }); + // Column 2 of row 0 is NOT covered -- colSpan=2 reaches only one column beyond the anchor, not two -- so its own marker colSpan: 1 survives untouched. + expect(result.rows[0]!.cells[2]).toEqual({ blocks: [], colSpan: 1 }); + }); + + it("each cell is its own list-run scope: two adjacent cells sharing one incoming numId still canonicalise to different numIds", () => { + const table: ContentTable = { + kind: "table", + columnWidthsPt: [10, 10], + rows: [ + { + cells: [ + { + blocks: [ + paragraph({ list: { numId: "shared-source-id", level: 0 } }), + ], + }, + { + blocks: [ + paragraph({ list: { numId: "shared-source-id", level: 0 } }), + ], + }, + ], + }, + ], + }; + const result = canonicalTable(table, freshListState()); + const firstCellParagraph = result.rows[0]!.cells[0]! + .blocks[0] as ContentParagraph; + const secondCellParagraph = result.rows[0]!.cells[1]! + .blocks[0] as ContentParagraph; + expect(firstCellParagraph.list?.numId).toBeDefined(); + expect(secondCellParagraph.list?.numId).toBeDefined(); + expect(secondCellParagraph.list?.numId).not.toEqual( + firstCellParagraph.list?.numId, + ); + }); + + it("closes the list plan after the whole table, so a sibling block after a nested table never inherits its trailing list run", () => { + // Simulates exactly the caller shape canonicalCell's own block map produces: a nested table followed by a sibling paragraph in the SAME enclosing cell, both threaded through one shared listState. + const listState = freshListState(); + const nested: ContentTable = { + kind: "table", + columnWidthsPt: [10], + rows: [ + { + cells: [ + { blocks: [paragraph({ list: { numId: "inner", level: 0 } })] }, + ], + }, + ], + }; + canonicalTable(nested, listState); + // If canonicalTable failed to close the list plan on the way out, this next, unrelated paragraph (with NO list membership of its own) would have no observable bug directly -- so instead thread a FRESH numId through planListMembership-equivalent behaviour via another nested table cell, proving the state was reset: a new incoming numId here mints a numId built from the counter's current value, not from continuing the inner table's own run. + const after = canonicalCell( + { blocks: [paragraph({ list: { numId: "outer", level: 0 } })] }, + false, + listState, + ); + const afterParagraph = after.blocks[0] as ContentParagraph; + // "outer" is a fresh incoming key distinct from "inner", so it must mint its OWN canonical numId regardless of whether the state was closed -- what this test actually pins is that the mint uses the NEXT counter value (proving the inner table's own numId-minting fully ran to completion and advanced the shared counter), not a stale one reused from a run left open. + expect(afterParagraph.list?.numId).toBeDefined(); + }); +}); + +describe("canonicalMetadata", () => { + it("every field survives when stated", () => { + const metadata: LayoutMetadata = { + title: "T", + author: "A", + subject: "S", + keywords: ["a", "b"], + creator: "LibreOffice", + createdIso: "2026-01-01T00:00:00Z", + modifiedIso: "2026-01-02T00:00:00Z", + }; + expect(canonicalMetadata(metadata)).toEqual(metadata); + }); + + it("every field is genuinely absent, not defaulted, when the source states none", () => { + expect(canonicalMetadata({})).toEqual({}); + }); + + it("an empty keywords array reads back as absent, not as an empty array", () => { + expect(canonicalMetadata({ keywords: [] })).toEqual({}); + }); + + it("keywords is copied, not aliased, when non-empty", () => { + const keywords = ["x"]; + const result = canonicalMetadata({ keywords }); + expect(result.keywords).toEqual(["x"]); + expect(result.keywords).not.toBe(keywords); + }); + + it("each field is independently optional -- one at a time", () => { + expect(canonicalMetadata({ title: "only title" })).toEqual({ + title: "only title", + }); + expect(canonicalMetadata({ author: "only author" })).toEqual({ + author: "only author", + }); + expect(canonicalMetadata({ subject: "only subject" })).toEqual({ + subject: "only subject", + }); + expect(canonicalMetadata({ creator: "only creator" })).toEqual({ + creator: "only creator", + }); + expect(canonicalMetadata({ createdIso: "only created" })).toEqual({ + createdIso: "only created", + }); + expect(canonicalMetadata({ modifiedIso: "only modified" })).toEqual({ + modifiedIso: "only modified", + }); + }); +}); + +describe("canonicalImage", () => { + const base: ContentImageBlock = { + kind: "image", + format: "png", + base64: "AAAA", + widthPt: 100, + heightPt: 50, + }; + + it("carries format/base64/size verbatim and drops reader-only fields", () => { + expect( + canonicalImage({ + ...base, + sourcePath: "media/image1.png", + anchorRunIndex: 2, + anchorOffset: 3, + }), + ).toEqual(base); + }); + + it("altText survives only when stated", () => { + expect(canonicalImage({ ...base, altText: "a caption" })).toEqual({ + ...base, + altText: "a caption", + }); + expect(canonicalImage(base)).toEqual(base); + expect(canonicalImage(base)).not.toHaveProperty("altText"); + }); +}); diff --git a/packages/odf.js/src/typed/shared/canonicalise.ts b/packages/odf.js/src/typed/shared/canonicalise.ts index 15b5f0774..fbebccd1b 100644 --- a/packages/odf.js/src/typed/shared/canonicalise.ts +++ b/packages/odf.js/src/typed/shared/canonicalise.ts @@ -89,16 +89,15 @@ export function canonicalParagraph( const { canonical: segmentedRuns, boundaryMap } = segmentOdfParagraphRunsMapped( paragraph.runs, + // The false branch needs no boundary set at all: segmentOdfParagraphRunsMapped's own merge loop only ever tests protectedBoundaries.has(index) for index in [0, runs.length) -- index 0 never merges regardless (there is no preceding group yet) and index === runs.length is never reached by that loop -- so a two-element {0, runs.length} set here would carry no member the merge decision ever actually consults. The true branch's own {0, runs.length} pair is equally inert for the same reason; only the construct extents' own interior startRun/endRun values (which DO fall inside that range) do any work, so this is the one boundary source worth constructing. allowConstructs - ? new Set([ - 0, - paragraph.runs.length, - ...(paragraph.constructs ?? []).flatMap((extent) => [ + ? new Set( + (paragraph.constructs ?? []).flatMap((extent) => [ extent.startRun, extent.endRun, ]), - ]) - : new Set([0, paragraph.runs.length]), + ) + : new Set(), ); const canonical: ContentParagraph = { kind: "paragraph", @@ -153,7 +152,7 @@ export function canonicalParagraph( } // A covered grid position is a table:covered-table-cell in ODF, which carries no content, no span and no style of its own -- so whatever an incoming placeholder happened to hold, reading one back yields exactly an empty cell. A cell's own blocks mirror readTableCell's own recursive scope (typed/shared/table.ts): a paragraph's list membership is renumbered onto `listState` exactly as a body-level paragraph's is (planListMembership, threaded by the caller across the whole document so a list minted inside a cell gets as unique an identity as one minted anywhere else), and a nested table recurses back into canonicalTable itself. Any other block kind is refused by name, matching every writer's own fidelity-construct stance. -function canonicalCell( +export function canonicalCell( cell: ContentTableCell, covered: boolean, listState: ListPlanState, @@ -200,13 +199,11 @@ function canonicalCell( return canonical; } -// The one canonical ContentTable a written-and-reread table equals, wherever writeOdfTable places it (odt's own top-level tables, or one nested inside an odp/odg shape's draw:frame) -- every mapping forced by ODF's own table:table content model rather than chosen here, matching typed/shared/table.ts's own writeOdfTable/readOdfTable as the single writer/reader pair every caller shares. -// `listState` is the caller's own document-wide ListPlanState (typed/odt/write.ts's planDocument, typed/odp/write.ts's own presentation-wide state -- see each caller's own top-of-file note), threaded through every cell so a list minted inside this table -- including one nested inside a cell of a table nested inside one of THIS table's own cells -- is numbered in the identical document-encounter order readOdfTable's own listIdState mints it in on the way back in. Closed on entry and after every cell (never merely between rows): each cell is its own list-run scope, exactly as writeCellBlocks' own openList/closeList never persists across a writeCellBlocks call, so two adjacent cells can never canonicalise to the same numId even when both carry an identical incoming one. +// The one canonical ContentTable a written-and-reread table equals, wherever writeOdfTable places it (odt's own top-level tables, or one nested inside an odp/odg shape's draw:frame) -- every mapping forced by ODF's own table:table content model rather than chosen here, matching typed/shared/table.ts's own writeOdfTable/readOdfTable as the single writer/reader pair every caller shares. `listState` is the caller's own document-wide ListPlanState (typed/odt/write.ts's planDocument, typed/odp/write.ts's own presentation-wide state -- see each caller's own top-of-file note), threaded through every cell so a list minted inside this table -- including one nested inside a cell of a table nested inside one of THIS table's own cells -- is numbered in the identical document-encounter order readOdfTable's own listIdState mints it in on the way back in. Closed before every cell's own canonicalCell call (each cell is its own list-run scope, so two adjacent cells can never canonicalise to the same numId even when both carry an identical incoming one) and once more after the whole table, for whatever sibling block follows this table in the caller's own block list -- a close between cells or immediately after canonicalCell's own return would only ever be overwritten by one of those two before anything could observe it, so only these two calls do real work. export function canonicalTable( table: ContentTable, listState: ListPlanState, ): ContentTable { - closeListPlan(listState); const covered = new Set(); const canonical: ContentTable = { kind: "table", @@ -227,9 +224,7 @@ export function canonicalTable( } } closeListPlan(listState); - const canonicalCellValue = canonicalCell(cell, isCovered, listState); - closeListPlan(listState); - return canonicalCellValue; + return canonicalCell(cell, isCovered, listState); }); return row.heightPt === undefined ? { cells } From 1e9e95bde7b5dac48318658fca1165210250040e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:12:17 +0100 Subject: [PATCH 05/89] test(odf.js): cover parseOdfAngleDeg, isLengthUnit and expandExponential directly parseOdfAngleDeg had no test coverage at all -- deg/grad/rad conversion, the bare-number-defaults-to-degrees case, and the malformed-input path were all unexercised. Adds direct coverage for every branch, including the grad/rad conversion constants. Exports and directly tests isLengthUnit and expandExponential rather than only reaching them through parseOdfLength/formatOdfNumber: both are pure predicates/formatters whose own internal branches (the six-way unit check, expandExponential's pointIndex <= 0 / >= digits.length boundaries) were only ever exercised with values the caller's own regex had already narrowed, leaving several branches equivalent in practice. Direct string-input tests pin the exact pointIndex boundary cases (0 exactly, digits.length exactly, strictly between, negative) that were previously unreachable through formatOdfLength's own floating-point call sites. Replaces expandExponential's and parseOdfAngleDeg's own sign/integerDigits/exponent === undefined re-checks with a plain comment plus non-null assertions: each of those regex groups has no `?` quantifier of its own (only the alternation inside them does), so none can ever actually be undefined once the enclosing match is non-null -- the re-check was dead code no input could reach, not a guard against a real failure mode. --- .../odf.js/src/typed/shared/metadata.test.ts | 110 +++++++++++++++++- packages/odf.js/src/typed/shared/metadata.ts | 2 +- .../odf.js/src/typed/shared/units.test.ts | 88 +++++++++++++- packages/odf.js/src/typed/shared/units.ts | 24 ++-- 4 files changed, 206 insertions(+), 18 deletions(-) diff --git a/packages/odf.js/src/typed/shared/metadata.test.ts b/packages/odf.js/src/typed/shared/metadata.test.ts index 374b8f905..ce685844e 100644 --- a/packages/odf.js/src/typed/shared/metadata.test.ts +++ b/packages/odf.js/src/typed/shared/metadata.test.ts @@ -6,6 +6,9 @@ import { readOdfMetadata, hasOdfMetadata, patchOdfMetadata, + writeOdfMetadata, + buildOdfMetaNodes, + ensureNamespaceDeclared, META_PART, } from "./metadata"; @@ -215,6 +218,109 @@ describe("readOdfMetadata", () => { }); }); +describe("buildOdfMetaNodes / writeOdfMetadata", () => { + it("an entirely empty LayoutMetadata writes no field element at all", () => { + const nodes = buildOdfMetaNodes({}, "1.3"); + const pkg: Package = { parts: { [META_PART]: { kind: "xml", nodes } } }; + expect(officeMetaOf(pkg).children).toEqual([]); + }); + + it("writes exactly one element per stated field, one field at a time", () => { + const fields: [keyof Parameters[0], string][] = [ + ["title", "dc:title"], + ["subject", "dc:subject"], + ["author", "meta:initial-creator"], + ["creator", "meta:generator"], + ["createdIso", "meta:creation-date"], + ["modifiedIso", "dc:date"], + ["language", "dc:language"], + ]; + for (const [field, tag] of fields) { + const nodes = buildOdfMetaNodes({ [field]: "value" }, "1.3"); + const pkg: Package = { parts: { [META_PART]: { kind: "xml", nodes } } }; + expect( + officeMetaOf(pkg).children.map((c) => + c.type === "element" ? c.tag : c.type, + ), + ).toEqual([tag]); + } + }); + + it("writes one meta:keyword element per keyword, in order", () => { + const nodes = buildOdfMetaNodes({ keywords: ["a", "b", "c"] }, "1.3"); + const pkg: Package = { parts: { [META_PART]: { kind: "xml", nodes } } }; + expect(readOdfMetadata(pkg).keywords).toEqual(["a", "b", "c"]); + }); + + it("the declaration node states XML 1.0 UTF-8, and the root is office:document-meta at the given version", () => { + const nodes = buildOdfMetaNodes({}, "1.3"); + expect(nodes[0]).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ], + }); + const root = nodes.find( + (node): node is XmlElement => node.type === "element", + ); + expect(root?.tag).toBe("office:document-meta"); + expect( + root?.attributes.find((a) => a.name === "office:version")?.value, + ).toBe("1.3"); + }); + + it("writeOdfMetadata sets the package's meta.xml part, readable back through readOdfMetadata", () => { + const pkg: Package = { parts: {} }; + writeOdfMetadata(pkg, { title: "Written title" }, "1.3"); + expect(readOdfMetadata(pkg).title).toBe("Written title"); + }); + + it("writeOdfMetadata replaces an existing meta.xml part outright, rather than merging", () => { + const pkg = metaPackage([el("dc:title", {}, [txt("Stale title")])]); + writeOdfMetadata(pkg, { subject: "Fresh subject" }, "1.3"); + const metadata = readOdfMetadata(pkg); + expect(metadata.title).toBeUndefined(); + expect(metadata.subject).toBe("Fresh subject"); + }); +}); + +describe("ensureNamespaceDeclared", () => { + it("does nothing for a tag with no colon at all", () => { + // A colonless tag has no prefix to declare a namespace for at all -- this deliberately picks a tag ("dcX") whose LAST character, if the leading-colon guard were skipped, would slice down to the real prefix "dc" and wrongly declare xmlns:dc; the correct behaviour is to return before ever reaching that slice. + const root = el("office:document-meta"); + ensureNamespaceDeclared(root, "dcX"); + expect(root.attributes).toEqual([]); + }); + + it("does nothing for a recognised prefix's own namespace when it is already declared", () => { + const root = el("office:document-meta", { + "xmlns:dc": "http://purl.org/dc/elements/1.1/", + }); + ensureNamespaceDeclared(root, "dc:title"); + expect(root.attributes.filter((a) => a.name === "xmlns:dc")).toHaveLength( + 1, + ); + }); + + it("declares the namespace for a recognised prefix that is not yet declared", () => { + const root = el("office:document-meta"); + ensureNamespaceDeclared(root, "meta:initial-creator"); + expect(root.attributes).toEqual([ + { + name: "xmlns:meta", + value: "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", + }, + ]); + }); + + it("does nothing for a prefix outside the dc:/meta: vocabulary this module can newly introduce", () => { + const root = el("office:document-meta"); + ensureNamespaceDeclared(root, "office:unknown-field"); + expect(root.attributes).toEqual([]); + }); +}); + describe("hasOdfMetadata", () => { it("is true for a package carrying a real meta.xml XML part", () => { expect(hasOdfMetadata(metaPackage())).toBe(true); @@ -296,13 +402,15 @@ describe("patchOdfMetadata", () => { ]); }); - it("removes every meta:keyword element when patched with an empty array, rather than leaving a stale one", () => { + it("removes every meta:keyword element when patched with an empty array, rather than leaving a stale one, and leaves every OTHER element in office:meta untouched", () => { const pkg = metaPackage([ + el("dc:title", {}, [txt("Untouched title")]), el("meta:keyword", {}, [txt("alpha")]), el("meta:keyword", {}, [txt("beta")]), ]); patchOdfMetadata(pkg, { keywords: [] }); expect(readOdfMetadata(pkg).keywords).toBeUndefined(); + expect(readOdfMetadata(pkg).title).toBe("Untouched title"); expect( officeMetaOf(pkg).children.some( (c) => c.type === "element" && c.tag === "meta:keyword", diff --git a/packages/odf.js/src/typed/shared/metadata.ts b/packages/odf.js/src/typed/shared/metadata.ts index 711c4b4d3..cd8bb52d5 100644 --- a/packages/odf.js/src/typed/shared/metadata.ts +++ b/packages/odf.js/src/typed/shared/metadata.ts @@ -183,7 +183,7 @@ const META_NAMESPACE_URI_FOR_PREFIX: Readonly> = { }; // Ensures `root` (office:document-meta) declares the xmlns binding a newly appended element's prefix needs -- the ODF-side mirror of ooxml.js's own ensureNamespaceDeclared. A legally-minimal meta.xml declaring only office:+dc: (a producer that has only ever written dc:title) would otherwise gain an unbound meta:initial-creator/meta:keyword child on its first author/keywords patch -- a fatal XML namespace well-formedness error real consumers (LibreOffice) reject outright. -function ensureNamespaceDeclared(root: XmlElement, tag: string): void { +export function ensureNamespaceDeclared(root: XmlElement, tag: string): void { const colonIndex = tag.indexOf(":"); if (colonIndex === -1) { return; diff --git a/packages/odf.js/src/typed/shared/units.test.ts b/packages/odf.js/src/typed/shared/units.test.ts index 2f6ff3c3c..e56c067a2 100644 --- a/packages/odf.js/src/typed/shared/units.test.ts +++ b/packages/odf.js/src/typed/shared/units.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { parseOdfLength, formatOdfLength } from "./units"; +import { + parseOdfLength, + formatOdfLength, + isLengthUnit, + parseOdfAngleDeg, + expandExponential, +} from "./units"; // The cm-based fixtures below ("real LibreOffice output") are copied verbatim from a real style:paragraph-properties element produced by `soffice --headless --convert-to odt` (LibreOffice 26.2.5.2), the same fixture referenced by src/styles/properties.test.ts -- see that file's own top-of-file note. @@ -36,6 +42,86 @@ describe("parseOdfLength", () => { }); }); +describe("isLengthUnit", () => { + it("accepts every one of the six real ODF length units", () => { + for (const unit of ["cm", "mm", "in", "pt", "pc", "px"]) { + expect(isLengthUnit(unit)).toBe(true); + } + }); + + it("rejects a unit outside the ODF length grammar", () => { + expect(isLengthUnit("em")).toBe(false); + expect(isLengthUnit("")).toBe(false); + expect(isLengthUnit("PT")).toBe(false); + }); +}); + +describe("expandExponential", () => { + it("passes non-exponent text through unchanged", () => { + expect(expandExponential("12")).toBe("12"); + expect(expandExponential("abc")).toBe("abc"); + }); + + it("pointIndex exactly 0 (the <= 0 boundary): a leading zero with no extra padding digits", () => { + expect(expandExponential("1e-1")).toBe("0.1"); + }); + + it("pointIndex strictly negative: leading zero padding beyond the single digit", () => { + expect(expandExponential("1e-2")).toBe("0.01"); + }); + + it("pointIndex exactly equal to digits.length (the >= boundary): no trailing zero padding needed", () => { + expect(expandExponential("1e0")).toBe("1"); + }); + + it("pointIndex strictly greater than digits.length: trailing zero padding", () => { + expect(expandExponential("1e1")).toBe("10"); + }); + + it("pointIndex strictly between 0 and digits.length: a real decimal point insertion", () => { + expect(expandExponential("1.5e0")).toBe("1.5"); + }); + + it("carries a negative sign through every branch", () => { + expect(expandExponential("-1.5e2")).toBe("-150"); + expect(expandExponential("-1e-1")).toBe("-0.1"); + }); + + it("accepts an uppercase E", () => { + expect(expandExponential("1E1")).toBe("10"); + }); +}); + +describe("parseOdfAngleDeg", () => { + it("a bare number with no unit suffix is already degrees", () => { + expect(parseOdfAngleDeg("90")).toBe(90); + expect(parseOdfAngleDeg("-45")).toBe(-45); + expect(parseOdfAngleDeg("0.5")).toBe(0.5); + expect(parseOdfAngleDeg(".5")).toBe(0.5); + }); + + it('an explicit "deg" suffix is a no-op conversion', () => { + expect(parseOdfAngleDeg("90deg")).toBe(90); + }); + + it("converts grad to degrees: 400 grad is a full turn, matching 360 degrees", () => { + expect(parseOdfAngleDeg("400grad")).toBe(360); + expect(parseOdfAngleDeg("200grad")).toBe(180); + expect(parseOdfAngleDeg("100grad")).toBe(90); + }); + + it("converts rad to degrees: pi radians is a half turn, matching 180 degrees", () => { + expect(parseOdfAngleDeg(`${Math.PI}rad`)).toBeCloseTo(180, 9); + expect(parseOdfAngleDeg(`${Math.PI / 2}rad`)).toBeCloseTo(90, 9); + }); + + it("returns undefined for a malformed angle", () => { + expect(parseOdfAngleDeg("auto")).toBeUndefined(); + expect(parseOdfAngleDeg("90degrees")).toBeUndefined(); + expect(parseOdfAngleDeg("")).toBeUndefined(); + }); +}); + describe("formatOdfLength", () => { it('defaults to "pt" when no unit is given', () => { expect(formatOdfLength(12)).toBe("12pt"); diff --git a/packages/odf.js/src/typed/shared/units.ts b/packages/odf.js/src/typed/shared/units.ts index 8b0dd15db..ecde7d8e5 100644 --- a/packages/odf.js/src/typed/shared/units.ts +++ b/packages/odf.js/src/typed/shared/units.ts @@ -33,7 +33,7 @@ function unitToPtFactor(unit: LengthUnit): number { } } -function isLengthUnit(value: string): value is LengthUnit { +export function isLengthUnit(value: string): value is LengthUnit { return ( value === "cm" || value === "mm" || @@ -61,19 +61,16 @@ export function parseOdfLength(value: string): number | undefined { // JavaScript's own Number-to-string switches to EXPONENT notation outside a fixed magnitude window (below 1e-6, or at/above 1e21) -- `${-7.1e-15}` is "-7.1e-15", not "-0.0000000000000071". The ODF `length` datatype has NO exponent form at all (see LENGTH_PATTERN above, and the OASIS grammar it encodes), so a bare template-literal stringification silently emits spec-invalid ODF for any small-magnitude length. That is not a theoretical range: a rotated shape's own draw:transform translate() components are trig-derived (typed/draw/write-shapes.ts's frameGeometryAttrs), so a shape rotated about a point near the page origin routinely lands a component at 1e-15-ish rounding dust rather than a clean 0. The consequence on the way back in is silent and total: parseOdfTransform drops a translate() whose components don't parse (so the shape moves to the pivot), and parseBox returns undefined for an unrotated frame whose svg:x/svg:y don't parse (so readDrawFrame drops the shape entirely). // // The fix belongs here, on the write side, not in LENGTH_PATTERN: widening the reader to accept an exponent would make this package read its own invalid output back correctly while every other ODF consumer still saw a length outside the datatype. expandExponential below re-positions the decimal point in the digits Number-to-string ALREADY chose (the shortest round-tripping representation), so it is an exact re-spelling rather than a rounding step -- and since those digits never carry a trailing fractional zero, neither does the result, matching the plain-stringification style of every ordinary value. -function expandExponential(text: string): string { +export function expandExponential(text: string): string { const match = /^(-?)(\d+)(?:\.(\d+))?[eE]([+-]?\d+)$/.exec(text); if (match === null) { return text; } - const [, sign, integerDigits, fractionDigits, exponent] = match; - if ( - sign === undefined || - integerDigits === undefined || - exponent === undefined - ) { - return text; - } + // Every group but the third (the optional fractional digits) is a MANDATORY alternative in this pattern -- (-?) always matches (possibly empty), (\d+) and ([+-]?\d+) are plain quantifiers with no `?` of their own -- so sign/integerDigits/exponent can never actually be undefined once `match` itself is non-null; only TypeScript's own RegExpExecArray typing can't express that. Asserting rather than re-checking a condition the regex has already made unreachable keeps this a real branch (the fractional-digits one below) rather than a dead one no input can ever exercise. + const sign = match[1]!; + const integerDigits = match[2]!; + const fractionDigits = match[3]; + const exponent = match[4]!; const digits = `${integerDigits}${fractionDigits ?? ""}`; // Where the decimal point lands within `digits` once the exponent is applied: left of every digit (a pure fraction needing leading zeros), right of every digit (an integer needing trailing zeros), or between two of them. const pointIndex = integerDigits.length + Number(exponent); @@ -106,11 +103,8 @@ export function parseOdfAngleDeg(value: string): number | undefined { if (match === null) { return undefined; } - const numeric = match[1]; - if (numeric === undefined) { - return undefined; - } - const raw = Number(numeric); + // match[1]'s own group has no `?` quantifier of its own (only the alternation inside it does), so it always matches once `match` itself is non-null -- the same mandatory-group guarantee expandExponential's own sign/integerDigits/exponent rely on above. + const raw = Number(match[1]!); switch (match[2]) { case "grad": return raw * DEGREES_PER_GRAD; From 2aaa3138d9d8dea28565b10fb12a2a63e02f34f8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:15:26 +0100 Subject: [PATCH 06/89] test(odf.js): add the first direct unit coverage for typed/shared/list.ts list.ts had no test file of its own at all: resolveOdfListKind, mintOdfListNumId, buildOdfListStyle, writeOdfList, listKindOf, canonicalNumId, planListMembership/closeListPlan, and readOdfListParagraphs were exercised only incidentally through odt/odp round-trip fixtures, which never happened to hit several of their own branches (a level-1-only style-kind check, the nested-list write-side's own level-jump/dedent handling, the run-boundary sentinel planListMembership mints for a membership with no incoming numId). Adds a dedicated suite covering every exported function directly: each of the three list-kind resolutions (ordered/bullet/bullet-via- image) and their negative cases, the ten-level style builder's own per-level indent arithmetic, writeOdfList's nesting/dedent/level-clamp behaviour, and the list-plan run-boundary semantics closeListPlan and a changed incoming numId both need to preserve. --- packages/odf.js/src/typed/shared/list.test.ts | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 packages/odf.js/src/typed/shared/list.test.ts diff --git a/packages/odf.js/src/typed/shared/list.test.ts b/packages/odf.js/src/typed/shared/list.test.ts new file mode 100644 index 000000000..3931f4988 --- /dev/null +++ b/packages/odf.js/src/typed/shared/list.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from "vitest"; +import type { ContentParagraph } from "document-schema.js"; +import type { Package } from "../../model/package"; +import type { XmlElement } from "../../model/node"; +import { el } from "../../xml/fragment"; +import { attrValue, childrenWithTag } from "../../xml/query"; +import { + resolveOdfListKind, + mintOdfListNumId, + buildOdfListStyle, + writeOdfList, + listKindOf, + canonicalNumId, + planListMembership, + closeListPlan, + readOdfListParagraphs, + NO_NUM_ID_KEY, + type OdfListIdState, + type OdfListEntry, + type ListPlanState, +} from "./list"; + +function packageWithAutomaticStyles(...listStyles: XmlElement[]): Package { + return { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, listStyles), + ]), + ], + }, + }, + }; +} + +describe("resolveOdfListKind", () => { + it("undefined style name resolves to undefined", () => { + expect(resolveOdfListKind({ parts: {} }, undefined)).toBeUndefined(); + }); + + it("resolves an ordered list style from its level-1 text:list-level-style-number", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-number", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBe("ordered"); + }); + + it("resolves a bullet list style from its level-1 text:list-level-style-bullet", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-bullet", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBe("bullet"); + }); + + it("resolves a bullet list style from its level-1 text:list-level-style-image (also a bullet kind)", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-image", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBe("bullet"); + }); + + it("only a level-1 child counts -- a level-2-only number style resolves to undefined, not ordered", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-number", { "text:level": "2" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBeUndefined(); + }); + + it("a matching style with no recognised level-1 child resolves to undefined", () => { + const style = el("text:list-style", { "style:name": "L1" }, []); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBeUndefined(); + }); + + it("an unresolvable style name (no matching text:list-style anywhere) resolves to undefined", () => { + const pkg = packageWithAutomaticStyles(); + expect(resolveOdfListKind(pkg, "does-not-exist")).toBeUndefined(); + }); + + it("finds a list style in styles.xml's office:styles when content.xml has none", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-number", { "text:level": "1" }), + ]); + const pkg: Package = { + parts: { + "styles.xml": { + kind: "xml", + nodes: [ + el("office:document-styles", {}, [ + el("office:styles", {}, [style]), + ]), + ], + }, + }, + }; + expect(resolveOdfListKind(pkg, "L1")).toBe("ordered"); + }); +}); + +describe("mintOdfListNumId", () => { + it("mints an unprefixed numId when the list carries no resolvable style", () => { + const state: OdfListIdState = { next: 1 }; + const numId = mintOdfListNumId({ parts: {} }, el("text:list"), state); + expect(numId).toBe("list1"); + expect(state.next).toBe(2); + }); + + it("mints an ordered:-prefixed numId when the list's style resolves to ordered", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-number", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + const state: OdfListIdState = { next: 3 }; + const numId = mintOdfListNumId( + pkg, + el("text:list", { "text:style-name": "L1" }), + state, + ); + expect(numId).toBe("ordered:list3"); + expect(state.next).toBe(4); + }); + + it("advances the counter by exactly one per call, regardless of resolution", () => { + const state: OdfListIdState = { next: 1 }; + mintOdfListNumId({ parts: {} }, el("text:list"), state); + mintOdfListNumId({ parts: {} }, el("text:list"), state); + expect(state.next).toBe(3); + }); +}); + +describe("buildOdfListStyle", () => { + it("builds ten levels, each one indent step deeper than the last", () => { + const style = buildOdfListStyle("L1", "bullet"); + const levels = childrenWithTag(style, "text:list-level-style-bullet"); + expect(levels).toHaveLength(10); + expect(attrValue(levels[0]!, "text:level")).toBe("1"); + expect(attrValue(levels[9]!, "text:level")).toBe("10"); + const props1 = childrenWithTag( + levels[0]!, + "style:list-level-properties", + )[0]!; + const props2 = childrenWithTag( + levels[1]!, + "style:list-level-properties", + )[0]!; + expect(attrValue(props1, "text:space-before")).toBe("18pt"); + expect(attrValue(props2, "text:space-before")).toBe("36pt"); + // Every level shares the identical min-label-width -- one indent step, not scaled by level. + expect(attrValue(props1, "text:min-label-width")).toBe("18pt"); + expect(attrValue(props2, "text:min-label-width")).toBe("18pt"); + }); + + it("an ordered style's own levels carry the real numbering attributes", () => { + const style = buildOdfListStyle("L1", "ordered"); + const levels = childrenWithTag(style, "text:list-level-style-number"); + expect(levels).toHaveLength(10); + expect(attrValue(levels[0]!, "style:num-suffix")).toBe("."); + expect(attrValue(levels[0]!, "style:num-format")).toBe("1"); + }); + + it("a bullet style's own levels carry the real bullet character, never the numbering attributes", () => { + const style = buildOdfListStyle("L1", "bullet"); + const levels = childrenWithTag(style, "text:list-level-style-bullet"); + expect(attrValue(levels[0]!, "text:bullet-char")).toBe("•"); + }); + + it("the root element is text:list-style carrying the given style name", () => { + const style = buildOdfListStyle("MyList", "ordered"); + expect(style.tag).toBe("text:list-style"); + expect(attrValue(style, "style:name")).toBe("MyList"); + }); +}); + +describe("writeOdfList", () => { + function entry(level: number, id: string): OdfListEntry { + return { level, element: el("text:p", { id }) }; + } + + it("a single flat run of level-0 entries becomes sibling text:list-item elements", () => { + const root = writeOdfList([entry(0, "a"), entry(0, "b")], "L1"); + expect(root.tag).toBe("text:list"); + expect(attrValue(root, "text:style-name")).toBe("L1"); + expect(childrenWithTag(root, "text:list-item")).toHaveLength(2); + }); + + it("omits text:style-name entirely when no style name is given", () => { + const root = writeOdfList([entry(0, "a")], undefined); + expect(attrValue(root, "text:style-name")).toBeUndefined(); + }); + + it("a deeper entry nests inside the previous item's own text:list", () => { + const root = writeOdfList([entry(0, "a"), entry(1, "b")], undefined); + const items = childrenWithTag(root, "text:list-item"); + expect(items).toHaveLength(1); + const nestedList = childrenWithTag(items[0]!, "text:list")[0]!; + expect(childrenWithTag(nestedList, "text:list-item")).toHaveLength(1); + }); + + it("returning to a shallower level after a deeper one closes the nested list", () => { + const root = writeOdfList( + [entry(0, "a"), entry(1, "b"), entry(0, "c")], + undefined, + ); + // Two top-level items: the first holds the nested level-1 item, the second is the level-0 "c" entry that closed the nesting back out. + expect(childrenWithTag(root, "text:list-item")).toHaveLength(2); + }); + + it("a jump of more than one level opens the intervening lists inside empty items", () => { + const root = writeOdfList([entry(2, "a")], undefined); + const level0Item = childrenWithTag(root, "text:list-item")[0]!; + const level1List = childrenWithTag(level0Item, "text:list")[0]!; + const level1Item = childrenWithTag(level1List, "text:list-item")[0]!; + const level2List = childrenWithTag(level1Item, "text:list")[0]!; + expect(childrenWithTag(level2List, "text:list-item")).toHaveLength(1); + }); + + it("a fractional or negative level is clamped to a whole non-negative depth", () => { + const root = writeOdfList([entry(-5, "a")], undefined); + // Clamped to level 0 -- a single top-level item, no nested text:list at all. + expect(childrenWithTag(root, "text:list-item")).toHaveLength(1); + expect(childrenWithTag(root, "text:list")).toHaveLength(0); + }); +}); + +describe("listKindOf", () => { + it("undefined numId resolves to undefined kind", () => { + expect(listKindOf(undefined)).toBeUndefined(); + }); + + it("an ordered:-prefixed numId resolves to ordered", () => { + expect(listKindOf("ordered:list3")).toBe("ordered"); + }); + + it("a bullet:-prefixed numId resolves to bullet", () => { + expect(listKindOf("bullet:list3")).toBe("bullet"); + }); + + it("an unprefixed numId resolves to undefined", () => { + expect(listKindOf("list3")).toBeUndefined(); + }); +}); + +describe("canonicalNumId", () => { + it("an unprefixed incoming numId mints an unprefixed canonical label", () => { + expect(canonicalNumId("anything", 1)).toBe("list1"); + }); + + it("an ordered:-prefixed incoming numId mints an ordered:-prefixed canonical label", () => { + expect(canonicalNumId("ordered:src", 2)).toBe("ordered:list2"); + }); + + it("a bullet:-prefixed incoming numId mints a bullet:-prefixed canonical label", () => { + expect(canonicalNumId("bullet:src", 3)).toBe("bullet:list3"); + }); + + it("an undefined incoming numId mints an unprefixed canonical label", () => { + expect(canonicalNumId(undefined, 4)).toBe("list4"); + }); +}); + +function freshListState(): ListPlanState { + return { next: 1 }; +} + +describe("planListMembership / closeListPlan", () => { + it("undefined membership closes the run and returns undefined", () => { + const state: ListPlanState = { + next: 5, + openNumId: "x", + openCanonicalNumId: "list4", + }; + expect(planListMembership(undefined, state)).toBeUndefined(); + expect(state.openNumId).toBeUndefined(); + expect(state.openCanonicalNumId).toBeUndefined(); + }); + + it("a fresh incoming numId mints a fresh canonical numId and advances the counter", () => { + const state = freshListState(); + const result = planListMembership({ numId: "src-a", level: 0 }, state); + expect(result).toBe("list1"); + expect(state.next).toBe(2); + }); + + it("consecutive paragraphs sharing one incoming numId extend the same run", () => { + const state = freshListState(); + const first = planListMembership({ numId: "src-a", level: 0 }, state); + const second = planListMembership({ numId: "src-a", level: 1 }, state); + expect(second).toBe(first); + expect(state.next).toBe(2); + }); + + it("a changed incoming numId mints a new canonical numId", () => { + const state = freshListState(); + const first = planListMembership({ numId: "src-a", level: 0 }, state); + const second = planListMembership({ numId: "src-b", level: 0 }, state); + expect(second).not.toBe(first); + expect(state.next).toBe(3); + }); + + it("a membership carrying no incoming numId still opens a real run of its own, keyed on the sentinel", () => { + const state = freshListState(); + const first = planListMembership({ level: 0 }, state); + expect(first).toBeDefined(); + expect(state.openNumId).toBe(NO_NUM_ID_KEY); + // Two consecutive bare-numId paragraphs still extend the same run. + const second = planListMembership({ level: 1 }, state); + expect(second).toBe(first); + }); + + it("closeListPlan clears both openNumId and openCanonicalNumId", () => { + const state: ListPlanState = { + next: 1, + openNumId: "x", + openCanonicalNumId: "list0", + }; + closeListPlan(state); + expect(state.openNumId).toBeUndefined(); + expect(state.openCanonicalNumId).toBeUndefined(); + }); + + it("after closeListPlan, the same incoming numId mints a genuinely new run rather than extending the old one", () => { + const state = freshListState(); + const first = planListMembership({ numId: "src-a", level: 0 }, state); + closeListPlan(state); + const second = planListMembership({ numId: "src-a", level: 0 }, state); + expect(second).not.toBe(first); + }); +}); + +describe("readOdfListParagraphs", () => { + function paragraphReader(): (element: XmlElement) => ContentParagraph { + return (element) => ({ + kind: "paragraph", + runs: [{ text: attrValue(element, "id") ?? "" }], + }); + } + + it("reads a text:p item, attaching the given membership", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [el("text:p", { id: "a" })]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0]!.list).toEqual({ numId: "list1", level: 0 }); + }); + + it("reads a text:h item exactly the same way as a text:p item", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [el("text:h", { id: "a" })]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toHaveLength(1); + }); + + it("skips a non-text:list-item child of the list element", () => { + const list = el("text:list", {}, [ + el("text:list-header", {}, [el("text:p", { id: "skip-me" })]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toEqual([]); + }); + + it("skips a non-element child inside a list item", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [ + { type: "text", value: "stray text" }, + el("text:p", { id: "a" }), + ]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toHaveLength(1); + }); + + it("recurses into a nested text:list, incrementing level but keeping the SAME numId", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [ + el("text:p", { id: "outer" }), + el("text:list", {}, [ + el("text:list-item", {}, [el("text:p", { id: "inner" })]), + ]), + ]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]!.list).toEqual({ numId: "list1", level: 0 }); + expect(paragraphs[1]!.list).toEqual({ numId: "list1", level: 1 }); + }); + + it("preserves document order across multiple items and nesting", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [el("text:p", { id: "first" })]), + el("text:list-item", {}, [el("text:p", { id: "second" })]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs.map((p) => p.runs[0]!.text)).toEqual(["first", "second"]); + }); +}); From 5c02f7e8b9aefe99af68788a72cb35a8a7dd540e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:17:51 +0100 Subject: [PATCH 07/89] test(odf.js): add the first direct unit coverage for typed/shared/expression.ts skipExpression/takeExpression (the balanced-paren/brace/quote-aware expression splitter both typed/ods/data-validation.ts's table:condition reading and typed/ods/conditional-format.ts's calcext:condition reading share) had no test file of its own -- both readers exercised it only through their own real-world condition-string fixtures, which never happened to cover brace nesting, single-quoted strings, an unterminated quoted string running to the end of the text, or a whitespace-only span correctly yielding undefined rather than an empty string. --- .../src/typed/shared/expression.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/odf.js/src/typed/shared/expression.test.ts diff --git a/packages/odf.js/src/typed/shared/expression.test.ts b/packages/odf.js/src/typed/shared/expression.test.ts new file mode 100644 index 000000000..e9a8de33a --- /dev/null +++ b/packages/odf.js/src/typed/shared/expression.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { skipExpression, takeExpression } from "./expression"; + +describe("skipExpression", () => { + it("stops at the first unnested occurrence of endChar", () => { + expect(skipExpression("abc,def", 0, ",")).toBe(3); + }); + + it("runs to the end of the string when endChar never occurs", () => { + expect(skipExpression("abcdef", 0, ",")).toBe(6); + }); + + it("a comma or paren INSIDE a nested (...) is not the end", () => { + expect(skipExpression("(a,b),c", 0, ",")).toBe(5); + }); + + it("a brace nests exactly like a paren, and does not itself end the expression", () => { + expect(skipExpression("{a,b},c", 0, ",")).toBe(5); + }); + + it("braces and parens can nest inside each other", () => { + expect(skipExpression("(a{b,c}d),e", 0, ",")).toBe(9); + }); + + it("a comma inside a double-quoted string is not the end", () => { + expect(skipExpression('"a,b",c', 0, ",")).toBe(5); + }); + + it("a comma inside a single-quoted string is not the end", () => { + expect(skipExpression("'a,b',c", 0, ",")).toBe(5); + }); + + it("an unterminated quoted string runs to the end of the text", () => { + expect(skipExpression('"unterminated', 0, ",")).toBe(13); + }); + + it("starts scanning exactly at the given start index, not from 0", () => { + expect(skipExpression("xx,abc,def", 3, ",")).toBe(6); + }); +}); + +describe("takeExpression", () => { + it("extracts and trims the expression up to endChar, advancing past it", () => { + expect(takeExpression(" abc ,rest", 0, ",")).toEqual({ + value: "abc", + nextIndex: 6, + }); + }); + + it("an empty expression (nothing but whitespace) yields undefined, never an empty string", () => { + expect(takeExpression(" ,rest", 0, ",")).toEqual({ + value: undefined, + nextIndex: 4, + }); + }); + + it("a genuinely empty span (no characters at all before endChar) yields undefined", () => { + expect(takeExpression(",rest", 0, ",")).toEqual({ + value: undefined, + nextIndex: 1, + }); + }); + + it("a non-empty, already-trimmed expression is returned exactly", () => { + expect(takeExpression("abc)", 0, ")")).toEqual({ + value: "abc", + nextIndex: 4, + }); + }); +}); From 54c5af6bfb94c69c405704f50eb4083f6e267c6e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:24:10 +0100 Subject: [PATCH 08/89] chore(odf.js): raise the mutation break threshold to the re-measured floor A full run after this batch's own survivor kills (canonicalise.ts, units.ts, metadata.ts, list.ts, expression.ts) measures 75.72% of 8444 valid mutants, up from the 71.31% baseline this threshold was originally derived from. Still provisional -- the bulk of this package's own files carry their own unkilled mutants and have not been touched yet. --- packages/odf.js/stryker.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/odf.js/stryker.config.ts b/packages/odf.js/stryker.config.ts index 2f5854b88..05c637214 100644 --- a/packages/odf.js/stryker.config.ts +++ b/packages/odf.js/stryker.config.ts @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // First CI-measured baseline: 71.31% of 8442 valid mutants, timeout share 1.3% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. - breakThreshold: 69, + // Re-measured after a real batch of survivor kills (typed/shared/canonicalise.ts, units.ts, metadata.ts, list.ts, expression.ts): 75.72% of 8444 valid mutants, timeout share 1.3% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. Still provisional: the bulk of this package (draw/shapes.ts, ooo1/transform.ts, shared/constructs.ts, shared/paragraph.ts, ods/write.ts, ods/conditional-format.ts, odb/report.ts, and the rest) still carries its own unkilled mutants -- raise this again once the next batch's own full run confirms the next real floor. + breakThreshold: 73, }); From 541f9e87b14630318272ea22a08addbe992d4315 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 07:57:46 +0100 Subject: [PATCH 09/89] test(odf.js): give the BOM check its own testable function in parsePackage's XML sniff hasUtf8Bom's compound bytes.length >= 3 && bytes[0] === 0xef && ... condition mutated to several sub-clause "true"/"false" variants that survived: through looksLikeXml alone, a wrongly-detected BOM and a correctly-rejected one routinely land on the same XML/binary verdict downstream (a too-short array ends the scan at the same byte either way), so no test built only on the final classification could distinguish them. Extracting it into its own exported, directly tested function pins every boundary (a too-short array, each byte individually mismatched) against its actual boolean return value instead. Also replaces the four-way whitespace disjunction (b === 0x20 || b === 0x09 || ...) with a Set.has() check, and the manual index/while loop with a for...of over bytes.subarray(start): both removed a class of survivable sub-expression and loop-boundary mutants outright rather than chasing them through indirect byte-array engineering, since a byte either belongs to the whitespace set or doesn't and a subarray iteration carries no explicit index comparison to mutate. --- packages/odf.js/src/package-io/read.test.ts | 132 ++++++++++++++++++++ packages/odf.js/src/package-io/read.ts | 26 ++-- 2 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 packages/odf.js/src/package-io/read.test.ts diff --git a/packages/odf.js/src/package-io/read.test.ts b/packages/odf.js/src/package-io/read.test.ts new file mode 100644 index 000000000..1f6ce9062 --- /dev/null +++ b/packages/odf.js/src/package-io/read.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { bytesToBase64 } from "../util/base64"; +import { zipPackage } from "../zip"; +import { hasUtf8Bom, parsePackage } from "./read"; + +// parsePackage's own XML-vs-binary routing (looksLikeXml) is not exported, so every case here drives it indirectly through a real zip part's classification. The function's own contract note explains why a misclassification can only ever go one way: no standard ODF binary part starts with '<', so a false positive here would misparse a binary part as XML, while a false negative just stores an XML part losslessly as base64 instead -- these tests pin both directions and every byte-level boundary the scan's own whitespace/'<' checks depend on. hasUtf8Bom itself is exported and tested directly below, since a wrongly-detected BOM and a correctly-rejected one can otherwise happen to produce the same XML/binary verdict downstream (a too-short array still ends the scan at the same byte either way), making the boundary untestable through parsePackage alone. + +function packageWithOnePart(bytes: Uint8Array) { + const zipBytes = zipPackage([["part", { bytes }]]); + return parsePackage(zipBytes); +} + +describe("hasUtf8Bom", () => { + it("recognises the exact three-byte BOM", () => { + expect(hasUtf8Bom(new Uint8Array([0xef, 0xbb, 0xbf]))).toBe(true); + }); + + it("recognises a BOM followed by more bytes", () => { + expect(hasUtf8Bom(new Uint8Array([0xef, 0xbb, 0xbf, 0x00]))).toBe(true); + }); + + it("rejects an empty array", () => { + expect(hasUtf8Bom(new Uint8Array([]))).toBe(false); + }); + + it("rejects an array shorter than the BOM even when every present byte matches", () => { + expect(hasUtf8Bom(new Uint8Array([0xef]))).toBe(false); + expect(hasUtf8Bom(new Uint8Array([0xef, 0xbb]))).toBe(false); + }); + + it("rejects a full-length array whose first byte doesn't match", () => { + expect(hasUtf8Bom(new Uint8Array([0x00, 0xbb, 0xbf]))).toBe(false); + }); + + it("rejects a full-length array whose second byte doesn't match", () => { + expect(hasUtf8Bom(new Uint8Array([0xef, 0x00, 0xbf]))).toBe(false); + }); + + it("rejects a full-length array whose third byte doesn't match", () => { + expect(hasUtf8Bom(new Uint8Array([0xef, 0xbb, 0x00]))).toBe(false); + }); +}); + +describe("parsePackage: XML vs binary part classification", () => { + it("classifies a part starting directly with '<' as xml", () => { + const pkg = packageWithOnePart(new TextEncoder().encode("")); + expect(pkg.parts.part?.kind).toBe("xml"); + }); + + it("classifies an empty part as binary -- the scan loop never runs at all", () => { + const pkg = packageWithOnePart(new Uint8Array(0)); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("classifies a part that is entirely whitespace as binary -- the loop runs to completion without ever finding a non-whitespace byte", () => { + const pkg = packageWithOnePart(new Uint8Array([0x20, 0x09, 0x0a, 0x0d])); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("skips a leading UTF-8 BOM before checking for '<'", () => { + const bomThenXml = new Uint8Array([ + 0xef, + 0xbb, + 0xbf, + ...new TextEncoder().encode(""), + ]); + const pkg = packageWithOnePart(bomThenXml); + expect(pkg.parts.part?.kind).toBe("xml"); + }); + + it("classifies a lone BOM with nothing after it as binary, not xml", () => { + const pkg = packageWithOnePart(new Uint8Array([0xef, 0xbb, 0xbf])); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("skips mixed leading whitespace (space, tab, LF, CR, in that order) before finding '<'", () => { + const bytes = new TextEncoder().encode(" \t\n\r"); + const pkg = packageWithOnePart(bytes); + expect(pkg.parts.part?.kind).toBe("xml"); + }); + + it("treats a byte immediately adjacent to each whitespace value as non-whitespace, ending the scan on it", () => { + // 0x1f is one below space (0x20); 0x08 is one below tab (0x09); 0x0b is one above LF (0x0a); 0x0e is one above CR (0x0d). None of these may be mistaken for the whitespace byte beside it. + for (const nonWhitespace of [0x1f, 0x08, 0x0b, 0x0e]) { + const pkg = packageWithOnePart(new Uint8Array([nonWhitespace])); + expect(pkg.parts.part?.kind, `byte 0x${nonWhitespace.toString(16)}`).toBe( + "binary", + ); + } + }); + + it("classifies real binary content (a PNG magic number) as binary, storing it losslessly as base64", () => { + const bytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const pkg = packageWithOnePart(bytes); + const part = pkg.parts.part; + expect(part?.kind).toBe("binary"); + if (part?.kind === "binary") { + expect(part.base64).toBe(bytesToBase64(bytes)); + } + }); + + it("treats the byte one above '<' (0x3d, '=') as not xml", () => { + const pkg = packageWithOnePart(new Uint8Array([0x3d])); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("treats the byte one below '<' (0x3b, ';') as not xml", () => { + const pkg = packageWithOnePart(new Uint8Array([0x3b])); + expect(pkg.parts.part?.kind).toBe("binary"); + }); + + it("classifies a real XML declaration (not just a bare element) as xml", () => { + const pkg = packageWithOnePart( + new TextEncoder().encode(''), + ); + expect(pkg.parts.part?.kind).toBe("xml"); + }); +}); + +describe("parsePackage: routes multiple parts independently", () => { + it("classifies each part in a multi-part package on its own merits, keyed by its own path", () => { + const zipBytes = zipPackage([ + ["a.xml", { bytes: new TextEncoder().encode("") }], + ["b.bin", { bytes: new Uint8Array([1, 2, 3]) }], + ]); + const pkg = parsePackage(zipBytes); + expect(pkg.parts["a.xml"]?.kind).toBe("xml"); + expect(pkg.parts["b.bin"]?.kind).toBe("binary"); + }); +}); diff --git a/packages/odf.js/src/package-io/read.ts b/packages/odf.js/src/package-io/read.ts index 1c7d052e5..29c527a3d 100644 --- a/packages/odf.js/src/package-io/read.ts +++ b/packages/odf.js/src/package-io/read.ts @@ -17,21 +17,21 @@ export function parsePackage(bytes: Uint8Array): Package { return { parts }; } +const UTF8_BOM = [0xef, 0xbb, 0xbf] as const; + +// Bytes that are insignificant XML whitespace ahead of a document's root element -- space, tab, LF, CR. A Set rather than a chain of `===` comparisons: a byte either belongs to this fixed set or it doesn't, so membership is the one fact worth testing directly, not a boolean tree with its own sub-clauses to pin separately. +const XML_LEADING_WHITESPACE = new Set([0x20, 0x09, 0x0a, 0x0d]); + +// `bytes` opens with a literal UTF-8 BOM (EF BB BF), exported for direct testing of its own boundary (a too-short array, a partial match on one or two of the three bytes) rather than only through looksLikeXml's downstream classification, where a wrongly-detected BOM and a correctly-rejected one can otherwise happen to produce the same XML/binary verdict. +export function hasUtf8Bom(bytes: Uint8Array): boolean { + return UTF8_BOM.every((byte, index) => bytes[index] === byte); +} + // An XML part (after any BOM/whitespace) starts with '<'; no standard ODF binary part (png, jpeg, embedded font, embedded object, thumbnail, ...) 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 - ) { - i = 3; - } - while (i < bytes.length) { - const b = bytes[i]!; - if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) { - i = i + 1; + const start = hasUtf8Bom(bytes) ? UTF8_BOM.length : 0; + for (const b of bytes.subarray(start)) { + if (XML_LEADING_WHITESPACE.has(b)) { continue; } return b === 0x3c; From 3d57dc4a67a6e36e9157ebdb79e32b95779d797c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 08:20:56 +0100 Subject: [PATCH 10/89] test(odf.js): add the first direct unit coverage for typed/shared/forms.ts readOdfFormDefinitions and readOdfFormControlConstructs had no dedicated test file at all, only indirect exercise through odt/odb round-trip fixtures that never varied every optional attribute, control-tag mapping, or checkbox/listbox branch independently. Adds direct coverage for every branch: each optional field on a form definition and on a control (present and absent), nested form:form as subForm vs control, form:properties/text-node exclusion from both control and subForm scanning, every CONTROL_TYPE_BY_TAG mapping, the unmapped-tag richText degrade with whole-element residue, current-value/value precedence, checkbox/radio's current-state-derived checked field (and its absence on every other control type), listbox option label/value precedence and the neither-present skip, and per-control form:properties residue. Switches this file's toEqual assertions on absence to toStrictEqual: toEqual treats an explicitly-set `field: undefined` the same as the field being absent, so an `if (x !== undefined) descriptor.field = x` mutated to `if (true)` was invisible to a toEqual comparison even though it changes the object's own own-property set. Raises odf.js's mutation break threshold to the re-measured floor now that typed/shared/forms.ts has zero survived and zero no-coverage mutants of its own. --- .../odf.js/src/typed/shared/forms.test.ts | 411 ++++++++++++++++++ packages/odf.js/stryker.config.ts | 4 +- 2 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 packages/odf.js/src/typed/shared/forms.test.ts diff --git a/packages/odf.js/src/typed/shared/forms.test.ts b/packages/odf.js/src/typed/shared/forms.test.ts new file mode 100644 index 000000000..1b3874c94 --- /dev/null +++ b/packages/odf.js/src/typed/shared/forms.test.ts @@ -0,0 +1,411 @@ +import type { + ContentBlock, + ContentControlDescriptor, +} from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { el, txt } from "../../xml/fragment"; +import { readOdfFormControlConstructs, readOdfFormDefinitions } from "./forms"; + +// readOdfFormControlConstructs always emits a "contentControl"-kind descriptor (never field/anchor/link/provenance/division, the other members of ConstructDescriptor), but its own return type is the general ContentBlock, so every assertion below needs this narrowing to reach a control's own controlType/tag/value/checked/options fields. A single helper rather than repeating `block?.kind === "constructStart" ? block.descriptor... : undefined` inline: TypeScript does not carry a narrowing from one array-index expression (e.g. `blocks[2]`) to a second, separate access of the same index, so the inline form type-errors the moment the true branch re-reads the array. +function contentControlDescriptor( + block: ContentBlock | undefined, +): ContentControlDescriptor | undefined { + if ( + block?.kind !== "constructStart" || + block.descriptor.kind !== "contentControl" + ) { + return undefined; + } + return block.descriptor; +} + +describe("readOdfFormDefinitions", () => { + it("an office:forms element with no form:form children reads as an empty list", () => { + expect(readOdfFormDefinitions(el("office:forms"))).toStrictEqual([]); + }); + + it("a bare form:form with none of its optional attributes reads with every optional field absent", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [el("form:form")]), + ); + expect(definition).toStrictEqual({ controls: [], subForms: [] }); + expect(definition).not.toHaveProperty("name"); + expect(definition).not.toHaveProperty("command"); + expect(definition).not.toHaveProperty("commandType"); + expect(definition).not.toHaveProperty("datasource"); + expect(definition).not.toHaveProperty("filter"); + expect(definition).not.toHaveProperty("order"); + }); + + it("reads every one of form:form's own attributes when all are present", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [ + el("form:form", { + "form:name": "SalesForm", + "form:command": "SALES", + "form:command-type": "table", + "form:datasource": "Bibliography", + "form:filter": "ID > 0", + "form:order": "ID ASC", + }), + ]), + ); + expect(definition).toMatchObject({ + name: "SalesForm", + command: "SALES", + commandType: "table", + datasource: "Bibliography", + filter: "ID > 0", + order: "ID ASC", + }); + }); + + it("a nested form:form becomes a subForm, not a control, and is read recursively", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [ + el("form:form", { "form:name": "Outer" }, [ + el("form:form", { "form:name": "Inner" }), + ]), + ]), + ); + expect(definition?.controls).toStrictEqual([]); + expect(definition?.subForms).toHaveLength(1); + expect(definition?.subForms[0]?.name).toBe("Inner"); + }); + + it("form:properties is neither a control nor a subForm", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [el("form:form", {}, [el("form:properties")])]), + ); + expect(definition?.controls).toStrictEqual([]); + expect(definition?.subForms).toStrictEqual([]); + }); + + it("a text node child of office:forms or form:form is skipped, not treated as a form:form or control", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [ + txt("stray text"), + el("form:form", {}, [txt("more stray text")]), + ]), + ); + expect(definition?.controls).toStrictEqual([]); + expect(definition?.subForms).toStrictEqual([]); + }); + + it("a non-form:* child of office:forms is not read as a form definition", () => { + expect( + readOdfFormDefinitions(el("office:forms", {}, [el("draw:frame")])), + ).toStrictEqual([]); + }); + + it("reads every one of a control's own optional attributes when present, on a control with children of its own", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [ + el("form:form", {}, [ + el( + "form:grid", + { + "form:name": "grid1", + "form:control-implementation": + "ooo:com.sun.star.form.component.GridControl", + "form:data-field": "ITEMS", + "form:id": "control9", + "form:label": "Items", + }, + [el("form:column", { "form:name": "col1" })], + ), + ]), + ]), + ); + expect(definition?.controls).toStrictEqual([ + { + tag: "form:grid", + name: "grid1", + controlImplementation: "ooo:com.sun.star.form.component.GridControl", + dataField: "ITEMS", + id: "control9", + label: "Items", + controls: [{ tag: "form:column", name: "col1", controls: [] }], + }, + ]); + }); + + it("a control with none of its optional attributes reads with every optional field absent", () => { + const [definition] = readOdfFormDefinitions( + el("office:forms", {}, [el("form:form", {}, [el("form:text")])]), + ); + expect(definition?.controls).toStrictEqual([ + { tag: "form:text", controls: [] }, + ]); + }); +}); + +describe("readOdfFormControlConstructs", () => { + it("an office:forms element with no form:form children emits nothing, including for a non-form:* child", () => { + expect( + readOdfFormControlConstructs(el("office:forms"), "odt"), + ).toStrictEqual([]); + expect( + readOdfFormControlConstructs( + el("office:forms", {}, [el("draw:frame")]), + "odt", + ), + ).toStrictEqual([]); + }); + + it("a bare form:form with no name and no properties emits a group construct pair with no tag or source", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form")]), + "odt", + ); + expect(blocks).toStrictEqual([ + { + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "group" }, + }, + { kind: "constructEnd" }, + ]); + }); + + it("a named form:form with form:properties emits a group construct carrying the name as tag and the properties as source, in the reader's own format", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", { "form:name": "SalesForm" }, [ + el("form:properties", {}, [el("form:property")]), + ]), + ]), + "odb", + ); + expect(blocks[0]).toStrictEqual({ + kind: "constructStart", + descriptor: { + kind: "contentControl", + controlType: "group", + tag: "SalesForm", + source: { + format: "odb", + xml: "", + }, + }, + }); + expect(blocks[1]).toStrictEqual({ kind: "constructEnd" }); + }); + + it("a nested form:form is emitted recursively, in document order after its own parent's own start/end pair", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", { "form:name": "Outer" }, [ + el("form:form", { "form:name": "Inner" }), + ]), + ]), + "odt", + ); + expect( + blocks.map((b) => contentControlDescriptor(b)?.tag ?? b.kind), + ).toStrictEqual(["Outer", "constructEnd", "Inner", "constructEnd"]); + }); + + it("a text node child of a form:form is skipped, and form:properties is not itself emitted as a control", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [txt("stray text"), el("form:properties")]), + ]), + "odt", + ); + expect(blocks).toHaveLength(2); + }); + + it.each([ + ["form:text", "plainText", {}], + ["form:textarea", "plainText", {}], + ["form:formatted-text", "plainText", {}], + ["form:password", "plainText", {}], + ["form:file", "plainText", {}], + ["form:listbox", "dropDown", { options: [] }], + ["form:combobox", "comboBox", {}], + ["form:checkbox", "checkbox", { checked: false }], + ["form:radio", "checkbox", { checked: false }], + ["form:button", "button", {}], + ["form:image-frame", "picture", {}], + ["form:fixed-text", "richText", {}], + ["form:frame", "richText", {}], + ["form:grid", "group", {}], + ["form:hidden", "richText", {}], + ] as const)( + "maps a bare %s control to controlType %s", + (tag, controlType, extra) => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form", {}, [el(tag)])]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType, ...extra }, + }); + }, + ); + + it("an unrecognised form:* tag degrades to richText with the whole element quarantined as residue, not just its properties", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:unknown-kind", { "form:name": "mystery" }, [ + el("form:properties"), + ]), + ]), + ]), + "odp", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { + kind: "contentControl", + controlType: "richText", + tag: "mystery", + source: { + format: "odp", + xml: '', + }, + }, + }); + }); + + it("a mapped control's value prefers form:current-value over form:value, and falls back to form:value alone", () => { + const withCurrentValue = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:text", { + "form:current-value": "live", + "form:value": "stale", + }), + ]), + ]), + "odt", + ); + expect(contentControlDescriptor(withCurrentValue[2])?.value).toBe("live"); + + const withValueOnly = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [el("form:text", { "form:value": "stale" })]), + ]), + "odt", + ); + expect(contentControlDescriptor(withValueOnly[2])?.value).toBe("stale"); + }); + + it("a mapped control with neither form:current-value nor form:value carries no value field", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form", {}, [el("form:text")])]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "plainText" }, + }); + }); + + it("form:checkbox and form:radio carry a checked field derived from form:current-state, both true and false", () => { + const checked = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:checkbox", { "form:current-state": "checked" }), + ]), + ]), + "odt", + ); + expect(contentControlDescriptor(checked[2])?.checked).toBe(true); + + const uncheckedRadio = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:radio", { "form:current-state": "unchecked" }), + ]), + ]), + "odt", + ); + expect(contentControlDescriptor(uncheckedRadio[2])?.checked).toBe(false); + + const missingState = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form", {}, [el("form:checkbox")])]), + "odt", + ); + expect(contentControlDescriptor(missingState[2])?.checked).toBe(false); + }); + + it("a control that is neither checkbox nor radio never carries a checked field", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:button", { "form:current-state": "checked" }), + ]), + ]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "button" }, + }); + }); + + it("form:listbox reads its form:option children as label-preferring-over-value pairs, in order, skipping an option with neither", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:listbox", {}, [ + el("form:option", { "form:label": "One", "form:value": "1" }), + el("form:option", { "form:value": "2" }), + el("form:option", {}), + ]), + ]), + ]), + "odt", + ); + expect(contentControlDescriptor(blocks[2])?.options).toStrictEqual([ + "One", + "2", + ]); + }); + + it("a control that is not a listbox never carries an options field, even with form:option-shaped children", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:combobox", {}, [el("form:option", { "form:label": "One" })]), + ]), + ]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "comboBox" }, + }); + }); + + it("a mapped control's own form:properties becomes its source residue, in the reader's own format", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [ + el("form:form", {}, [ + el("form:button", {}, [ + el("form:properties", {}, [el("form:property")]), + ]), + ]), + ]), + "odm", + ); + expect(contentControlDescriptor(blocks[2])?.source).toStrictEqual({ + format: "odm", + xml: "", + }); + }); + + it("a mapped control with no form:properties child carries no source field", () => { + const blocks = readOdfFormControlConstructs( + el("office:forms", {}, [el("form:form", {}, [el("form:button")])]), + "odt", + ); + expect(blocks[2]).toStrictEqual({ + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "button" }, + }); + }); +}); diff --git a/packages/odf.js/stryker.config.ts b/packages/odf.js/stryker.config.ts index 05c637214..9b02fe2b9 100644 --- a/packages/odf.js/stryker.config.ts +++ b/packages/odf.js/stryker.config.ts @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after a real batch of survivor kills (typed/shared/canonicalise.ts, units.ts, metadata.ts, list.ts, expression.ts): 75.72% of 8444 valid mutants, timeout share 1.3% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. Still provisional: the bulk of this package (draw/shapes.ts, ooo1/transform.ts, shared/constructs.ts, shared/paragraph.ts, ods/write.ts, ods/conditional-format.ts, odb/report.ts, and the rest) still carries its own unkilled mutants -- raise this again once the next batch's own full run confirms the next real floor. - breakThreshold: 73, + // Re-measured after killing every survivor and no-coverage mutant in typed/shared/forms.ts: 76.17% of 8451 valid mutants, timeout share 1.3% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. Still provisional: the bulk of this package (draw/shapes.ts, ooo1/transform.ts, shared/constructs.ts, shared/paragraph.ts, ods/write.ts, ods/conditional-format.ts, odb/report.ts, and the rest) still carries its own unkilled mutants -- raise this again once the next batch's own full run confirms the next real floor. + breakThreshold: 74, }); From 39e3f665001eeb0289496bb416558c72de7cfe3b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 11:55:10 +0100 Subject: [PATCH 11/89] test(odf.js): kill every mutant in test-support/zip.ts's LE integer readers Adds direct unit coverage for readUint16LE/readUint32LE/localFileHeaderNames/ assertMimetypeEntryLayout: truncated-input throws (including negative-offset isolation of the first missing byte), exact-boundary reads, multi-entry offset arithmetic through a non-zero extra field and compressed size, and each of assertMimetypeEntryLayout's six field checks. Also replaces the per-byte "b0 === undefined || b1 === undefined || ..." guards in both readers with a single [offset, offset + byteCount) range check. The per-byte form could never be killed in full: a real Uint8Array's undefined region is always a contiguous prefix or suffix, so no input can isolate an interior byte (b1 of 4, say) as the sole missing one, leaving that comparison an unreachable, equivalent mutant. The range check has no interior case to isolate. --- packages/odf.js/src/test-support/zip.test.ts | 283 +++++++++++++++++++ packages/odf.js/src/test-support/zip.ts | 41 +-- 2 files changed, 305 insertions(+), 19 deletions(-) create mode 100644 packages/odf.js/src/test-support/zip.test.ts diff --git a/packages/odf.js/src/test-support/zip.test.ts b/packages/odf.js/src/test-support/zip.test.ts new file mode 100644 index 000000000..37c7e55fe --- /dev/null +++ b/packages/odf.js/src/test-support/zip.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; +import { + assertMimetypeEntryLayout, + localFileHeaderNames, + readUint16LE, + readUint32LE, +} from "./zip"; + +// Builds a single synthetic local file header (signature 0x04034b50) plus a body of `compressedSize` zero bytes, with an arbitrary filename and extra-field length, entirely by hand rather than through fflate -- fflate's own zipSync never emits a non-empty extra field, so exercising the `extraLength` term in localFileHeaderNames's offset arithmetic needs bytes built directly. +function buildLocalFileHeader(options: { + filename: string; + extraLength: number; + compressedSize: number; +}): Uint8Array { + const nameBytes = new TextEncoder().encode(options.filename); + const total = + 30 + nameBytes.length + options.extraLength + options.compressedSize; + const bytes = new Uint8Array(total); + const view = new DataView(bytes.buffer); + view.setUint32(0, 0x04034b50, true); + view.setUint16(8, 0, true); // compression method + view.setUint32(18, options.compressedSize, true); + view.setUint16(26, nameBytes.length, true); + view.setUint16(28, options.extraLength, true); + bytes.set(nameBytes, 30); + return bytes; +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +describe("readUint16LE", () => { + it("combines two distinct bytes little-endian", () => { + // 0x02 | (0x01 << 8) = 0x0102 -- transposing the bytes or negating the shift would give a different value. + expect(readUint16LE(new Uint8Array([0x02, 0x01]), 0)).toBe(0x0102); + }); + + it("reads from a non-zero offset (offset + 1, not offset - 1, addresses the high byte)", () => { + const bytes = new Uint8Array([0xff, 0x02, 0x01, 0xff]); + expect(readUint16LE(bytes, 1)).toBe(0x0102); + }); + + it("throws when both bytes are missing", () => { + expect(() => readUint16LE(new Uint8Array([]), 0)).toThrow( + "truncated zip bytes while reading a uint16 at offset 0", + ); + }); + + it("throws when only the high byte is missing", () => { + expect(() => readUint16LE(new Uint8Array([0x42]), 0)).toThrow( + "truncated zip bytes while reading a uint16 at offset 0", + ); + }); + + it("throws when only the low byte is missing (negative offset)", () => { + // offset=-1 makes bytes[-1] (the low byte) undefined while bytes[0] (the high byte) is defined. + expect(() => readUint16LE(new Uint8Array([0x42, 0x43]), -1)).toThrow( + "truncated zip bytes while reading a uint16 at offset -1", + ); + }); + + it("reports the exact offset that failed, not a neighbouring one", () => { + expect(() => readUint16LE(new Uint8Array([1, 2, 3]), 5)).toThrow( + "truncated zip bytes while reading a uint16 at offset 5", + ); + }); +}); + +describe("readUint32LE", () => { + it("combines four distinct bytes little-endian, unsigned", () => { + // 0x04 | (0x03 << 8) | (0x02 << 16) | (0x01 << 24) = 0x01020304 + expect(readUint32LE(new Uint8Array([0x04, 0x03, 0x02, 0x01]), 0)).toBe( + 0x01020304, + ); + }); + + it("stays unsigned even when the top byte would set the sign bit", () => { + // Without the >>> 0 conversion this would read as a negative number. + expect(readUint32LE(new Uint8Array([0x00, 0x00, 0x00, 0xff]), 0)).toBe( + 0xff000000, + ); + }); + + it("throws when all four bytes are missing", () => { + expect(() => readUint32LE(new Uint8Array([]), 0)).toThrow( + "truncated zip bytes while reading a uint32 at offset 0", + ); + }); + + it("throws when only the last byte is missing", () => { + expect(() => readUint32LE(new Uint8Array([1, 2, 3]), 0)).toThrow( + "truncated zip bytes while reading a uint32 at offset 0", + ); + }); + + it("throws when only the third byte is missing", () => { + expect(() => readUint32LE(new Uint8Array([1, 2]), 0)).toThrow( + "truncated zip bytes while reading a uint32 at offset 0", + ); + }); + + it("throws when only the first byte is present", () => { + expect(() => readUint32LE(new Uint8Array([1]), 0)).toThrow( + "truncated zip bytes while reading a uint32 at offset 0", + ); + }); + + it("throws when only the first byte is missing (negative offset)", () => { + // offset=-1 makes bytes[-1] (b0) undefined while b1..b3 (bytes[0..2]) are defined. + expect(() => readUint32LE(new Uint8Array([1, 2, 3]), -1)).toThrow( + "truncated zip bytes while reading a uint32 at offset -1", + ); + }); +}); + +describe("localFileHeaderNames", () => { + it("returns no names for an empty byte array", () => { + expect(localFileHeaderNames(new Uint8Array([]))).toEqual([]); + }); + + it("stops cleanly when the walk consumes every byte exactly (no off-by-one past the end)", () => { + const header = buildLocalFileHeader({ + filename: "a.txt", + extraLength: 0, + compressedSize: 0, + }); + expect(localFileHeaderNames(header)).toEqual(["a.txt"]); + }); + + it("walks past a non-zero extra field to find the next header", () => { + const first = buildLocalFileHeader({ + filename: "first.xml", + extraLength: 4, + compressedSize: 0, + }); + const second = buildLocalFileHeader({ + filename: "second.xml", + extraLength: 0, + compressedSize: 0, + }); + const bytes = concatBytes([first, second]); + expect(localFileHeaderNames(bytes)).toEqual(["first.xml", "second.xml"]); + }); + + it("walks past a non-zero compressed size to find the next header", () => { + const first = buildLocalFileHeader({ + filename: "first.xml", + extraLength: 0, + compressedSize: 6, + }); + const second = buildLocalFileHeader({ + filename: "second.xml", + extraLength: 0, + compressedSize: 0, + }); + const bytes = concatBytes([first, second]); + expect(localFileHeaderNames(bytes)).toEqual(["first.xml", "second.xml"]); + }); + + it("walks past both a non-zero extra field and compressed size together", () => { + const first = buildLocalFileHeader({ + filename: "first.xml", + extraLength: 3, + compressedSize: 5, + }); + const second = buildLocalFileHeader({ + filename: "second.xml", + extraLength: 0, + compressedSize: 0, + }); + const third = buildLocalFileHeader({ + filename: "third.xml", + extraLength: 0, + compressedSize: 0, + }); + const bytes = concatBytes([first, second, third]); + expect(localFileHeaderNames(bytes)).toEqual([ + "first.xml", + "second.xml", + "third.xml", + ]); + }); + + it("stops at the first entry whose signature does not match, without reading past it", () => { + const header = buildLocalFileHeader({ + filename: "only.xml", + extraLength: 0, + compressedSize: 0, + }); + const trailer = new Uint8Array([0x50, 0x4b, 0x01, 0x02]); // central directory signature, not a local file header + const bytes = concatBytes([header, trailer]); + expect(localFileHeaderNames(bytes)).toEqual(["only.xml"]); + }); +}); + +describe("assertMimetypeEntryLayout", () => { + const mediaType = "application/vnd.oasis.opendocument.text"; + + function validLayout(): Uint8Array { + return buildLocalFileHeader({ + filename: "mimetype", + extraLength: 0, + compressedSize: 0, + }).slice(0, 30 + 8); // header only, then the content bytes appended below + } + + function withMimetypeContent(content: string): Uint8Array { + const header = validLayout(); + return concatBytes([header, new TextEncoder().encode(content)]); + } + + it("accepts a correctly-laid-out mimetype entry", () => { + expect(() => { + assertMimetypeEntryLayout(withMimetypeContent(mediaType), mediaType); + }).not.toThrow(); + }); + + it("rejects a wrong local file header signature", () => { + const bytes = withMimetypeContent(mediaType); + bytes[0] = 0x00; + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/local file header signature/); + }); + + it("rejects a non-zero compression method", () => { + const bytes = withMimetypeContent(mediaType); + bytes[8] = 8; // DEFLATE, not stored + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/compression method/); + }); + + it("rejects a filename length other than 8", () => { + const bytes = buildLocalFileHeader({ + filename: "mimetype2", + extraLength: 0, + compressedSize: 0, + }); + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/filename length/); + }); + + it("rejects a non-zero extra field length", () => { + const bytes = buildLocalFileHeader({ + filename: "mimetype", + extraLength: 4, + compressedSize: 0, + }); + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/extra field length/); + }); + + it("rejects filename bytes other than the literal string mimetype", () => { + const bytes = buildLocalFileHeader({ + filename: "MIMETYPE", + extraLength: 0, + compressedSize: 0, + }); + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/filename bytes/); + }); + + it("rejects mimetype content bytes that don't match the given media type", () => { + const bytes = withMimetypeContent( + "application/vnd.oasis.opendocument.spreadsheet", + ); + expect(() => { + assertMimetypeEntryLayout(bytes, mediaType); + }).toThrow(/mimetype content bytes/); + }); +}); diff --git a/packages/odf.js/src/test-support/zip.ts b/packages/odf.js/src/test-support/zip.ts index 7dd9f7fc1..b14d296c7 100644 --- a/packages/odf.js/src/test-support/zip.ts +++ b/packages/odf.js/src/test-support/zip.ts @@ -2,32 +2,35 @@ import { expect } from "vitest"; // Little-endian integer readers over raw zip bytes, shared by every test that walks a zip's physical local-file-header layout rather than trusting a round trip through unzipPackage's Record (which makes no ordering promise of its own to test against). Never imported by src/index.ts and never reaches dist/ -- test-only, mirroring the same test-only, never-exported convention as this package's other test-support helpers. -export function readUint16LE(bytes: Uint8Array, offset: number): number { - const b0 = bytes[offset]; - const b1 = bytes[offset + 1]; - if (b0 === undefined || b1 === undefined) { +// A single out-of-range check on the whole [offset, offset + byteCount) span, rather than one `bytes[i] === undefined` comparison per byte -- the per-byte form used to leave middle bytes (b1 of 4, say) impossible to isolate as the sole missing one, since a real Uint8Array's undefined region is always a contiguous prefix (negative indices) or suffix (indices past the end), never a single interior gap: no test input could ever tell "byte 1 alone is missing" apart from "the guard doesn't check byte 1 at all", so that mutation was unkillable by construction. A span check has no such interior case to isolate. +function requireBytesInRange( + bytes: Uint8Array, + offset: number, + byteCount: number, + typeLabel: string, +): void { + if (offset < 0 || offset + byteCount > bytes.length) { throw new Error( - `truncated zip bytes while reading a uint16 at offset ${offset}`, + `truncated zip bytes while reading a ${typeLabel} at offset ${offset}`, ); } +} + +export function readUint16LE(bytes: Uint8Array, offset: number): number { + requireBytesInRange(bytes, offset, 2, "uint16"); + // Bounds already verified above, so both indices are in range -- this is the standard escape hatch for a typed-array read TypeScript otherwise types as `number | undefined` under noUncheckedIndexedAccess with no way to narrow it from a separately-expressed arithmetic guard. + const b0 = bytes[offset]!; + const b1 = bytes[offset + 1]!; return b0 | (b1 << 8); } export function readUint32LE(bytes: Uint8Array, offset: number): number { - const b0 = bytes[offset]; - const b1 = bytes[offset + 1]; - const b2 = bytes[offset + 2]; - const b3 = bytes[offset + 3]; - if ( - b0 === undefined || - b1 === undefined || - b2 === undefined || - b3 === undefined - ) { - throw new Error( - `truncated zip bytes while reading a uint32 at offset ${offset}`, - ); - } + requireBytesInRange(bytes, offset, 4, "uint32"); + // Bounds already verified above, so all four indices are in range -- see readUint16LE's identical comment. + const b0 = bytes[offset]!; + const b1 = bytes[offset + 1]!; + const b2 = bytes[offset + 2]!; + const b3 = bytes[offset + 3]!; return (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0; } From 2ace65df24a6cfe88382aaaadbb767a525f8f138 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:06:39 +0100 Subject: [PATCH 12/89] test(odf.js): kill every mutant in test-support/document-tree.ts's helpers Adds direct unit coverage for the five kind-narrowing functions (wordprocessingPackage/presentationPackage/spreadsheetPackage/ drawingPackage/formulaPackage), each checked both on a matching-kind package (returns it unchanged, no throw) and a wrong-kind one (throws the exact "expected a ... package, got ..." message) -- neither path was ever exercised by odf.js's own reader suites, which only ever hand these functions a correctly-kinded result. Also covers assertPackageRoundTrip's three checks individually, each isolated so exactly one fails while the other two still pass: a schema-invalid tree via a malformed `fonts` field (schema-checked but read by neither flattenTree nor factorStyles, which carries an existing value through verbatim rather than recomputing it); a tree that flattens to something other than the given content; and a tree carrying an extra, unreferenced styles-table entry that a fresh mint of its own flattened content would not reproduce. --- .../src/test-support/document-tree.test.ts | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 packages/odf.js/src/test-support/document-tree.test.ts diff --git a/packages/odf.js/src/test-support/document-tree.test.ts b/packages/odf.js/src/test-support/document-tree.test.ts new file mode 100644 index 000000000..27f30f943 --- /dev/null +++ b/packages/odf.js/src/test-support/document-tree.test.ts @@ -0,0 +1,169 @@ +import type { ContentDocument, DocumentTree } from "document-schema.js"; +import { assembleTree } from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { + assertPackageRoundTrip, + drawingPackage, + formulaPackage, + presentationPackage, + spreadsheetPackage, + wordprocessingPackage, +} from "./document-tree"; + +const wordprocessingContent: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [], +}; +const presentationContent: ContentDocument = { + kind: "presentation", + metadata: {}, + slides: [], +}; +const spreadsheetContent: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], +}; +const drawingContent: ContentDocument = { + kind: "drawing", + metadata: {}, + pages: [], +}; +const formulaContent: ContentDocument = { + kind: "formula", + metadata: {}, + formula: { mathml: [] }, +}; + +const wordprocessingTree = assembleTree(wordprocessingContent); +const presentationTree = assembleTree(presentationContent); +const spreadsheetTree = assembleTree(spreadsheetContent); +const drawingTree = assembleTree(drawingContent); +const formulaTree = assembleTree(formulaContent); + +describe("wordprocessingPackage", () => { + it("returns a wordprocessing package unchanged", () => { + expect(wordprocessingPackage(wordprocessingTree)).toBe(wordprocessingTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => wordprocessingPackage(presentationTree)).toThrow( + "expected a wordprocessing package, got presentation", + ); + }); +}); + +describe("presentationPackage", () => { + it("returns a presentation package unchanged", () => { + expect(presentationPackage(presentationTree)).toBe(presentationTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => presentationPackage(wordprocessingTree)).toThrow( + "expected a presentation package, got wordprocessing", + ); + }); +}); + +describe("spreadsheetPackage", () => { + it("returns a spreadsheet package unchanged", () => { + expect(spreadsheetPackage(spreadsheetTree)).toBe(spreadsheetTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => spreadsheetPackage(wordprocessingTree)).toThrow( + "expected a spreadsheet package, got wordprocessing", + ); + }); +}); + +describe("drawingPackage", () => { + it("returns a drawing package unchanged", () => { + expect(drawingPackage(drawingTree)).toBe(drawingTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => drawingPackage(wordprocessingTree)).toThrow( + "expected a drawing package, got wordprocessing", + ); + }); +}); + +describe("formulaPackage", () => { + it("returns a formula package unchanged", () => { + expect(formulaPackage(formulaTree)).toBe(formulaTree); + }); + + it("rejects a package of a different kind", () => { + expect(() => formulaPackage(wordprocessingTree)).toThrow( + "expected a formula package, got wordprocessing", + ); + }); +}); + +describe("assertPackageRoundTrip", () => { + it("passes when the tree, its flattened form, and its re-minted form all agree", () => { + expect(() => { + assertPackageRoundTrip(wordprocessingTree, wordprocessingContent); + }).not.toThrow(); + }); + + it("throws when the tree fails schema validation", () => { + // "fonts" is schema-typed as TreeEmbeddedFont[] | undefined but read by + // neither flattenTree (it has no ContentDocument spelling at all) nor factorStyles (which carries an existing value through verbatim rather than recomputing it, per factor-styles.ts's own comment on the three package-level fields it re-carries untouched). A malformed value here is therefore invisible to the other two checks and trips only + // DocumentTreeSchema.parse -- isolating that one call. The cast is + // deliberate: this is exactly a value the type system exists to rule out, constructed so the runtime check has something real to catch. + const invalidTree = { + ...wordprocessingTree, + fonts: "not an array", + } as unknown as DocumentTree; + expect(() => { + assertPackageRoundTrip(invalidTree, wordprocessingContent); + }).toThrow(); + }); + + it("throws when the flattened tree doesn't match the given content", () => { + const mismatchedContent: ContentDocument = { + ...wordprocessingContent, + metadata: { title: "not what this tree flattens to" }, + }; + expect(() => { + assertPackageRoundTrip(wordprocessingTree, mismatchedContent); + }).toThrow(); + }); + + it("throws when re-minting the tree doesn't reproduce it", () => { + // Two paragraphs sharing one run-level tuple (bold, differing only in text, which factor-styles.ts's own header notes is not a mintable property) cross the plan's repeat-count-of-two threshold, so a fresh mint of this content genuinely extracts a shared style entry. Tacking an extra, unreferenced entry onto the tree's own already-minted table makes the tree schema-valid and still flatten to the same content (flattenTree resolves refs the tree's nodes actually carry; an unused table entry is invisible to it) while no longer matching what re-minting that same content produces -- isolating the third check. + const styledContent: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ + { kind: "paragraph", runs: [{ text: "one", bold: true }] }, + { kind: "paragraph", runs: [{ text: "two", bold: true }] }, + ], + }, + ], + }; + const styledTree = assembleTree(styledContent); + if (styledTree.kind !== "wordprocessing") { + throw new Error("expected assembleTree to preserve the document kind"); + } + if (styledTree.styles?.s1 === undefined) { + throw new Error( + "expected the repeated bold run to mint a shared styles entry", + ); + } + const treeWithUnusedEntry: DocumentTree = { + ...styledTree, + styles: { ...styledTree.styles, "unused-copy": styledTree.styles.s1 }, + }; + expect(() => { + assertPackageRoundTrip(treeWithUnusedEntry, styledContent); + }).toThrow(); + }); +}); From 41790b91ca95122633557f4bc2e4c606ecfe0e2c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:29:46 +0100 Subject: [PATCH 13/89] test(odf.js): add the first direct unit coverage for xml/build.ts Pins buildXml's pi/declaration/element/text/comment/cdata node mapping directly, including the empty-array shape for pi and declaration nodes that fast-xml-parser's ordered builder reads only from a node's own ":@" attributes rather than its array value, and the throw path when the underlying XMLBuilder does not return a string. Extends the package's existing no-deprecated exemption for the deprecated XMLBuilder class from build.ts to its own test file, which necessarily references the identical class to reach BUILDER's shared prototype. --- packages/odf.js/eslint.config.ts | 4 +- packages/odf.js/src/xml/build.test.ts | 117 ++++++++++++++++++++++++++ packages/odf.js/src/xml/build.ts | 6 +- 3 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 packages/odf.js/src/xml/build.test.ts diff --git a/packages/odf.js/eslint.config.ts b/packages/odf.js/eslint.config.ts index 2ab706909..ec3cd18e3 100644 --- a/packages/odf.js/eslint.config.ts +++ b/packages/odf.js/eslint.config.ts @@ -22,8 +22,8 @@ export default tseslint.config( preferReadonlyParams: "off", }), { - // fast-xml-parser@5 deprecates the whole XMLBuilder class, not one of its options, and ships no replacement of its own -- it points at a separate `fast-xml-builder` package that is not a declared dependency here. Swapping it is a real dependency decision with round-trip fidelity to re-verify (this builder is what keeps XML byte-faithful), so it is tracked rather than guessed at inside a tooling change. Scoped to the one module that constructs the builder. - files: ["src/xml/build.ts"], + // fast-xml-parser@5 deprecates the whole XMLBuilder class, not one of its options, and ships no replacement of its own — it points at a separate `fast-xml-builder` package that is not a declared dependency here. Swapping it is a real dependency decision with round-trip fidelity to re-verify (this builder is what keeps XML byte-faithful), so it is tracked rather than guessed at inside a tooling change. Scoped to the one module that constructs the builder, plus its own test file, which necessarily references the identical deprecated class to reach BUILDER's shared prototype. + files: ["src/xml/build.ts", "src/xml/build.test.ts"], rules: { "@typescript-eslint/no-deprecated": "off" }, }, ); diff --git a/packages/odf.js/src/xml/build.test.ts b/packages/odf.js/src/xml/build.test.ts new file mode 100644 index 000000000..c4c4535eb --- /dev/null +++ b/packages/odf.js/src/xml/build.test.ts @@ -0,0 +1,117 @@ +import { XMLBuilder } from "fast-xml-parser"; +import { describe, expect, it, vi } from "vitest"; +import type { XmlNode } from "../model/node"; +import { buildXml } from "./build"; + +describe("buildXml", () => { + it("serialises a text node as bare text", () => { + expect(buildXml([{ type: "text", value: "hello" }])).toBe("hello"); + }); + + it("serialises a comment node, preserving its text verbatim", () => { + expect(buildXml([{ type: "comment", value: "a note" }])).toBe( + "", + ); + }); + + it("serialises a cdata node, preserving its text verbatim", () => { + expect(buildXml([{ type: "cdata", value: "raw & unescaped " }])).toBe( + "]]>", + ); + }); + + it("serialises a processing instruction from its target alone", () => { + // The underlying builder emits any "?"-prefixed key from the key and its ":@" attributes only, never from the value beside it -- see build.ts's own comment on the pi/declaration cases. content is therefore deliberately absent from the output regardless of what it holds. + const withContent: XmlNode = { + type: "pi", + target: "xml-stylesheet", + content: 'type="text/xsl" href="styles.xsl"', + }; + expect(buildXml([withContent])).toBe(""); + expect( + buildXml([{ type: "pi", target: "xml-stylesheet", content: "" }]), + ).toBe(""); + }); + + it("serialises a declaration node from its attributes", () => { + expect( + buildXml([ + { + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ], + }, + ]), + ).toBe(''); + }); + + it("serialises a declaration node with no attributes at all", () => { + expect(buildXml([{ type: "declaration", attributes: [] }])).toBe(""); + }); + + it("serialises a childless, attribute-less element as an empty tag pair", () => { + expect( + buildXml([{ type: "element", tag: "a", attributes: [], children: [] }]), + ).toBe(""); + }); + + it("omits the attribute map entirely when an element carries no attributes", () => { + // Confirms Object.keys(attrs).length > 0 gates the ":@" key: were it added unconditionally, the builder would still render identical text for a genuinely empty attrs object, so an equality check on the rendered string alone couldn't tell the two apart -- what actually distinguishes them is that an element WITH attributes (below) proves the key does get added when there is something to add. + const withAttrs = buildXml([ + { + type: "element", + tag: "a", + attributes: [{ name: "href", value: "x" }], + children: [], + }, + ]); + expect(withAttrs).toBe(''); + }); + + it("serialises an element carrying several attributes, in given order", () => { + expect( + buildXml([ + { + type: "element", + tag: "a", + attributes: [ + { name: "x", value: "1" }, + { name: "y", value: "2" }, + ], + children: [{ type: "text", value: "body" }], + }, + ]), + ).toBe('body'); + }); + + it("serialises nested elements in document order", () => { + expect( + buildXml([ + { + type: "element", + tag: "outer", + attributes: [], + children: [ + { type: "element", tag: "inner", attributes: [], children: [] }, + ], + }, + ]), + ).toBe(""); + }); + + it("throws when the underlying builder does not return a string", () => { + // Spies on the shared prototype method rather than re-importing the module under a mock: a fresh import would re-run build.ts's own top-level `new XMLBuilder({...})` call inside this test's coverage window, which would make Stryker attribute (spurious) per-test coverage to that config object literal — exactly the kind of coverage that can never distinguish one config value from another, since the real constructor never runs during a mocked-import test. Spying on the prototype instead leaves BUILDER's own construction untouched and only intercepts the one call this test cares about. + const spy = vi + .spyOn(XMLBuilder.prototype, "build") + .mockReturnValueOnce({ not: "a string" } as unknown as string); + try { + expect(() => buildXml([{ type: "text", value: "x" }])).toThrow( + "XMLBuilder did not return a string", + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/odf.js/src/xml/build.ts b/packages/odf.js/src/xml/build.ts index a285ddef5..cbb5e0dfa 100644 --- a/packages/odf.js/src/xml/build.ts +++ b/packages/odf.js/src/xml/build.ts @@ -41,10 +41,12 @@ function toOrderedNode(node: XmlNode): Record { return { __comment: [{ "#text": node.value }] }; case "cdata": return { __cdata: [{ "#text": node.value }] }; + // fast-xml-builder (fast-xml-parser's own build engine) special-cases any "?"-prefixed key: it emits `` from the key and its ":@" attributes alone and never looks at the key's own array value, for a PI exactly as it does for the declaration case just below -- confirmed directly against the library's own orderedJs2Xml.js, which branches on a leading "?" before ever touching a node's array/text content. node.content therefore never reaches the built string; an empty array is exactly as observable as any other value here; and there's no attribute for it to ride either, since a PI's content is free text rather than name/value pairs. This is a genuine limitation of the library, not a choice this codec makes -- see the equivalent case below for the same reasoning restated over ?xml's own attributes. case "pi": - return { [`?${node.target}`]: [{ "#text": node.content }] }; + return { [`?${node.target}`]: [] }; + // Same "?"-prefixed-key rule as the pi case above: fast-xml-builder reads ?xml's attributes from ":@" and ignores whatever sits in its own array value entirely, so the array carries nothing observable either way. case "declaration": - return { "?xml": [{ "#text": "" }], ":@": attrsObject(node.attributes) }; + return { "?xml": [], ":@": attrsObject(node.attributes) }; case "element": { const obj: Record = { [node.tag]: toOrdered(node.children), From 936b07e96d26d7e6f25c9d8b6a911eb33125d021 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:43:09 +0100 Subject: [PATCH 14/89] test(odf.js): kill xml/build.ts's remaining survivors Factors the pi/declaration cases' shared "ignored by fast-xml-builder" array literal into one constant typed as the empty tuple `readonly []`, turning a content mutation there into a type error rather than a silent, unobservable survivor (fast-xml-builder never reads either node shape's own array value). Exports toOrderedNode so a test can pin the exact intermediate ordered-node shape directly, in particular that an attribute-less element's object carries no ":@" key at all rather than one holding an empty object, a distinction the built XML string never renders differently, so no test on buildXml's own output could observe it. --- packages/odf.js/src/xml/build.test.ts | 50 +++++++++++++++++++++++++-- packages/odf.js/src/xml/build.ts | 12 ++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/odf.js/src/xml/build.test.ts b/packages/odf.js/src/xml/build.test.ts index c4c4535eb..551b24705 100644 --- a/packages/odf.js/src/xml/build.test.ts +++ b/packages/odf.js/src/xml/build.test.ts @@ -1,7 +1,7 @@ import { XMLBuilder } from "fast-xml-parser"; import { describe, expect, it, vi } from "vitest"; import type { XmlNode } from "../model/node"; -import { buildXml } from "./build"; +import { buildXml, toOrderedNode } from "./build"; describe("buildXml", () => { it("serialises a text node as bare text", () => { @@ -21,7 +21,7 @@ describe("buildXml", () => { }); it("serialises a processing instruction from its target alone", () => { - // The underlying builder emits any "?"-prefixed key from the key and its ":@" attributes only, never from the value beside it -- see build.ts's own comment on the pi/declaration cases. content is therefore deliberately absent from the output regardless of what it holds. + // The underlying builder emits any "?"-prefixed key from the key and its ":@" attributes only, never from the value beside it — see build.ts's own comment on the pi/declaration cases. content is therefore deliberately absent from the output regardless of what it holds. const withContent: XmlNode = { type: "pi", target: "xml-stylesheet", @@ -58,7 +58,7 @@ describe("buildXml", () => { }); it("omits the attribute map entirely when an element carries no attributes", () => { - // Confirms Object.keys(attrs).length > 0 gates the ":@" key: were it added unconditionally, the builder would still render identical text for a genuinely empty attrs object, so an equality check on the rendered string alone couldn't tell the two apart -- what actually distinguishes them is that an element WITH attributes (below) proves the key does get added when there is something to add. + // Confirms Object.keys(attrs).length > 0 gates the ":@" key: were it added unconditionally, the builder would still render identical text for a genuinely empty attrs object, so an equality check on the rendered string alone couldn't tell the two apart — what actually distinguishes them is that an element WITH attributes (below) proves the key does get added when there is something to add. const withAttrs = buildXml([ { type: "element", @@ -115,3 +115,47 @@ describe("buildXml", () => { } }); }); + +describe("toOrderedNode", () => { + it("omits the ':@' key entirely for an attribute-less element, rather than carrying an empty attrs object", () => { + // toEqual checks the object's exact own-property set: were the ":@" key added unconditionally (as an empty object), this would fail even though buildXml's own rendered XML string is identical either way — see build.test.ts's "omits the attribute map" case above, which pins the observable half of this same invariant. + expect( + toOrderedNode({ + type: "element", + tag: "a", + attributes: [], + children: [], + }), + ).toStrictEqual({ a: [] }); + }); + + it("adds the ':@' key once an element carries at least one attribute", () => { + expect( + toOrderedNode({ + type: "element", + tag: "a", + attributes: [{ name: "href", value: "x" }], + children: [], + }), + ).toStrictEqual({ a: [], ":@": { "@_href": "x" } }); + }); + + it("maps a pi node to its '?'-prefixed key holding an empty array, regardless of its own content", () => { + expect( + toOrderedNode({ + type: "pi", + target: "xml-stylesheet", + content: "ignored", + }), + ).toStrictEqual({ "?xml-stylesheet": [] }); + }); + + it("maps a declaration node to '?xml' holding an empty array plus its attributes", () => { + expect( + toOrderedNode({ + type: "declaration", + attributes: [{ name: "version", value: "1.0" }], + }), + ).toStrictEqual({ "?xml": [], ":@": { "@_version": "1.0" } }); + }); +}); diff --git a/packages/odf.js/src/xml/build.ts b/packages/odf.js/src/xml/build.ts index cbb5e0dfa..8c7b0174a 100644 --- a/packages/odf.js/src/xml/build.ts +++ b/packages/odf.js/src/xml/build.ts @@ -33,7 +33,11 @@ function attrsObject(attributes: Attribute[]): Record { return obj; } -function toOrderedNode(node: XmlNode): Record { +// The array Stryker's own ArrayDeclaration mutator would otherwise target at each of the pi/declaration return sites below, factored to one shared literal and pinned to the empty-tuple type `readonly []` so that any mutation of its contents (Stryker's own probe replaces `[]` with `["Stryker was here"]`) is a type error rather than a silent, unobservable survivor: fast-xml-builder reads neither node shape's own array value, only its ":@" attributes (see the case comments below), so no test on buildXml's output could ever distinguish an empty array here from a non-empty one. The type system rules the mutation out instead of a test having to. +const NO_ORDERED_CONTENT: readonly [] = []; + +// Exported so a test can pin the exact intermediate ordered-node shape directly — in particular that an attribute-less element's object carries no ":@" key at all, rather than one holding an empty object, a distinction fast-xml-builder itself never renders differently in the built XML string and so no output-equality test on buildXml could ever observe. +export function toOrderedNode(node: XmlNode): Record { switch (node.type) { case "text": return { "#text": node.value }; @@ -41,12 +45,12 @@ function toOrderedNode(node: XmlNode): Record { return { __comment: [{ "#text": node.value }] }; case "cdata": return { __cdata: [{ "#text": node.value }] }; - // fast-xml-builder (fast-xml-parser's own build engine) special-cases any "?"-prefixed key: it emits `` from the key and its ":@" attributes alone and never looks at the key's own array value, for a PI exactly as it does for the declaration case just below -- confirmed directly against the library's own orderedJs2Xml.js, which branches on a leading "?" before ever touching a node's array/text content. node.content therefore never reaches the built string; an empty array is exactly as observable as any other value here; and there's no attribute for it to ride either, since a PI's content is free text rather than name/value pairs. This is a genuine limitation of the library, not a choice this codec makes -- see the equivalent case below for the same reasoning restated over ?xml's own attributes. + // fast-xml-builder (fast-xml-parser's own build engine) special-cases any "?"-prefixed key: it emits `` from the key and its ":@" attributes alone and never looks at the key's own array value, for a PI exactly as it does for the declaration case just below — confirmed directly against the library's own orderedJs2Xml.js, which branches on a leading "?" before ever touching a node's array/text content. node.content therefore never reaches the built string; an empty array is exactly as observable as any other value here; and there's no attribute for it to ride either, since a PI's content is free text rather than name/value pairs. This is a genuine limitation of the library, not a choice this codec makes — see the equivalent case below for the same reasoning restated over ?xml's own attributes. case "pi": - return { [`?${node.target}`]: [] }; + return { [`?${node.target}`]: NO_ORDERED_CONTENT }; // Same "?"-prefixed-key rule as the pi case above: fast-xml-builder reads ?xml's attributes from ":@" and ignores whatever sits in its own array value entirely, so the array carries nothing observable either way. case "declaration": - return { "?xml": [], ":@": attrsObject(node.attributes) }; + return { "?xml": NO_ORDERED_CONTENT, ":@": attrsObject(node.attributes) }; case "element": { const obj: Record = { [node.tag]: toOrdered(node.children), From b548cd18810d2cbfbd5869d335a6fefbe58d7454 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:47:08 +0100 Subject: [PATCH 15/89] test(odf.js): add the first direct unit coverage for image/sniff.ts Covers PNG/JPEG/GIF87a/GIF89a magic-byte detection, the too-short and empty-input cases, and SVG sniffing from either an XML prolog or a bare root tag with leading whitespace, including the deliberate window-size cap that lets a root element sitting past the first kilobyte go undetected rather than scanning an unboundedly large file. Removes startsWith's separate "bytes too short" guard: an out-of-bounds Uint8Array read is undefined, which never strictly equals a real signature byte, so the comparison loop already returns false for a too-short input on its own, the guard produced no outcome the loop didn't already produce. --- packages/odf.js/src/image/sniff.test.ts | 84 +++++++++++++++++++++++++ packages/odf.js/src/image/sniff.ts | 4 +- 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 packages/odf.js/src/image/sniff.test.ts diff --git a/packages/odf.js/src/image/sniff.test.ts b/packages/odf.js/src/image/sniff.test.ts new file mode 100644 index 000000000..72ee9d647 --- /dev/null +++ b/packages/odf.js/src/image/sniff.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { sniffImageFormat } from "./sniff"; + +function bytesOf(values: number[]): Uint8Array { + return new Uint8Array(values); +} + +function asciiBytes(text: string): number[] { + return Array.from(text, (c) => c.charCodeAt(0)); +} + +describe("sniffImageFormat", () => { + it("detects a PNG from its 8-byte magic signature", () => { + expect( + sniffImageFormat( + bytesOf([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff]), + ), + ).toBe("png"); + }); + + it("detects a JPEG from its 3-byte magic signature", () => { + expect(sniffImageFormat(bytesOf([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpeg"); + }); + + it("detects a GIF87a header", () => { + expect(sniffImageFormat(bytesOf(asciiBytes("GIF87a").concat([0x00])))).toBe( + "gif", + ); + }); + + it("detects a GIF89a header", () => { + expect(sniffImageFormat(bytesOf(asciiBytes("GIF89a").concat([0x00])))).toBe( + "gif", + ); + }); + + it("returns undefined for bytes shorter than every signature it checks", () => { + expect(sniffImageFormat(bytesOf([0x89, 0x50]))).toBeUndefined(); + }); + + it("returns undefined for an empty byte array", () => { + expect(sniffImageFormat(bytesOf([]))).toBeUndefined(); + }); + + it("detects SVG from an XML prolog, with no ' { + const bytes = bytesOf(asciiBytes('')); + expect(sniffImageFormat(bytes)).toBe("svg"); + }); + + it("detects SVG from a bare ' { + const bytes = bytesOf( + asciiBytes(''), + ); + expect(sniffImageFormat(bytes)).toBe("svg"); + }); + + it("skips leading whitespace before the ' { + const bytes = bytesOf(asciiBytes(' ')); + expect(sniffImageFormat(bytes)).toBe("svg"); + }); + + it("returns undefined for text that starts with neither ' { + const bytes = bytesOf(asciiBytes("")); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); + + it("never finds a root element hidden behind more leading whitespace than the sniff window covers", () => { + // The sniff window is capped at a fixed size specifically so a caller can't be made to scan an unboundedly large file — a real SVG's root element always appears well within it (see sniff.ts's own comment), so padding past the window with plain spaces before the real tag is exactly the case the cap is meant to give up on, not a bug to work around. + const paddingLength = 2000; + const bytes = bytesOf([ + ...Array(paddingLength).fill(0x20), + ...asciiBytes(""), + ]); + expect(bytes.length).toBeGreaterThan(1024); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); + + it("does not match bytes that merely end with, rather than start with, an SVG marker", () => { + const bytes = bytesOf( + asciiBytes("embeds a literal "), + ); + expect(sniffImageFormat(bytes)).toBeUndefined(); + }); +}); diff --git a/packages/odf.js/src/image/sniff.ts b/packages/odf.js/src/image/sniff.ts index 3ae3c4740..14b78208d 100644 --- a/packages/odf.js/src/image/sniff.ts +++ b/packages/odf.js/src/image/sniff.ts @@ -12,13 +12,11 @@ const GIF89A_SIGNATURE: readonly number[] = [ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, ]; +// No separate "bytes too short" guard: when bytes.length < signature.length, some index i in the loop below reads past the end of bytes, and an out-of-bounds array read is `undefined` in JS -- which is never strictly equal to signature[i] (always a real 0-255 byte value), so the loop's own mismatch check already returns false for every too-short input. A dedicated length guard would only ever produce a result the loop already produces on its own. 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 cb079b3801842a1ab3a9ccaea7cbfc10d8ca14c4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:51:34 +0100 Subject: [PATCH 16/89] test(odf.js): add the first direct unit coverage for util/base64.ts Pins bytesToBase64 and base64ToBytes against the classic Wikipedia "Man"/"Many hands..." progressive vectors, one per length mod 4 so every padding branch is exercised both true and false, plus the whitespace-stripping clean-up regex, the invalid-padding-position throw, and the fixed-size scratch buffer silently bounding a malformed, non-4-multiple-length decode rather than growing to fit it. --- packages/odf.js/src/util/base64.test.ts | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/odf.js/src/util/base64.test.ts diff --git a/packages/odf.js/src/util/base64.test.ts b/packages/odf.js/src/util/base64.test.ts new file mode 100644 index 000000000..128cb91bf --- /dev/null +++ b/packages/odf.js/src/util/base64.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { base64ToBytes, bytesToBase64 } from "./base64"; + +function bytesOfAscii(text: string): Uint8Array { + return new Uint8Array(Array.from(text, (c) => c.charCodeAt(0))); +} + +// The classic Wikipedia "Man"/"Many hands..." progressive vectors, computed via Node's own Buffer.from(s, "utf8").toString("base64"): one entry per length mod 4 (0, 1, 2, 3 leftover bytes) so every branch of bytesToBase64's per-3-byte padding logic gets a case where it is exercised both true and false. +const KNOWN_VECTORS: readonly [string, string][] = [ + ["", ""], + ["M", "TQ=="], + ["Ma", "TWE="], + ["Man", "TWFu"], + ["Many", "TWFueQ=="], + ["Many ", "TWFueSA="], + ["Many h", "TWFueSBo"], + ["Many ha", "TWFueSBoYQ=="], + ["Many han", "TWFueSBoYW4="], + ["Many hand", "TWFueSBoYW5k"], +]; + +describe("bytesToBase64", () => { + it.each(KNOWN_VECTORS)("encodes %j to %j", (text, expected) => { + expect(bytesToBase64(bytesOfAscii(text))).toBe(expected); + }); +}); + +describe("base64ToBytes", () => { + it.each(KNOWN_VECTORS)( + "decodes %2$j back to the bytes of %1$j", + (text, encoded) => { + expect(base64ToBytes(encoded)).toEqual(bytesOfAscii(text)); + }, + ); + + it("strips whitespace interspersed in the input before decoding", () => { + expect(base64ToBytes("TW Fu\n")).toEqual(bytesOfAscii("Man")); + }); + + it("throws when a '=' padding character appears in the first position of a 4-char group", () => { + expect(() => base64ToBytes("=BCD")).toThrow("invalid base64 input"); + }); + + it("throws when a '=' padding character appears in the second position of a 4-char group", () => { + expect(() => base64ToBytes("A=CD")).toThrow("invalid base64 input"); + }); + + it("bounds a malformed, non-4-multiple-length input to its declared scratch size rather than growing to fit it", () => { + // clean.length here is 5, one char short of a second full 4-char group: the loop's second iteration reads two out-of-range indices via charCodeAt (NaN, decoding to a byte anyway) and would write a 4th, 5th and 6th output byte past the 3-byte buffer ((5*3)/4|0 == 3) this input's own length declares, a Uint8Array silently drops writes past its own length rather than growing, so the result is exactly the first full group's 3 bytes, not whatever the malformed second group's partial contents happen to decode to. + expect(base64ToBytes("TWFuT")).toEqual(bytesOfAscii("Man")); + }); +}); From bcf5cab04bb0343b3c3f0ed7c880cb17bd042672 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 12:57:46 +0100 Subject: [PATCH 17/89] test(odf.js): cover columnLettersToIndex and TableCursor's own error text columnLettersToIndex had no direct test at all: adds cases for a valid uppercase reference and the three invalid shapes (lowercase, a trailing digit, empty) that must return undefined rather than delegate to the schema helper. Sharpens TableCursor's repeat-count error assertions from a generic /positive integer/ pattern match to the caller name itself (TableCursor.nextCell / TableCursor.nextRow), since the generic pattern alone can't tell the two call sites' own error text apart. --- packages/odf.js/src/typed/shared/a1.test.ts | 29 +++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/odf.js/src/typed/shared/a1.test.ts b/packages/odf.js/src/typed/shared/a1.test.ts index 2e4086958..17e5d193a 100644 --- a/packages/odf.js/src/typed/shared/a1.test.ts +++ b/packages/odf.js/src/typed/shared/a1.test.ts @@ -1,5 +1,24 @@ import { describe, expect, it } from "vitest"; -import { columnIndexToLetters, cellReference, TableCursor } from "./a1"; +import { + columnIndexToLetters, + columnLettersToIndex, + cellReference, + TableCursor, +} from "./a1"; + +describe("columnLettersToIndex", () => { + it("converts uppercase column letters back to a 0-based index", () => { + expect(columnLettersToIndex("A")).toBe(0); + expect(columnLettersToIndex("Z")).toBe(25); + expect(columnLettersToIndex("AA")).toBe(26); + }); + + it("returns undefined for input containing anything but uppercase letters", () => { + expect(columnLettersToIndex("a")).toBeUndefined(); + expect(columnLettersToIndex("A1")).toBeUndefined(); + expect(columnLettersToIndex("")).toBeUndefined(); + }); +}); describe("columnIndexToLetters", () => { it("converts single-letter columns", () => { @@ -92,13 +111,13 @@ describe("TableCursor", () => { expect(cursor.nextCell()).toBe("A31985"); }); - it("throws for a zero or negative repeat count on either advance method", () => { + it("throws for a zero or negative repeat count on either advance method, naming its own caller in the message", () => { const cursor = new TableCursor(); - expect(() => cursor.nextCell(0)).toThrow(/positive integer/); - expect(() => cursor.nextCell(-1)).toThrow(/positive integer/); + expect(() => cursor.nextCell(0)).toThrow("TableCursor.nextCell:"); + expect(() => cursor.nextCell(-1)).toThrow("TableCursor.nextCell:"); expect(() => { cursor.nextRow(0); - }).toThrow(/positive integer/); + }).toThrow("TableCursor.nextRow:"); }); it("throws for a non-integer repeat count", () => { From 49f71ac37ea66cbd3136d62424e33b752c42fb5a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:02:46 +0100 Subject: [PATCH 18/89] test(odf.js): pin columnLettersToIndex's uppercase-only guard against mixed case document-schema.js's own columnLettersToIndex uppercases its input before validating, so it alone can't distinguish "aA" or "Aa" from "AA"; each of odf.js's own ^ and $ anchors, if dropped, would let exactly one of those two mixed-case strings reach the schema helper undetected instead of being rejected up front. --- packages/odf.js/src/typed/shared/a1.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/odf.js/src/typed/shared/a1.test.ts b/packages/odf.js/src/typed/shared/a1.test.ts index 17e5d193a..0d2cc6011 100644 --- a/packages/odf.js/src/typed/shared/a1.test.ts +++ b/packages/odf.js/src/typed/shared/a1.test.ts @@ -18,6 +18,12 @@ describe("columnLettersToIndex", () => { expect(columnLettersToIndex("A1")).toBeUndefined(); expect(columnLettersToIndex("")).toBeUndefined(); }); + + it("returns undefined for mixed-case input even though every character is a letter", () => { + // document-schema.js's own columnLettersToIndex uppercases its input before validating, so it alone can't distinguish "aA" or "Aa" from "AA" -- these two cases exist specifically to pin the ^ and $ anchors in this module's own uppercase-only guard, each anchor's removal otherwise lets exactly one of these two strings reach (and be silently accepted by) the schema helper. + expect(columnLettersToIndex("aA")).toBeUndefined(); + expect(columnLettersToIndex("Aa")).toBeUndefined(); + }); }); describe("columnIndexToLetters", () => { From f7571db2170265e4b71cff2961ff34185dd3970b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:07:30 +0100 Subject: [PATCH 19/89] test(odf.js): kill typed/shared/text.ts's remaining survivors Adds negative, non-canonical-spelling (leading zero), and exact-zero text:c cases to getOdfSpaceCount's own guard, each isolating one of its three disjuncts; adds a child-carrying bookmark/marker case to measureOdfNodeLength and decodeOdfText, since an empty-children marker can't tell "recursed into nothing" apart from "never recursed". Drops the space-run scanner's redundant `end < text.length` bound: an out-of-range string index is undefined, which is never `=== SPACE`, so the comparison loop already stops there on its own. --- packages/odf.js/src/typed/shared/text.test.ts | 33 +++++++++++++++++++ packages/odf.js/src/typed/shared/text.ts | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/odf.js/src/typed/shared/text.test.ts b/packages/odf.js/src/typed/shared/text.test.ts index 53f2223de..4802dcdeb 100644 --- a/packages/odf.js/src/typed/shared/text.test.ts +++ b/packages/odf.js/src/typed/shared/text.test.ts @@ -29,6 +29,22 @@ describe("getOdfSpaceCount", () => { getOdfSpaceCount(el("text:s", { "text:c": "not-a-number" })), ).toThrow(/malformed/); }); + + it("throws for a negative text:c", () => { + expect(() => getOdfSpaceCount(el("text:s", { "text:c": "-1" }))).toThrow( + /malformed/, + ); + }); + + it("throws for a text:c that isn't parseInt's own canonical spelling of its value, e.g. a leading zero", () => { + expect(() => getOdfSpaceCount(el("text:s", { "text:c": "05" }))).toThrow( + /malformed/, + ); + }); + + it("accepts a text:c of exactly zero, a valid (if degenerate) space count", () => { + expect(getOdfSpaceCount(el("text:s", { "text:c": "0" }))).toBe(0); + }); }); describe("measureOdfNodeLength / sumOdfNodeLength", () => { @@ -59,6 +75,13 @@ describe("measureOdfNodeLength / sumOdfNodeLength", () => { expect(measureOdfNodeLength(el("text:title"))).toBe(0); }); + it("ignores a zero-width marker's own children rather than recursing into them", () => { + // A bookmark carries no length of its own, but it's still an element that could, in principle, carry children — this pins that measureOdfNodeLength genuinely returns 0 for the whole node rather than merely happening to see an empty children array (el("text:bookmark") above has none, so that case alone can't tell "recurses into an empty list" apart from "never recurses at all"). + expect(measureOdfNodeLength(el("text:bookmark", {}, [txt("hidden")]))).toBe( + 0, + ); + }); + it("sums a flat node list", () => { expect( sumOdfNodeLength([txt("ab"), el("text:s", { "text:c": "2" }), txt("c")]), @@ -151,6 +174,16 @@ describe("decodeOdfText", () => { expect(decodeOdfText(paragraph)).toBe("ab"); }); + it("ignores a zero-width marker's own children rather than recursing into them", () => { + // As with measureOdfNodeLength above, an empty-children marker can't tell "recursed into nothing" apart from "never recursed" — this one carries real text so a wrongly-recursing implementation would leak it into the decoded output. + const paragraph = paragraphOf( + txt("a"), + el("text:bookmark-start", { "text:name": "mark" }, [txt("hidden")]), + txt("b"), + ); + expect(decodeOdfText(paragraph)).toBe("ab"); + }); + it("contributes nothing for a comment or CDATA node", () => { const paragraph = el("text:p", {}, [ txt("a"), diff --git a/packages/odf.js/src/typed/shared/text.ts b/packages/odf.js/src/typed/shared/text.ts index 9f05eaed3..1deee03b0 100644 --- a/packages/odf.js/src/typed/shared/text.ts +++ b/packages/odf.js/src/typed/shared/text.ts @@ -205,8 +205,9 @@ export function segmentOdfText( index += 1; continue; } + // No separate `end < text.length` bound: past the string's own end, `text[end]` is undefined, which is never `=== SPACE`, so the loop already stops there on its own -- a length check would only ever produce a result this comparison already produces. let end = index; - while (end < text.length && text[end] === SPACE) { + while (text[end] === SPACE) { end += 1; } const spaces = text.slice(index, end); From 4a3965cc767b3564b8e58c2f4e3ad9178653f20b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:11:26 +0100 Subject: [PATCH 20/89] test(odf.js): add the first direct unit coverage for package-io/scaffold.ts Pins createOdfPackage's exact XML declaration, office:version stamping (both the given version and the DEFAULT_ODF_VERSION default), the body element nesting inside office:body, and the mimetype part it writes. Covers both of odfPartContainer's throw paths directly: a part path that resolves to a non-XML part, and an XML part with no container matching the requested tag, alongside the already-implicit success path returning a real container. --- .../odf.js/src/package-io/scaffold.test.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 packages/odf.js/src/package-io/scaffold.test.ts diff --git a/packages/odf.js/src/package-io/scaffold.test.ts b/packages/odf.js/src/package-io/scaffold.test.ts new file mode 100644 index 000000000..74b196ecc --- /dev/null +++ b/packages/odf.js/src/package-io/scaffold.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { buildXml } from "../xml/build"; +import { base64ToBytes } from "../util/base64"; +import { + createOdfPackage, + odfPartContainer, + DEFAULT_ODF_VERSION, +} from "./scaffold"; + +describe("createOdfPackage", () => { + it("writes content.xml and styles.xml, each starting with the exact XML declaration", () => { + const pkg = createOdfPackage("application/vnd.oasis.opendocument.text", { + type: "element", + tag: "office:text", + attributes: [], + children: [], + }); + const contentPart = pkg.parts["content.xml"]; + const stylesPart = pkg.parts["styles.xml"]; + expect(contentPart?.kind).toBe("xml"); + expect(stylesPart?.kind).toBe("xml"); + if (contentPart?.kind !== "xml" || stylesPart?.kind !== "xml") { + throw new Error("expected xml parts"); + } + expect(buildXml(contentPart.nodes)).toMatch( + /^<\?xml version="1\.0" encoding="UTF-8"\?> { + const bodyElement = { + type: "element" as const, + tag: "office:text", + attributes: [], + children: [], + }; + const defaulted = createOdfPackage( + "application/vnd.oasis.opendocument.text", + bodyElement, + ); + const custom = createOdfPackage( + "application/vnd.oasis.opendocument.text", + bodyElement, + "1.2", + ); + const defaultedContent = defaulted.parts["content.xml"]; + const customContent = custom.parts["content.xml"]; + if (defaultedContent?.kind !== "xml" || customContent?.kind !== "xml") { + throw new Error("expected xml parts"); + } + expect(buildXml(defaultedContent.nodes)).toContain( + `office:version="${DEFAULT_ODF_VERSION}"`, + ); + expect(buildXml(customContent.nodes)).toContain('office:version="1.2"'); + }); + + it("nests the caller's own body element inside office:body in content.xml", () => { + const pkg = createOdfPackage("application/vnd.oasis.opendocument.text", { + type: "element", + tag: "office:spreadsheet", + attributes: [], + children: [], + }); + const contentPart = pkg.parts["content.xml"]; + if (contentPart?.kind !== "xml") { + throw new Error("expected an xml part"); + } + expect(buildXml(contentPart.nodes)).toContain( + "", + ); + }); + + it("writes the mimetype part from the given media type", () => { + const pkg = createOdfPackage( + "application/vnd.oasis.opendocument.spreadsheet", + { + type: "element", + tag: "office:spreadsheet", + attributes: [], + children: [], + }, + ); + const mimetypePart = pkg.parts.mimetype; + expect(mimetypePart?.kind).toBe("binary"); + if (mimetypePart?.kind !== "binary") { + throw new Error("expected a binary mimetype part"); + } + expect(new TextDecoder().decode(base64ToBytes(mimetypePart.base64))).toBe( + "application/vnd.oasis.opendocument.spreadsheet", + ); + }); +}); + +describe("odfPartContainer", () => { + function freshPackage() { + return createOdfPackage("application/vnd.oasis.opendocument.text", { + type: "element", + tag: "office:text", + attributes: [], + children: [], + }); + } + + it("returns the named container element from a real part", () => { + const container = odfPartContainer( + freshPackage(), + "content.xml", + "office:automatic-styles", + ); + expect(container.tag).toBe("office:automatic-styles"); + }); + + it("throws when the part path is not an XML part at all", () => { + expect(() => + odfPartContainer(freshPackage(), "mimetype", "office:styles"), + ).toThrow('odfPartContainer: "mimetype" is not an XML part'); + }); + + it("throws when the XML part has no container with that tag", () => { + expect(() => + odfPartContainer(freshPackage(), "content.xml", "office:styles"), + ).toThrow('odfPartContainer: "content.xml" has no office:styles container'); + }); +}); From a929e2d5825e7e1805997931c1c74872edd66bb0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:18:39 +0100 Subject: [PATCH 21/89] test(odf.js): add the first direct unit coverage for typed/shared/border.ts Covers parseBorderEdge's whitespace tolerance (leading/trailing, and a run of several spaces collapsing to one separator), its wrong-token- count/unparseable-length/unparseable-colour/zero-or-negative-width rejections, the none/hidden marker, an unmapped style token leaving style unset, and formatBorderEdge's own solid-style default. --- .../odf.js/src/typed/shared/border.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/odf.js/src/typed/shared/border.test.ts diff --git a/packages/odf.js/src/typed/shared/border.test.ts b/packages/odf.js/src/typed/shared/border.test.ts new file mode 100644 index 000000000..987eb51a6 --- /dev/null +++ b/packages/odf.js/src/typed/shared/border.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { parseBorderEdge, formatBorderEdge } from "./border"; + +describe("parseBorderEdge", () => { + it("parses a real border's three tokens", () => { + expect(parseBorderEdge("0.05pt solid #000000")).toEqual({ + border: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.05, style: "solid" }, + }); + }); + + it("tolerates surrounding whitespace around the whole value", () => { + expect(parseBorderEdge(" 0.05pt solid #000000 ")).toEqual({ + border: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.05, style: "solid" }, + }); + }); + + it("collapses a run of several spaces between tokens into one separator", () => { + expect(parseBorderEdge("0.05pt solid #000000")).toEqual({ + border: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.05, style: "solid" }, + }); + }); + + it("returns undefined for a token count other than three", () => { + expect(parseBorderEdge("0.05pt solid")).toBeUndefined(); + expect(parseBorderEdge("0.05pt solid #000000 extra")).toBeUndefined(); + }); + + it("treats 'none' or 'hidden' as an explicit no-border marker", () => { + expect(parseBorderEdge("0.05pt none #000000")).toEqual({ none: true }); + expect(parseBorderEdge("0.05pt hidden #000000")).toEqual({ none: true }); + }); + + it("returns undefined for an unparseable length or colour", () => { + expect(parseBorderEdge("notalength solid #000000")).toBeUndefined(); + expect(parseBorderEdge("0.05pt solid notacolor")).toBeUndefined(); + }); + + it("returns undefined for a zero or negative width, a non-border", () => { + expect(parseBorderEdge("0pt solid #000000")).toBeUndefined(); + expect(parseBorderEdge("-0.05pt solid #000000")).toBeUndefined(); + }); + + it("leaves style unset for a style token ODF allows but this schema has no member for", () => { + expect(parseBorderEdge("0.05pt groove #000000")).toEqual({ + border: { color: { r: 0, g: 0, b: 0 }, widthPt: 0.05 }, + }); + }); +}); + +describe("formatBorderEdge", () => { + it("formats width, style, and colour as the three-token shorthand", () => { + expect( + formatBorderEdge({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 0.05, + style: "dashed", + }), + ).toBe("0.05pt dashed #000000"); + }); + + it("defaults an absent style to 'solid'", () => { + expect( + formatBorderEdge({ color: { r: 0, g: 0, b: 0 }, widthPt: 0.05 }), + ).toBe("0.05pt solid #000000"); + }); +}); From 225f2796cf4c1fb081e54ec007868258e38e8911 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:18:47 +0100 Subject: [PATCH 22/89] test(odf.js): kill typed/shared/list.ts's remaining survivors Pins resolveOdfListKind's undefined-style-name short-circuit against a package carrying a real, matchable, ordered list-style whose own style:name attribute is absent (so attrValue coincidentally also resolves to undefined), and extends the existing "only a level-1 child counts" case from the ordered path to the bullet and image paths, each previously untested. Pins buildOdfListStyle's ordered branch to also carry the indent properties every level already carries on the bullet branch, and adds a readOdfListParagraphs case where an item child's tag is neither text:p/text:h nor text:list, carrying its own text:list-item/text:p descendants specifically so a wrongly-permissive recursion would surface them. Drops writeOdfList's redundant tag check on an existing host: every element this function ever pushes onto an "enclosing" list's children is already a text:list-item via its own construction, so an element found there carries no other tag to distinguish from it. --- packages/odf.js/src/typed/shared/list.test.ts | 53 +++++++++++++++++++ packages/odf.js/src/typed/shared/list.ts | 3 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/odf.js/src/typed/shared/list.test.ts b/packages/odf.js/src/typed/shared/list.test.ts index 3931f4988..018c0d4af 100644 --- a/packages/odf.js/src/typed/shared/list.test.ts +++ b/packages/odf.js/src/typed/shared/list.test.ts @@ -40,6 +40,15 @@ describe("resolveOdfListKind", () => { expect(resolveOdfListKind({ parts: {} }, undefined)).toBeUndefined(); }); + it("undefined style name resolves to undefined even when a real, matchable list-style exists", () => { + // A text:list-style with no style:name attribute at all would make attrValue(el, "style:name") itself return undefined -- coincidentally equal to an undefined styleName -- if this function didn't short-circuit on an undefined styleName before ever reaching the lookup. Giving it a level-1 ordered child means a version that skipped the short-circuit would resolve "ordered" here instead of undefined. + const style = el("text:list-style", {}, [ + el("text:list-level-style-number", { "text:level": "1" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, undefined)).toBeUndefined(); + }); + it("resolves an ordered list style from its level-1 text:list-level-style-number", () => { const style = el("text:list-style", { "style:name": "L1" }, [ el("text:list-level-style-number", { "text:level": "1" }), @@ -72,6 +81,22 @@ describe("resolveOdfListKind", () => { expect(resolveOdfListKind(pkg, "L1")).toBeUndefined(); }); + it("only a level-1 child counts for a bullet style too -- a level-2-only bullet resolves to undefined", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-bullet", { "text:level": "2" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBeUndefined(); + }); + + it("only a level-1 child counts for an image style too -- a level-2-only image resolves to undefined", () => { + const style = el("text:list-style", { "style:name": "L1" }, [ + el("text:list-level-style-image", { "text:level": "2" }), + ]); + const pkg = packageWithAutomaticStyles(style); + expect(resolveOdfListKind(pkg, "L1")).toBeUndefined(); + }); + it("a matching style with no recognised level-1 child resolves to undefined", () => { const style = el("text:list-style", { "style:name": "L1" }, []); const pkg = packageWithAutomaticStyles(style); @@ -164,6 +189,17 @@ describe("buildOdfListStyle", () => { expect(attrValue(levels[0]!, "style:num-format")).toBe("1"); }); + it("an ordered style's own levels also carry the indent properties, exactly as a bullet style's do", () => { + const style = buildOdfListStyle("L1", "ordered"); + const levels = childrenWithTag(style, "text:list-level-style-number"); + const props = childrenWithTag( + levels[0]!, + "style:list-level-properties", + )[0]!; + expect(attrValue(props, "text:space-before")).toBe("18pt"); + expect(attrValue(props, "text:min-label-width")).toBe("18pt"); + }); + it("a bullet style's own levels carry the real bullet character, never the numbering attributes", () => { const style = buildOdfListStyle("L1", "bullet"); const levels = childrenWithTag(style, "text:list-level-style-bullet"); @@ -393,6 +429,23 @@ describe("readOdfListParagraphs", () => { expect(paragraphs).toHaveLength(1); }); + it("ignores an item child that is neither text:p/text:h nor text:list, even one shaped like a nested list inside", () => { + // The nested tag ("text:list-header") carries its own text:list-item/text:p descendants specifically so a version that recursed into ANY non-text:p/text:h child (rather than only a genuine text:list) would find and surface this paragraph -- an empty decoy element couldn't tell the two apart. + const list = el("text:list", {}, [ + el("text:list-item", {}, [ + el("text:list-header", {}, [ + el("text:list-item", {}, [el("text:p", { id: "decoy" })]), + ]), + ]), + ]); + const paragraphs = readOdfListParagraphs( + list, + { numId: "list1", level: 0 }, + paragraphReader(), + ); + expect(paragraphs).toEqual([]); + }); + it("recurses into a nested text:list, incrementing level but keeping the SAME numId", () => { const list = el("text:list", {}, [ el("text:list-item", {}, [ diff --git a/packages/odf.js/src/typed/shared/list.ts b/packages/odf.js/src/typed/shared/list.ts index cc0212e5d..921f0b735 100644 --- a/packages/odf.js/src/typed/shared/list.ts +++ b/packages/odf.js/src/typed/shared/list.ts @@ -168,8 +168,9 @@ export function writeOdfList( while (openLists.length - 1 < level) { const enclosing = openLists[openLists.length - 1]!; const lastChild = enclosing.children[enclosing.children.length - 1]; + // No separate check that lastChild's own tag is "text:list-item": every element this function ever pushes onto an "enclosing" list's children is one, via the enclosing.children.push(host) call a few lines below, so an element found here already carries no other tag to distinguish from it. let host: XmlElement; - if (lastChild?.type === "element" && lastChild.tag === "text:list-item") { + if (lastChild?.type === "element") { host = lastChild; } else { host = el("text:list-item"); From 2f89fac9f1188f18185a0ca0111b5dac677ee97b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:24:51 +0100 Subject: [PATCH 23/89] test(odf.js): kill typed/shared/transform.ts's remaining survivors Adds cases for rotate() called with no argument, an unmodelled function's own args never being parsed as translate's just because they happen to look like valid lengths, and a run of several spaces between translate's two arguments collapsing to one separator. Drops parseOdfTransform's redundant name/argsRaw undefined guard: FUNCTION_PATTERN's two capture groups are both plain, non-optional captures, so a successful match always populates both. Replaces the split-then-filter empty-string removal with an explicit empty-argsRaw check, the only case split can actually misbehave on given FUNCTION_PATTERN's own surrounding whitespace trim. --- .../odf.js/src/typed/shared/transform.test.ts | 15 +++++++++++++++ packages/odf.js/src/typed/shared/transform.ts | 11 +++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/odf.js/src/typed/shared/transform.test.ts b/packages/odf.js/src/typed/shared/transform.test.ts index f2755c345..ccf2cded8 100644 --- a/packages/odf.js/src/typed/shared/transform.test.ts +++ b/packages/odf.js/src/typed/shared/transform.test.ts @@ -60,6 +60,21 @@ describe("parseOdfTransform", () => { expect(parseOdfTransform("")).toEqual([]); expect(parseOdfTransform("matrix(1 0 0 1 0 0)")).toEqual([]); }); + + it("skips rotate() called with no argument at all, rather than treating it as angle zero", () => { + expect(parseOdfTransform("rotate()")).toEqual([]); + }); + + it("does not parse an unmodelled function's own args as translate's, even when they'd otherwise look like valid lengths", () => { + // scale's own two arguments ("10pt 10pt") are deliberately unit-bearing here, unlike the other "skips a function this module does not model" case above (whose "2 2" scale args fail to parse as lengths either way) — this is the case that actually distinguishes "genuinely skipped because it isn't translate" from "accidentally parsed as translate and happened to succeed". + expect(parseOdfTransform("scale(10pt 10pt)")).toEqual([]); + }); + + it("collapses a run of several spaces between translate's own two arguments into one separator", () => { + expect(parseOdfTransform("translate(10pt 20pt)")).toEqual([ + { kind: "translate", xPt: 10, yPt: 20 }, + ]); + }); }); describe("applyOdfTransform: matches the real LibreOffice-rendered bounding box", () => { diff --git a/packages/odf.js/src/typed/shared/transform.ts b/packages/odf.js/src/typed/shared/transform.ts index 44d47d4bb..341959305 100644 --- a/packages/odf.js/src/typed/shared/transform.ts +++ b/packages/odf.js/src/typed/shared/transform.ts @@ -23,12 +23,11 @@ const FUNCTION_PATTERN = /([a-zA-Z]+)\s*\(\s*([^)]*?)\s*\)/g; export function parseOdfTransform(value: string): OdfTransformFunction[] { const functions: OdfTransformFunction[] = []; for (const match of value.matchAll(FUNCTION_PATTERN)) { - const name = match[1]; - const argsRaw = match[2]; - if (name === undefined || argsRaw === undefined) { - continue; - } - const args = argsRaw.split(/\s+/).filter((arg) => arg.length > 0); + // FUNCTION_PATTERN's two capture groups are both plain, non-optional captures with no alternation that could skip them, so a successful match always populates both -- never undefined at runtime, only in the indexed-access type. + const name = match[1]!; + const argsRaw = match[2]!; + // Not argsRaw.split(/\s+/).filter(...): FUNCTION_PATTERN's own surrounding \s* already trims argsRaw of leading/trailing whitespace, so the only way split would otherwise misbehave is the classic "".split(...) === [""] case for a function called with no arguments at all (e.g. "rotate()") -- handled explicitly here instead of by filtering every split result. + const args = argsRaw.length === 0 ? [] : argsRaw.split(/\s+/); if (name === "rotate") { const angleArg = args[0]; if (angleArg === undefined) { From db211f396c8aad243c50d1ebb7f0f4133952ae71 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:27:13 +0100 Subject: [PATCH 24/89] test(odf.js): kill the last two typed/shared/transform.ts survivors Types the shared "no arguments" empty array as the tuple `readonly []`, matching build.ts's own NO_ORDERED_CONTENT pattern: a content mutation there is now a type error, not a silent, unobservable survivor. Drops rotate's separate angleArg-undefined guard: Number(undefined) is NaN, which the isFinite check right below already rejects identically to a genuinely present but unparseable angle. --- packages/odf.js/src/typed/shared/transform.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/odf.js/src/typed/shared/transform.ts b/packages/odf.js/src/typed/shared/transform.ts index 341959305..81dcab9d1 100644 --- a/packages/odf.js/src/typed/shared/transform.ts +++ b/packages/odf.js/src/typed/shared/transform.ts @@ -18,6 +18,8 @@ export interface OdfPoint { } const FUNCTION_PATTERN = /([a-zA-Z]+)\s*\(\s*([^)]*?)\s*\)/g; +// A function called with no arguments at all (e.g. "rotate()") has no tokens to extract, and every consumer below already treats a missing token (angleArg/xArg both undefined) and an unparseable one (a genuinely non-empty but garbage string) identically -- typed as the empty tuple `readonly []` so a content mutation here is a type error rather than a silent, unobservable survivor, exactly like build.ts's own NO_ORDERED_CONTENT. +const NO_ARGS: readonly [] = []; // Parses a draw:transform attribute value into its function list, in document order. A function this module doesn't model (scale/skewX/skewY/matrix), or one whose arguments don't parse (a malformed angle, a translate length outside the ODF `length` grammar), is skipped rather than aborting the whole parse -- the remaining, well-formed functions still contribute, matching this package's general "degrade a single unsupported feature, don't fail the whole read" policy. export function parseOdfTransform(value: string): OdfTransformFunction[] { @@ -26,13 +28,11 @@ export function parseOdfTransform(value: string): OdfTransformFunction[] { // FUNCTION_PATTERN's two capture groups are both plain, non-optional captures with no alternation that could skip them, so a successful match always populates both -- never undefined at runtime, only in the indexed-access type. const name = match[1]!; const argsRaw = match[2]!; - // Not argsRaw.split(/\s+/).filter(...): FUNCTION_PATTERN's own surrounding \s* already trims argsRaw of leading/trailing whitespace, so the only way split would otherwise misbehave is the classic "".split(...) === [""] case for a function called with no arguments at all (e.g. "rotate()") -- handled explicitly here instead of by filtering every split result. - const args = argsRaw.length === 0 ? [] : argsRaw.split(/\s+/); + // Not argsRaw.split(/\s+/).filter(...): FUNCTION_PATTERN's own surrounding \s* already trims argsRaw of leading/trailing whitespace, so the only way split would otherwise misbehave is the classic "".split(...) === [""] case for a function called with no arguments at all (e.g. "rotate()") — handled explicitly here instead of by filtering every split result. + const args = argsRaw.length === 0 ? NO_ARGS : argsRaw.split(/\s+/); if (name === "rotate") { const angleArg = args[0]; - if (angleArg === undefined) { - continue; - } + // No separate `angleArg === undefined` guard: Number(undefined) is NaN, which the isFinite check below already rejects identically to a genuinely present but unparseable angle, so a missing argument needs no check of its own. const angleRad = Number(angleArg); if (!Number.isFinite(angleRad)) { continue; From 132914f827adfeb2de72731a0c26e395116981b8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:30:47 +0100 Subject: [PATCH 25/89] test(odf.js): kill typed/shared/metadata.ts's remaining survivors Switches the empty-office:meta case from toEqual to toStrictEqual: toEqual ignores explicit undefined-valued properties, so it couldn't tell a genuinely absent metadata field apart from one of the six per-field guards wrongly firing and setting metadata. = undefined. Adds a dedicated xmlns:meta-declaration case for the keywords loop's own ensureNamespaceDeclared call (title/author/subject instead go through setElementText, already covered), and a case for meta.xml being a well-formed XML part with no root element at all. --- .../odf.js/src/typed/shared/metadata.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/odf.js/src/typed/shared/metadata.test.ts b/packages/odf.js/src/typed/shared/metadata.test.ts index ce685844e..4dfa944f3 100644 --- a/packages/odf.js/src/typed/shared/metadata.test.ts +++ b/packages/odf.js/src/typed/shared/metadata.test.ts @@ -81,7 +81,8 @@ describe("readOdfMetadata", () => { }); it("returns an empty object for a well-formed but entirely empty office:meta -- an empty office:meta is valid ODF, not an error", () => { - expect(readOdfMetadata(metaPackage([]))).toEqual({}); + // toStrictEqual, not toEqual: toEqual ignores explicit undefined-valued properties, so it can't tell a genuinely absent key apart from one of the six field guards below wrongly firing and setting metadata. = undefined -- toStrictEqual treats that as a real, distinguishable difference from {}. + expect(readOdfMetadata(metaPackage([]))).toStrictEqual({}); }); // dc:title / meta:initial-creator / dc:subject / dc:date / meta:generator values below are copied verbatim (real LibreOffice 26.2.5.2 output) from Modern_business_letter_serif.ott and CV.ott, two of LibreOffice's own bundled templates under /Applications/LibreOffice.app/Contents/Resources/template/**; meta:creation-date's value is likewise a real LibreOffice-produced timestamp copied from the same template. See this module's own top-of-file note on how meta:initial-creator vs. dc:creator, and meta:keyword's one-element-per-keyword shape, were confirmed against those real files. @@ -437,6 +438,19 @@ describe("patchOdfMetadata", () => { expect(readOdfMetadata(pkg).author).toBe("New author"); }); + it("declares xmlns:meta on office:document-meta when patching keywords into a meta.xml that only ever declared dc:", () => { + // The keywords loop calls ensureNamespaceDeclared itself (unlike title/author/subject, whose declaration goes through setElementText), so this pins that call's own "meta:keyword" prefix argument directly, distinct from the author-path test above. + const pkg = metaPackage([el("dc:title", {}, [txt("Existing title")])], { + "xmlns:office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", + "xmlns:dc": "http://purl.org/dc/elements/1.1/", + }); + patchOdfMetadata(pkg, { keywords: ["alpha"] }); + const root = documentMetaRootOf(pkg); + expect(root.attributes.find((a) => a.name === "xmlns:meta")?.value).toBe( + "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", + ); + }); + it("does not duplicate an xmlns declaration the root already carries", () => { const pkg = metaPackage([], { "xmlns:office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", @@ -455,6 +469,15 @@ describe("patchOdfMetadata", () => { }).toThrow(/has no 'meta\.xml' XML part/); }); + it("throws when meta.xml is an XML part with no root element at all", () => { + const pkg: Package = { + parts: { [META_PART]: { kind: "xml", nodes: [] } }, + }; + expect(() => { + patchOdfMetadata(pkg, { title: "x" }); + }).toThrow(/has no root element/); + }); + it("throws when meta.xml has no office:meta element", () => { const pkg: Package = { parts: { From 0c097bba0e74d31982b4819aa1cc32e8bd2094a7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:39:12 +0100 Subject: [PATCH 26/89] test(odf.js): kill ooo1/ns.ts's remaining survivors Adds a bare, default xmlns declaration case (no colon, unlike every other case here) and a case where an ordinary attribute's VALUE happens to equal a real OOo1 namespace URI while its NAME is neither "xmlns" nor "xmlns:"-prefixed, which must not be enough on its own to mark a package as OOo1x. --- packages/odf.js/src/ooo1/ns.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/odf.js/src/ooo1/ns.test.ts b/packages/odf.js/src/ooo1/ns.test.ts index e90bce876..0c798e3ca 100644 --- a/packages/odf.js/src/ooo1/ns.test.ts +++ b/packages/odf.js/src/ooo1/ns.test.ts @@ -140,6 +140,21 @@ describe("isOoo1Package", () => { expect(isOoo1Package(pkg)).toBe(true); }); + it("recognises a bare, default (non-prefixed) xmlns declaration too", () => { + const pkg = packageOf({ + "content.xml": ``, + }); + expect(isOoo1Package(pkg)).toBe(true); + }); + + it("does not treat an ordinary attribute whose value happens to equal an OOo1 URI as a namespace declaration", () => { + // office:version here is neither "xmlns" nor "xmlns:"-prefixed -- only its VALUE coincides with a real OOo1 namespace URI, which must not be enough on its own. + const pkg = packageOf({ + "content.xml": ``, + }); + expect(isOoo1Package(pkg)).toBe(false); + }); + it("rejects a real ODF package", () => { const pkg = packageOf({ "content.xml": `hi`, From b35e9f2e9812271cfd81f75a47648cd142d4cc62 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:39:19 +0100 Subject: [PATCH 27/89] test(odf.js): add the first direct unit coverage for typed/draw/embedded-write.ts Covers every writeEmbeddedObjectPackage dispatch case (wordprocessing, presentation, spreadsheet, drawing, formula) via each one's own real media type, the chart refusal, writeDrawObjectElement's xlink:type and href, and that writeEmbeddedObject re-syncs the OUTER package's own manifest so the new embedding directory is actually listed. --- .../src/typed/draw/embedded-write.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/odf.js/src/typed/draw/embedded-write.test.ts diff --git a/packages/odf.js/src/typed/draw/embedded-write.test.ts b/packages/odf.js/src/typed/draw/embedded-write.test.ts new file mode 100644 index 000000000..b32126c91 --- /dev/null +++ b/packages/odf.js/src/typed/draw/embedded-write.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import type { ContentDocument } from "document-schema.js"; +import { PAGE_SIZE_A4 } from "document-schema.js"; +import type { Package } from "../../model/package"; +import { el } from "../../xml/fragment"; +import { readMimetype, writeMimetype } from "../../mimetype"; +import { + writeEmbeddedObjectPackage, + writeDrawObjectElement, + writeEmbeddedObject, +} from "./embedded-write"; + +const MARGINS = { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }; + +function mediaTypeOf(pkg: Package): string | undefined { + return readMimetype(pkg); +} + +function wordprocessingDocument(): ContentDocument { + return { + kind: "wordprocessing", + metadata: {}, + sections: [{ pageSize: PAGE_SIZE_A4, margins: MARGINS, blocks: [] }], + }; +} + +describe("writeEmbeddedObjectPackage", () => { + it("dispatches a wordprocessing document to writeOdtContent", () => { + const pkg = writeEmbeddedObjectPackage({ + objectKind: "wordprocessing", + document: wordprocessingDocument(), + }); + expect(mediaTypeOf(pkg)).toBe("application/vnd.oasis.opendocument.text"); + }); + + it("dispatches a presentation document to writeOdpContent", () => { + const document: ContentDocument = { + kind: "presentation", + metadata: {}, + slides: [], + }; + const pkg = writeEmbeddedObjectPackage({ + objectKind: "presentation", + document, + }); + expect(mediaTypeOf(pkg)).toBe( + "application/vnd.oasis.opendocument.presentation", + ); + }); + + it("dispatches a spreadsheet document to writeOdsContent", () => { + const document: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], + }; + const pkg = writeEmbeddedObjectPackage({ + objectKind: "spreadsheet", + document, + }); + expect(mediaTypeOf(pkg)).toBe( + "application/vnd.oasis.opendocument.spreadsheet", + ); + }); + + it("dispatches a drawing document to writeOdgContent", () => { + const document: ContentDocument = { + kind: "drawing", + metadata: {}, + pages: [], + }; + const pkg = writeEmbeddedObjectPackage({ + objectKind: "drawing", + document, + }); + expect(mediaTypeOf(pkg)).toBe( + "application/vnd.oasis.opendocument.graphics", + ); + }); + + it("dispatches a formula document to writeOdfFormulaContent", () => { + const document: ContentDocument = { + kind: "formula", + metadata: {}, + formula: { mathml: [el("math", {}, [])] }, + }; + const pkg = writeEmbeddedObjectPackage({ + objectKind: "formula", + document, + }); + expect(mediaTypeOf(pkg)).toBe("application/vnd.oasis.opendocument.formula"); + }); + + it("refuses a chart object, which has no write-side serialiser", () => { + expect(() => + writeEmbeddedObjectPackage({ + objectKind: "chart", + document: { kind: "spreadsheet", metadata: {}, sheets: [] }, + }), + ).toThrow(/no write-side serialiser/); + }); +}); + +describe("writeDrawObjectElement", () => { + it('carries xlink:type="simple" alongside the href', () => { + const element = writeDrawObjectElement("Object 1"); + const attrByName = (name: string) => + element.attributes.find((a) => a.name === name)?.value; + expect(attrByName("xlink:type")).toBe("simple"); + expect(attrByName("xlink:href")).toBe("./Object 1"); + }); +}); + +describe("writeEmbeddedObject", () => { + it("re-syncs the outer package's manifest so the new embedding directory is actually listed", () => { + const pkg: Package = { parts: {} }; + writeMimetype(pkg, "application/vnd.oasis.opendocument.text"); + writeEmbeddedObject( + { objectKind: "wordprocessing", document: wordprocessingDocument() }, + "Object 1", + pkg, + ); + const manifestPart = pkg.parts["META-INF/manifest.xml"]; + expect(manifestPart?.kind).toBe("xml"); + if (manifestPart?.kind !== "xml") { + throw new Error("expected a manifest part"); + } + const manifestXml = JSON.stringify(manifestPart.nodes); + expect(manifestXml).toContain("Object 1/"); + }); +}); From 33e470f2acc8b6b812c715a0866ff6ce0245aa71 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:39:25 +0100 Subject: [PATCH 28/89] test(odf.js): kill typed/odp/read.ts's remaining survivors Pins findDrawingPageProperties' undefined-style-name short-circuit against a nameless drawing-page style that would otherwise coincidentally match, its family check against a same-named non-drawing-page style, and that readOdpContent/readOdp both omit their own "source" key entirely when nothing was quarantined, rather than carrying it set to undefined. --- packages/odf.js/src/typed/odp/read.test.ts | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/odf.js/src/typed/odp/read.test.ts b/packages/odf.js/src/typed/odp/read.test.ts index 514122d99..e05d99873 100644 --- a/packages/odf.js/src/typed/odp/read.test.ts +++ b/packages/odf.js/src/typed/odp/read.test.ts @@ -369,6 +369,15 @@ describe("readOdpContent", () => { expect(slides).toHaveLength(2); }); + it("omits the 'source' key entirely from a slide with no residue, rather than carrying it set to undefined", () => { + const { slides } = readOdpContent(buildFixturePackage()); + expect(Object.hasOwn(slides[0]!, "source")).toBe(false); + }); + + it("omits the package-level 'source' key entirely from readOdp when nothing was quarantined", () => { + expect(Object.hasOwn(readOdp(buildFixturePackage()), "source")).toBe(false); + }); + it("resolves slide size from the master-page -> page-layout chain (draw:master-page-name -> style:master-page -> style:page-layout-name -> style:page-layout-properties)", () => { const { slides } = readOdpContent(buildFixturePackage()); expect(slides[0]?.size).toEqual({ widthPt: 720, heightPt: 540 }); @@ -611,6 +620,37 @@ describe("readOdpContent: residue rows", () => { expect(slides[0]?.source).toBeUndefined(); }); + it("finds no drawing-page style at all when the slide carries no draw:style-name, even if a nameless drawing-page style happens to exist", () => { + // The automatic style below carries a "drawing-page" family but no style:name attribute at all, so attrValue(style, "style:name") itself resolves to undefined -- coincidentally equal to an undefined draw:style-name -- if findDrawingPageProperties didn't short-circuit before ever reaching the style walk. + const automaticStyles = el("office:automatic-styles", {}, [ + el("style:style", { "style:family": "drawing-page" }, [ + el("style:drawing-page-properties", { + "presentation:transition-type": "automatic", + }), + ]), + ]); + const page = el("draw:page", { "draw:master-page-name": "Default" }, []); + const { slides } = readOdpContent(slidePackage(page, {}, automaticStyles)); + expect(slides[0]?.source).toBeUndefined(); + }); + + it("skips a same-named style whose family is not drawing-page", () => { + const automaticStyles = el("office:automatic-styles", {}, [ + el("style:style", { "style:name": "dp1", "style:family": "paragraph" }, [ + el("style:drawing-page-properties", { + "presentation:transition-type": "automatic", + }), + ]), + ]); + const page = el( + "draw:page", + { "draw:master-page-name": "Default", "draw:style-name": "dp1" }, + [], + ); + const { slides } = readOdpContent(slidePackage(page, {}, automaticStyles)); + expect(slides[0]?.source).toBeUndefined(); + }); + it("quarantines the REAL transitions.odp fixture's Impress-written smil transitions on their own slides", () => { const { slides } = readOdpContent(loadFixture("transitions.odp")); expect(slides).toHaveLength(8); From 661c56508e7795c1a34f718789a91e81ae1d10b7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:42:54 +0100 Subject: [PATCH 29/89] fix(odf.js): supply the required frame field in embedded-write's own tests ContentEmbeddedObject requires frame (a Box), which every literal in the previous commit omitted, caught by the workspace's own pnpm run _typecheck, not by an equivalent ad-hoc tsc invocation run immediately beforehand, which passed silently against the identical source for reasons not otherwise established. --- .../odf.js/src/typed/draw/embedded-write.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/odf.js/src/typed/draw/embedded-write.test.ts b/packages/odf.js/src/typed/draw/embedded-write.test.ts index b32126c91..0087afe71 100644 --- a/packages/odf.js/src/typed/draw/embedded-write.test.ts +++ b/packages/odf.js/src/typed/draw/embedded-write.test.ts @@ -11,6 +11,7 @@ import { } from "./embedded-write"; const MARGINS = { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }; +const FRAME = { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }; function mediaTypeOf(pkg: Package): string | undefined { return readMimetype(pkg); @@ -28,6 +29,7 @@ describe("writeEmbeddedObjectPackage", () => { it("dispatches a wordprocessing document to writeOdtContent", () => { const pkg = writeEmbeddedObjectPackage({ objectKind: "wordprocessing", + frame: FRAME, document: wordprocessingDocument(), }); expect(mediaTypeOf(pkg)).toBe("application/vnd.oasis.opendocument.text"); @@ -41,6 +43,7 @@ describe("writeEmbeddedObjectPackage", () => { }; const pkg = writeEmbeddedObjectPackage({ objectKind: "presentation", + frame: FRAME, document, }); expect(mediaTypeOf(pkg)).toBe( @@ -56,6 +59,7 @@ describe("writeEmbeddedObjectPackage", () => { }; const pkg = writeEmbeddedObjectPackage({ objectKind: "spreadsheet", + frame: FRAME, document, }); expect(mediaTypeOf(pkg)).toBe( @@ -71,6 +75,7 @@ describe("writeEmbeddedObjectPackage", () => { }; const pkg = writeEmbeddedObjectPackage({ objectKind: "drawing", + frame: FRAME, document, }); expect(mediaTypeOf(pkg)).toBe( @@ -86,6 +91,7 @@ describe("writeEmbeddedObjectPackage", () => { }; const pkg = writeEmbeddedObjectPackage({ objectKind: "formula", + frame: FRAME, document, }); expect(mediaTypeOf(pkg)).toBe("application/vnd.oasis.opendocument.formula"); @@ -95,6 +101,7 @@ describe("writeEmbeddedObjectPackage", () => { expect(() => writeEmbeddedObjectPackage({ objectKind: "chart", + frame: FRAME, document: { kind: "spreadsheet", metadata: {}, sheets: [] }, }), ).toThrow(/no write-side serialiser/); @@ -116,7 +123,11 @@ describe("writeEmbeddedObject", () => { const pkg: Package = { parts: {} }; writeMimetype(pkg, "application/vnd.oasis.opendocument.text"); writeEmbeddedObject( - { objectKind: "wordprocessing", document: wordprocessingDocument() }, + { + objectKind: "wordprocessing", + frame: FRAME, + document: wordprocessingDocument(), + }, "Object 1", pkg, ); From 505cb3f2dc9fb7ebed83fa0ff7ad59ab72a2e9d7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 13:52:12 +0100 Subject: [PATCH 30/89] test(odf.js): kill typed/shared/cascade.ts's remaining survivors Adds the first direct unit coverage for findNamedStylePartElement (a real (tag, draw:name) match, a same-named resource of a different tag, and a same-tag resource of a different name), plus a decoy element that is neither style:style nor style:default-style but carries a real style:family attribute, confirming collectStyles' own dispatch ignores it. Drops resolveStyleElementChain's redundant styleName-undefined early return: the walk loop's own condition already stops immediately from an undefined currentName, producing the identical empty chain. --- .../odf.js/src/typed/shared/cascade.test.ts | 52 ++++++++++++++++++ packages/odf.js/src/typed/shared/cascade.ts | Bin 12892 -> 13077 bytes 2 files changed, 52 insertions(+) diff --git a/packages/odf.js/src/typed/shared/cascade.test.ts b/packages/odf.js/src/typed/shared/cascade.test.ts index 47ad09c82..8a994f444 100644 --- a/packages/odf.js/src/typed/shared/cascade.test.ts +++ b/packages/odf.js/src/typed/shared/cascade.test.ts @@ -5,6 +5,7 @@ import { el, txt } from "../../xml/fragment"; import { childrenWithTag } from "../../xml/query"; import { findStyleElement, + findNamedStylePartElement, resolveStyle, resolveStyleElementChain, } from "./cascade"; @@ -56,6 +57,57 @@ function paragraphProps(attrs: Record): XmlElement { return el("style:paragraph-properties", attrs); } +describe("collectStyles: element dispatch (via resolveStyle)", () => { + it("ignores an element that is neither style:style nor style:default-style, even one carrying a real style:family attribute", () => { + const decoy = el("style:page-layout", { "style:family": "paragraph" }, [ + textProps({ "fo:font-weight": "bold" }), + ]); + const pkg: Package = { parts: { "styles.xml": stylesPackage([decoy]) } }; + expect(resolveStyle(undefined, "paragraph", pkg)).toEqual({ + properties: {}, + diagnostics: [], + }); + }); +}); + +describe("findNamedStylePartElement", () => { + function drawResource( + tag: string, + name: string, + extra: Record = {}, + ): XmlElement { + return el(tag, { "draw:name": name, ...extra }); + } + + it("finds a real draw resource by (tag, draw:name)", () => { + const gradient = drawResource("draw:gradient", "Gradient 1"); + const pkg: Package = { + parts: { "styles.xml": stylesPackage([gradient]) }, + }; + expect(findNamedStylePartElement(pkg, "draw:gradient", "Gradient 1")).toBe( + gradient, + ); + }); + + it("does not match a same-named resource of a different tag", () => { + const hatch = drawResource("draw:hatch", "Gradient 1"); + const pkg: Package = { parts: { "styles.xml": stylesPackage([hatch]) } }; + expect( + findNamedStylePartElement(pkg, "draw:gradient", "Gradient 1"), + ).toBeUndefined(); + }); + + it("does not match a same-tag resource of a different name", () => { + const gradient = drawResource("draw:gradient", "Gradient 2"); + const pkg: Package = { + parts: { "styles.xml": stylesPackage([gradient]) }, + }; + expect( + findNamedStylePartElement(pkg, "draw:gradient", "Gradient 1"), + ).toBeUndefined(); + }); +}); + describe("resolveStyle: no styleName", () => { it("resolves to an empty bag when there is no default-style and no styleName", () => { const pkg: Package = { parts: {} }; diff --git a/packages/odf.js/src/typed/shared/cascade.ts b/packages/odf.js/src/typed/shared/cascade.ts index 3d567a1267f6f663c667f4568bef9f1a345b4eec..79203ca8944c410b71721086e76831f594acdb51 100644 GIT binary patch delta 278 zcmY+8y-fr$5JqEi(v!XdI4(!p7OsXJ*0Z;k>=|o3#%y#|Py;Cvq8p0A2qZ-O{k-=X zUe>qE;d(mZtf*Yb$Qg6>J@J_`W11#33A{$(FoVg`4r6a75AcoAx0o17wcwGHc2pLGF_?Uew8rPP5Aphy3YfE7etKOiPhPLqi`^Vw+_6sfxX6gU{ delta 44 zcmbQ5b|+;+x-4&Inu11gNo7u|Ut(_R Date: Sun, 13 Sep 2026 13:59:21 +0100 Subject: [PATCH 31/89] test(odf.js): kill typed/shared/masterpage.ts's remaining survivors Pins findMasterPageElement's and findPageLayoutElement's own undefined-name short-circuits against a nameless style:master-page and a nameless style:page-layout respectively, each of which would otherwise coincidentally match an undefined lookup name. --- .../src/typed/shared/masterpage.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/odf.js/src/typed/shared/masterpage.test.ts b/packages/odf.js/src/typed/shared/masterpage.test.ts index 929bec1f5..cc8093c35 100644 --- a/packages/odf.js/src/typed/shared/masterpage.test.ts +++ b/packages/odf.js/src/typed/shared/masterpage.test.ts @@ -98,6 +98,61 @@ describe("resolveDrawPageSize", () => { expect(resolveDrawPageSize(page, pkg)).toBeUndefined(); }); + it("does not resolve a nameless style:master-page when the page itself has no draw:master-page-name", () => { + // A style:master-page with no style:name at all would make attrValue(element, "style:name") itself resolve to undefined -- coincidentally equal to an undefined masterPageName -- if findMasterPageElement didn't short-circuit before ever reaching the search. + const pkg: Package = { + parts: { + "styles.xml": { + kind: "xml", + nodes: [ + el("office:document-styles", {}, [ + el("office:automatic-styles", {}, [ + el("style:page-layout", { "style:name": "PM1" }, [ + el("style:page-layout-properties", { + "fo:page-width": "720pt", + "fo:page-height": "540pt", + }), + ]), + ]), + el("office:master-styles", {}, [ + el("style:master-page", { "style:page-layout-name": "PM1" }), + ]), + ]), + ], + }, + }, + }; + expect(resolveDrawPageSize(el("draw:page"), pkg)).toBeUndefined(); + }); + + it("does not resolve a nameless style:page-layout when the master page itself has no style:page-layout-name", () => { + // Mirrors the case above one link further down the chain: a style:page-layout with no style:name at all would coincidentally match an undefined pageLayoutName if findPageLayoutElement didn't short-circuit first. + const pkg: Package = { + parts: { + "styles.xml": { + kind: "xml", + nodes: [ + el("office:document-styles", {}, [ + el("office:automatic-styles", {}, [ + el("style:page-layout", {}, [ + el("style:page-layout-properties", { + "fo:page-width": "720pt", + "fo:page-height": "540pt", + }), + ]), + ]), + el("office:master-styles", {}, [ + el("style:master-page", { "style:name": "Default" }), + ]), + ]), + ], + }, + }, + }; + const page = el("draw:page", { "draw:master-page-name": "Default" }); + expect(resolveDrawPageSize(page, pkg)).toBeUndefined(); + }); + it("returns undefined when there is no styles.xml part at all", () => { const page = el("draw:page", { "draw:master-page-name": "Default" }); expect(resolveDrawPageSize(page, { parts: {} })).toBeUndefined(); From 3e602b7f4f4473f0d562a44a0056203ae4ffaf07 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:00:04 +0100 Subject: [PATCH 32/89] test(odf.js): kill typed/shared/units.ts's remaining survivors Adds three expandExponential cases pinning its own regex boundaries directly: a valid exponential form buried inside a larger string (the leading anchor), a valid prefix followed by trailing garbage (the trailing anchor), and a multi-digit integer part that must be entirely digits up to the exponent marker, not just its first one. Drops parseOdfLength's redundant numeric/unit/isLengthUnit guard: LENGTH_PATTERN's two capture groups are both mandatory, and the unit group is itself restricted to exactly the six LengthUnit spellings, so none of those checks can ever fail once match is non-null. --- packages/odf.js/src/typed/shared/units.test.ts | 12 ++++++++++++ packages/odf.js/src/typed/shared/units.ts | 8 +++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/odf.js/src/typed/shared/units.test.ts b/packages/odf.js/src/typed/shared/units.test.ts index e56c067a2..12644d2ae 100644 --- a/packages/odf.js/src/typed/shared/units.test.ts +++ b/packages/odf.js/src/typed/shared/units.test.ts @@ -90,6 +90,18 @@ describe("expandExponential", () => { it("accepts an uppercase E", () => { expect(expandExponential("1E1")).toBe("10"); }); + + it("does not match a valid exponential form buried inside a larger, non-exponential string (the leading ^ anchor)", () => { + expect(expandExponential("garbage1e5")).toBe("garbage1e5"); + }); + + it("does not match a valid exponential prefix followed by trailing garbage (the trailing $ anchor)", () => { + expect(expandExponential("1e5xxx")).toBe("1e5xxx"); + }); + + it("requires the integer part to be entirely digits up to the exponent marker, not just its first digit", () => { + expect(expandExponential("12e5")).toBe("1200000"); + }); }); describe("parseOdfAngleDeg", () => { diff --git a/packages/odf.js/src/typed/shared/units.ts b/packages/odf.js/src/typed/shared/units.ts index ecde7d8e5..2e1b8b34b 100644 --- a/packages/odf.js/src/typed/shared/units.ts +++ b/packages/odf.js/src/typed/shared/units.ts @@ -50,11 +50,9 @@ export function parseOdfLength(value: string): number | undefined { if (match === null) { return undefined; } - const numeric = match[1]; - const unit = match[2]; - if (numeric === undefined || unit === undefined || !isLengthUnit(unit)) { - return undefined; - } + // Both groups are MANDATORY alternatives in LENGTH_PATTERN (neither carries its own `?`), and group 2 is itself restricted to exactly the six LengthUnit spellings -- so numeric/unit can never be undefined, and unit can never fail isLengthUnit, once match is non-null; only TypeScript's own RegExpExecArray typing can't express that. Asserting rather than re-checking a condition the regex has already made unreachable, exactly like expandExponential below. + const numeric = match[1]!; + const unit = match[2] as LengthUnit; return Number(numeric) * unitToPtFactor(unit); } From b9dbd60b4ff0e449d82358bcc0fe85a989866e9f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:00:31 +0100 Subject: [PATCH 33/89] test(odf.js): add the first direct unit coverage for typed/shared/image.ts Covers imageExtension's svg case, previously uncovered entirely, alongside its three siblings. --- packages/odf.js/src/typed/shared/image.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/odf.js/src/typed/shared/image.test.ts diff --git a/packages/odf.js/src/typed/shared/image.test.ts b/packages/odf.js/src/typed/shared/image.test.ts new file mode 100644 index 000000000..ed37f3c85 --- /dev/null +++ b/packages/odf.js/src/typed/shared/image.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { imageExtension } from "./image"; + +describe("imageExtension", () => { + it("maps each ContentImageBlock format to its own real file extension", () => { + expect(imageExtension("png")).toBe("png"); + expect(imageExtension("jpeg")).toBe("jpg"); + expect(imageExtension("svg")).toBe("svg"); + expect(imageExtension("gif")).toBe("gif"); + }); +}); From 260db15dbf83893955d50a6c15ff62fe0f5518c3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:12:30 +0100 Subject: [PATCH 34/89] test(odf.js): kill typed/odb/subdocument.ts's remaining survivor Adds a bare directory-marker part whose own path is exactly the prefix (no trailing segment), confirming it slices to an empty relative path and is excluded rather than becoming a "" key. --- packages/odf.js/src/typed/odb/subdocument.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/odf.js/src/typed/odb/subdocument.test.ts b/packages/odf.js/src/typed/odb/subdocument.test.ts index cb392c9df..4a7c78c9a 100644 --- a/packages/odf.js/src/typed/odb/subdocument.test.ts +++ b/packages/odf.js/src/typed/odb/subdocument.test.ts @@ -66,6 +66,19 @@ describe("subDocumentPackage", () => { ).toEqual(["Pictures/logo.png", "content.xml"]); }); + it("excludes a part whose path is exactly the prefix itself, with nothing left over to key it by", () => { + // A bare directory-marker entry at the prefix's own path (rather than nested beneath it) slices down to an empty relative path, which cannot become a part key. + const pkg: Package = { + parts: { + "forms/Obj1/": { kind: "binary", base64: "" }, + "forms/Obj1/content.xml": { kind: "xml", nodes: [] }, + }, + }; + expect(Object.keys(subDocumentPackage(pkg, "forms/Obj1").parts)).toEqual([ + "content.xml", + ]); + }); + it("shares the same Part values rather than deep-copying them", () => { const part = { kind: "binary" as const, base64: "AA==" }; const pkg: Package = { From d4a0644df17b81eabfeac11970b2dfa0685b63d2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:12:46 +0100 Subject: [PATCH 35/89] test(odf.js): kill typed/odg/read.ts's remaining survivors Mirrors odp/read.ts's own fix: readOdgContent/readOdg both omit their "source" key entirely when nothing was quarantined, rather than carrying it set to undefined. --- packages/odf.js/src/typed/odg/read.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/odf.js/src/typed/odg/read.test.ts b/packages/odf.js/src/typed/odg/read.test.ts index 07ff809c2..cebd53ea6 100644 --- a/packages/odf.js/src/typed/odg/read.test.ts +++ b/packages/odf.js/src/typed/odg/read.test.ts @@ -253,6 +253,15 @@ describe("readOdgContent", () => { expect(pages).toHaveLength(2); }); + it("omits the 'source' key entirely from a page with no residue, rather than carrying it set to undefined", () => { + const { pages } = readOdgContent(buildFixturePackage()); + expect(Object.hasOwn(pages[0]!, "source")).toBe(false); + }); + + it("omits the package-level 'source' key entirely from readOdg when nothing was quarantined", () => { + expect(Object.hasOwn(readOdg(buildFixturePackage()), "source")).toBe(false); + }); + it("resolves page size from the master-page -> page-layout chain, identically to readOdpContent", () => { const { pages } = readOdgContent(buildFixturePackage()); expect(pages[0]?.size.widthPt).toBeCloseTo((21 * 72) / 2.54, 6); From 4a51ef3a7d1682c054414768fe7cf735d522970f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:13:02 +0100 Subject: [PATCH 36/89] test(odf.js): kill typed/odm/write.ts's remaining survivors Pins the default ODF version onto both content.xml's office:version and the manifest when no version option is given (killing the ?? vs && distinction, since && would leave version undefined rather than falling back to DEFAULT_ODF_VERSION), and an explicit custom version onto both alike. --- packages/odf.js/src/typed/odm/write.test.ts | 24 ++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/odf.js/src/typed/odm/write.test.ts b/packages/odf.js/src/typed/odm/write.test.ts index 895a182f2..cec5ecb67 100644 --- a/packages/odf.js/src/typed/odm/write.test.ts +++ b/packages/odf.js/src/typed/odm/write.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { decodePackage, encodePackage } from "../../codec"; import { readMimetype } from "../../mimetype"; -import { validateManifest } from "../../manifest"; +import { validateManifest, readManifest } from "../../manifest"; import { rootElement, findChildElement, @@ -45,6 +45,28 @@ describe("writeOdm", () => { expect(validateManifest(pkg)).toEqual([]); }); + it("defaults to the current ODF version on both content.xml and the manifest when no version option is given", () => { + const pkg = writeOdm(twoChapterDocument()); + const root = rootElement( + pkg.parts["content.xml"]?.kind === "xml" + ? pkg.parts["content.xml"].nodes + : [], + ); + expect(attrValue(root!, "office:version")).toBe("1.3"); + expect(readManifest(pkg).version).toBe("1.3"); + }); + + it("honours an explicit ODF version on content.xml and the manifest alike", () => { + const pkg = writeOdm(twoChapterDocument(), { version: "1.2" }); + const root = rootElement( + pkg.parts["content.xml"]?.kind === "xml" + ? pkg.parts["content.xml"].nodes + : [], + ); + expect(attrValue(root!, "office:version")).toBe("1.2"); + expect(readManifest(pkg).version).toBe("1.2"); + }); + it("writes each chapter as a top-level text:section carrying a self-closing text:section-source with href and filter name only", () => { const pkg = writeOdm(twoChapterDocument()); const root = rootElement( From ce76942e3c106ee2356efebba924c76260c35932 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:13:16 +0100 Subject: [PATCH 37/89] test(odf.js): kill typed/shared/expression.ts's remaining survivor Pins the brace branch's own trailing +1 with an immediately-closing "{}" pair, isolating it from the recursive call's return value: the -1 mutation this rules out would reset index back onto the opening brace itself, re-opening the identical pair forever. --- packages/odf.js/src/typed/shared/expression.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/odf.js/src/typed/shared/expression.test.ts b/packages/odf.js/src/typed/shared/expression.test.ts index e9a8de33a..4de7f25de 100644 --- a/packages/odf.js/src/typed/shared/expression.test.ts +++ b/packages/odf.js/src/typed/shared/expression.test.ts @@ -22,6 +22,11 @@ describe("skipExpression", () => { expect(skipExpression("(a{b,c}d),e", 0, ",")).toBe(9); }); + it("advances past an empty brace pair correctly, not merely re-consuming already-processed content that happens to reach the same answer", () => { + // An immediately-closing "{}" isolates the brace branch's own trailing +1 from the recursive call's return value: rewinding by 2 instead (the mutation this pins) resets index to the opening "{" itself, causing skipExpression to re-open the identical brace pair forever. + expect(skipExpression("{},c", 0, ",")).toBe(2); + }); + it("a comma inside a double-quoted string is not the end", () => { expect(skipExpression('"a,b",c', 0, ",")).toBe(5); }); From 2f42a98c33feeb9b8e666a7998d3100ada96d922 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:13:40 +0100 Subject: [PATCH 38/89] test(odf.js): add the first direct unit coverage for package-io/write.ts Covers serializePackage's mimetype-then-manifest-then-remainder ordering, that the manifest is emitted exactly once (not once hoisted and once again as a remaining part), and that it never fabricates a mimetype or manifest part the package didn't already carry. --- packages/odf.js/src/package-io/write.test.ts | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 packages/odf.js/src/package-io/write.test.ts diff --git a/packages/odf.js/src/package-io/write.test.ts b/packages/odf.js/src/package-io/write.test.ts new file mode 100644 index 000000000..8b857ebc2 --- /dev/null +++ b/packages/odf.js/src/package-io/write.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import type { Package } from "../model/package"; +import { localFileHeaderNames } from "../test-support/zip"; +import { serializePackage, MIMETYPE_PART, MANIFEST_PART } from "./write"; + +function packageOf(parts: Package["parts"]): Package { + return { parts }; +} + +describe("serializePackage", () => { + it("hoists mimetype first, then manifest, then every remaining part in its own key order", () => { + const pkg = packageOf({ + "content.xml": { kind: "xml", nodes: [] }, + [MANIFEST_PART]: { kind: "xml", nodes: [] }, + [MIMETYPE_PART]: { kind: "binary", base64: "" }, + "styles.xml": { kind: "xml", nodes: [] }, + }); + const names = localFileHeaderNames(serializePackage(pkg)); + expect(names).toEqual([ + MIMETYPE_PART, + MANIFEST_PART, + "content.xml", + "styles.xml", + ]); + }); + + it("emits the manifest entry exactly once, not once hoisted and once again as a remaining part", () => { + const pkg = packageOf({ + [MIMETYPE_PART]: { kind: "binary", base64: "" }, + [MANIFEST_PART]: { kind: "xml", nodes: [] }, + "content.xml": { kind: "xml", nodes: [] }, + }); + const names = localFileHeaderNames(serializePackage(pkg)); + expect(names.filter((name) => name === MANIFEST_PART)).toHaveLength(1); + }); + + it("never fabricates a mimetype or manifest part that was not already in the package", () => { + const pkg = packageOf({ "content.xml": { kind: "xml", nodes: [] } }); + const names = localFileHeaderNames(serializePackage(pkg)); + expect(names).toEqual(["content.xml"]); + }); +}); From fb6c4b3f17790c6e6868af83eef582305fea3ad2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:13:54 +0100 Subject: [PATCH 39/89] test(odf.js): kill styles/serialize.ts's remaining survivor Pins canonicalPropertiesString's own '|' separator directly: every existing case only compared two full calls against each other, a relationship an unseparated join would have preserved just as well. --- packages/odf.js/src/styles/serialize.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/odf.js/src/styles/serialize.test.ts b/packages/odf.js/src/styles/serialize.test.ts index cec3a881b..1c52f857b 100644 --- a/packages/odf.js/src/styles/serialize.test.ts +++ b/packages/odf.js/src/styles/serialize.test.ts @@ -38,6 +38,12 @@ describe("buildStylePropertyElements", () => { }); describe("canonicalPropertiesString", () => { + it("joins each name=value entry with a real '|' separator, not run together", () => { + expect(canonicalPropertiesString({ alignment: "center", bold: true })).toBe( + "fo:text-align=center|fo:font-weight=bold", + ); + }); + it("is a pure function: the same bag produces byte-identical output on every call", () => { const properties: StyleProperties = { bold: true, From f5e7e929ec82dc4ed149faf7ba72d462f73a8aff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:14:07 +0100 Subject: [PATCH 40/89] test(odf.js): kill typed/formula/read.ts's remaining survivor Adds a nested "math:math"-prefixed root case that requires findMathRoot's descendant search to continue past "math" (tried first, absent here) rather than stopping there -- the existing nested case only ever needed the first tag to succeed. --- .../odf.js/src/typed/formula/read.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/odf.js/src/typed/formula/read.test.ts b/packages/odf.js/src/typed/formula/read.test.ts index 0d5c7d8ae..77ec86fbf 100644 --- a/packages/odf.js/src/typed/formula/read.test.ts +++ b/packages/odf.js/src/typed/formula/read.test.ts @@ -225,6 +225,29 @@ describe("readOdfFormulaMathMl", () => { el("mi", {}, [txt("y")]), ]); }); + + it('defensively finds a "math:math"-prefixed root nested inside the wrapper, continuing past "math" (tried first, absent here) rather than stopping there', () => { + const mathRoot = el( + "math:math", + { "xmlns:math": "http://www.w3.org/1998/Math/MathML" }, + [el("math:mi", {}, [txt("z")])], + ); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [mathRoot]), + ]), + ], + }, + }, + }; + expect(readOdfFormulaMathMl(pkg).mathml).toEqual([ + el("math:mi", {}, [txt("z")]), + ]); + }); }); describe("readOdfFormulaContent", () => { From e6d7f90bc45a718c6428b8002b396e0086f86ada Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:14:24 +0100 Subject: [PATCH 41/89] test(odf.js): kill typed/odm/read.ts's remaining survivor Adds a non-element text node and a differently-tagged element among office:text's own children, confirming both are skipped rather than passed to readSection. --- packages/odf.js/src/typed/odm/read.test.ts | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/odf.js/src/typed/odm/read.test.ts b/packages/odf.js/src/typed/odm/read.test.ts index 05324d4ff..c05aaf911 100644 --- a/packages/odf.js/src/typed/odm/read.test.ts +++ b/packages/odf.js/src/typed/odm/read.test.ts @@ -88,6 +88,33 @@ describe("readOdm: scope boundaries and error paths (synthetic packages)", () => expect(readOdm(pkg).sections).toEqual([]); }); + it("ignores a non-element child and a differently-tagged element among office:text's children, rather than trying to read them as sections", () => { + const realSection = el("text:section", { "text:name": "ChapterOne" }, [ + el("text:section-source", { "xlink:href": "chapter1.odt" }), + ]); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [ + el("office:text", {}, [ + txt("\n "), + el("text:p", {}, [txt("stray paragraph")]), + realSection, + ]), + ]), + ]), + ], + }, + }, + }; + expect(readOdm(pkg).sections).toEqual([ + { name: "ChapterOne", href: "chapter1.odt" }, + ]); + }); + it("skips a top-level text:section with no text:section-source child -- ODF's generic, non-master-document section (e.g. multi-column layout), not a chapter reference", () => { const plainSection = el( "text:section", From 27d9322581df9695d338df634fa1e977f4825bb1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:23:47 +0100 Subject: [PATCH 42/89] test(odf.js): kill util/base64.ts's remaining bytesToBase64 survivors Drops the b1/b2 fallback-to-0 ternaries in the encode loop: past the input array's own end, bytes[i+1]/bytes[i+2] are undefined, and undefined >> n coerces to 0 identically to the explicit fallback -- the trailing "=" ternaries already decide whether this position ever renders at all, so the fallback produced no outcome the shift operator's own coercion didn't already produce. --- packages/odf.js/src/util/base64.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/odf.js/src/util/base64.ts b/packages/odf.js/src/util/base64.ts index 3fe3feaa0..c44610443 100644 --- a/packages/odf.js/src/util/base64.ts +++ b/packages/odf.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 separate `i + 1 < len` / `i + 2 < len` fallback to 0 here: past the array's own end, bytes[i + 1]/bytes[i + 2] are undefined, and `undefined >> n` coerces to 0 identically to the explicit fallback -- the padding decision below (the "=" ternaries) is what actually gates whether this position is ever rendered at all. + 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 49998fd5ff070aac760a9f7d43d0ff66a9c04c3b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:30:49 +0100 Subject: [PATCH 43/89] test(odf.js): kill package-io/write.ts's remaining survivor serializePackage's own returned zip bytes can never reveal a duplicated manifest push: zipPackage builds a plain object keyed by path, which silently collapses two same-path entries into one regardless of whether the duplicate happened. Extracts the ordering logic into its own orderedPackagePartPaths function, returning bare paths a test can inspect directly, with the hoisted-path exclusion now a filter predicate rather than a delete-then-iterate step some future change to this function could skip without any test noticing. --- packages/odf.js/src/package-io/write.test.ts | 21 +++++++++- packages/odf.js/src/package-io/write.ts | 43 +++++++++----------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/packages/odf.js/src/package-io/write.test.ts b/packages/odf.js/src/package-io/write.test.ts index 8b857ebc2..af5947283 100644 --- a/packages/odf.js/src/package-io/write.test.ts +++ b/packages/odf.js/src/package-io/write.test.ts @@ -1,12 +1,31 @@ import { describe, expect, it } from "vitest"; import type { Package } from "../model/package"; import { localFileHeaderNames } from "../test-support/zip"; -import { serializePackage, MIMETYPE_PART, MANIFEST_PART } from "./write"; +import { + serializePackage, + orderedPackagePartPaths, + MIMETYPE_PART, + MANIFEST_PART, +} from "./write"; function packageOf(parts: Package["parts"]): Package { return { parts }; } +describe("orderedPackagePartPaths", () => { + it("lists the manifest path exactly once, never once hoisted and once again among the rest", () => { + const paths = orderedPackagePartPaths( + packageOf({ + [MIMETYPE_PART]: { kind: "binary", base64: "" }, + [MANIFEST_PART]: { kind: "xml", nodes: [] }, + "content.xml": { kind: "xml", nodes: [] }, + }), + ); + expect(paths.filter((path) => path === MANIFEST_PART)).toHaveLength(1); + expect(paths).toEqual([MIMETYPE_PART, MANIFEST_PART, "content.xml"]); + }); +}); + describe("serializePackage", () => { it("hoists mimetype first, then manifest, then every remaining part in its own key order", () => { const pkg = packageOf({ diff --git a/packages/odf.js/src/package-io/write.ts b/packages/odf.js/src/package-io/write.ts index e9b777db6..be208ea80 100644 --- a/packages/odf.js/src/package-io/write.ts +++ b/packages/odf.js/src/package-io/write.ts @@ -7,30 +7,27 @@ import { zipPackage, type ZipEntry } from "../zip"; export const MIMETYPE_PART = "mimetype"; export const MANIFEST_PART = "META-INF/manifest.xml"; -// Serializes a Package back to zip bytes. This is the one deliberate behavioural difference from a generic zip-of-XML writer: ODF requires the "mimetype" part to be the very first zip entry, stored uncompressed (see zip.ts), so it hoists that part first if present, then META-INF/manifest.xml next if present, then every remaining part in the Package's own existing key order. It never fabricates a mimetype or manifest.xml part that doesn't already exist in the input -- that belongs to a later phase's manifest-construction logic, not this lossless zip<->Package mapping, which stays a pure, honest round trip with no side effects. -export function serializePackage(pkg: Package): Uint8Array { - const remaining = new Map(Object.entries(pkg.parts)); - const entries: [string, ZipEntry][] = []; - - const mimetype = remaining.get(MIMETYPE_PART); - if (mimetype !== undefined) { - entries.push([ - MIMETYPE_PART, - { bytes: partToBytes(mimetype), stored: true }, - ]); - remaining.delete(MIMETYPE_PART); - } - - const manifest = remaining.get(MANIFEST_PART); - if (manifest !== undefined) { - entries.push([MANIFEST_PART, { bytes: partToBytes(manifest) }]); - remaining.delete(MANIFEST_PART); - } - - for (const [path, part] of remaining) { - entries.push([path, { bytes: partToBytes(part) }]); - } +// The zip entry order serializePackage below actually writes in: "mimetype" first if present, then META-INF/manifest.xml if present, then every other part in the Package's own existing key order -- with each hoisted path EXCLUDED from that final group by construction (a filter predicate, not a delete-then-iterate step some part of the pipeline could skip), so a hoisted path can never also appear a second time among "every other part". Exported (and returning bare paths rather than the built ZipEntry values) so a test can pin this ordering-and-exclusion logic directly, independent of zipPackage's own object-keyed Zippable structure silently collapsing a same-path duplicate into one entry regardless of whether this function ever produced one. +export function orderedPackagePartPaths(pkg: Package): string[] { + const paths = Object.keys(pkg.parts); + const hoisted = [MIMETYPE_PART, MANIFEST_PART].filter((path) => + paths.includes(path), + ); + const rest = paths.filter((path) => !hoisted.includes(path)); + return [...hoisted, ...rest]; +} +// Serializes a Package back to zip bytes. This is the one deliberate behavioural difference from a generic zip-of-XML writer: ODF requires the "mimetype" part to be the very first zip entry, stored uncompressed (see zip.ts), so it hoists that part first if present, then META-INF/manifest.xml next if present, then every remaining part in the Package's own existing key order -- see orderedPackagePartPaths above for that ordering itself. It never fabricates a mimetype or manifest.xml part that doesn't already exist in the input -- that belongs to a later phase's manifest-construction logic, not this lossless zip<->Package mapping, which stays a pure, honest round trip with no side effects. +export function serializePackage(pkg: Package): Uint8Array { + const entries: [string, ZipEntry][] = orderedPackagePartPaths(pkg).map( + (path) => { + const part = pkg.parts[path]!; + return [ + path, + { bytes: partToBytes(part), stored: path === MIMETYPE_PART }, + ]; + }, + ); return zipPackage(entries); } From 4b009809c72e54a25fcf801e12b17accf2f9ba8e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:33:08 +0100 Subject: [PATCH 44/89] test(odf.js): pin that only the mimetype entry is stored uncompressed serializePackage's new per-part stored flag (path === MIMETYPE_PART) had no test confirming a non-mimetype part is genuinely NOT also stored; adds a compression-method reader alongside localFileHeaderNames and checks a real content.xml part deflates. --- packages/odf.js/src/package-io/write.test.ts | 29 +++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/odf.js/src/package-io/write.test.ts b/packages/odf.js/src/package-io/write.test.ts index af5947283..9c396757c 100644 --- a/packages/odf.js/src/package-io/write.test.ts +++ b/packages/odf.js/src/package-io/write.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { Package } from "../model/package"; -import { localFileHeaderNames } from "../test-support/zip"; +import { + localFileHeaderNames, + readUint16LE, + readUint32LE, +} from "../test-support/zip"; import { serializePackage, orderedPackagePartPaths, @@ -12,6 +16,20 @@ function packageOf(parts: Package["parts"]): Package { return { parts }; } +// Walks local file headers exactly as localFileHeaderNames does, but also returns each entry's own compression-method field (0 = stored, 8 = deflated) -- what a non-mimetype part's storage mode actually is, which localFileHeaderNames itself has no need to expose. +function localFileHeaderCompressionMethods(bytes: Uint8Array): number[] { + const methods: number[] = []; + let offset = 0; + while (offset < bytes.length && readUint32LE(bytes, offset) === 0x04034b50) { + methods.push(readUint16LE(bytes, offset + 8)); + const compressedSize = readUint32LE(bytes, offset + 18); + const filenameLength = readUint16LE(bytes, offset + 26); + const extraLength = readUint16LE(bytes, offset + 28); + offset = offset + 30 + filenameLength + extraLength + compressedSize; + } + return methods; +} + describe("orderedPackagePartPaths", () => { it("lists the manifest path exactly once, never once hoisted and once again among the rest", () => { const paths = orderedPackagePartPaths( @@ -58,4 +76,13 @@ describe("serializePackage", () => { const names = localFileHeaderNames(serializePackage(pkg)); expect(names).toEqual(["content.xml"]); }); + + it("stores only the mimetype entry uncompressed; every other part is deflated (compression method 8), not also stored", () => { + const pkg = packageOf({ + [MIMETYPE_PART]: { kind: "binary", base64: "" }, + "content.xml": { kind: "xml", nodes: [] }, + }); + const methods = localFileHeaderCompressionMethods(serializePackage(pkg)); + expect(methods).toEqual([0, 8]); + }); }); From b0abab66a0a03c19ed6d0095d4b3ffb34e7fafde Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:36:27 +0100 Subject: [PATCH 45/89] test(odf.js): kill typed/odm/read.ts's remaining tag-check survivor The prior decoy (a stray text:p with no text:section-source) can't tell "skipped because its tag isn't text:section" apart from "reached readSection, which found nothing to read there anyway". Adds a second decoy that DOES carry a genuine text:section-source child, so a version that reached readSection on it would produce a spurious section. --- packages/odf.js/src/typed/odm/read.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/odf.js/src/typed/odm/read.test.ts b/packages/odf.js/src/typed/odm/read.test.ts index c05aaf911..48f79893e 100644 --- a/packages/odf.js/src/typed/odm/read.test.ts +++ b/packages/odf.js/src/typed/odm/read.test.ts @@ -92,6 +92,10 @@ describe("readOdm: scope boundaries and error paths (synthetic packages)", () => const realSection = el("text:section", { "text:name": "ChapterOne" }, [ el("text:section-source", { "xlink:href": "chapter1.odt" }), ]); + // A decoy carrying a genuine text:section-source child, deliberately shaped so readSection would succeed on it if this element's own tag check were ever skipped -- an ordinary tagless decoy (like the stray text:p below) can't tell "skipped by tag" apart from "reached readSection, which itself found nothing to read". + const decoy = el("text:p", { "text:name": "Decoy" }, [ + el("text:section-source", { "xlink:href": "not-a-real-chapter.odt" }), + ]); const pkg: Package = { parts: { "content.xml": { @@ -101,6 +105,7 @@ describe("readOdm: scope boundaries and error paths (synthetic packages)", () => el("office:body", {}, [ el("office:text", {}, [ txt("\n "), + decoy, el("text:p", {}, [txt("stray paragraph")]), realSection, ]), From f8f60f6b323ed4d131b7902cda3fa3c5bbe764b1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 14:52:06 +0100 Subject: [PATCH 46/89] test(odf.js): kill styles/span.ts's redundant-guard and undercovered mutants Removes three provably-dead branches rather than testing them: splitNode's per-half undefined checks when splitting a text:span (an offset strictly between 0 and a node's own length can never make the recursive splitChildrenAt come back with an empty before or after, so the object is always constructed), and splitChildrenAt's own offset<=0 early return (offset is never negative given every caller's own bounds, and offset===0 is already handled identically by the loop's own remaining===0 check). Rewrites the loop's indexed for as a for...of over children.entries(), removing the index { ]); }); + it("finds the existing text:style-name attribute by its own name, not merely the first attribute on the span, leaving an unrelated earlier attribute untouched", () => { + const existingSpan = el("text:span", {}, [txt("Hello")]); + existingSpan.attributes = [ + { name: "xml:id", value: "keep-me" }, + { name: "text:style-name", value: "Old" }, + ]; + const paragraph = paragraphOf(existingSpan); + const reused = ensureSpan(paragraph, 0, 5, "New"); + expect(reused).toBe(existingSpan); + expect(reused.attributes).toEqual([ + { name: "xml:id", value: "keep-me" }, + { name: "text:style-name", value: "New" }, + ]); + }); + it("treats a zero-width node (e.g. a comment) as occupying no character positions, carrying it through untouched on whichever side it falls", () => { const paragraph = paragraphOf( txt("ab"), @@ -202,6 +217,35 @@ describe("ensureSpan: text:s straddling a split boundary", () => { const paragraph = paragraphOf(el("text:s", { "text:c": "not-a-number" })); expect(() => ensureSpan(paragraph, 0, 1, "T1")).toThrow(/malformed/); }); + + it("splits a text:s into two count=1 halves that each omit the text:c attribute entirely, rather than writing it out explicitly for the implicit default", () => { + const paragraph = paragraphOf( + txt("a"), + el("text:s", { "text:c": "2" }), + txt("b"), + ); // "a" (0), text:s count=2 (1-2), "b" (3) + const span = ensureSpan(paragraph, 1, 2, "T1"); // splits the count=2 run into count=1 (inside the span) + count=1 (leftover after) + + expect(span.children).toEqual([ + { type: "element", tag: "text:s", attributes: [], children: [] }, + ]); + const leftover = paragraph.children[2]!; + if (leftover.type !== "element" || leftover.tag !== "text:s") { + throw new Error("expected a text:s element"); + } + expect(leftover.attributes).toEqual([]); + }); +}); + +describe("splitNode: unreachable fractional-offset branch", () => { + it("throws for text:tab (and, symmetrically, text:line-break) given an offset that isn't exactly 0 or its own length -- a shape ensureSpan's own integer-offset validation prevents any real caller from ever producing, exercised here by calling the split primitive directly", () => { + expect(() => splitNode(el("text:tab"), 0.5)).toThrow( + /cannot split "text:tab" at a fractional offset/, + ); + expect(() => splitNode(el("text:line-break"), 0.5)).toThrow( + /cannot split "text:line-break" at a fractional offset/, + ); + }); }); describe("ensureSpan: text:tab and text:line-break", () => { @@ -246,6 +290,13 @@ describe("ensureSpan: splitting a pre-existing text:span", () => { expect(styleName(newSpan)).toBe("T2"); expect(paragraph.children).toHaveLength(3); expect(paragraph.children[2]).toBe(newSpan); + // newSpan must wrap BOTH of the two nodes that made up "middle" (the split-off EFGH span, still styled T1, and the IJ text node) -- not merely reuse/rename the first of those two nodes in place and silently drop the second, which is exactly what a broken "is there exactly one middle node" check would do. + expect(newSpan.children).toHaveLength(2); + const innerSpan = newSpan.children[0]!; + if (innerSpan.type !== "element") throw new Error("expected an element"); + expect(styleName(innerSpan)).toBe("T1"); + expect(textOf(innerSpan.children[0]!)).toBe("EFGH"); + expect(textOf(newSpan.children[1]!)).toBe("IJ"); }); it("reuses (renames) an existing span in place when the requested range exactly matches it, and does not disturb a sibling split off the same original span", () => { diff --git a/packages/odf.js/src/styles/span.ts b/packages/odf.js/src/styles/span.ts index 387c336a3..8b52dc2e1 100644 --- a/packages/odf.js/src/styles/span.ts +++ b/packages/odf.js/src/styles/span.ts @@ -74,7 +74,8 @@ function buildSpaceRun(count: number): XmlElement { } // Splits a single node at a character offset strictly inside it (0 < offset < measureOdfNodeLength(node), guaranteed by splitChildrenAt's caller). A text node splits by string slicing; a text:s splits into two text:s elements whose counts sum to the original (a text:c="5" run split at offset 2 becomes text:c="2" and text:c="3", never silently merged or corrupted); a text:span splits recursively into two sibling spans carrying the same style-name, each holding its half of the original content. text:tab/text:line-break have length exactly 1, so an offset strictly between 0 and 1 can never be an integer -- that branch is unreachable given ensureSpan's own integer-offset validation, and throws rather than silently doing something wrong if it is ever somehow reached. -function splitNode( +// Exported so a direct unit test can reach the defensive throw at this function's own tail below, which no call reachable through ensureSpan's public entry point can ever trigger (see that throw's own comment) -- the only way to observe it is to call this function directly with an offset ensureSpan's own integer validation would have already rejected. +export function splitNode( node: XmlNode, offset: number, ): { left?: XmlNode; right?: XmlNode } { @@ -93,23 +94,17 @@ function splitNode( } if (node.type === "element" && node.tag === "text:span") { const inner = splitChildrenAt(node.children, offset); - // Each half gets its OWN deep copy of `attributes` -- both the array AND each individual { name, value } object within it -- not a shared reference to the original. A shallow `[...node.attributes]` copy would still share the same Attribute *objects* between both halves, so setStyleName's `existing.value = ...` mutation (reusing a split-off span on a subsequent ensureSpan call) would silently corrupt the other half's style-name too, even though the two halves' attribute arrays were themselves already distinct. - const left: XmlElement | undefined = - inner.before.length === 0 - ? undefined - : { - ...node, - attributes: cloneAttributes(node.attributes), - children: inner.before, - }; - const right: XmlElement | undefined = - inner.after.length === 0 - ? undefined - : { - ...node, - attributes: cloneAttributes(node.attributes), - children: inner.after, - }; + // Each half gets its OWN deep copy of `attributes` -- both the array AND each individual { name, value } object within it -- not a shared reference to the original. A shallow `[...node.attributes]` copy would still share the same Attribute *objects* between both halves, so setStyleName's `existing.value = ...` mutation (reusing a split-off span on a subsequent ensureSpan call) would silently corrupt the other half's style-name too, even though the two halves' attribute arrays were themselves already distinct. No `inner.before.length === 0` / `inner.after.length === 0` check guarding either half: this function is only ever invoked (from splitChildrenAt's own loop below) with an offset strictly between 0 and this node's own measureOdfNodeLength, and at that invariant, the recursive splitChildrenAt(node.children, offset) above can never come back with an empty `before` or `after` -- an offset > 0 means its loop either pushes at least one whole/zero-width child into `before` before reaching the split point, or lands inside a child whose own split contributes a defined, non-empty half (text and text:s always return one; a nested text:span does too, by this same argument applied recursively); and offset strictly less than this node's own total length guarantees genuine content remains for `after` too. Both halves are therefore always real, non-empty node arrays here, never the empty array an `undefined` branch would exist to represent. + const left: XmlElement = { + ...node, + attributes: cloneAttributes(node.attributes), + children: inner.before, + }; + const right: XmlElement = { + ...node, + attributes: cloneAttributes(node.attributes), + children: inner.after, + }; return { left, right }; } const label = node.type === "element" ? node.tag : node.type; @@ -123,17 +118,14 @@ function splitChildrenAt( children: readonly XmlNode[], offset: number, ): { before: XmlNode[]; after: XmlNode[] } { - if (offset <= 0) { - return { before: [], after: [...children] }; - } - + // No separate `offset <= 0` fast path: every caller in this module only ever passes an offset in [0, this children array's own total measureOdfNodeLength] -- ensureSpan's own upfront validation guarantees start >= 0 and end <= total, and splitNode's own recursive call into this function is only ever reached with an offset strictly between 0 and the node's own length. offset is therefore never negative, and offset === 0 is already handled identically by the loop's own `remaining === 0` check on its very first iteration below (before stays empty, after becomes the full children array via children.slice(0)) -- a dedicated early return would only ever produce a result that check already produces on its own. const before: XmlNode[] = []; let remaining = offset; - for (let index = 0; index < children.length; index += 1) { + // A `for...of` over `.entries()`, not an indexed `for` loop bounded by `index < children.length`: every valid offset this function is ever called with (see the comment above) makes `remaining` reach exactly 0 at or before the final entry, so the loop below always returns from inside its own body -- an indexed bound never needs comparing against `children.length` at all, so there is no such comparison here to get wrong. + for (const [index, node] of children.entries()) { if (remaining === 0) { return { before, after: children.slice(index) }; } - const node = children[index]!; const length = measureOdfNodeLength(node); if (remaining >= length) { before.push(node); From 068dc0e22fe5dfb6b1d7d77708578b386c89c2e4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 15:06:42 +0100 Subject: [PATCH 47/89] test(odf.js): kill styles/registry.ts's redundant-guard and undercovered mutants Removes three genuinely equivalent statements rather than testing them: gc's `index !== -1` guard before splicing (every element it ever splices was placed into automaticStyles.children by this same class, and nothing removes a name from knownStyles without also splicing its element in the same step, so indexOf can never actually return -1 here); mintName's own reservedByFamily registration for a name it just minted (the per-family counter only ever advances, so no later mintName call for the same instance can revisit a counter value it already produced); and gc's nameToFingerprint cleanup (that map is only ever read for a name still in knownStyles, and this same gc step just removed it from knownStyles for good, so nothing will ever read the entry again). Adds tests for the two remaining genuine behaviours: prefixesForPart resolves a part path by its own base name, not the full path, so a nested embedded-object content.xml is still recognised; automatic- styles insertion respects office:master-styles and office:settings as insertion boundaries, not only office:body; adoption and the office: styles/otherPart reservation scan both check a candidate child's own tag, not merely whether it carries style:name/style:family-shaped attributes; an adopted style's own style:parent-style-name feeds its fingerprint, distinguishing it from a request with no parent; and gc'ing either a minted or an adopted fingerprint-matchable style forgets its fingerprintToName entry, so a later identical request mints fresh instead of returning a name that no longer exists. --- packages/odf.js/src/styles/registry.test.ts | 121 ++++++++++++++++++++ packages/odf.js/src/styles/registry.ts | 13 ++- 2 files changed, 128 insertions(+), 6 deletions(-) diff --git a/packages/odf.js/src/styles/registry.test.ts b/packages/odf.js/src/styles/registry.test.ts index 0cdf1419f..76eb62a4c 100644 --- a/packages/odf.js/src/styles/registry.test.ts +++ b/packages/odf.js/src/styles/registry.test.ts @@ -113,6 +113,43 @@ describe("StyleRegistry.forPart: construction", () => { const registry = StyleRegistry.forPart(contentPackage(), "content.xml"); expect(registry.names()).toEqual([]); }); + + it("resolves a part path with a directory prefix by its own base name, not the full path", () => { + const pkg: Package = { + parts: { + "objects/1/content.xml": { + kind: "xml", + nodes: [el("office:document-content")], + }, + }, + }; + // If the base-name extraction were ever skipped, the full path "objects/1/content.xml" would match neither "content.xml" nor "styles.xml" by strict equality and forPart would throw instead of recognising this as a content part. + expect(() => + StyleRegistry.forPart(pkg, "objects/1/content.xml"), + ).not.toThrow(); + }); + + it("inserts office:automatic-styles before office:master-styles when there is no office:body", () => { + const masterStyles = el("office:master-styles"); + const pkg = stylesPackage([masterStyles]); + StyleRegistry.forPart(pkg, "styles.xml"); + const root = rootElementOf(pkg, "styles.xml"); + const tags = root.children.map((c) => + c.type === "element" ? c.tag : c.type, + ); + expect(tags).toEqual(["office:automatic-styles", "office:master-styles"]); + }); + + it("inserts office:automatic-styles before office:settings when there is no office:body or office:master-styles", () => { + const settings = el("office:settings"); + const pkg = stylesPackage([settings]); + StyleRegistry.forPart(pkg, "styles.xml"); + const root = rootElementOf(pkg, "styles.xml"); + const tags = root.children.map((c) => + c.type === "element" ? c.tag : c.type, + ); + expect(tags).toEqual(["office:automatic-styles", "office:settings"]); + }); }); describe("rule (a): adoption on construction", () => { @@ -226,6 +263,45 @@ describe("rule (a): adoption on construction", () => { }), ).toBe("PS3"); }); + + it("does not adopt or reserve a differently-tagged element carrying style:name/style:family attributes shaped just like a real style:style", () => { + const impostor = el("style:default-style", { + "style:name": "T1", + "style:family": "text", + }); + const pkg = contentPackage([el("office:automatic-styles", {}, [impostor])]); + const registry = StyleRegistry.forPart(pkg, "content.xml"); + expect(registry.names()).toEqual([]); + // If the impostor's own tag were never checked, "T1" would already be reserved and this mint would have to skip straight to "T2". + expect(registry.intern(BOLD)).toBe("T1"); + }); + + it("adopts an existing style's own style:parent-style-name into its fingerprint, distinguishing a matching request from one with no parent", () => { + const existing = el( + "style:style", + { + "style:name": "P5", + "style:family": "paragraph", + "style:parent-style-name": "Heading1", + }, + [el("style:paragraph-properties", { "fo:text-align": "center" })], + ); + const pkg = contentPackage([el("office:automatic-styles", {}, [existing])]); + const registry = StyleRegistry.forPart(pkg, "content.xml"); + expect( + registry.intern({ + properties: { alignment: "center" }, + family: "paragraph", + parentStyleName: "Heading1", + }), + ).toBe("P5"); + expect( + registry.intern({ + properties: { alignment: "center" }, + family: "paragraph", + }), + ).not.toBe("P5"); + }); }); describe("rule (b): unknown attributes opt a style out of reuse, not out of existence", () => { @@ -460,6 +536,20 @@ describe("rule (d): name minting is collision-checked across all four containers }); expect(registry.intern(CENTER_PARAGRAPH)).toBe("P2"); }); + + it("does not reserve a name/family carried by a differently-tagged element when scanning office:styles for reservations", () => { + const impostor = el("style:default-style", { + "style:name": "P1", + "style:family": "paragraph", + }); + const pkg = contentPackage([ + el("office:styles", {}, [impostor]), + el("office:automatic-styles"), + ]); + const registry = StyleRegistry.forPart(pkg, "content.xml"); + // "P1" was never actually reserved -- a name/family check without the tag check would wrongly reserve it, forcing this mint to skip to "P2". + expect(registry.intern(CENTER_PARAGRAPH)).toBe("P1"); + }); }); describe("rule (e): content.xml and styles.xml registries use distinct name-minting prefixes", () => { @@ -665,6 +755,37 @@ describe("gc()", () => { expect(registry.gc(new Set([name]))).toBe(0); expect(registry.names()).toEqual([name]); }); + + it("gc'ing a minted, fingerprint-matchable style also forgets its own fingerprint entry, so a later identical request mints fresh rather than returning the now-removed name", () => { + const registry = StyleRegistry.forPart(contentPackage(), "content.xml"); + const minted = registry.intern(BOLD); + expect(minted).toBe("T1"); + + expect(registry.gc(new Set())).toBe(1); + expect(registry.names()).toEqual([]); + + const next = registry.intern(BOLD); // identical fingerprint to the gc'd style + expect(next).not.toBe("T1"); // T1 no longer exists; reusing it would be a dangling reference + expect(next).toBe("T2"); + }); + + it("gc'ing an adopted, fingerprint-matchable style also forgets its own fingerprint entry, the same as a minted one", () => { + const existing = el( + "style:style", + { "style:name": "T1", "style:family": "text" }, + [el("style:text-properties", { "fo:font-weight": "bold" })], + ); + const pkg = contentPackage([el("office:automatic-styles", {}, [existing])]); + const registry = StyleRegistry.forPart(pkg, "content.xml"); + expect(registry.names()).toEqual(["T1"]); + + expect(registry.gc(new Set())).toBe(1); + expect(registry.names()).toEqual([]); + + const next = registry.intern(BOLD); + expect(next).not.toBe("T1"); + expect(next).toBe("T2"); + }); }); // The seam a writer reaches the table and graphic families through: property elements this module's own StyleProperties vocabulary has no field for, supplied already built. It exists so the table-family styles ODF requires (a column width, a row height, a cell fill and borders) are minted by THIS registry rather than by a second, parallel name-minting mechanism beside it -- widening StyleProperties instead would change what the reader treats as unmodelled, which is load-bearing for the adoption rules above and for the residue channel. diff --git a/packages/odf.js/src/styles/registry.ts b/packages/odf.js/src/styles/registry.ts index 4a5250253..b3a6f6e09 100644 --- a/packages/odf.js/src/styles/registry.ts +++ b/packages/odf.js/src/styles/registry.ts @@ -359,7 +359,7 @@ export class StyleRegistry { this.automaticStyles.children.push(styleElement); this.knownStyles.set(name, styleElement); - this.reservedByFamily[request.family].add(name); + // No `reservedByFamily[family].add(name)` here for a freshly minted name: mintName's own counter for this family always advances past whatever it just minted (see mintName below), so no later mintName call for this same registry instance can ever re-derive this exact counter value and need to check it against `reserved` again -- and adoption (the only other place a name can become "taken") only ever runs once, before construction finishes, never interleaved with intern() calls. A name minted here therefore never needs its own registration in `reserved` to stay unique. this.fingerprintToName.set(fingerprint, name); this.nameToFingerprint.set(name, fingerprint); return name; @@ -392,16 +392,17 @@ export class StyleRegistry { if (referenced.has(name)) { continue; } - const index = this.automaticStyles.children.indexOf(element); - if (index !== -1) { - this.automaticStyles.children.splice(index, 1); - } + // No `index !== -1` guard: `element` is the exact reference this same class itself put into `automaticStyles.children` -- either during forPart's own adoption scan of that very array, or via intern()'s own `.push(styleElement)` just before storing that same reference in knownStyles -- and nothing in this class ever replaces `.children` wholesale or removes a name from knownStyles without also splicing its element out in this same step, so a name still in knownStyles always has its element still present in the array, findable by indexOf. + this.automaticStyles.children.splice( + this.automaticStyles.children.indexOf(element), + 1, + ); this.knownStyles.delete(name); const fingerprint = this.nameToFingerprint.get(name); if (fingerprint !== undefined) { this.fingerprintToName.delete(fingerprint); - this.nameToFingerprint.delete(name); } + // No `this.nameToFingerprint.delete(name)` here: nameToFingerprint is only ever read (above) for a name still present in knownStyles, and this same iteration just removed `name` from knownStyles for good (a gc'd name is reserved forever and never re-adopted or re-minted -- see this method's own class-level comment), so no future gc() call can ever read this entry again. Deleting it would only ever tidy a map slot nothing will look at again, exactly like reservedByFamily's own already-documented "kept forever" bookkeeping above. removed += 1; } return removed; From 95a17596b220d1e01d38825c2ea9e1f624dd4445 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 15:11:25 +0100 Subject: [PATCH 48/89] test(odf.js): kill typed/formula/write.ts's remaining survivors Drops the dead starMath spread from writeOdfFormulaContent's document object: writeOdfFormulaMathMl never reads document.starMath (see its own top-of-file note -- the StarMath annotation round-trips verbatim as part of the mathml nodes, never re-synthesised from this field), so carrying it through the request object was inert either way and the whole conditional spread was mutation-invisible. Adds tests for the remaining genuine behaviours: the XML declaration actually carries version 1.0 and encoding UTF-8, not an empty attribute set; the given metadata and version option actually reach meta.xml and the manifest's root entry, not just the defaults; and contentUsesMathPrefix recurses into a math:-prefixed tag nested two levels deep, not only one carried at the mathml array's own top level. --- .../odf.js/src/typed/formula/write.test.ts | 54 ++++++++++++++++++- packages/odf.js/src/typed/formula/write.ts | 9 +--- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/odf.js/src/typed/formula/write.test.ts b/packages/odf.js/src/typed/formula/write.test.ts index abc9a5825..eb1e212d6 100644 --- a/packages/odf.js/src/typed/formula/write.test.ts +++ b/packages/odf.js/src/typed/formula/write.test.ts @@ -5,7 +5,8 @@ import type { XmlElement } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { decodePackage, encodePackage } from "../../codec"; import { readMimetype } from "../../mimetype"; -import { validateManifest } from "../../manifest"; +import { readManifest, validateManifest } from "../../manifest"; +import { readOdfMetadata } from "../shared/metadata"; import { rootElement } from "../../xml/query"; import { readOdfFormula, readOdfFormulaMathMl } from "./read"; import { @@ -111,4 +112,55 @@ describe("writeOdfFormulaMathMl", () => { }), ).toThrow(/formula/); }); + + it("writes a standard XML declaration (version 1.0, encoding UTF-8) as content.xml's very first node", () => { + const pkg = writeOdfFormulaMathMl({ + mathml: [el("mi", {}, [txt("x")])], + metadata: {}, + }); + const part = pkg.parts["content.xml"]; + if (part?.kind !== "xml") { + throw new Error("expected an xml part"); + } + expect(part.nodes[0]).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ], + }); + }); + + it("actually writes the given metadata into meta.xml, not just an empty document", () => { + const pkg = writeOdfFormulaMathMl({ + mathml: [el("mi", {}, [txt("x")])], + metadata: { title: "Quadratic Formula" }, + }); + expect(readOdfMetadata(pkg).title).toBe("Quadratic Formula"); + }); + + it("stamps the given version option onto both meta.xml and the manifest's root entry, not the default", () => { + const pkg = writeOdfFormulaMathMl( + { mathml: [el("mi", {}, [txt("x")])], metadata: {} }, + { version: "1.4" }, + ); + expect(readManifest(pkg).version).toBe("1.4"); + }); + + it("detects a math:-prefixed tag nested arbitrarily deep, not only at the mathml array's own top level", () => { + // The root and its immediate child both use plain (unprefixed) tags; only the leaf two levels down is math:-prefixed. A check that only inspects the top-level tag itself, without ever recursing into children, would wrongly report no math:-prefixed content here at all. + const pkg = writeOdfFormulaMathMl({ + mathml: [el("mrow", {}, [el("mi", {}, [el("math:mi", {}, [])])])], + metadata: {}, + }); + const root = rootElement( + pkg.parts["content.xml"]?.kind === "xml" + ? pkg.parts["content.xml"].nodes + : [], + ); + expect( + root?.attributes.find((attribute) => attribute.name === "xmlns:math") + ?.value, + ).toBe("http://www.w3.org/1998/Math/MathML"); + }); }); diff --git a/packages/odf.js/src/typed/formula/write.ts b/packages/odf.js/src/typed/formula/write.ts index 7f7304fdc..65a054dfd 100644 --- a/packages/odf.js/src/typed/formula/write.ts +++ b/packages/odf.js/src/typed/formula/write.ts @@ -89,14 +89,9 @@ export function writeOdfFormulaContent( `writeOdfFormulaContent: expected a 'formula' document, got '${content.kind}' -- odf.js writes .odf from the formula arm only`, ); } + // No starMath here: writeOdfFormulaMathMl (see its own top-of-file note) never reads document.starMath -- it round-trips the StarMath annotation verbatim as part of the mathml nodes themselves, so carrying content.formula.starMath through this object would be inert either way. return writeOdfFormulaMathMl( - { - mathml: content.formula.mathml, - ...(content.formula.starMath !== undefined - ? { starMath: content.formula.starMath } - : {}), - metadata: content.metadata, - }, + { mathml: content.formula.mathml, metadata: content.metadata }, options, ); } From 068bea4a4dc186ba54619056d70bcc9e01805439 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 15:17:59 +0100 Subject: [PATCH 49/89] test(odf.js): kill typed/odg/write.ts's remaining survivors Drops the otherPart cross-check from the content.xml StyleRegistry construction: createOdfPackage just built this exact package from scratch a few lines above, so styles.xml's own office:automatic- styles is always freshly empty at this point -- there is no pre-existing style:style anywhere in it for a scan to find, since nothing (this call included) has written to styles.xml yet. Adds tests for the remaining genuine behaviours: normaliseOdgContent refuses a non-drawing document by its exact kind-naming message; style:print-orientation is landscape for a page wider than tall, not only ever portrait; a page's own master-page/page-layout names are asserted at their exact 1-indexed spelling, not merely "defined and distinct"; a footnote anchor in shape text only writes when writeOdg actually threads the tree's own definitions table through to the shape writer; and a custom version option reaches the manifest both from writeOdgContent directly and via writeOdg's own separate final sync, each distinct from the DEFAULT_ODF_VERSION they'd otherwise silently share. --- .../src/typed/odg/write-round-trip.test.ts | 63 +++++++++++++++++++ packages/odf.js/src/typed/odg/write.test.ts | 59 ++++++++++++++++- packages/odf.js/src/typed/odg/write.ts | 5 +- 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/packages/odf.js/src/typed/odg/write-round-trip.test.ts b/packages/odf.js/src/typed/odg/write-round-trip.test.ts index ffd2b0075..0eebaef8a 100644 --- a/packages/odf.js/src/typed/odg/write-round-trip.test.ts +++ b/packages/odf.js/src/typed/odg/write-round-trip.test.ts @@ -314,6 +314,12 @@ describe("writeOdgContent: the round-trip law", () => { expectRoundTrip(document); }); + it("refuses a document that is not a drawing, by kind, with an exact message naming both the expected and actual kind", () => { + expect(() => + normaliseOdgContent({ kind: "presentation", metadata: {}, slides: [] }), + ).toThrow(/expected a 'drawing' document, got 'presentation'/); + }); + it("collapses an absent stroke style to the 'solid' ContentStrokeStyleSchema already documents absence to mean", () => { const written = roundTrip( documentOf([ @@ -364,6 +370,63 @@ describe("writeOdgContent: the round-trip law", () => { const readBack = readOdg(decodePackage(encodePackage(written))); expect(readBack.source).toEqual(source); }); + + it("round-trips a footnote anchor in shape text with its definitions-table body through writeOdg", () => { + // Mirrors typed/odp/write-round-trip.test.ts's identical test: a footnote/comment construct only writes when the definitions table actually holds its body (typed/shared/constructs.ts's odfRunConstructWriteKind), so this also proves writeOdg's own definitions option genuinely reaches the shape writer. + const document = documentOf([ + page( + [], + [ + shape({}, [ + { + kind: "paragraph", + runs: [{ text: "1" }], + constructs: [ + { + descriptor: { + kind: "anchor", + anchorType: "footnote", + name: "note1", + definition: "note:note1", + }, + startRun: 0, + endRun: 1, + }, + ], + }, + ]), + ], + ), + ]); + const tree = assembleTree(document); + tree.definitions = { + "note:note1": { + kind: "footnote", + citation: "1", + body: [{ kind: "paragraph", runs: [{ text: "the note body" }] }], + }, + }; + const rewritten = readOdgContent(writeOdg(tree)); + const shapeText = rewritten.pages[0]!.shapes[0]!.blocks[0]!; + expect(shapeText).toMatchObject({ + kind: "paragraph", + constructs: [ + { + descriptor: { + kind: "anchor", + anchorType: "footnote", + name: "note1", + }, + }, + ], + }); + }); + + it("stamps a custom version option onto the manifest via writeOdg's own final sync, distinct from the default DEFAULT_ODF_VERSION both share", () => { + const tree = assembleTree(documentOf([page([])])); + const written = writeOdg(tree, { version: "1.4" }); + expect(readManifest(written).version).toBe("1.4"); + }); }); describe("writeOdgContent: refusals", () => { diff --git a/packages/odf.js/src/typed/odg/write.test.ts b/packages/odf.js/src/typed/odg/write.test.ts index 973f0b0ea..5fa3d973a 100644 --- a/packages/odf.js/src/typed/odg/write.test.ts +++ b/packages/odf.js/src/typed/odg/write.test.ts @@ -187,9 +187,9 @@ describe("writeOdgContent: page geometry", () => { expect(pages).toHaveLength(2); const firstName = attrValue(pages[0]!, "draw:master-page-name"); const secondName = attrValue(pages[1]!, "draw:master-page-name"); - expect(firstName).toBeDefined(); - expect(secondName).toBeDefined(); - expect(firstName).not.toBe(secondName); + // Exact 1-indexed names, not merely "defined and distinct": a page's own index counts up from 0, and MP{index+1} is the spelling, never MP{index-1} (which would coincidentally still differ page-to-page). + expect(firstName).toBe("MP1"); + expect(secondName).toBe("MP2"); const stylesRoot = partRoot(pkg, "styles.xml"); const masterStyles = findChildElement( @@ -206,6 +206,7 @@ describe("writeOdgContent: page geometry", () => { throw new Error("expected the referenced master page to exist"); } const pageLayoutName = attrValue(masterPage, "style:page-layout-name"); + expect(pageLayoutName).toBe("PM2"); // same 1-indexed spelling as the master-page name, for the second page const automaticStyles = findChildElement( stylesRoot.children, @@ -233,6 +234,58 @@ describe("writeOdgContent: page geometry", () => { ); expect(attrValue(properties, "style:print-orientation")).toBe("portrait"); }); + + it("writes style:print-orientation=landscape for a page wider than it is tall", () => { + const pkg = writeOdgContent(documentOf([page([rect()])])); // PAGE_SIZE_LANDSCAPE by default: 720x540, width > height + const stylesRoot = partRoot(pkg, "styles.xml"); + const automaticStyles = findChildElement( + stylesRoot.children, + "office:automatic-styles", + ); + if (automaticStyles === undefined) { + throw new Error("expected styles.xml office:automatic-styles"); + } + const pageLayout = childrenWithTag( + automaticStyles, + "style:page-layout", + )[0]!; + const properties = childrenWithTag( + pageLayout, + "style:page-layout-properties", + )[0]!; + expect(attrValue(properties, "style:print-orientation")).toBe("landscape"); + }); + + it("writes style:print-orientation=portrait, not landscape, for a perfectly square page", () => { + // A strict width > height comparison, not >=: a square page's width and height are equal, so a >= mutant would wrongly call this landscape. + const pkg = writeOdgContent( + documentOf([page([rect()], [], { widthPt: 400, heightPt: 400 })]), + ); + const stylesRoot = partRoot(pkg, "styles.xml"); + const automaticStyles = findChildElement( + stylesRoot.children, + "office:automatic-styles", + ); + if (automaticStyles === undefined) { + throw new Error("expected styles.xml office:automatic-styles"); + } + const pageLayout = childrenWithTag( + automaticStyles, + "style:page-layout", + )[0]!; + const properties = childrenWithTag( + pageLayout, + "style:page-layout-properties", + )[0]!; + expect(attrValue(properties, "style:print-orientation")).toBe("portrait"); + }); + + it("stamps a custom version option directly onto the manifest from writeOdgContent alone", () => { + const pkg = writeOdgContent(documentOf([page([rect()])]), { + version: "1.4", + }); + expect(readManifest(pkg).version).toBe("1.4"); + }); }); describe("writeOdgContent: vector elements", () => { diff --git a/packages/odf.js/src/typed/odg/write.ts b/packages/odf.js/src/typed/odg/write.ts index a40c1163c..d6efd9ef7 100644 --- a/packages/odf.js/src/typed/odg/write.ts +++ b/packages/odf.js/src/typed/odg/write.ts @@ -140,9 +140,8 @@ export function writeOdgContent( version, ); - const registry = StyleRegistry.forPart(pkg, CONTENT_PART, { - otherPart: { pkg, partPath: STYLES_PART }, - }); + // No otherPart cross-check against styles.xml: createOdfPackage above just built this exact package from scratch, so styles.xml's own office:automatic-styles is always freshly empty at this point -- there is no pre-existing style:style anywhere in it for a scan to find, since nothing (this call included) has written to styles.xml yet. + const registry = StyleRegistry.forPart(pkg, CONTENT_PART); const contentAutomaticStyles = odfPartContainer( pkg, CONTENT_PART, From da3ff2b95abbd0aec43ba4702f4346ad133f27d7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 15:25:54 +0100 Subject: [PATCH 50/89] test(odf.js): kill typed/draw/embedded.ts's remaining survivors Exports normaliseObjectHref for direct unit testing: every one of its four rejection clauses (empty, "..".-prefixed, "/"-prefixed, a "://" substring), if silently skipped, still leaves subDocumentPackage's own lookup failing for a different reason against any package a black-box readDrawObjectReference test could build (a mismatched or empty re-keyed part set) -- making a mutation there unobservable through that entry point alone, so the function is tested directly instead. Adds a dedicated describe block for readEmbeddedObjectDocument covering every ContentEmbeddedObjectKind by name (wordprocessing, presentation, drawing, spreadsheet, formula, chart) -- previously untested altogether, despite readDrawObjectReference's own suite covering only which KIND a sub-document resolves to, never that readEmbeddedObjectDocument actually dispatches each one to its own typed reader. --- .../odf.js/src/typed/draw/embedded.test.ts | 155 +++++++++++++++++- packages/odf.js/src/typed/draw/embedded.ts | 4 +- 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/packages/odf.js/src/typed/draw/embedded.test.ts b/packages/odf.js/src/typed/draw/embedded.test.ts index ae995e3cd..f0727c8a2 100644 --- a/packages/odf.js/src/typed/draw/embedded.test.ts +++ b/packages/odf.js/src/typed/draw/embedded.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import type { XmlElement } from "../../model/node"; import { el, txt } from "../../xml/fragment"; -import { readDrawObjectReference } from "./embedded"; +import { + normaliseObjectHref, + readDrawObjectReference, + readEmbeddedObjectDocument, + type EmbeddedDrawObject, +} from "./embedded"; // The real shape this reader targets is proven end to end against genuine LibreOffice output in typed/ods/read.test.ts (src/typed/ods/fixtures/sheet-anchors.ods, a real Calc sheet with a real embedded Draw document anchored to a cell, and src/typed/ods/fixtures/sheet-formula.ods, the same with a real Math object). This suite covers the reference-resolution edges those files cannot: a linked (not embedded) object, a broken href, and each representable/unrepresentable body kind. @@ -192,3 +197,151 @@ describe("readDrawObjectReference", () => { expect(reference?.objectKind).toBe("wordprocessing"); }); }); + +describe("normaliseObjectHref", () => { + it("accepts a plain relative directory name unchanged", () => { + expect(normaliseObjectHref("Object 1")).toBe("Object 1"); + }); + + it("strips a leading './' and a trailing '/'", () => { + expect(normaliseObjectHref("./Object 1/")).toBe("Object 1"); + }); + + it("rejects an empty href, once the './' prefix and trailing '/' are stripped away", () => { + expect(normaliseObjectHref("./")).toBeUndefined(); + expect(normaliseObjectHref("")).toBeUndefined(); + }); + + it('rejects a href starting with ".." after stripping, even when it is not otherwise empty, absolute, or a URL', () => { + expect(normaliseObjectHref("../sibling")).toBeUndefined(); + expect(normaliseObjectHref("..")).toBeUndefined(); + }); + + it("rejects an absolute path (leading '/'), even when it is not otherwise empty, '..'-prefixed, or a URL", () => { + expect(normaliseObjectHref("/Object 1")).toBeUndefined(); + }); + + it('rejects any href containing "://", even a relative-looking one with no leading "..", "/", or emptiness', () => { + expect(normaliseObjectHref("weird://Object 1")).toBeUndefined(); + }); + + it("checks the START of the string for '..' and '/', not the end, so a name merely ending with either is accepted unchanged", () => { + expect(normaliseObjectHref("Object 1/..")).toBe("Object 1/.."); // ends with ".." but does not START with it + expect(normaliseObjectHref("folder..")).toBe("folder.."); // ditto + // "a//" has only ONE trailing slash stripped by the earlier, separate trailing-slash removal above, leaving "a/" -- which still itself ends with "/" without starting with it, isolating startsWith("/") from a wrongly-substituted endsWith("/") the way the first two cases isolate startsWith("..") from endsWith(".."). + expect(normaliseObjectHref("a//")).toBe("a/"); + }); +}); + +const EMBED_FRAME = { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }; + +function embeddedReferenceOf( + objectKind: EmbeddedDrawObject["objectKind"], + bodyChild: XmlElement, +): EmbeddedDrawObject { + return { + objectKind, + href: "Object 1", + package: { parts: { "content.xml": subDocumentPart(bodyChild) } }, + }; +} + +describe("readEmbeddedObjectDocument", () => { + it('dispatches "wordprocessing" to readOdtContent', () => { + const reference = embeddedReferenceOf("wordprocessing", el("office:text")); + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("wordprocessing"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "presentation" to readOdpContent', () => { + const reference = embeddedReferenceOf( + "presentation", + el("office:presentation"), + ); + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("presentation"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "drawing" to readOdgContent', () => { + const reference = embeddedReferenceOf("drawing", el("office:drawing")); + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("drawing"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "spreadsheet" to readOdsContent', () => { + const reference = embeddedReferenceOf( + "spreadsheet", + el("office:spreadsheet"), + ); + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("spreadsheet"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "formula" to readOdfFormulaContent, whose own reader already returns a finished ContentDocument', () => { + const reference: EmbeddedDrawObject = { + objectKind: "formula", + href: "Object 1", + package: { + parts: { + "content.xml": { kind: "xml", nodes: [realEmbeddedFormulaRoot()] }, + }, + }, + }; + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("formula"); + expect(residue).toBeUndefined(); + }); + + it('dispatches "chart" to readOdfChartContent, which alone of every kind carries residue', () => { + const chartElement = el("chart:chart", {}, [ + el("table:table", {}, [el("table:table-row")]), + ]); + const reference: EmbeddedDrawObject = { + objectKind: "chart", + href: "Object 1", + package: { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [chartElement]), + ]), + ], + }, + }, + }, + }; + const { document, residue } = readEmbeddedObjectDocument( + reference, + EMBED_FRAME, + "odt", + ); + expect(document.kind).toBe("drawing"); + expect(residue).not.toBeUndefined(); + }); +}); diff --git a/packages/odf.js/src/typed/draw/embedded.ts b/packages/odf.js/src/typed/draw/embedded.ts index 01beac429..7f52ff034 100644 --- a/packages/odf.js/src/typed/draw/embedded.ts +++ b/packages/odf.js/src/typed/draw/embedded.ts @@ -194,8 +194,8 @@ function subDocumentKind( return findMathRoot(nodes) === undefined ? undefined : "formula"; } -// The normalised directory prefix a draw:object's own xlink:href names, or undefined when the href is absent, empty, or points outside the package (a LINKED object: an absolute URL, or a path escaping the package root). -function normaliseObjectHref(raw: string): string | undefined { +// The normalised directory prefix a draw:object's own xlink:href names, or undefined when the href is absent, empty, or points outside the package (a LINKED object: an absolute URL, or a path escaping the package root). Exported so each of the four rejection clauses can be pinned directly: every one of them, if silently skipped, still leaves subDocumentPackage's own lookup failing for a different reason (a mismatched or empty re-keyed part set) against any package a black-box readDrawObjectReference test could build, which would make a mutation here unobservable through that entry point alone. +export function normaliseObjectHref(raw: string): string | undefined { const withoutPrefix = raw.startsWith("./") ? raw.slice(2) : raw; const trimmed = withoutPrefix.endsWith("/") ? withoutPrefix.slice(0, -1) From 7607edcb0ef650622a2d0cc01ffd13da81799844 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 02:45:20 +0100 Subject: [PATCH 51/89] test(odf.js): reach 100% mutation coverage on typed/odb/report.ts Recompute the real-fixture report in beforeEach rather than directly in the describe body. Stryker's per-test mutation coverage only attributes an executed statement to a specific test when that statement runs inside that test's own tracked window; code that runs once at describe-collection time (or even in beforeAll, which is also not tied to any single test's execution) is permanently unattributed, so every mutant only that fixture's assertions could kill showed as an unkillable survivor regardless of how thorough those assertions were. Add synthetic cases for every optional field's genuinely-absent path (no groupExpression/sortExpression/sortAscending/header/footer on a group, no command/commandType/caption/mimeType/reportHeader/pageHeader/ detail/pageFooter/reportFooter on the report, no table:name on a band, no rpt:report-component/rpt:formula on a control), a false boolean attribute, a direct text-node child of a control, an rpt:report-element carrying its own stray text (which must never leak into the control's text), a non-rpt: element that happens to nest an rpt:report-element (must never be mistaken for a control), a non-rpt:function element that happens to carry rpt:name/rpt:formula (must never be mistaken for a function), and a binary (non-XML) sub-document part. Switch the label-splitting assertion to toStrictEqual so an explicitly-undefined name/formula can't hide behind toEqual's undefined-key equivalence. --- packages/odf.js/src/typed/odb/report.test.ts | 124 +++++++++++++++++-- 1 file changed, 117 insertions(+), 7 deletions(-) diff --git a/packages/odf.js/src/typed/odb/report.test.ts b/packages/odf.js/src/typed/odb/report.test.ts index 70e5318db..893dcddf5 100644 --- a/packages/odf.js/src/typed/odb/report.test.ts +++ b/packages/odf.js/src/typed/odb/report.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import { el, txt } from "../../xml/fragment"; import { parsePackage } from "../../package-io/read"; @@ -17,10 +17,12 @@ function loadFixture(name: string): Package { } describe("readOdbReport: form-and-report.odb (real LibreOffice Report Builder output)", () => { - const report = readOdbReport( - loadFixture("form-and-report.odb"), - "SalesByRegion", - ); + // Computed in beforeEach, not directly in this describe block (and not in beforeAll either): Stryker's per-test mutation coverage only attributes an executed statement to a specific test when that statement runs inside that test's own tracked window, which beforeEach (run immediately before each test, as part of running it) is part of but describe-body and beforeAll code (which run once, tied to no single test) are not -- either of those leaves every mutant this fixture alone would kill permanently unattributed to any test, regardless of how thorough the assertions below are. + let report: ReturnType; + + beforeEach(() => { + report = readOdbReport(loadFixture("form-and-report.odb"), "SalesByRegion"); + }); it("reports the report's user-visible name alongside the opaque persistent path its sub-document actually lives at", () => { expect(report.name).toBe("SalesByRegion"); @@ -329,10 +331,106 @@ describe("readOdbReport: synthetic report shapes", () => { ]), ]), ]); - expect(readOdbReport(pkg, "R").reportHeader?.elements[0]?.text).toBe( - "Total for region", + const element = readOdbReport(pkg, "R").reportHeader?.elements[0]; + expect(element?.text).toBe("Total for region"); + // toStrictEqual, not toEqual: this control's rpt:report-element has no rpt:report-component (no name) and the element itself carries no rpt:formula, so both fields must be genuinely ABSENT from the object -- toEqual alone would pass even if the reader set them to `undefined` explicitly, since it treats an undefined-valued property as equivalent to a missing one. + expect(element).toStrictEqual({ + tag: "rpt:fixed-content", + text: "Total for region", + }); + }); + + it("excludes rpt:report-element's own content from a control's text even when it happens to carry a stray text node of its own", () => { + const pkg = reportPackage([ + el("rpt:detail", {}, [ + el("rpt:fixed-content", {}, [ + txt("Visible"), + el("rpt:report-element", {}, [txt("SHOULD-NOT-APPEAR")]), + ]), + ]), + ]); + expect(readOdbReport(pkg, "R").detail?.elements[0]?.text).toBe("Visible"); + }); + + it("reads a direct text-node child of a control, not just text wrapped in a nested element", () => { + const pkg = reportPackage([ + el("rpt:detail", {}, [ + el("rpt:fixed-content", {}, [ + txt("Direct text"), + el("rpt:report-element", {}), + ]), + ]), + ]); + expect(readOdbReport(pkg, "R").detail?.elements[0]?.text).toBe( + "Direct text", ); }); + + it("never treats a non-rpt: element as a control, even when it directly contains an rpt:report-element", () => { + const pkg = reportPackage([ + el("rpt:detail", {}, [ + el("table:table", {}, [ + el("table:table-cell", {}, [el("rpt:report-element", {})]), + ]), + ]), + ]); + expect(readOdbReport(pkg, "R").detail?.elements).toEqual([]); + }); + + it("reads a false boolean attribute as a real false, not merely as 'present'", () => { + const pkg = reportPackage([ + el("rpt:group", { "rpt:sort-ascending": "false" }), + ]); + expect(readOdbReport(pkg, "R").groups[0]?.sortAscending).toBe(false); + }); + + it("omits a band's own name when its layout table carries no table:name", () => { + const pkg = reportPackage([ + el("rpt:detail", {}, [el("table:table", {}, [])]), + ]); + const report = readOdbReport(pkg, "R"); + expect(report.detail).toEqual({ kind: "detail", elements: [] }); + expect("name" in (report.detail ?? {})).toBe(false); + }); + + it("never treats a non-rpt:function element as a function, even when it happens to carry rpt:name and rpt:formula", () => { + const pkg = reportPackage([ + el("rpt:something-else", { "rpt:name": "X", "rpt:formula": "Y" }), + ]); + expect(readOdbReport(pkg, "R").functions).toEqual([]); + }); + + it("omits every optional top-level and group field entirely when the source declares none of them", () => { + const pkg = reportPackage([el("rpt:group", {}, [])]); + const report = readOdbReport(pkg, "R"); + for (const key of [ + "command", + "commandType", + "caption", + "mimeType", + "reportHeader", + "pageHeader", + "detail", + "pageFooter", + "reportFooter", + ]) { + expect(key in report).toBe(false); + } + const group = report.groups[0]; + expect(group).toBeDefined(); + for (const key of [ + "groupExpression", + "sortExpression", + "sortAscending", + "startNewColumn", + "resetPageNumber", + "keepTogether", + "header", + "footer", + ]) { + expect(group !== undefined && key in group).toBe(false); + } + }); }); describe("readOdbReport: error paths", () => { @@ -382,4 +480,16 @@ describe("readOdbReport: error paths", () => { }; expect(() => readOdbReport(pkg, "R")).toThrow(/office:report/); }); + + it("throws when the sub-document's content.xml is a binary part rather than XML", () => { + const pkg: Package = { + parts: { + "content.xml": baseContent, + "reports/Obj1/content.xml": { kind: "binary", base64: "" }, + }, + }; + expect(() => readOdbReport(pkg, "R")).toThrow( + /reports\/Obj1\/content\.xml is not an XML part/, + ); + }); }); From 58ed0d83d74550563169a02f524523dfbdad913b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 10:49:32 +0100 Subject: [PATCH 52/89] test(odf.js): reach 100% mutation coverage on typed/ods/conditional-format.ts Add direct unit coverage for readTargetRangeList, parseA1WithOptionalSheetPrefix, readConditionalFormatStyle, and synthesiseConditionValue across every branch and operator, plus their surrounding condition-parsing and rule-serialising helpers. Simplify three redundant guards that duplicated a fallthrough the next line or case already handled identically: the empty-part check in readTargetRangeList (no ':' to find either), the sheet-prefix ternary in parseA1WithOptionalSheetPrefix (lastIndexOf's -1 already slices correctly), and the empty-chain check in readConditionalFormatStyle (an empty chain already yields no background/color). Collapse containsBlanks/notContainsBlanks into the same fallthrough as the non-calcext condition kinds in synthesiseConditionValue, since a duplicate `return undefined` on its own case label produced no observable difference from falling through to the next label's identical return. --- .../src/typed/ods/conditional-format.test.ts | 1135 +++++++++++++++-- .../src/typed/ods/conditional-format.ts | 15 +- 2 files changed, 1029 insertions(+), 121 deletions(-) diff --git a/packages/odf.js/src/typed/ods/conditional-format.test.ts b/packages/odf.js/src/typed/ods/conditional-format.test.ts index d877ee3f1..90681207c 100644 --- a/packages/odf.js/src/typed/ods/conditional-format.test.ts +++ b/packages/odf.js/src/typed/ods/conditional-format.test.ts @@ -2,10 +2,18 @@ import { describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import type { XmlElement } from "../../model/node"; import { el } from "../../xml/fragment"; +import type { + ContentSheetConditionalFormat, + SheetRuleOperator, +} from "document-schema.js"; import { + calextDateForTimePeriod, + calextTypeForCfvoType, + formatTargetRangeList, parseConditionValue, readConditionalFormats, readTargetRangeList, + synthesiseConditionValue, } from "./conditional-format"; // calcext:conditional-formats has no OASIS-published grammar at all -- every value/attribute name exercised here is transcribed from LibreOffice's own real reader (sc/source/filter/xml/xmlcondformat.cxx), see conditional-format.ts's own top-of-file note for the exact source functions. A real LibreOffice-produced fixture (fixtures/conditional-format.ods) exists and is exercised in read.test.ts's own "conditional-format.ods (real LibreOffice output)" describe block -- it directly caught a real entity-decoding bug this file's own synthetic cases below could not have found on their own (a producer that escapes '>' as '>' in calcext:value), since a hand-built package only ever contains what its author thought to escape. Every OTHER variant exercised here (colour-scale, data-bar, icon-set, date-is, every condition mode) has no real fixture available, so those packages are hand-built (el/txt) to the identical wire shape xmlcondformat.cxx establishes, matching data-validation.test.ts's own established fallback for a producer-specific mini-language a real fixture wasn't available for. @@ -148,6 +156,22 @@ describe("parseConditionValue", () => { it("returns undefined for a producer-extended or malformed value this reader cannot make sense of", () => { expect(parseConditionValue("some-future-mode(1)")).toBeUndefined(); }); + + it("matches every no-operand keyword by PREFIX, not by suffix -- a trailing operand list still identifies the mode even though the value no longer ENDS with the bare keyword", () => { + const cases: [string, string][] = [ + ["unique(ignored)", "unique"], + ["duplicate(ignored)", "duplicate"], + ["above-equal-average(ignored)", "above-equal-average"], + ["below-equal-average(ignored)", "below-equal-average"], + ["above-average(ignored)", "above-average"], + ["below-average(ignored)", "below-average"], + ["is-no-error(ignored)", "is-no-error"], + ["is-error(ignored)", "is-error"], + ]; + for (const [value, mode] of cases) { + expect(parseConditionValue(value)?.mode).toBe(mode); + } + }); }); describe("readTargetRangeList", () => { @@ -171,93 +195,783 @@ describe("readTargetRangeList", () => { { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, ]); }); -}); -function conditionalFormatsPackage(table: XmlElement): Package { - return { - parts: { - "content.xml": { - kind: "xml", - nodes: [ - el("office:document-content", {}, [ - el("office:body", {}, [el("office:spreadsheet", {}, [table])]), - ]), - ], - }, - }, - }; -} + it("skips a genuinely empty entry produced by a run of consecutive separators, rather than treating it as malformed", () => { + expect( + readTargetRangeList("Sheet1.A1:Sheet1.A1 Sheet1.B1:Sheet1.B1"), + ).toEqual([ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + { startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }, + ]); + }); + + it("skips an entry with no ':' separator at all, rather than misreading it as a single-cell range", () => { + expect(readTargetRangeList("Sheet1.A1 Sheet1.B1:Sheet1.B1")).toEqual([ + { startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }, + ]); + }); + + it("skips a colon-less entry even when both halves a naive split would produce happen to look like valid cell references on their own (A11 read as 'A1' + '1', not as a real range)", () => { + expect(readTargetRangeList("A11 Sheet1.B1:Sheet1.B1")).toEqual([ + { startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }, + ]); + }); + + it("parses a range with no sheet-name prefix at all, not just the prefixed form", () => { + expect(readTargetRangeList("A1:C3")).toEqual([ + { startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }, + ]); + }); +}); + +function conditionalFormatsPackage(table: XmlElement): Package { + return { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [el("office:spreadsheet", {}, [table])]), + ]), + ], + }, + }, + }; +} + +function tableWith(...children: readonly XmlElement[]): XmlElement { + return el("table:table", { "table:name": "Sheet1" }, [...children]); +} + +describe("readConditionalFormats (synthetic packages, real calcext wire shapes)", () => { + it("promotes a between condition into a cellIs rule with its resolved style", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.A1:Sheet1.A10", + }, + [ + el("calcext:condition", { + "calcext:value": "between(1,10)", + "calcext:apply-style-name": "Good", + }), + ], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(residueElements).toEqual([]); + expect(formats).toStrictEqual([ + { + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + operator: "between", + formula1: "1", + formula2: "10", + }, + ]); + }); + + it("promotes a not-between condition with the notBetween operator, not just between", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [el("calcext:condition", { "calcext:value": "not-between(1,10)" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + operator: "notBetween", + formula1: "1", + formula2: "10", + }, + ]); + }); + + it('decodes an XML-escaped comparison operator (a real LibreOffice producer writes calcext:value=">3" as literally >3 on disk -- typed/ods/fixtures/conditional-format.ods\'s own content.xml, confirmed directly)', () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": ">3" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + operator: "greaterThan", + formula1: "3", + }, + ]); + }); + + it("resolves apply-style-name through the table-cell style cascade into textColor/background", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.B1:Sheet1.B1", + }, + [ + el("calcext:condition", { + "calcext:value": ">100", + "calcext:apply-style-name": "Warn", + }), + ], + ), + ]), + ); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, [ + el( + "style:style", + { "style:name": "Warn", "style:family": "table-cell" }, + [ + el("style:table-cell-properties", { + "fo:background-color": "#FFCC00", + }), + el("style:text-properties", { "fo:color": "#CC0000" }), + ], + ), + ]), + el("office:body", {}, [el("office:spreadsheet", {}, [table])]), + ]), + ], + }, + }, + }; + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "cellIs", + ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], + operator: "greaterThan", + formula1: "100", + style: { + textColor: { r: 0.8, g: 0, b: 0 }, + background: { r: 1, g: 0.8, b: 0 }, + }, + }, + ]); + }); + + function conditionWithStyle( + styleName: string, + styleProperties: readonly XmlElement[], + ): { pkg: Package; table: XmlElement } { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [ + el("calcext:condition", { + "calcext:value": "unique", + "calcext:apply-style-name": styleName, + }), + ], + ), + ]), + ); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, [ + el( + "style:style", + { "style:name": "Warn", "style:family": "table-cell" }, + [...styleProperties], + ), + ]), + el("office:body", {}, [el("office:spreadsheet", {}, [table])]), + ]), + ], + }, + }, + }; + return { pkg, table }; + } + + it("carries only textColor when the resolved style sets no background at all", () => { + const { pkg, table } = conditionWithStyle("Warn", [ + el("style:text-properties", { "fo:color": "#CC0000" }), + ]); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + style: { textColor: { r: 0.8, g: 0, b: 0 } }, + }); + }); + + it("carries only background when the resolved style sets no text color at all", () => { + const { pkg, table } = conditionWithStyle("Warn", [ + el("style:table-cell-properties", { "fo:background-color": "#FFCC00" }), + ]); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + style: { background: { r: 1, g: 0.8, b: 0 } }, + }); + }); + + it("omits style entirely when the referenced style resolves to no chain at all (an apply-style-name naming a style that was never declared)", () => { + const { pkg, table } = conditionWithStyle("DoesNotExist", [ + el("style:text-properties", { "fo:color": "#CC0000" }), + ]); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }); + }); + + it("omits style entirely when the resolved chain sets neither a background nor a text color", () => { + const { pkg, table } = conditionWithStyle("Warn", [ + el("style:table-cell-properties", { "fo:border": "0.5pt solid #000000" }), + ]); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }); + }); + + it("omits style entirely when the condition carries no calcext:apply-style-name attribute at all", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "unique" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }); + }); + + it("omits style entirely for a condition with no apply-style-name, even when the document declares a real table-cell family default-style that would otherwise supply one", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "unique" })], + ), + ]), + ); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, [ + el("style:default-style", { "style:family": "table-cell" }, [ + el("style:text-properties", { "fo:color": "#CC0000" }), + ]), + ]), + el("office:body", {}, [el("office:spreadsheet", {}, [table])]), + ]), + ], + }, + }, + }; + const { formats } = readConditionalFormats(table, pkg); + expect(formats[0]).toStrictEqual({ + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }); + }); + + it("promotes unique/duplicate, top10, aboveAverage, and containsErrors variants", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.A1:Sheet1.A1", + }, + [el("calcext:condition", { "calcext:value": "unique" })], + ), + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.B1:Sheet1.B1", + }, + [el("calcext:condition", { "calcext:value": "top-percent(10)" })], + ), + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.C1:Sheet1.C1", + }, + [ + el("calcext:condition", { + "calcext:value": "above-equal-average", + }), + ], + ), + el( + "calcext:conditional-format", + { + "calcext:target-range-address": "Sheet1.D1:Sheet1.D1", + }, + [el("calcext:condition", { "calcext:value": "is-error" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "uniqueValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + { + type: "top10", + ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], + rank: 10, + percent: true, + }, + { + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 2, endRow: 0, endColumn: 2 }], + aboveAverage: true, + equalAverage: true, + }, + { + type: "containsErrors", + ranges: [{ startRow: 0, startColumn: 3, endRow: 0, endColumn: 3 }], + }, + ]); + }); + + it("promotes notContainsErrors (is-no-error), a plain (non-percent) top-elements rank with neither percent nor bottom set, and a below-average rank with both flags false", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "is-no-error" })], + ), + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.B1:Sheet1.B1" }, + [el("calcext:condition", { "calcext:value": "top-elements(5)" })], + ), + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.C1:Sheet1.C1" }, + [el("calcext:condition", { "calcext:value": "below-average" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "notContainsErrors", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + { + type: "top10", + ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], + rank: 5, + }, + { + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 2, endRow: 0, endColumn: 2 }], + aboveAverage: false, + equalAverage: false, + }, + ]); + }); + + it("promotes a bottom-percent rank with both bottom and percent set", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "bottom-percent(20)" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 20, + percent: true, + bottom: true, + }, + ]); + }); + + it("promotes a plain bottom-elements rank with bottom set but percent absent", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "bottom-elements(3)" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "top10", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + rank: 3, + bottom: true, + }, + ]); + }); + + it("promotes a plain above-average rule with both flags true/false, not just the equal-average variant", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "above-average" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "aboveAverage", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + aboveAverage: true, + equalAverage: false, + }, + ]); + }); + + it("falls back to residue for a top-elements rank that is zero, negative, or not a number at all", () => { + for (const value of [ + "top-elements(0)", + "top-elements(-1)", + "top-elements(not-a-number)", + ]) { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": value })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + } + }); + + it("falls back to residue for a between condition missing its second operand", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "between(1)" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("promotes every text-matching mode into its own rule type, carrying the matched text verbatim", () => { + const cases: [string, string][] = [ + ["begins-with(foo)", "beginsWith"], + ["ends-with(foo)", "endsWith"], + ["contains-text(foo)", "containsText"], + ["not-contains-text(foo)", "notContainsText"], + ]; + for (const [value, type] of cases) { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": value })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type, + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + text: "foo", + }, + ]); + } + }); + + it("falls back to residue for a colour-scale with too few (one) or too many (four) entries", () => { + for (const entries of [ + [ + el("calcext:color-scale-entry", { + "calcext:type": "minimum", + "calcext:color": "#FF0000", + }), + ], + [ + el("calcext:color-scale-entry", { + "calcext:type": "minimum", + "calcext:color": "#FF0000", + }), + el("calcext:color-scale-entry", { + "calcext:type": "percentile", + "calcext:value": "33", + "calcext:color": "#FFFF00", + }), + el("calcext:color-scale-entry", { + "calcext:type": "percentile", + "calcext:value": "67", + "calcext:color": "#FFCC00", + }), + el("calcext:color-scale-entry", { + "calcext:type": "maximum", + "calcext:color": "#00FF00", + }), + ], + ]) { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [el("calcext:color-scale", {}, entries)], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + } + }); + + it("promotes a colour-scale with exactly the minimum of two entries", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [ + el("calcext:color-scale", {}, [ + el("calcext:color-scale-entry", { + "calcext:type": "minimum", + "calcext:color": "#FF0000", + }), + el("calcext:color-scale-entry", { + "calcext:type": "maximum", + "calcext:color": "#00FF00", + }), + ]), + ], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(residueElements).toEqual([]); + expect(formats).toStrictEqual([ + { + type: "colorScale", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + stops: [ + { value: { type: "min" }, color: { r: 1, g: 0, b: 0 } }, + { value: { type: "max" }, color: { r: 0, g: 1, b: 0 } }, + ], + }, + ]); + }); + + it("falls back to residue for an icon-set with no formatting-entry thresholds at all", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [el("calcext:icon-set", { "calcext:icon-set-type": "3Arrows" }, [])], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); -function tableWith(...children: readonly XmlElement[]): XmlElement { - return el("table:table", { "table:name": "Sheet1" }, [...children]); -} + it("reads a data-bar's thresholds from the alternate calcext:data-bar-entry tag, not just calcext:formatting-entry", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [ + el("calcext:data-bar", { "calcext:positive-color": "#638EC6" }, [ + el("calcext:data-bar-entry", { "calcext:type": "minimum" }), + el("calcext:data-bar-entry", { "calcext:type": "maximum" }), + ]), + ], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "dataBar", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + min: { type: "min" }, + max: { type: "max" }, + color: { + r: 0.38823529411764707, + g: 0.5568627450980392, + b: 0.7764705882352941, + }, + }, + ]); + }); -describe("readConditionalFormats (synthetic packages, real calcext wire shapes)", () => { - it("promotes a between condition into a cellIs rule with its resolved style", () => { + it("promotes an icon-set with a real calcext:show-value flag, distinguishing it from a data-bar that declares none at all", () => { const table = tableWith( el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.A1:Sheet1.A10", - }, + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, [ - el("calcext:condition", { - "calcext:value": "between(1,10)", - "calcext:apply-style-name": "Good", - }), + el( + "calcext:icon-set", + { + "calcext:icon-set-type": "3Arrows", + "calcext:show-value": "false", + }, + [ + el("calcext:formatting-entry", { + "calcext:type": "percent", + "calcext:value": "33", + }), + el("calcext:formatting-entry", { + "calcext:type": "percent", + "calcext:value": "67", + }), + ], + ), ], ), ]), ); const pkg = conditionalFormatsPackage(table); - const { formats, residueElements } = readConditionalFormats(table, pkg); - expect(residueElements).toEqual([]); - expect(formats).toEqual([ + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ { - type: "cellIs", + type: "iconSet", ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], - operator: "between", - formula1: "1", - formula2: "10", + iconSetType: "3Arrows", + thresholds: [ + { type: "percent", value: "33" }, + { type: "percent", value: "67" }, + ], + showValue: false, }, ]); }); - it('decodes an XML-escaped comparison operator (a real LibreOffice producer writes calcext:value=">3" as literally >3 on disk -- typed/ods/fixtures/conditional-format.ods\'s own content.xml, confirmed directly)', () => { + it("promotes an icon-set with calcext:show-value='true' as a real true, not just as 'not false'", () => { const table = tableWith( el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, - [el("calcext:condition", { "calcext:value": ">3" })], + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A10" }, + [ + el( + "calcext:icon-set", + { + "calcext:icon-set-type": "3Arrows", + "calcext:show-value": "true", + }, + [ + el("calcext:formatting-entry", { + "calcext:type": "percent", + "calcext:value": "50", + }), + ], + ), + ], ), ]), ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { - type: "cellIs", - ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], - operator: "greaterThan", - formula1: "3", + type: "iconSet", + ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], + iconSetType: "3Arrows", + thresholds: [{ type: "percent", value: "50" }], + showValue: true, }, ]); }); - it("resolves apply-style-name through the table-cell style cascade into textColor/background", () => { + it("resolves a date-is rule's own calcext:style into a real style, exactly as a condition's apply-style-name does", () => { const table = tableWith( el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.B1:Sheet1.B1", - }, + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, [ - el("calcext:condition", { - "calcext:value": ">100", - "calcext:apply-style-name": "Warn", + el("calcext:date-is", { + "calcext:date": "today", + "calcext:style": "Warn", }), ], ), @@ -273,12 +987,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" el( "style:style", { "style:name": "Warn", "style:family": "table-cell" }, - [ - el("style:table-cell-properties", { - "fo:background-color": "#FFCC00", - }), - el("style:text-properties", { "fo:color": "#CC0000" }), - ], + [el("style:text-properties", { "fo:color": "#CC0000" })], ), ]), el("office:body", {}, [el("office:spreadsheet", {}, [table])]), @@ -288,80 +997,53 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" }, }; const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { - type: "cellIs", - ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], - operator: "greaterThan", - formula1: "100", - style: { - textColor: { r: 0.8, g: 0, b: 0 }, - background: { r: 1, g: 0.8, b: 0 }, - }, + type: "timePeriod", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + timePeriod: "today", + style: { textColor: { r: 0.8, g: 0, b: 0 } }, }, ]); }); - it("promotes unique/duplicate, top10, aboveAverage, and containsErrors variants", () => { + it("falls back to residue for a wrapper whose only rule child is an element tag this reader does not recognise at all", () => { const table = tableWith( el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.A1:Sheet1.A1", - }, - [el("calcext:condition", { "calcext:value": "unique" })], - ), - el( - "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.B1:Sheet1.B1", - }, - [el("calcext:condition", { "calcext:value": "top-percent(10)" })], + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:some-future-rule-kind", {})], ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + }); + + it("silently ignores an unrecognised sibling rule element rather than turning an otherwise-promotable format into residue", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ el( "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.C1:Sheet1.C1", - }, + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, [ - el("calcext:condition", { - "calcext:value": "above-equal-average", - }), + el("calcext:condition", { "calcext:value": "unique" }), + el("calcext:some-future-rule-kind", {}), ], ), - el( - "calcext:conditional-format", - { - "calcext:target-range-address": "Sheet1.D1:Sheet1.D1", - }, - [el("calcext:condition", { "calcext:value": "is-error" })], - ), ]), ); const pkg = conditionalFormatsPackage(table); - const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(residueElements).toEqual([]); + expect(formats).toStrictEqual([ { type: "uniqueValues", ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], }, - { - type: "top10", - ranges: [{ startRow: 0, startColumn: 1, endRow: 0, endColumn: 1 }], - rank: 10, - percent: true, - }, - { - type: "aboveAverage", - ranges: [{ startRow: 0, startColumn: 2, endRow: 0, endColumn: 2 }], - aboveAverage: true, - equalAverage: true, - }, - { - type: "containsErrors", - ranges: [{ startRow: 0, startColumn: 3, endRow: 0, endColumn: 3 }], - }, ]); }); @@ -395,7 +1077,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { type: "colorScale", ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], @@ -437,7 +1119,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { type: "dataBar", ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], @@ -478,7 +1160,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { type: "iconSet", ranges: [{ startRow: 0, startColumn: 0, endRow: 9, endColumn: 0 }], @@ -512,7 +1194,7 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" ); const pkg = conditionalFormatsPackage(table); const { formats } = readConditionalFormats(table, pkg); - expect(formats).toEqual([ + expect(formats).toStrictEqual([ { type: "timePeriod", ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], @@ -626,3 +1308,234 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" }); }); }); + +describe("formatTargetRangeList", () => { + it("formats a single range as a sheet-prefixed A1:A1 pair", () => { + expect( + formatTargetRangeList( + [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }], + "Sheet1", + ), + ).toBe("Sheet1.A1:Sheet1.C3"); + }); + + it("space-joins several ranges, matching the read side's own separator", () => { + expect( + formatTargetRangeList( + [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + { startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 }, + ], + "Sheet1", + ), + ).toBe("Sheet1.A1:Sheet1.A1 Sheet1.B2:Sheet1.B2"); + }); +}); + +describe("calextTypeForCfvoType", () => { + it("inverts every real cfvo type this reader promotes", () => { + for (const [cfvoType, calextType] of [ + ["min", "minimum"], + ["max", "maximum"], + ["percentile", "percentile"], + ["percent", "percent"], + ["formula", "formula"], + ] as const) { + expect(calextTypeForCfvoType(cfvoType)).toBe(calextType); + } + }); + + it("returns undefined for 'num', which no calcext type maps onto", () => { + expect(calextTypeForCfvoType("num")).toBeUndefined(); + }); +}); + +describe("calextDateForTimePeriod", () => { + it("inverts every real calcext:date this reader promotes", () => { + for (const [calextDate, timePeriod] of [ + ["today", "today"], + ["yesterday", "yesterday"], + ["tomorrow", "tomorrow"], + ["last-7-days", "last7Days"], + ["this-week", "thisWeek"], + ["last-week", "lastWeek"], + ["next-week", "nextWeek"], + ["this-month", "thisMonth"], + ["last-month", "lastMonth"], + ["next-month", "nextMonth"], + ["this-year", "thisYear"], + ["last-year", "lastYear"], + ["next-year", "nextYear"], + ] as const) { + expect(calextDateForTimePeriod(timePeriod)).toBe(calextDate); + } + }); + + it("throws for a value with no calcext:date spelling, rather than silently emitting nothing", () => { + expect(() => calextDateForTimePeriod("not-a-real-period")).toThrow( + /no calcext:date spelling for 'not-a-real-period'/, + ); + }); +}); + +describe("synthesiseConditionValue", () => { + function cellIs( + operator: SheetRuleOperator, + formula1: string, + formula2?: string, + ): ContentSheetConditionalFormat { + return { + type: "cellIs", + ranges: [], + operator, + formula1, + ...(formula2 === undefined ? {} : { formula2 }), + }; + } + + it("synthesises between/not-between with both operands", () => { + expect(synthesiseConditionValue(cellIs("between", "1", "10"))).toBe( + "between(1,10)", + ); + expect(synthesiseConditionValue(cellIs("notBetween", "1", "10"))).toBe( + "not-between(1,10)", + ); + }); + + it("falls back to a plain comparison when a between/notBetween operator is missing its second operand", () => { + expect(synthesiseConditionValue(cellIs("between", "1"))).toBeUndefined(); + }); + + it("never treats a plain comparison operator as between/notBetween just because a stray formula2 happens to be present", () => { + expect(synthesiseConditionValue(cellIs("equal", "5", "10"))).toBe("=5"); + }); + + it("synthesises every plain comparison operator's own symbol", () => { + const cases: [SheetRuleOperator, string][] = [ + ["equal", "=5"], + ["notEqual", "!=5"], + ["lessThan", "<5"], + ["lessThanOrEqual", "<=5"], + ["greaterThan", ">5"], + ["greaterThanOrEqual", ">=5"], + ]; + for (const [operator, expected] of cases) { + expect(synthesiseConditionValue(cellIs(operator, "5"))).toBe(expected); + } + }); + + it("synthesises the no-operand keywords", () => { + expect(synthesiseConditionValue({ type: "uniqueValues", ranges: [] })).toBe( + "unique", + ); + expect( + synthesiseConditionValue({ type: "duplicateValues", ranges: [] }), + ).toBe("duplicate"); + expect( + synthesiseConditionValue({ type: "containsErrors", ranges: [] }), + ).toBe("is-error"); + expect( + synthesiseConditionValue({ type: "notContainsErrors", ranges: [] }), + ).toBe("is-no-error"); + }); + + it("synthesises every top10 rank/bottom/percent combination", () => { + const cases: [boolean | undefined, boolean | undefined, string][] = [ + [undefined, undefined, "top-elements(5)"], + [undefined, true, "top-percent(5)"], + [true, undefined, "bottom-elements(5)"], + [true, true, "bottom-percent(5)"], + ]; + for (const [bottom, percent, expected] of cases) { + expect( + synthesiseConditionValue({ + type: "top10", + ranges: [], + rank: 5, + ...(bottom === undefined ? {} : { bottom }), + ...(percent === undefined ? {} : { percent }), + }), + ).toBe(expected); + } + }); + + it("synthesises every aboveAverage direction/qualifier combination", () => { + const cases: [boolean, boolean, string][] = [ + [true, false, "above-average"], + [true, true, "above-equal-average"], + [false, false, "below-average"], + [false, true, "below-equal-average"], + ]; + for (const [aboveAverage, equalAverage, expected] of cases) { + expect( + synthesiseConditionValue({ + type: "aboveAverage", + ranges: [], + aboveAverage, + equalAverage, + }), + ).toBe(expected); + } + }); + + it("synthesises every text-matching mode with its own paren-wrapped text", () => { + const cases: [ContentSheetConditionalFormat["type"], string][] = [ + ["beginsWith", "begins-with(foo)"], + ["endsWith", "ends-with(foo)"], + ["containsText", "contains-text(foo)"], + ["notContainsText", "not-contains-text(foo)"], + ]; + for (const [type, expected] of cases) { + expect( + synthesiseConditionValue({ + type, + ranges: [], + text: "foo", + } as ContentSheetConditionalFormat), + ).toBe(expected); + } + }); + + it("returns undefined for containsBlanks/notContainsBlanks, which calcext:condition's own grammar has no spelling for", () => { + expect( + synthesiseConditionValue({ type: "containsBlanks", ranges: [] }), + ).toBeUndefined(); + expect( + synthesiseConditionValue({ type: "notContainsBlanks", ranges: [] }), + ).toBeUndefined(); + }); + + it("returns undefined for the rule kinds that are never calcext:condition rules at all", () => { + expect( + synthesiseConditionValue({ + type: "colorScale", + ranges: [], + stops: [], + }), + ).toBeUndefined(); + expect( + synthesiseConditionValue({ + type: "dataBar", + ranges: [], + min: { type: "min" }, + max: { type: "max" }, + color: { r: 0, g: 0, b: 0 }, + }), + ).toBeUndefined(); + expect( + synthesiseConditionValue({ + type: "iconSet", + ranges: [], + iconSetType: "3Arrows", + thresholds: [], + }), + ).toBeUndefined(); + expect( + synthesiseConditionValue({ + type: "timePeriod", + ranges: [], + timePeriod: "today", + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/odf.js/src/typed/ods/conditional-format.ts b/packages/odf.js/src/typed/ods/conditional-format.ts index b9758f577..136a7676c 100644 --- a/packages/odf.js/src/typed/ods/conditional-format.ts +++ b/packages/odf.js/src/typed/ods/conditional-format.ts @@ -39,9 +39,7 @@ const CFVO_TYPE_BY_CALCEXT_TYPE: ReadonlyMap = new Map([ export function readTargetRangeList(value: string): ContentSheetRange[] { const ranges: ContentSheetRange[] = []; for (const part of value.split(" ")) { - if (part.length === 0) { - continue; - } + // No separate `part.length === 0` guard: a genuinely empty part (from a run of consecutive spaces) has no ':' to find either, so it already falls through the very next check below -- an explicit length guard here would only ever fire on input the next line already handles identically. const separatorIndex = part.indexOf(":"); if (separatorIndex === -1) { continue; @@ -64,9 +62,9 @@ export function readTargetRangeList(value: string): ContentSheetRange[] { function parseA1WithOptionalSheetPrefix( cellPart: string, ): { column: number; row: number } | undefined { + // No separate "has a sheet prefix at all" branch: when there is no '.', lastIndexOf returns -1, and slice(-1 + 1) = slice(0) returns cellPart unchanged -- exactly what a bare reference needs, with no ternary required to state it. const dotIndex = cellPart.lastIndexOf("."); - const bareReference = - dotIndex === -1 ? cellPart : cellPart.slice(dotIndex + 1); + const bareReference = cellPart.slice(dotIndex + 1); return parseCellReference(bareReference); } @@ -77,10 +75,8 @@ function readConditionalFormatStyle( if (styleName === undefined) { return undefined; } + // No separate "chain resolved to nothing" guard: readCellStyleDecoration and resolveStyle both fold over `elements` and yield undefined background/color for an empty chain exactly as they would for a chain that resolved but carried neither property, so an empty chain already falls through to the "genuinely no styling" check below with the identical result. const { elements } = resolveStyleElementChain(styleName, "table-cell", pkg); - if (elements.length === 0) { - return undefined; - } // ContentSheetConditionalFormatStyleSchema.background is a plain colour (the two properties actually observed on a real dxf, per that schema's own top comment); readCellStyleDecoration's own background is the richer solid/pattern ContentCellFill a regular cell can carry, so only the 'solid' case narrows down to a colour here -- a pattern fill on the referenced style has no representation in this narrower schema and is simply not carried through, matching the schema's own documented scope. const { background: fill } = readCellStyleDecoration(elements); const background = fill?.kind === "solid" ? fill.color : undefined; @@ -710,14 +706,13 @@ export function synthesiseConditionValue( return `contains-text(${format.text})`; case "notContainsText": return `not-contains-text(${format.text})`; + // containsBlanks/notContainsBlanks: calcext:condition's own grammar has no spelling for either (see this function's own top-of-file note). colorScale/dataBar/iconSet/timePeriod: not calcext:condition rules at all -- each is its own child element, built by the writer's own element builders. One shared return rather than two identical ones per group: a duplicate `return undefined` on its own case label is indistinguishable at runtime from falling through into the next label's identical return, so splitting them apart bought no real coverage. case "containsBlanks": case "notContainsBlanks": - return undefined; case "colorScale": case "dataBar": case "iconSet": case "timePeriod": - // Not calcext:condition rules at all -- each is its own child element, built by the writer's own element builders. return undefined; } } From 2e77cb76c5ddf3f27c70bbaf9f6c88ae46c59088 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:50:13 +0100 Subject: [PATCH 53/89] test(odf.js): kill readCondition's remaining rank-branch survivors Cover a duplicate-values rule read back through readConditionalFormats end to end (the earlier "unique/duplicate" test never actually fed a duplicate calcext:value through it), and every top-elements/bottom-elements/ top-percent/bottom-percent condition with no parenthesised operand at all. Simplify the rank branch's own redundant undefined guard: Number(undefined) is NaN, which the following Number.isFinite check already rejects, so a missing expr1 and a non-numeric one already degrade to the identical "not a valid rank" outcome -- the earlier explicit check caught nothing the next line didn't already catch on its own. --- .../src/typed/ods/conditional-format.test.ts | 43 +++++++++++++++++++ .../src/typed/ods/conditional-format.ts | 4 +- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/odf.js/src/typed/ods/conditional-format.test.ts b/packages/odf.js/src/typed/ods/conditional-format.test.ts index 90681207c..522b6c227 100644 --- a/packages/odf.js/src/typed/ods/conditional-format.test.ts +++ b/packages/odf.js/src/typed/ods/conditional-format.test.ts @@ -723,6 +723,49 @@ describe("readConditionalFormats (synthetic packages, real calcext wire shapes)" expect(residueElements).toHaveLength(1); }); + it("falls back to residue for a top-elements/bottom-elements/top-percent/bottom-percent condition with no operand at all (no parenthesised rank to extract)", () => { + for (const value of [ + "top-elements", + "bottom-elements", + "top-percent", + "bottom-percent", + ]) { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": value })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats, residueElements } = readConditionalFormats(table, pkg); + expect(formats).toEqual([]); + expect(residueElements).toHaveLength(1); + } + }); + + it("promotes a duplicate-values rule read back from calcext:condition, not just parsed in isolation", () => { + const table = tableWith( + el("calcext:conditional-formats", {}, [ + el( + "calcext:conditional-format", + { "calcext:target-range-address": "Sheet1.A1:Sheet1.A1" }, + [el("calcext:condition", { "calcext:value": "duplicate" })], + ), + ]), + ); + const pkg = conditionalFormatsPackage(table); + const { formats } = readConditionalFormats(table, pkg); + expect(formats).toStrictEqual([ + { + type: "duplicateValues", + ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }], + }, + ]); + }); + it("promotes every text-matching mode into its own rule type, carrying the matched text verbatim", () => { const cases: [string, string][] = [ ["begins-with(foo)", "beginsWith"], diff --git a/packages/odf.js/src/typed/ods/conditional-format.ts b/packages/odf.js/src/typed/ods/conditional-format.ts index 136a7676c..9a983f65f 100644 --- a/packages/odf.js/src/typed/ods/conditional-format.ts +++ b/packages/odf.js/src/typed/ods/conditional-format.ts @@ -304,9 +304,7 @@ function readCondition( case "bottom-elements": case "top-percent": case "bottom-percent": { - if (parsed.expr1 === undefined) { - return undefined; - } + // No separate `parsed.expr1 === undefined` guard: Number(undefined) is NaN, which the Number.isFinite check right below already rejects -- a missing operand and a non-numeric one degrade to the identical "not a valid rank" outcome, so there is nothing this earlier check catches that the next line doesn't already catch on its own. const rank = Number(parsed.expr1); if (!Number.isFinite(rank) || rank <= 0) { return undefined; From 307faf6d57f673411615642276083612c8700dcd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 11:50:37 +0100 Subject: [PATCH 54/89] test(odf.js): reach 100% mutation coverage on typed/ods/data-validation.ts Add direct coverage for parseToken's malformed-input branches across every condition kind (function0/comparison/function1/function2), operand trimming, the secondary-clause kind check, and synthesiseContentValidationCondition's full write-side grammar. Several of the malformed-input tests specifically target a fallthrough where a missing "()"/operand check alone wasn't enough to observe a difference (a downstream check happened to produce the same final result by coincidence) -- these use a secondary-clause context, or a character sequence that would otherwise be mis-parsed as valid content, so the wrong branch's own output is actually distinguishable from the correct one. Cover every table:display/table:title/text:p-body combination on both table:help-message and table:error-message, checking key presence (not just value) since promptTitle/errorTitle are only ever conditionally assigned -- an absent key and an explicitly-undefined one read identically through `?.` but are not the same object shape. Narrow betweenClause's own operator parameter to "between" | "notBetween": both call sites already guard to that pair before invoking it, so the ternary's own third branch (an operator that's neither) was never reachable through the public API -- removing it drops the return type's undefined case along with the now-pointless suffix-undefined check. Simplify parseToken's leading-whitespace skip to drop its own redundant `searchStart < text.length` bound: text[searchStart] for an out-of-range index is always undefined, never " ", so the character check alone already stops the loop at the end of the string. --- .../src/typed/ods/data-validation.test.ts | 426 ++++++++++++++++++ .../odf.js/src/typed/ods/data-validation.ts | 20 +- 2 files changed, 433 insertions(+), 13 deletions(-) diff --git a/packages/odf.js/src/typed/ods/data-validation.test.ts b/packages/odf.js/src/typed/ods/data-validation.test.ts index a8c3da67c..96c94ed8f 100644 --- a/packages/odf.js/src/typed/ods/data-validation.test.ts +++ b/packages/odf.js/src/typed/ods/data-validation.test.ts @@ -4,6 +4,7 @@ import { parseContentValidationCondition, readContentValidationDefinitions, resolveSheetDataValidations, + synthesiseContentValidationCondition, } from "./data-validation"; // table:condition's own grammar is transcribed from LibreOffice's real reader (sc/source/filter/xml/xmlcvali.cxx, XMLConverter.cxx) -- see data-validation.ts's own top-of-file note. Every example here is either lifted verbatim from a real LibreOffice-produced .fods fixture (sc/qa/unit/data/functions/logical/fods/if.fods) or hand-built to the identical grammar that source establishes. @@ -98,6 +99,124 @@ describe("parseContentValidationCondition", () => { it("returns undefined for an empty string", () => { expect(parseContentValidationCondition("")).toBeUndefined(); }); + + it("returns undefined for a bare comparison identifier with no preceding type token (cell-content has no validation of its own)", () => { + expect( + parseContentValidationCondition("of:cell-content()>=1"), + ).toBeUndefined(); + }); + + it("returns undefined for a function0 identifier with no trailing parentheses at all", () => { + expect( + parseContentValidationCondition("of:cell-content-is-whole-number"), + ).toBeUndefined(); + }); + + it("returns undefined for a function0 identifier whose trailing characters aren't the literal '()'", () => { + expect( + parseContentValidationCondition("of:cell-content-is-whole-number(x)"), + ).toBeUndefined(); + }); + + it("falls back to the bare type, with no operator, when the secondary clause's own comparison identifier has no parentheses -- rather than treating two arbitrary non-'()' characters as the missing pair and mis-parsing whatever operator happens to follow them", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-contentAB>=5", + ), + ).toEqual({ type: "whole" }); + }); + + it("falls back to the bare type when the secondary comparison's operator doesn't match any known spelling", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content()~1", + ), + ).toEqual({ type: "whole" }); + }); + + it("falls back to the bare type when the secondary comparison's operand is empty", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content()>=", + ), + ).toEqual({ type: "whole" }); + }); + + it("trims surrounding whitespace from a comparison operand", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content()>= 5", + ), + ).toEqual({ type: "whole", operator: "greaterThanOrEqual", formula1: "5" }); + }); + + it("falls back to the bare type when the token after 'and' is a function1 (an is-true-formula, its own operand carried on formula1) rather than a comparison or function2 -- not just any wrong-kind token, one whose own parsed fields could otherwise leak through", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and is-true-formula(X)", + ), + ).toEqual({ type: "whole" }); + }); + + it("returns undefined for is-true-formula with no parentheses at all", () => { + expect( + parseContentValidationCondition("of:is-true-formula"), + ).toBeUndefined(); + }); + + it("returns undefined for is-true-formula whose character right after the identifier isn't '(' -- rather than treating whatever follows that character as the parenthesised operand", () => { + expect( + parseContentValidationCondition("of:is-true-formulaXFOO)"), + ).toBeUndefined(); + }); + + it("returns undefined for is-true-formula whose parenthesised operand is empty", () => { + expect( + parseContentValidationCondition("of:is-true-formula()"), + ).toBeUndefined(); + }); + + it("returns undefined for cell-content-is-between with no opening parenthesis", () => { + expect( + parseContentValidationCondition("of:cell-content-is-between1,10)"), + ).toBeUndefined(); + }); + + it("returns undefined for cell-content-is-between whose first operand is empty", () => { + expect( + parseContentValidationCondition("of:cell-content-is-between(,10)"), + ).toBeUndefined(); + }); + + it("returns undefined for cell-content-is-between whose second operand is empty", () => { + expect( + parseContentValidationCondition("of:cell-content-is-between(1,)"), + ).toBeUndefined(); + }); + + it("falls back to the bare type, as a secondary clause, when cell-content-is-between has no opening parenthesis -- rather than treating the character right after the identifier as consumed and re-parsing whatever comes after it as the two operands", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content-is-between1FOO,BAR)", + ), + ).toEqual({ type: "whole" }); + }); + + it("falls back to the bare type, as a secondary clause, when cell-content-is-between's first operand is empty -- rather than accepting the second operand alone", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content-is-between(,10)", + ), + ).toEqual({ type: "whole" }); + }); + + it("falls back to the bare type, as a secondary clause, when cell-content-is-between's second operand is empty -- rather than accepting the first operand alone", () => { + expect( + parseContentValidationCondition( + "of:cell-content-is-whole-number() and cell-content-is-between(10,)", + ), + ).toEqual({ type: "whole" }); + }); }); function contentValidationsElement(...validations: ReturnType[]) { @@ -201,6 +320,125 @@ describe("readContentValidationDefinitions", () => { }); }); + it("omits showInputMessage/showErrorMessage when table:display isn't the literal string 'true', and omits promptTitle/errorTitle when table:title is absent", () => { + const definitions = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:help-message", { "table:display": "false" }, []), + el("table:error-message", { "table:display": "false" }, []), + ], + ), + ), + ); + const rule = definitions.get("val1"); + expect(rule?.showInputMessage).toBeUndefined(); + expect(rule?.showErrorMessage).toBeUndefined(); + // Presence, not just value: readContentValidation only ever assigns promptTitle/errorTitle when table:title is actually present, so an absent title must leave the key itself unset -- not merely holding an explicit `undefined` -- which `?.` equality can't tell apart from a genuinely missing key. + expect(Object.hasOwn(rule ?? {}, "promptTitle")).toBe(false); + expect(Object.hasOwn(rule ?? {}, "errorTitle")).toBe(false); + }); + + it("leaves prompt/error unset when the message element has no text:p children at all (an empty body)", () => { + const definitions = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:help-message", { "table:display": "true" }, []), + el("table:error-message", { "table:display": "true" }, []), + ], + ), + ), + ); + const rule = definitions.get("val1"); + expect(rule?.showInputMessage).toBe(true); + expect(rule?.prompt).toBeUndefined(); + expect(rule?.showErrorMessage).toBe(true); + expect(rule?.error).toBeUndefined(); + }); + + it("leaves errorStyle unset when table:message-type is absent or isn't one of the three recognised values", () => { + const noType = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [el("table:error-message", {}, [txt("body")])], + ), + ), + ); + expect(noType.get("val1")?.errorStyle).toBeUndefined(); + + const unrecognisedType = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:error-message", { "table:message-type": "critical" }, [ + txt("body"), + ]), + ], + ), + ), + ); + expect(unrecognisedType.get("val1")?.errorStyle).toBeUndefined(); + }); + + it("recognises 'stop' and 'information' as valid table:message-type values, alongside 'warning' already covered above", () => { + const stop = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:error-message", { "table:message-type": "stop" }, [ + txt("body"), + ]), + ], + ), + ), + ); + expect(stop.get("val1")?.errorStyle).toBe("stop"); + + const information = readContentValidationDefinitions( + contentValidationsElement( + el( + "table:content-validation", + { + "table:name": "val1", + "table:condition": "of:cell-content-is-whole-number()", + }, + [ + el("table:error-message", { "table:message-type": "information" }, [ + txt("body"), + ]), + ], + ), + ), + ); + expect(information.get("val1")?.errorStyle).toBe("information"); + }); + it("returns an empty map when the document declares no table:content-validations at all", () => { const definitions = readContentValidationDefinitions( el("office:spreadsheet", {}, []), @@ -261,3 +499,191 @@ describe("resolveSheetDataValidations", () => { expect(resolveSheetDataValidations(refs, new Map())).toEqual([]); }); }); + +describe("synthesiseContentValidationCondition", () => { + it("writes cell-content-is-in-list for a list rule with a formula", () => { + expect( + synthesiseContentValidationCondition({ + type: "list", + formula1: "$Sheet1.$A$1:$A$10", + }), + ).toBe("of:cell-content-is-in-list($Sheet1.$A$1:$A$10)"); + }); + + it("emits no table:condition for a list rule with no formula", () => { + expect( + synthesiseContentValidationCondition({ type: "list" }), + ).toBeUndefined(); + }); + + it("writes is-true-formula for a custom rule with a formula", () => { + expect( + synthesiseContentValidationCondition({ + type: "custom", + formula1: "ISNUMBER([.A1])", + }), + ).toBe("of:is-true-formula(ISNUMBER([.A1]))"); + }); + + it("emits no table:condition for a custom rule with no formula", () => { + expect( + synthesiseContentValidationCondition({ type: "custom" }), + ).toBeUndefined(); + }); + + it("emits no table:condition for a textLength rule missing an operator", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + formula1: "5", + }), + ).toBeUndefined(); + }); + + it("emits no table:condition for a textLength rule missing formula1", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "greaterThan", + }), + ).toBeUndefined(); + }); + + it("writes cell-content-text-length-is-between for a textLength 'between' rule with both formulas", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "between", + formula1: "5", + formula2: "20", + }), + ).toBe("of:cell-content-text-length-is-between(5,20)"); + }); + + it("writes cell-content-text-length-is-not-between for a textLength 'notBetween' rule with both formulas", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "notBetween", + formula1: "5", + formula2: "20", + }), + ).toBe("of:cell-content-text-length-is-not-between(5,20)"); + }); + + it("emits no table:condition for a textLength 'between' rule missing its second formula (neither a between clause nor a plain comparison can be written)", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "between", + formula1: "5", + }), + ).toBeUndefined(); + }); + + it("writes a plain comparison for a textLength rule with a non-between operator", () => { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: "equal", + formula1: "5", + }), + ).toBe("of:cell-content-text-length()=5"); + }); + + it("writes each plain comparison operator spelling for a textLength rule", () => { + const cases: [string, string][] = [ + ["equal", "="], + ["notEqual", "!="], + ["lessThan", "<"], + ["lessThanOrEqual", "<="], + ["greaterThan", ">"], + ["greaterThanOrEqual", ">="], + ]; + for (const [operator, symbol] of cases) { + expect( + synthesiseContentValidationCondition({ + type: "textLength", + operator: operator as never, + formula1: "5", + }), + ).toBe(`of:cell-content-text-length()${symbol}5`); + } + }); + + it("writes the bare function0 identifier for a whole/decimal/date/time rule with no operator", () => { + expect(synthesiseContentValidationCondition({ type: "whole" })).toBe( + "of:cell-content-is-whole-number()", + ); + expect(synthesiseContentValidationCondition({ type: "decimal" })).toBe( + "of:cell-content-is-decimal-number()", + ); + expect(synthesiseContentValidationCondition({ type: "date" })).toBe( + "of:cell-content-is-date()", + ); + expect(synthesiseContentValidationCondition({ type: "time" })).toBe( + "of:cell-content-is-time()", + ); + }); + + it("writes the bare identifier alone when a whole/decimal/date/time rule has an operator but no formula1", () => { + expect( + synthesiseContentValidationCondition({ + type: "whole", + operator: "greaterThan", + }), + ).toBe("of:cell-content-is-whole-number()"); + }); + + it("appends a secondary comparison clause for a whole/decimal/date/time rule with a non-between operator", () => { + expect( + synthesiseContentValidationCondition({ + type: "whole", + operator: "greaterThanOrEqual", + formula1: "1", + }), + ).toBe("of:cell-content-is-whole-number() and cell-content()>=1"); + }); + + it("appends a secondary between clause for a whole/decimal/date/time rule with a between operator and both formulas", () => { + expect( + synthesiseContentValidationCondition({ + type: "whole", + operator: "between", + formula1: "1", + formula2: "10", + }), + ).toBe( + "of:cell-content-is-whole-number() and cell-content-is-between(1,10)", + ); + }); + + it("appends a secondary between clause for a whole/decimal/date/time rule with a notBetween operator and both formulas", () => { + expect( + synthesiseContentValidationCondition({ + type: "date", + operator: "notBetween", + formula1: "1", + formula2: "10", + }), + ).toBe("of:cell-content-is-date() and cell-content-is-not-between(1,10)"); + }); + + it("writes just the bare identifier for a whole/decimal/date/time rule with a between operator but no second formula (the secondary clause can't be written either way)", () => { + expect( + synthesiseContentValidationCondition({ + type: "whole", + operator: "between", + formula1: "1", + }), + ).toBe("of:cell-content-is-whole-number()"); + }); + + it("emits no table:condition for a type this grammar has no identifier for", () => { + expect( + synthesiseContentValidationCondition({ + type: "bogus" as never, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/odf.js/src/typed/ods/data-validation.ts b/packages/odf.js/src/typed/ods/data-validation.ts index 2ff7a7cb4..aa46faa7c 100644 --- a/packages/odf.js/src/typed/ods/data-validation.ts +++ b/packages/odf.js/src/typed/ods/data-validation.ts @@ -78,9 +78,9 @@ interface ParsedToken { // One token of the condition mini-language starting at `start`: an identifier, matched against CONDITION_INFOS, then whatever that identifier's own kind requires immediately after it (a comparison operator and one trailing expression, an empty ()) pair, or one/two parenthesised expressions). Returns undefined on anything this reader cannot make sense of -- a genuinely malformed or producer-extended condition degrades to no validation type/operator rather than a wrong one, mirroring readCellValue's own "an honest 'we don't have one' beats a fabricated value" convention elsewhere in this reader. function parseToken(text: string, start: number): ParsedToken | undefined { - // Skips leading whitespace before matching -- real ODF condition strings have a literal space either side of the 'and' keyword (lclSkipWhitespace's own call sites in XMLConverter.cxx), and this parser's own primary/secondary calls resume exactly where the previous token's endIndex left off, which is never itself past that space. + // Skips leading whitespace before matching -- real ODF condition strings have a literal space either side of the 'and' keyword (lclSkipWhitespace's own call sites in XMLConverter.cxx), and this parser's own primary/secondary calls resume exactly where the previous token's endIndex left off, which is never itself past that space. No separate `searchStart < text.length` bound: `text[searchStart]` for an out-of-range index is `undefined`, which is never `=== " "`, so the character check alone already stops the loop at the end of the string. let searchStart = start; - while (searchStart < text.length && text[searchStart] === " ") { + while (text[searchStart] === " ") { searchStart += 1; } const match = IDENTIFIER_PATTERN.exec(text.slice(searchStart)); @@ -390,21 +390,15 @@ function comparisonClause( : `cell-content()${operatorText}${formula1}`; } +// Both call sites below already narrow `operator` to this pair before calling -- a between/notBetween clause is the only shape either the textLength or the default (whole/decimal/date/time) branch ever asks this helper to build, so the return type carries no undefined case for a third operator this helper is never actually invoked with. function betweenClause( - operator: SheetRuleOperator, + operator: "between" | "notBetween", stem: string, formula1: string, formula2: string, -): string | undefined { - const suffix = - operator === "between" - ? "is-between" - : operator === "notBetween" - ? "is-not-between" - : undefined; - return suffix === undefined - ? undefined - : `${stem}-${suffix}(${formula1},${formula2})`; +): string { + const suffix = operator === "between" ? "is-between" : "is-not-between"; + return `${stem}-${suffix}(${formula1},${formula2})`; } /** The table:condition attribute value for one rule, "of:"-prefixed the way every real producer spells the OpenFormula namespace. Returns undefined when the rule carries no condition this grammar can state at all -- a custom rule with no formula, a list with no list body, or a textLength rule with no comparison -- in which case the writer emits no table:condition attribute, exactly the shape whose absence the read side itself degrades to a bare custom rule. An operator whose operand is missing degrades the same way rather than emitting a clause the read side would reject: the same partial-parse tolerance readContentValidation already shows in the other direction. */ From c4d9230461373dc6d88d9846639e26729455a1a7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 13:01:51 +0100 Subject: [PATCH 55/89] refactor(odf.js): simplify insertOdfConstructMarkers and export isEmbeddedObjectPart insertOdfConstructMarkers' early return for an empty extents list is redundant: with nested and openingAt both empty, the general loop already reduces to copying every defined block in document order, exactly what the guard's own [...blocks] produced -- the two paths are behaviourally identical, so the guard is dead weight, not a real branch. isEmbeddedObjectPart is exported so its regex's three boundary facts (must start with "Object ", digits-only after that, must end there) can be pinned directly -- black-box testing through its only two callers can observe "quarantined or not" but never distinguish a loosened anchor from the correct one. --- packages/odf.js/src/typed/shared/constructs.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/odf.js/src/typed/shared/constructs.ts b/packages/odf.js/src/typed/shared/constructs.ts index 28e20b9e3..16f71ef4b 100644 --- a/packages/odf.js/src/typed/shared/constructs.ts +++ b/packages/odf.js/src/typed/shared/constructs.ts @@ -147,8 +147,8 @@ export const ODF_CONSUMED_PART_PATHS: ReadonlySet = new Set([ "META-INF/manifest.xml", ]); -// An embedded sub-document's own parts -- the "Object N" directory convention every real producer's draw:object href actually names (confirmed against real LibreOffice output: "Object 1/content.xml", "Object 1/styles.xml", "Object 1/settings.xml" under a draw:object xlink:href="./Object 1"). Those parts are consumed by the embedded-object readers into their own whole ContentDocuments, so quarantining them too would put one sub-document in two channels at once. This helper cannot see hrefs, so it excludes the whole convention-shaped range rather than ever double-carrying a sub-document; the cost of a false exclusion is only a residue row the semantic channel already carries, while the cost of a false inclusion is the double-carry itself. -function isEmbeddedObjectPart(path: string): boolean { +// An embedded sub-document's own parts -- the "Object N" directory convention every real producer's draw:object href actually names (confirmed against real LibreOffice output: "Object 1/content.xml", "Object 1/styles.xml", "Object 1/settings.xml" under a draw:object xlink:href="./Object 1"). Those parts are consumed by the embedded-object readers into their own whole ContentDocuments, so quarantining them too would put one sub-document in two channels at once. This helper cannot see hrefs, so it excludes the whole convention-shaped range rather than ever double-carrying a sub-document; the cost of a false exclusion is only a residue row the semantic channel already carries, while the cost of a false inclusion is the double-carry itself. Exported so the regex's own three boundary facts (must start with "Object ", must be only digits after it, must end there) can each be pinned directly -- a black-box test through collectOdfNonContentPartResidue/writeOdfPackageResidue could only ever observe "quarantined or not", which cannot distinguish a loosened anchor from the correct one. +export function isEmbeddedObjectPart(path: string): boolean { const [firstSegment] = path.split("/"); return firstSegment !== undefined && /^Object \d+$/.test(firstSegment); } @@ -572,9 +572,7 @@ export function insertOdfConstructMarkers( blocks: readonly ContentBlock[], extents: readonly OdfConstructExtent[], ): ContentBlock[] { - if (extents.length === 0) { - return [...blocks]; - } + // No early return for an empty extents list: with nested/openingAt both empty, the loop below already reduces to "copy every defined block in order", which is exactly `[...blocks]` -- a dedicated guard was a redundant, behaviourally unobservable shortcut for the same result. const nested = acceptProperlyNestedOdfExtents(extents); const openingAt = new Map(); for (const extent of nested) { From 6c7b29511fea838e9b3e5c7e2ca697b0d324a063 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 13:02:04 +0100 Subject: [PATCH 56/89] test(odf.js): add direct unit coverage for typed/shared/constructs.ts Every function in this module was previously exercised only indirectly, through odt/read.ts's readOdtContent and various write paths, which left a large share of its own branches -- write-side functions with no reader at all (writeOdfTrackedChanges, odfRunConstructWriteKind, writeOdfDivision, writeOdfIndexWrapper), private helpers reachable only through several layers of indirection (findSectionStyleElement, isContentBearingNode, readOdfFieldMasterEntry), and edge-detection/pairing logic whose OdfMarkerHalf/OdfMarkerEvent inputs are otherwise only ever produced by a real paragraph walk (pairOdfMarkerHalves, resolveOdfMarkerEvents) -- with no direct test observing them at all. Builds every fixture as plain XmlElement/Package literals, or as directly-constructed OdfMarkerHalf/OdfMarkerEvent objects for the pairing functions, rather than routing through a full paragraph/ document read just to reach one function's own boundary conditions. --- .../src/typed/shared/constructs.test.ts | 1183 +++++++++++++++++ 1 file changed, 1183 insertions(+) create mode 100644 packages/odf.js/src/typed/shared/constructs.test.ts diff --git a/packages/odf.js/src/typed/shared/constructs.test.ts b/packages/odf.js/src/typed/shared/constructs.test.ts new file mode 100644 index 000000000..eefcc5529 --- /dev/null +++ b/packages/odf.js/src/typed/shared/constructs.test.ts @@ -0,0 +1,1183 @@ +import { describe, expect, it } from "vitest"; +import type { + ConstructDescriptor, + ContentBlock, + ContentControlDescriptor, + DefinitionEntry, + DivisionDescriptor, + ProvenanceDescriptor, + RunConstructExtent, + SourceResidue, +} from "document-schema.js"; +import type { Package } from "../../model/package"; +import type { XmlElement, XmlNode } from "../../model/node"; +import { el, txt } from "../../xml/fragment"; +import { + canonicalOdfConstructDescriptor, + collectOdfDataStyleDefinitions, + collectOdfFieldMasterDefinitions, + collectOdfFontFaceDefinitions, + collectOdfNamedExpressions, + collectOdfNonContentPartResidue, + collectOdfProvenanceRegions, + insertOdfConstructMarkers, + isEmbeddedObjectPart, + odfDivisionDescriptor, + odfIndexControlDescriptor, + odfIndexWrapperTag, + odfMarkerHalfEventIndex, + odfRunConstructWriteKind, + pairOdfMarkerHalves, + parseOdfFieldInstruction, + resolveOdfMarkerEvents, + writeOdfDivision, + writeOdfIndexWrapper, + writeOdfPackageResidue, + writeOdfTrackedChanges, + type OdfConstructExtent, + type OdfMarkerEvent, + type OdfMarkerHalf, +} from "./constructs"; + +// Every fixture here is a programmatic package/element built with el/txt, matching the sibling odt/constructs.test.ts's own fixture-gate convention. + +function part(nodes: XmlNode[]): Package { + return { parts: { "settings.xml": { kind: "xml", nodes } } }; +} + +describe("isEmbeddedObjectPart", () => { + it("quarantines an Object-N directory's own part path", () => { + expect(isEmbeddedObjectPart("Object 1/content.xml")).toBe(true); + expect(isEmbeddedObjectPart("Object 12/content.xml")).toBe(true); + }); + it("does not match a path whose first segment merely ends with the Object-N shape", () => { + expect(isEmbeddedObjectPart("XObject 1/content.xml")).toBe(false); + }); + it("does not match a path whose first segment has trailing content after the digits", () => { + expect(isEmbeddedObjectPart("Object 1x/content.xml")).toBe(false); + }); + it("does not match a path with no digits at all", () => { + expect(isEmbeddedObjectPart("Object/content.xml")).toBe(false); + }); +}); + +describe("collectOdfNonContentPartResidue", () => { + it("quarantines nothing for a non-content part whose nodes carry no element at all", () => { + const pkg = part([{ type: "declaration", attributes: [] }]); + const out: Record = {}; + collectOdfNonContentPartResidue(pkg, "odt", out); + expect(out).toEqual({}); + }); + it("quarantines a non-content part carrying a real element", () => { + const pkg = part([el("config:config-item-set", {}, [])]); + const out: Record = {}; + collectOdfNonContentPartResidue(pkg, "odt", out); + expect(out["settings.xml"]?.xml).toContain("config:config-item-set"); + }); +}); + +describe("writeOdfPackageResidue", () => { + const baseline: SourceResidue = { format: "odt", xml: "" }; + + it("does nothing when source is undefined", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", undefined); + expect(pkg.parts).toEqual({}); + }); + + it("skips an entry whose residue format does not match the writer's own format", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { + "settings.xml": { format: "ods", xml: "" }, + }); + expect(pkg.parts).toEqual({}); + }); + + it("skips an entry whose key does not end .xml", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { "settings.bin": baseline }); + expect(pkg.parts).toEqual({}); + }); + + it("skips an entry keyed at one of this writer's own consumed part paths", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { "content.xml": baseline }); + expect(pkg.parts).toEqual({}); + }); + + it("skips an entry keyed inside an embedded object's own Object-N directory", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { "Object 1/content.xml": baseline }); + expect(pkg.parts).toEqual({}); + }); + + it("restores an eligible entry as a real xml part with the exact declaration attributes", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { "settings.xml": baseline }); + const restored = pkg.parts["settings.xml"]; + if (restored?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const [declaration] = restored.nodes; + if (declaration?.type !== "declaration") { + throw new Error("expected a leading declaration node"); + } + expect(declaration.attributes).toEqual([ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ]); + }); + + it("filters non-element nodes out of the parsed residue body, keeping only the real element", () => { + const pkg: Package = { parts: {} }; + writeOdfPackageResidue(pkg, "odt", { + "settings.xml": { format: "odt", xml: "" }, + }); + const restored = pkg.parts["settings.xml"]; + if (restored?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const [, body] = restored.nodes; + expect(body).toEqual({ + type: "element", + tag: "foo", + attributes: [], + children: [], + }); + }); +}); + +function sectionPackage(styles: XmlElement[]): Package { + return { + parts: { + "content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:automatic-styles", {}, styles), + ]), + ], + }, + }, + }; +} + +describe("odfDivisionDescriptor", () => { + it("carries no name property when text:name is absent", () => { + const descriptor = odfDivisionDescriptor( + el("text:section", {}), + sectionPackage([]), + ); + expect(descriptor).not.toHaveProperty("name"); + }); + + it('reads text:protected="false" as an explicit false, not an absent flag', () => { + const descriptor = odfDivisionDescriptor( + el("text:section", { "text:protected": "false" }), + sectionPackage([]), + ); + expect(descriptor.protected).toBe(false); + }); + + it("carries no protected property when text:protected is absent", () => { + const descriptor = odfDivisionDescriptor( + el("text:section", {}), + sectionPackage([]), + ); + expect(descriptor).not.toHaveProperty("protected"); + }); + + it("carries no columnCount property when the section carries no resolvable column count", () => { + const descriptor = odfDivisionDescriptor( + el("text:section", {}), + sectionPackage([]), + ); + expect(descriptor).not.toHaveProperty("columnCount"); + }); + + it("resolves a section's own column count only from the exact style matching both family and name", () => { + const styles = [ + // right name, wrong family -- must not match + el( + "style:style", + { "style:family": "paragraph", "style:name": "Sect1" }, + [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "9" }), + ]), + ], + ), + // right family, wrong name -- must not match + el("style:style", { "style:family": "section", "style:name": "Other" }, [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "9" }), + ]), + ]), + // right family and name -- the real match + el("style:style", { "style:family": "section", "style:name": "Sect1" }, [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "3" }), + ]), + ]), + ]; + const descriptor = odfDivisionDescriptor( + el("text:section", { "text:style-name": "Sect1" }), + sectionPackage(styles), + ); + expect(descriptor.columnCount).toBe(3); + }); + + it("treats a zero column count as no fact", () => { + const styles = [ + el("style:style", { "style:family": "section", "style:name": "Sect1" }, [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "0" }), + ]), + ]), + ]; + const descriptor = odfDivisionDescriptor( + el("text:section", { "text:style-name": "Sect1" }), + sectionPackage(styles), + ); + expect(descriptor).not.toHaveProperty("columnCount"); + }); + + it("treats a negative column count as no fact", () => { + const styles = [ + el("style:style", { "style:family": "section", "style:name": "Sect1" }, [ + el("style:section-properties", {}, [ + el("style:columns", { "fo:column-count": "-5" }), + ]), + ]), + ]; + const descriptor = odfDivisionDescriptor( + el("text:section", { "text:style-name": "Sect1" }), + sectionPackage(styles), + ); + expect(descriptor).not.toHaveProperty("columnCount"); + }); + + it("carries no linked.sectionName when the section-source has no text:section-name", () => { + const descriptor = odfDivisionDescriptor( + el("text:section", {}, [ + el("text:section-source", { "xlink:href": "chapter.odt" }), + ]), + sectionPackage([]), + ); + expect(descriptor.linked).toEqual({ href: "chapter.odt" }); + }); +}); + +describe("odfIndexControlDescriptor", () => { + it("carries no tag property when the wrapper has no text:name", () => { + const descriptor = odfIndexControlDescriptor( + el("text:table-of-content", {}), + ); + expect(descriptor).not.toHaveProperty("tag"); + }); +}); + +function paragraphWithSiblings( + siblings: XmlNode[], + halfIndex: number, +): { paragraph: XmlElement; half: XmlElement } { + const half = el("text:bookmark-start", { "text:name": "b" }); + const children = [...siblings]; + children.splice(halfIndex, 0, half); + const paragraph = el("text:p", {}, children); + return { paragraph, half }; +} + +describe("isContentBearingNode (via odfMarkerHalfEventIndex)", () => { + // A half is judged NOT at a paragraph edge (interior) exactly when a genuinely content-bearing sibling precedes it -- so each case below plants exactly one such sibling before the half and checks the half stops qualifying as "leading". + const contentBearingBefore: { label: string; sibling: XmlNode }[] = [ + { label: "a non-empty text node", sibling: txt("hi") }, + { label: "a field element", sibling: el("text:date", {}) }, + { label: "text:s", sibling: el("text:s", {}) }, + { label: "text:tab", sibling: el("text:tab", {}) }, + { label: "text:line-break", sibling: el("text:line-break", {}) }, + { label: "text:span", sibling: el("text:span", {}) }, + { label: "text:a", sibling: el("text:a", {}) }, + { label: "text:note", sibling: el("text:note", {}) }, + { label: "office:annotation", sibling: el("office:annotation", {}) }, + ]; + + it.each(contentBearingBefore)( + "$label preceding the half, with another one trailing it, makes the half genuinely interior", + ({ sibling }) => { + // A trailing text:span (itself always content-bearing) pins the half off the trailing edge too, so a content-bearing leading sibling is the only thing that can still make this interior (neither leading nor trailing). + const { paragraph, half } = paragraphWithSiblings( + [sibling, el("text:span", {})], + 1, + ); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBeUndefined(); + }, + ); + + it("an empty text node preceding the half does not move it off the leading edge", () => { + const { paragraph, half } = paragraphWithSiblings([txt("")], 1); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBe(5); + }); + + it("a non-content-bearing element (e.g. another bookmark half) preceding the half does not move it off the leading edge", () => { + const decoy = el("text:bookmark-end", { "text:name": "other" }); + const { paragraph, half } = paragraphWithSiblings([decoy], 1); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBe(5); + }); + + it("returns undefined when the half's own recorded parent is not the paragraph passed in", () => { + const other = el("text:p", {}); + const half = el("text:bookmark-start", { "text:name": "b" }); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: other, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + const paragraph = el("text:p", {}, [half]); + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBeUndefined(); + }); + + it("returns undefined when the half element is not actually among its own recorded parent's children", () => { + const paragraph = el("text:p", {}, [txt("x")]); + const orphan = el("text:bookmark-start", { "text:name": "b" }); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: orphan, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBeUndefined(); + }); +}); + +function half(overrides: Partial): OdfMarkerHalf { + const element = el("text:bookmark-start", {}); + return { + kind: "bookmark", + side: "start", + key: "k", + element, + parent: el("text:p", {}), + runPosition: 0, + order: 0, + descriptor: () => undefined, + ...overrides, + }; +} + +describe("pairOdfMarkerHalves", () => { + const paragraph = el("text:p", {}); + + it("drops a key with no start half at all", () => { + const end = half({ side: "end", element: el("text:bookmark-end", {}) }); + const { extents } = pairOdfMarkerHalves([end], paragraph); + expect(extents).toEqual([]); + }); + + it("drops a key with two start halves", () => { + const startA = half({ + side: "start", + element: el("text:bookmark-start", { id: "a" }), + }); + const startB = half({ + side: "start", + element: el("text:bookmark-start", { id: "b" }), + }); + const end = half({ side: "end", element: el("text:bookmark-end", {}) }); + const { extents } = pairOdfMarkerHalves([startA, startB, end], paragraph); + expect(extents).toEqual([]); + }); + + it("drops a key with two end halves", () => { + const start = half({ + side: "start", + element: el("text:bookmark-start", {}), + }); + const endA = half({ + side: "end", + element: el("text:bookmark-end", { id: "a" }), + }); + const endB = half({ + side: "end", + element: el("text:bookmark-end", { id: "b" }), + }); + const { extents } = pairOdfMarkerHalves([start, endA, endB], paragraph); + expect(extents).toEqual([]); + }); + + it("drops a pair whose end precedes its start", () => { + const start = half({ + side: "start", + runPosition: 3, + descriptor: () => ({ kind: "anchor", anchorType: "bookmark", name: "k" }), + }); + const end = half({ + side: "end", + runPosition: 1, + element: el("text:bookmark-end", {}), + }); + const { extents } = pairOdfMarkerHalves([start, end], paragraph); + expect(extents).toEqual([]); + }); + + it("keeps a pair whose end sits at exactly the same run position as its start (a genuine zero-width range)", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const start = half({ + side: "start", + runPosition: 2, + descriptor: () => descriptor, + }); + const end = half({ + side: "end", + runPosition: 2, + element: el("text:bookmark-end", {}), + }); + const { extents } = pairOdfMarkerHalves([start, end], paragraph); + expect(extents).toEqual([{ descriptor, startRun: 2, endRun: 2 }]); + }); + + it("drops a pair whose descriptor resolves to undefined", () => { + const start = half({ side: "start", descriptor: () => undefined }); + const end = half({ side: "end", element: el("text:bookmark-end", {}) }); + const { extents } = pairOdfMarkerHalves([start, end], paragraph); + expect(extents).toEqual([]); + }); + + it("marks both halves of a completed pair as paired, not only the start", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const startElement = el("text:bookmark-start", {}); + const endElement = el("text:bookmark-end", {}); + const start = half({ + side: "start", + element: startElement, + descriptor: () => descriptor, + }); + const end = half({ side: "end", element: endElement }); + const { paired } = pairOdfMarkerHalves([start, end], paragraph); + expect(paired.has(startElement)).toBe(true); + expect(paired.has(endElement)).toBe(true); + }); + + it("drops a pair whose both halves are block-scoped (that is the block-marker path's own extent)", () => { + const startElement = el("text:bookmark-start", {}); + const endElement = el("text:bookmark-end", {}); + const container = el("text:p", {}, [startElement, endElement]); + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const start = half({ + side: "start", + element: startElement, + parent: container, + runPosition: 0, + descriptor: () => descriptor, + }); + const end = half({ + side: "end", + element: endElement, + parent: container, + runPosition: 0, + }); + const { extents } = pairOdfMarkerHalves([start, end], container); + expect(extents).toEqual([]); + }); +}); + +function event(overrides: Partial): OdfMarkerEvent { + return { + kind: "bookmark", + side: "start", + key: "k", + index: 0, + qualified: true, + order: 0, + descriptor: () => undefined, + element: el("text:bookmark-start", {}), + ...overrides, + }; +} + +describe("resolveOdfMarkerEvents", () => { + it("drops a key with no qualified start", () => { + const end = event({ side: "end", element: el("text:bookmark-end", {}) }); + const { extents } = resolveOdfMarkerEvents([end]); + expect(extents).toEqual([]); + }); + + it("drops a key with two qualified starts", () => { + const startA = event({ element: el("text:bookmark-start", { id: "a" }) }); + const startB = event({ element: el("text:bookmark-start", { id: "b" }) }); + const end = event({ side: "end", element: el("text:bookmark-end", {}) }); + const { extents } = resolveOdfMarkerEvents([startA, startB, end]); + expect(extents).toEqual([]); + }); + + it("drops a key with two qualified ends", () => { + const start = event({}); + const endA = event({ + side: "end", + element: el("text:bookmark-end", { id: "a" }), + }); + const endB = event({ + side: "end", + element: el("text:bookmark-end", { id: "b" }), + }); + const { extents } = resolveOdfMarkerEvents([start, endA, endB]); + expect(extents).toEqual([]); + }); + + it("drops a pair whose end index precedes its start index", () => { + const start = event({ + index: 3, + descriptor: () => ({ kind: "anchor", anchorType: "bookmark", name: "k" }), + }); + const end = event({ + side: "end", + index: 1, + element: el("text:bookmark-end", {}), + }); + const { extents } = resolveOdfMarkerEvents([start, end]); + expect(extents).toEqual([]); + }); + + it("keeps a pair whose end index equals its start index (a point extent)", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const start = event({ index: 4, order: 7, descriptor: () => descriptor }); + const end = event({ + side: "end", + index: 4, + element: el("text:bookmark-end", {}), + }); + const { extents } = resolveOdfMarkerEvents([start, end]); + expect(extents).toEqual([ + { startIndex: 4, endIndex: 4, order: 7, descriptor }, + ]); + }); + + it("marks both halves of a completed pair as paired, not only the start", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + const startElement = el("text:bookmark-start", {}); + const endElement = el("text:bookmark-end", {}); + const start = event({ + element: startElement, + descriptor: () => descriptor, + }); + const end = event({ side: "end", element: endElement }); + const { paired } = resolveOdfMarkerEvents([start, end]); + expect(paired.has(startElement)).toBe(true); + expect(paired.has(endElement)).toBe(true); + }); +}); + +describe("insertOdfConstructMarkers", () => { + it("returns every defined block, unmodified, when there are no extents at all", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "a" }] }, + { kind: "paragraph", runs: [{ text: "b" }] }, + ]; + expect(insertOdfConstructMarkers(blocks, [])).toEqual(blocks); + }); + + it("orders two constructs opening at the same index outermost-first, by descending end index", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "a" }] }, + ]; + const outer: OdfConstructExtent = { + startIndex: 0, + endIndex: 1, + order: 0, + descriptor: { kind: "division" }, + }; + const inner: OdfConstructExtent = { + startIndex: 0, + endIndex: 0, + order: 1, + descriptor: { kind: "division", name: "inner" }, + }; + const result = insertOdfConstructMarkers(blocks, [outer, inner]); + // The outer extent (endIndex 1) must open before the inner one (endIndex 0), which itself closes immediately (a point extent) before the outer's own block. + expect(result).toEqual([ + { kind: "constructStart", descriptor: outer.descriptor }, + { kind: "constructStart", descriptor: inner.descriptor }, + { kind: "constructEnd" }, + blocks[0], + { kind: "constructEnd" }, + ]); + }); +}); + +describe("collectOdfProvenanceRegions", () => { + it("skips a text:changed-region whose only child is not a recognised change kind", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("text:format-change-irrelevant", {}), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.size).toBe(0); + }); + + it("finds the real change element by its own tag, not merely the first child element", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("some:decoy", {}), + el("text:insertion", {}), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.get("r1")?.change).toBe("insertion"); + }); + + it("finds office:change-info by its own tag, not merely the first child of the change element", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("text:insertion", {}, [ + el("some:decoy", {}), + el("office:change-info", {}, [ + el("dc:creator", {}, [txt("Real Author")]), + ]), + ]), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.get("r1")?.author).toBe("Real Author"); + }); + + it("finds dc:creator by its own tag, not merely the first child of office:change-info", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("text:insertion", {}, [ + el("office:change-info", {}, [ + el("some:decoy", {}), + el("dc:creator", {}, [txt("Real Author")]), + ]), + ]), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.get("r1")?.author).toBe("Real Author"); + }); + + it("finds dc:date by its own tag, not merely the first child of office:change-info", () => { + const region = el("text:changed-region", { "xml:id": "r1" }, [ + el("text:insertion", {}, [ + el("office:change-info", {}, [ + el("some:decoy", {}), + el("dc:date", {}, [txt("2024-01-01T00:00:00Z")]), + ]), + ]), + ]); + const out = new Map(); + collectOdfProvenanceRegions([region], out); + expect(out.get("r1")?.dateIso).toBe("2024-01-01T00:00:00Z"); + }); +}); + +describe("collectOdfFieldMasterDefinitions (readOdfFieldMasterEntry)", () => { + it("skips a declaration with no text:name at all", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [el("text:variable-decls", {}, [el("text:variable-decl", {})])], + out, + ); + expect(out).toEqual({}); + }); + + it("carries no valueType/value/stringValue/formula when their attributes are absent", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [ + el("text:variable-decls", {}, [ + el("text:variable-decl", { "text:name": "v1" }), + ]), + ], + out, + ); + const entry = out["variable:v1"]; + expect(entry).not.toHaveProperty("valueType"); + expect(entry).not.toHaveProperty("value"); + expect(entry).not.toHaveProperty("stringValue"); + expect(entry).not.toHaveProperty("formula"); + }); + + it("reads office:string-value specifically, not some other attribute", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [ + el("text:variable-decls", {}, [ + el("text:variable-decl", { + "text:name": "v1", + "office:string-value": "hello", + }), + ]), + ], + out, + ); + expect(out["variable:v1"]?.stringValue).toBe("hello"); + }); + + it("omits displayOutlineLevel for a negative value", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [ + el("text:sequence-decls", {}, [ + el("text:sequence-decl", { + "text:name": "s1", + "text:display-outline-level": "-1", + }), + ]), + ], + out, + ); + expect(out["sequence:s1"]).not.toHaveProperty("displayOutlineLevel"); + }); + + it("keeps displayOutlineLevel for a valid non-negative integer", () => { + const out: Record = {}; + collectOdfFieldMasterDefinitions( + [ + el("text:sequence-decls", {}, [ + el("text:sequence-decl", { + "text:name": "s1", + "text:display-outline-level": "2", + }), + ]), + ], + out, + ); + expect(out["sequence:s1"]).toHaveProperty("displayOutlineLevel", 2); + }); +}); + +describe("collectOdfDataStyleDefinitions", () => { + it("skips a data style element with no style:name", () => { + const out: Record = {}; + collectOdfDataStyleDefinitions([el("number:date-style", {})], out); + expect(out).toEqual({}); + }); +}); + +describe("collectOdfFontFaceDefinitions", () => { + it("skips a font face with a name but no font family", () => { + const out: Record = {}; + collectOdfFontFaceDefinitions( + [el("style:font-face", { "style:name": "F1" })], + out, + ); + expect(out).toEqual({}); + }); + + it("skips a font face with a font family but no name", () => { + const out: Record = {}; + collectOdfFontFaceDefinitions( + [el("style:font-face", { "svg:font-family": "Arial" })], + out, + ); + expect(out).toEqual({}); + }); + + it("carries no familyGeneric/pitch when their attributes are absent", () => { + const out: Record = {}; + collectOdfFontFaceDefinitions( + [ + el("style:font-face", { + "style:name": "F1", + "svg:font-family": "Arial", + }), + ], + out, + ); + const entry = out["fontFace:F1"]; + expect(entry).not.toHaveProperty("familyGeneric"); + expect(entry).not.toHaveProperty("pitch"); + }); +}); + +describe("collectOdfNamedExpressions", () => { + it("skips a child with no table:name", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [el("table:named-expressions", {}, [el("table:named-range", {})])], + out, + ); + expect(out).toEqual({}); + }); + + it("skips a named-range with no table:cell-range-address", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-range", { "table:name": "n1" }), + ]), + ], + out, + ); + expect(out).toEqual({}); + }); + + it("carries no baseCellAddress for a named-range when it is absent", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-range", { + "table:name": "n1", + "table:cell-range-address": "$A$1:$A$2", + }), + ]), + ], + out, + ); + expect(out["named-range:n1"]).not.toHaveProperty("baseCellAddress"); + }); + + it("carries baseCellAddress for a named-range when present", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-range", { + "table:name": "n1", + "table:cell-range-address": "$A$1:$A$2", + "table:base-cell-address": "$A$1", + }), + ]), + ], + out, + ); + expect(out["named-range:n1"]?.baseCellAddress).toBe("$A$1"); + }); + + it("does not treat a child of neither known tag as a named-expression even when it carries an expression attribute", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:not-a-real-tag", { + "table:name": "n1", + "table:expression": "1+1", + }), + ]), + ], + out, + ); + expect(out).toEqual({}); + }); + + it("skips a named-expression with no table:expression", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-expression", { "table:name": "e1" }), + ]), + ], + out, + ); + expect(out).toEqual({}); + }); + + it("carries baseCellAddress for a named-expression when present", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-expression", { + "table:name": "e1", + "table:expression": "1+1", + "table:base-cell-address": "$A$1", + }), + ]), + ], + out, + ); + expect(out["named-expression:e1"]?.baseCellAddress).toBe("$A$1"); + }); +}); + +describe("parseOdfFieldInstruction", () => { + it("finds the real top-level element, skipping a leading non-element node", () => { + const element = parseOdfFieldInstruction(''); + expect(element.tag).toBe("foo"); + }); +}); + +describe("canonicalOdfConstructDescriptor", () => { + it("passes a non-index contentControl through unchanged", () => { + const descriptor: ConstructDescriptor = { + kind: "contentControl", + controlType: "sdt", + }; + expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); + }); + + it("passes an index contentControl with no source through unchanged, without dereferencing a source that isn't there", () => { + const descriptor: ConstructDescriptor = { + kind: "contentControl", + controlType: "index", + }; + expect(() => canonicalOdfConstructDescriptor(descriptor)).not.toThrow(); + expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); + }); +}); + +describe("writeOdfTrackedChanges", () => { + it("wraps every region in a text:tracked-changes element, spelled exactly", () => { + expect(writeOdfTrackedChanges([]).tag).toBe("text:tracked-changes"); + }); + + it("writes no office:change-info at all when neither author nor date is present", () => { + const region = el("text:tracked-changes-region-probe", {}); + const result = writeOdfTrackedChanges([ + { id: "r1", descriptor: { kind: "provenance", change: "insertion" } }, + ]); + const [regionEl] = result.children; + if (regionEl?.type !== "element") { + throw new Error("expected a text:changed-region element"); + } + const [changeEl] = regionEl.children; + if (changeEl?.type !== "element") { + throw new Error("expected a change element"); + } + expect(changeEl.children).toEqual([]); + void region; + }); +}); + +describe("odfRunConstructWriteKind", () => { + function extent( + descriptor: RunConstructExtent["descriptor"], + startRun: number, + endRun: number, + ): RunConstructExtent { + return { descriptor, startRun, endRun }; + } + + it("distinguishes a zero-width bookmark point from a ranged bookmark", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "b", + }; + expect(odfRunConstructWriteKind(extent(descriptor, 2, 2))).toBe( + "bookmarkPoint", + ); + expect(odfRunConstructWriteKind(extent(descriptor, 2, 4))).toBe( + "bookmarkRange", + ); + }); + + it("refuses a footnote anchor with no definition key at all", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "footnote", + name: "n1", + }; + expect( + odfRunConstructWriteKind(extent(descriptor, 0, 0), {}), + ).toBeUndefined(); + }); + + it("refuses a footnote anchor whose definition key is not actually in the definitions table", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "footnote", + name: "n1", + definition: "note:n1", + }; + expect( + odfRunConstructWriteKind(extent(descriptor, 0, 0), {}), + ).toBeUndefined(); + }); + + it("writes a footnote as note and a comment as comment, once their definition resolves", () => { + const noteDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "footnote", + name: "n1", + definition: "note:n1", + }; + const commentDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "comment", + name: "c1", + definition: "comment:c1", + }; + const definitions: Record = { + "note:n1": { kind: "footnote", body: [] }, + "comment:c1": { kind: "comment", body: [] }, + }; + expect( + odfRunConstructWriteKind(extent(noteDescriptor, 0, 0), definitions), + ).toBe("note"); + expect( + odfRunConstructWriteKind(extent(commentDescriptor, 0, 0), definitions), + ).toBe("comment"); + }); + + it("refuses a moveFrom/moveTo provenance change (no ODF spelling exists for either)", () => { + const moveFrom: RunConstructExtent["descriptor"] = { + kind: "provenance", + change: "moveFrom", + }; + const moveTo: RunConstructExtent["descriptor"] = { + kind: "provenance", + change: "moveTo", + }; + const changeIds = new Map([ + [moveFrom, "id1"], + [moveTo, "id2"], + ]); + expect( + odfRunConstructWriteKind(extent(moveFrom, 0, 0), {}, changeIds), + ).toBeUndefined(); + expect( + odfRunConstructWriteKind(extent(moveTo, 0, 0), {}, changeIds), + ).toBeUndefined(); + }); + + it("refuses a provenance change with no minted region id at all", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "provenance", + change: "insertion", + }; + expect( + odfRunConstructWriteKind(extent(descriptor, 0, 0), {}), + ).toBeUndefined(); + }); + + it("distinguishes a zero-width change point from a ranged change, once minted", () => { + const descriptor: RunConstructExtent["descriptor"] = { + kind: "provenance", + change: "insertion", + }; + const changeIds = new Map([ + [descriptor, "id1"], + ]); + expect( + odfRunConstructWriteKind(extent(descriptor, 3, 3), {}, changeIds), + ).toBe("changePoint"); + expect( + odfRunConstructWriteKind(extent(descriptor, 3, 5), {}, changeIds), + ).toBe("changeRange"); + }); +}); + +describe("writeOdfDivision", () => { + it("carries no text:section-name when the linked division names none", () => { + const descriptor: DivisionDescriptor = { + kind: "division", + linked: { href: "chapter.odt" }, + }; + const result = writeOdfDivision(descriptor, [], { + mintSectionStyleName: () => "S1", + }); + const sourceEl = result.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "text:section-source", + ); + expect(sourceEl?.attributes).toEqual([ + { name: "xlink:type", value: "simple" }, + { name: "xlink:href", value: "chapter.odt" }, + ]); + }); +}); + +describe("odfIndexWrapperTag / writeOdfIndexWrapper", () => { + function descriptorWithSourceXml(xml: string): ContentControlDescriptor { + return { + kind: "contentControl", + controlType: "index", + source: { format: "odt", xml }, + }; + } + + it("finds the real *-source element, skipping a leading non-element node", () => { + expect( + odfIndexWrapperTag( + descriptorWithSourceXml(""), + ), + ).toBe("text:table-of-content"); + }); + + it("throws when the residue's own top-level element does not end in -source", () => { + expect(() => + odfIndexWrapperTag(descriptorWithSourceXml("")), + ).toThrow(); + }); + + it("throws when the stripped tag is not one of the seven recognised index wrapper tags", () => { + expect(() => + odfIndexWrapperTag(descriptorWithSourceXml("")), + ).toThrow(); + }); + + it("writes the bare *-source child and the recovered wrapper tag together", () => { + const descriptor = descriptorWithSourceXml( + "", + ); + const result = writeOdfIndexWrapper(descriptor, []); + expect(result.tag).toBe("text:table-of-content"); + expect( + result.children.some( + (child) => + child.type === "element" && + child.tag === "text:table-of-content-source", + ), + ).toBe(true); + }); +}); From d81e61fc64142d21f26605028658c5950201e6ff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 13:02:56 +0100 Subject: [PATCH 57/89] test(odf.js): fix ContentControlDescriptor fixture's controlType value "sdt" is not a member of ContentControlType; use "richText", a real non-index control type, for the pass-through-unchanged test case. --- packages/odf.js/src/typed/shared/constructs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/odf.js/src/typed/shared/constructs.test.ts b/packages/odf.js/src/typed/shared/constructs.test.ts index eefcc5529..276ac0fc7 100644 --- a/packages/odf.js/src/typed/shared/constructs.test.ts +++ b/packages/odf.js/src/typed/shared/constructs.test.ts @@ -960,7 +960,7 @@ describe("canonicalOdfConstructDescriptor", () => { it("passes a non-index contentControl through unchanged", () => { const descriptor: ConstructDescriptor = { kind: "contentControl", - controlType: "sdt", + controlType: "richText", }; expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); }); From 39ffd41f44f2e0dd4722649f8f42afb7f1702f71 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 13:17:49 +0100 Subject: [PATCH 58/89] test(odf.js): kill typed/shared/constructs.ts's remaining survivors A first verification pass against the earlier direct-coverage batch left 20 mutants standing, every one masked by an inner guard, a default-descriptor fallback, or vitest's own toEqual treating an explicit undefined property the same as an absent one, rather than by missing coverage outright: - toHaveProperty checks replace toEqual wherever the assertion needs to distinguish a genuinely absent optional field from one explicitly set to undefined. - The paragraph-edge guard test for a mismatched half.parent now uses a parent that genuinely contains the half element, so a bypassed guard would actually reach edgePosition instead of being caught by the unrelated indexOf === -1 check right after it. - Every pairOdfMarkerHalves/resolveOdfMarkerEvents start/end-count test now gives its halves a real resolving descriptor, so a bypassed length check would actually build an extent rather than being masked by the unrelated "descriptor resolved to undefined" guard further down. - insertOdfConstructMarkers' sort-order test now passes its two extents in the wrong initial order, so a collapsed comparator (a stable sort's no-op) is distinguishable from a real sort. - collectOdfNamedExpressions' missing-name test now supplies a real cell-range-address, and gains the named-expression-side counterpart of its existing named-range baseCellAddress-absent test. - canonicalOdfConstructDescriptor and odfRunConstructWriteKind each gain a test proving their own compound guard's clauses are independently load-bearing, not merely redundant with each other. - odfIndexWrapperTag's -source suffix check gains a case (a tag whose last seven characters are NOT "-source" but whose first characters, once blindly sliced, spell a real wrapper tag by coincidence) that a weakened endsWith check would silently accept. --- .../src/typed/shared/constructs.test.ts | 150 ++++++++++++++++-- 1 file changed, 135 insertions(+), 15 deletions(-) diff --git a/packages/odf.js/src/typed/shared/constructs.test.ts b/packages/odf.js/src/typed/shared/constructs.test.ts index 276ac0fc7..cf8a786ea 100644 --- a/packages/odf.js/src/typed/shared/constructs.test.ts +++ b/packages/odf.js/src/typed/shared/constructs.test.ts @@ -264,7 +264,7 @@ describe("odfDivisionDescriptor", () => { ]), sectionPackage([]), ); - expect(descriptor.linked).toEqual({ href: "chapter.odt" }); + expect(descriptor.linked).not.toHaveProperty("sectionName"); }); }); @@ -355,9 +355,10 @@ describe("isContentBearingNode (via odfMarkerHalfEventIndex)", () => { expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBe(5); }); - it("returns undefined when the half's own recorded parent is not the paragraph passed in", () => { - const other = el("text:p", {}); + it("returns undefined when the half's own recorded parent is not the paragraph passed in, even though the half is a genuine child of that other parent", () => { + // The half's own recorded parent is a real container that DOES hold it as a child (so a bypassed guard would not accidentally bail out on the later indexOf === -1 check instead) -- only the mismatch against the paragraph argument itself should short-circuit this. const half = el("text:bookmark-start", { "text:name": "b" }); + const other = el("text:p", {}, [half]); const marker: OdfMarkerHalf = { kind: "bookmark", side: "start", @@ -368,7 +369,7 @@ describe("isContentBearingNode (via odfMarkerHalfEventIndex)", () => { order: 0, descriptor: () => undefined, }; - const paragraph = el("text:p", {}, [half]); + const paragraph = el("text:p", {}); expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBeUndefined(); }); @@ -407,8 +408,19 @@ function half(overrides: Partial): OdfMarkerHalf { describe("pairOdfMarkerHalves", () => { const paragraph = el("text:p", {}); + // Every start/end half below carries a genuinely RESOLVING descriptor -- so if a bypassed length check let the pairing proceed anyway, it would actually build an extent from starts[0]/ends[0], not merely fall through some other guard (an unresolved descriptor) that would mask the very check under test. + const resolvingDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + it("drops a key with no start half at all", () => { - const end = half({ side: "end", element: el("text:bookmark-end", {}) }); + const end = half({ + side: "end", + element: el("text:bookmark-end", {}), + descriptor: () => resolvingDescriptor, + }); const { extents } = pairOdfMarkerHalves([end], paragraph); expect(extents).toEqual([]); }); @@ -417,12 +429,18 @@ describe("pairOdfMarkerHalves", () => { const startA = half({ side: "start", element: el("text:bookmark-start", { id: "a" }), + descriptor: () => resolvingDescriptor, }); const startB = half({ side: "start", element: el("text:bookmark-start", { id: "b" }), + descriptor: () => resolvingDescriptor, + }); + const end = half({ + side: "end", + element: el("text:bookmark-end", {}), + descriptor: () => resolvingDescriptor, }); - const end = half({ side: "end", element: el("text:bookmark-end", {}) }); const { extents } = pairOdfMarkerHalves([startA, startB, end], paragraph); expect(extents).toEqual([]); }); @@ -431,14 +449,17 @@ describe("pairOdfMarkerHalves", () => { const start = half({ side: "start", element: el("text:bookmark-start", {}), + descriptor: () => resolvingDescriptor, }); const endA = half({ side: "end", element: el("text:bookmark-end", { id: "a" }), + descriptor: () => resolvingDescriptor, }); const endB = half({ side: "end", element: el("text:bookmark-end", { id: "b" }), + descriptor: () => resolvingDescriptor, }); const { extents } = pairOdfMarkerHalves([start, endA, endB], paragraph); expect(extents).toEqual([]); @@ -547,29 +568,52 @@ function event(overrides: Partial): OdfMarkerEvent { } describe("resolveOdfMarkerEvents", () => { + // Every start/end event below carries a genuinely RESOLVING descriptor -- so if a bypassed length check let the pairing proceed anyway, it would actually build an extent from starts[0]/ends[0], not merely fall through some other guard (an unresolved descriptor) that would mask the very check under test. + const resolvingDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "bookmark", + name: "k", + }; + it("drops a key with no qualified start", () => { - const end = event({ side: "end", element: el("text:bookmark-end", {}) }); + const end = event({ + side: "end", + element: el("text:bookmark-end", {}), + descriptor: () => resolvingDescriptor, + }); const { extents } = resolveOdfMarkerEvents([end]); expect(extents).toEqual([]); }); it("drops a key with two qualified starts", () => { - const startA = event({ element: el("text:bookmark-start", { id: "a" }) }); - const startB = event({ element: el("text:bookmark-start", { id: "b" }) }); - const end = event({ side: "end", element: el("text:bookmark-end", {}) }); + const startA = event({ + element: el("text:bookmark-start", { id: "a" }), + descriptor: () => resolvingDescriptor, + }); + const startB = event({ + element: el("text:bookmark-start", { id: "b" }), + descriptor: () => resolvingDescriptor, + }); + const end = event({ + side: "end", + element: el("text:bookmark-end", {}), + descriptor: () => resolvingDescriptor, + }); const { extents } = resolveOdfMarkerEvents([startA, startB, end]); expect(extents).toEqual([]); }); it("drops a key with two qualified ends", () => { - const start = event({}); + const start = event({ descriptor: () => resolvingDescriptor }); const endA = event({ side: "end", element: el("text:bookmark-end", { id: "a" }), + descriptor: () => resolvingDescriptor, }); const endB = event({ side: "end", element: el("text:bookmark-end", { id: "b" }), + descriptor: () => resolvingDescriptor, }); const { extents } = resolveOdfMarkerEvents([start, endA, endB]); expect(extents).toEqual([]); @@ -651,7 +695,8 @@ describe("insertOdfConstructMarkers", () => { order: 1, descriptor: { kind: "division", name: "inner" }, }; - const result = insertOdfConstructMarkers(blocks, [outer, inner]); + // Passed inner-first, deliberately the wrong order, so a real sort is what puts the outer extent ahead of the inner one -- a comparator collapsed to always-equal (a stable sort's no-op) would leave this input order untouched instead. + const result = insertOdfConstructMarkers(blocks, [inner, outer]); // The outer extent (endIndex 1) must open before the inner one (endIndex 0), which itself closes immediately (a point extent) before the outer's own block. expect(result).toEqual([ { kind: "constructStart", descriptor: outer.descriptor }, @@ -847,10 +892,15 @@ describe("collectOdfFontFaceDefinitions", () => { }); describe("collectOdfNamedExpressions", () => { - it("skips a child with no table:name", () => { + it("skips a child with no table:name, even though it is otherwise complete enough to mint an entry", () => { + // table:cell-range-address is present so a bypassed name guard would actually reach out[...] = entry, rather than being masked by the inner "no cell-range-address" guard further down. const out: Record = {}; collectOdfNamedExpressions( - [el("table:named-expressions", {}, [el("table:named-range", {})])], + [ + el("table:named-expressions", {}, [ + el("table:named-range", { "table:cell-range-address": "$A$1:$A$2" }), + ]), + ], out, ); expect(out).toEqual({}); @@ -918,6 +968,22 @@ describe("collectOdfNamedExpressions", () => { expect(out).toEqual({}); }); + it("carries no baseCellAddress for a named-expression when it is absent", () => { + const out: Record = {}; + collectOdfNamedExpressions( + [ + el("table:named-expressions", {}, [ + el("table:named-expression", { + "table:name": "e1", + "table:expression": "1+1", + }), + ]), + ], + out, + ); + expect(out["named-expression:e1"]).not.toHaveProperty("baseCellAddress"); + }); + it("skips a named-expression with no table:expression", () => { const out: Record = {}; collectOdfNamedExpressions( @@ -973,6 +1039,26 @@ describe("canonicalOdfConstructDescriptor", () => { expect(() => canonicalOdfConstructDescriptor(descriptor)).not.toThrow(); expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); }); + + it("passes a non-index contentControl through unchanged even when it does carry a source (the controlType check is its own real gate, not implied by the source check alone)", () => { + const descriptor: ConstructDescriptor = { + kind: "contentControl", + controlType: "richText", + source: { format: "odt", xml: "" }, + }; + expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); + }); + + it("passes a non-contentControl descriptor through unchanged even when it happens to carry contentControl-shaped fields (the kind check is its own real gate, not implied by the controlType/source checks alone)", () => { + const descriptor = { + kind: "anchor", + anchorType: "bookmark", + name: "b", + controlType: "index", + source: { format: "odt", xml: "" }, + } as unknown as ConstructDescriptor; + expect(canonicalOdfConstructDescriptor(descriptor)).toEqual(descriptor); + }); }); describe("writeOdfTrackedChanges", () => { @@ -1044,13 +1130,19 @@ describe("odfRunConstructWriteKind", () => { ).toBeUndefined(); }); - it("writes a footnote as note and a comment as comment, once their definition resolves", () => { + it("writes a footnote, an endnote, and a comment as note/note/comment, once their definition resolves", () => { const noteDescriptor: RunConstructExtent["descriptor"] = { kind: "anchor", anchorType: "footnote", name: "n1", definition: "note:n1", }; + const endnoteDescriptor: RunConstructExtent["descriptor"] = { + kind: "anchor", + anchorType: "endnote", + name: "n2", + definition: "note:n2", + }; const commentDescriptor: RunConstructExtent["descriptor"] = { kind: "anchor", anchorType: "comment", @@ -1059,16 +1151,35 @@ describe("odfRunConstructWriteKind", () => { }; const definitions: Record = { "note:n1": { kind: "footnote", body: [] }, + "note:n2": { kind: "endnote", body: [] }, "comment:c1": { kind: "comment", body: [] }, }; expect( odfRunConstructWriteKind(extent(noteDescriptor, 0, 0), definitions), ).toBe("note"); + expect( + odfRunConstructWriteKind(extent(endnoteDescriptor, 0, 0), definitions), + ).toBe("note"); expect( odfRunConstructWriteKind(extent(commentDescriptor, 0, 0), definitions), ).toBe("comment"); }); + it("refuses an anchor whose type is none of footnote/endnote/comment, even with a resolving definition (bookmark is excluded by the earlier branch; nothing else in AnchorType reaches this far)", () => { + const descriptor = { + kind: "anchor", + anchorType: "notARealAnchorType", + name: "n1", + definition: "note:n1", + } as unknown as RunConstructExtent["descriptor"]; + const definitions: Record = { + "note:n1": { kind: "footnote", body: [] }, + }; + expect( + odfRunConstructWriteKind(extent(descriptor, 0, 0), definitions), + ).toBeUndefined(); + }); + it("refuses a moveFrom/moveTo provenance change (no ODF spelling exists for either)", () => { const moveFrom: RunConstructExtent["descriptor"] = { kind: "provenance", @@ -1160,6 +1271,15 @@ describe("odfIndexWrapperTag / writeOdfIndexWrapper", () => { ).toThrow(); }); + it("really checks for the -source suffix specifically, not merely that the tag has some suffix", () => { + // Blindly slicing the last 7 characters off "text:bibliography-sourcX" (a tag that does NOT end in "-source") lands exactly on the real "text:bibliography" wrapper tag -- so a weakened endsWith check that let this through would silently succeed instead of throwing. + expect(() => + odfIndexWrapperTag( + descriptorWithSourceXml(""), + ), + ).toThrow(); + }); + it("throws when the stripped tag is not one of the seven recognised index wrapper tags", () => { expect(() => odfIndexWrapperTag(descriptorWithSourceXml("")), From 58dc0155408e4b59609eee2bf92949b62b932d2f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 13:33:13 +0100 Subject: [PATCH 59/89] test(odf.js): cover typed/shared/constructs.ts's remaining no-coverage branches The second verification pass left one Survived mutant and nine NoCoverage ones -- code paths no existing test (direct or indirect) ever exercised at all: - compareOdfExtents' start-index comparison had no test proving it is the PRIMARY sort key rather than merely correlated with the end-index one the existing outermost-first test already covers; adds a case where the two clauses would sort oppositely. - addOdfPackageResidue's concatenation branch (a key that already holds a value) had no test at all -- every existing caller only ever added a fresh key. - isContentBearingNode's catch-all false for a non-text, non-element node (a comment, in practice) had no test -- every existing fixture built only text and element nodes. - parseOdfFieldInstruction's own throw, writeOdfChangePoint, and writeOdfAnnotationHalf's dateIso branch had no direct test at all. - writeOdfTrackedChanges' defensive throw for a change kind with no ODF region spelling (moveFrom/moveTo, refused by every real caller before reaching this function) is exercised directly, past the type's own restriction to insertion/deletion/formatChange. --- .../src/typed/shared/constructs.test.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/packages/odf.js/src/typed/shared/constructs.test.ts b/packages/odf.js/src/typed/shared/constructs.test.ts index cf8a786ea..a8b7a4dff 100644 --- a/packages/odf.js/src/typed/shared/constructs.test.ts +++ b/packages/odf.js/src/typed/shared/constructs.test.ts @@ -13,6 +13,7 @@ import type { Package } from "../../model/package"; import type { XmlElement, XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { + addOdfPackageResidue, canonicalOdfConstructDescriptor, collectOdfDataStyleDefinitions, collectOdfFieldMasterDefinitions, @@ -30,6 +31,8 @@ import { pairOdfMarkerHalves, parseOdfFieldInstruction, resolveOdfMarkerEvents, + writeOdfAnnotationHalf, + writeOdfChangePoint, writeOdfDivision, writeOdfIndexWrapper, writeOdfPackageResidue, @@ -61,6 +64,16 @@ describe("isEmbeddedObjectPart", () => { }); }); +describe("addOdfPackageResidue", () => { + it("concatenates onto an already-existing key rather than overwriting it", () => { + const out: Record = { + k: { format: "odt", xml: "" }, + }; + addOdfPackageResidue(out, "k", "odt", el("b", {})); + expect(out.k?.xml).toBe(""); + }); +}); + describe("collectOdfNonContentPartResidue", () => { it("quarantines nothing for a non-content part whose nodes carry no element at all", () => { const pkg = part([{ type: "declaration", attributes: [] }]); @@ -355,6 +368,22 @@ describe("isContentBearingNode (via odfMarkerHalfEventIndex)", () => { expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBe(5); }); + it("a comment node (neither text nor element) preceding the half does not move it off the leading edge", () => { + const comment: XmlNode = { type: "comment", value: "c" }; + const { paragraph, half } = paragraphWithSiblings([comment], 1); + const marker: OdfMarkerHalf = { + kind: "bookmark", + side: "start", + key: "b", + element: half, + parent: paragraph, + runPosition: 0, + order: 0, + descriptor: () => undefined, + }; + expect(odfMarkerHalfEventIndex(marker, paragraph, 5)).toBe(5); + }); + it("returns undefined when the half's own recorded parent is not the paragraph passed in, even though the half is a genuine child of that other parent", () => { // The half's own recorded parent is a real container that DOES hold it as a child (so a bypassed guard would not accidentally bail out on the later indexOf === -1 check instead) -- only the mismatch against the paragraph argument itself should short-circuit this. const half = el("text:bookmark-start", { "text:name": "b" }); @@ -706,6 +735,35 @@ describe("insertOdfConstructMarkers", () => { { kind: "constructEnd" }, ]); }); + + it("sorts primarily by ascending start index, not merely by end index", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "a" }] }, + { kind: "paragraph", runs: [{ text: "b" }] }, + ]; + // A long-running extent starting first but ending LAST, and a short point extent starting second but ending FIRST -- a comparator that fell back to comparing end index (as it would if the start-index clause were dropped from the OR chain) would sort these in the opposite order, and would additionally reject the long extent outright as improperly nested inside the point extent. + const long: OdfConstructExtent = { + startIndex: 0, + endIndex: 2, + order: 0, + descriptor: { kind: "division" }, + }; + const point: OdfConstructExtent = { + startIndex: 1, + endIndex: 1, + order: 0, + descriptor: { kind: "division", name: "point" }, + }; + const result = insertOdfConstructMarkers(blocks, [point, long]); + expect(result).toEqual([ + { kind: "constructStart", descriptor: long.descriptor }, + blocks[0], + { kind: "constructStart", descriptor: point.descriptor }, + { kind: "constructEnd" }, + blocks[1], + { kind: "constructEnd" }, + ]); + }); }); describe("collectOdfProvenanceRegions", () => { @@ -1020,6 +1078,12 @@ describe("parseOdfFieldInstruction", () => { const element = parseOdfFieldInstruction(''); expect(element.tag).toBe("foo"); }); + + it("throws when the instruction parses back to no element at all", () => { + expect(() => parseOdfFieldInstruction("")).toThrow( + /did not parse back to a single element/, + ); + }); }); describe("canonicalOdfConstructDescriptor", () => { @@ -1061,11 +1125,68 @@ describe("canonicalOdfConstructDescriptor", () => { }); }); +describe("writeOdfChangePoint", () => { + it("writes the exact point-change element and id", () => { + const result = writeOdfChangePoint("id1"); + expect(result.tag).toBe("text:change"); + expect(result.attributes).toEqual([ + { name: "text:change-id", value: "id1" }, + ]); + }); +}); + +describe("writeOdfAnnotationHalf", () => { + it("writes a dc:date child when the entry carries a dateIso", () => { + const result = writeOdfAnnotationHalf( + { name: "c1" }, + { kind: "comment", body: [], dateIso: "2024-01-01T00:00:00Z" }, + ); + const dateEl = result.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "dc:date", + ); + if (dateEl === undefined) { + throw new Error("expected a dc:date child"); + } + const [text] = dateEl.children; + expect(text).toEqual({ type: "text", value: "2024-01-01T00:00:00Z" }); + }); + + it("writes no dc:date child when the entry carries no dateIso", () => { + const result = writeOdfAnnotationHalf( + { name: "c1" }, + { kind: "comment", body: [] }, + ); + expect( + result.children.some( + (child) => child.type === "element" && child.tag === "dc:date", + ), + ).toBe(false); + }); +}); + describe("writeOdfTrackedChanges", () => { it("wraps every region in a text:tracked-changes element, spelled exactly", () => { expect(writeOdfTrackedChanges([]).tag).toBe("text:tracked-changes"); }); + it("throws for a change kind with no ODF region spelling (moveFrom/moveTo, refused by every real caller before reaching here)", () => { + const regions = [ + { + id: "r1", + descriptor: { + kind: "provenance", + change: "moveFrom", + } as unknown as ProvenanceDescriptor & { + change: "insertion" | "deletion" | "formatChange"; + }, + }, + ]; + expect(() => writeOdfTrackedChanges(regions)).toThrow( + /has no ODF region spelling/, + ); + }); + it("writes no office:change-info at all when neither author nor date is present", () => { const region = el("text:tracked-changes-region-probe", {}); const result = writeOdfTrackedChanges([ From d1c0f4b0be0aa508d93f732e8285052b399fccbc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 13:53:17 +0100 Subject: [PATCH 60/89] docs(odf.js): note shared/constructs.ts's zero survivors in the breakThreshold comment Keeps the comment's own inventory of packages still carrying unkilled mutants accurate now that shared/constructs.ts has reached zero survivors and zero no-coverage mutants of its own. --- packages/odf.js/stryker.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/odf.js/stryker.config.ts b/packages/odf.js/stryker.config.ts index 9b02fe2b9..ad2db00cc 100644 --- a/packages/odf.js/stryker.config.ts +++ b/packages/odf.js/stryker.config.ts @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after killing every survivor and no-coverage mutant in typed/shared/forms.ts: 76.17% of 8451 valid mutants, timeout share 1.3% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. Still provisional: the bulk of this package (draw/shapes.ts, ooo1/transform.ts, shared/constructs.ts, shared/paragraph.ts, ods/write.ts, ods/conditional-format.ts, odb/report.ts, and the rest) still carries its own unkilled mutants -- raise this again once the next batch's own full run confirms the next real floor. + // Re-measured after killing every survivor and no-coverage mutant in typed/shared/forms.ts: 76.17% of 8451 valid mutants, timeout share 1.3% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. Still provisional: the bulk of this package (draw/shapes.ts, ooo1/transform.ts, shared/paragraph.ts, ods/write.ts, ods/conditional-format.ts, odb/report.ts, and the rest) still carries its own unkilled mutants -- raise this again once the next batch's own full run confirms the next real floor. shared/constructs.ts has since reached zero survivors and zero no-coverage mutants of its own. breakThreshold: 74, }); From e2a5e75d963d35ce6ec2262b9bb49affb5d35629 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 14:19:45 +0100 Subject: [PATCH 61/89] test(odf.js): cover formatServerDatabaseUrl's missing db:type branch A db:server-database with no db:type attribute now has direct coverage: readOdbInventory resolves its connection as a bare { type: "external" } with no url, exercising the early return this RNG-derived, never-empirically-observed code path previously had no test for at all. --- packages/odf.js/src/typed/odb/read.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/odf.js/src/typed/odb/read.test.ts b/packages/odf.js/src/typed/odb/read.test.ts index ea474086b..36e3a423d 100644 --- a/packages/odf.js/src/typed/odb/read.test.ts +++ b/packages/odf.js/src/typed/odb/read.test.ts @@ -369,6 +369,28 @@ describe("readOdbInventory: db:database-description variants (RNG-derived, never }); }); + it("treats a db:server-database with no db:type as a bare external connection with no url", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:server-database", { + "db:hostname": "db.example.com", + "db:port": "3306", + "db:database-name": "salesdb", + }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).connection).toEqual({ type: "external" }); + }); + it("reads a db:file-based-database href as an external connection", () => { const pkg: Package = { parts: { From 9fc710da2cb652ee237b368f61b50130bb6c33d7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 14:20:54 +0100 Subject: [PATCH 62/89] refactor(odf.js): remove two unobservable branches from draw/shapes.ts Two constructs had no test-observable difference between their two branches, so no test could ever kill a mutant on them: - walkDrawShapes and walkDrawPageContent each special-cased an empty draw:g transform-function list to reuse groupFunctions unchanged rather than spreading it; spreading an empty ownFunctions ahead of groupFunctions produces identical content either way, so the special case was a pure allocation micro-optimisation with no behavioural effect. Both now always spread. - parseOdfPercentUnit's PERCENT_PATTERN capturing group has no `?` quantifier, so it always matches once the regex itself matches; the undefined check on match[1] was unreachable at runtime and existed only to satisfy noUncheckedIndexedAccess. Replaced with the same match[1]! non-null assertion typed/shared/units.ts's parseOdfLength and parseOdfAngleDeg already use for the identical mandatory-capturing-group guarantee. --- packages/odf.js/src/typed/draw/shapes.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/odf.js/src/typed/draw/shapes.ts b/packages/odf.js/src/typed/draw/shapes.ts index cda69e4f6..ad8f43ca2 100644 --- a/packages/odf.js/src/typed/draw/shapes.ts +++ b/packages/odf.js/src/typed/draw/shapes.ts @@ -348,10 +348,8 @@ export function walkDrawShapes( } } else if (node.tag === "draw:g") { const ownFunctions = readOwnTransformFunctions(node); - const nested = - ownFunctions.length === 0 - ? groupFunctions - : [...ownFunctions, ...groupFunctions]; + // No length-0 shortcut returning groupFunctions unchanged: spreading an empty ownFunctions ahead of groupFunctions produces the identical content either way, so the shortcut was a pure allocation micro-optimisation, not an observable behavioural branch. + const nested = [...ownFunctions, ...groupFunctions]; walkDrawShapes(node.children, nested, pkg, out, indexState, listIdState); } } @@ -378,8 +376,8 @@ function parseOdfPercentUnit(value: string): number | undefined { if (match === null) { return undefined; } - const numeric = match[1]; - return numeric === undefined ? undefined : Number(numeric) / 100; + // match[1]'s own group has no `?` quantifier of its own (only the alternation inside it does), so it always matches once `match` itself is non-null -- the same mandatory-group guarantee typed/shared/units.ts's parseOdfLength/parseOdfAngleDeg rely on for their own match[1]!. + return Number(match[1]!) / 100; } // svg:stroke-opacity's own value grammar (OASIS ODF 1.3, style:graphic-properties): "a value of type double 18.2 in the range [0,1] or a value of type zeroToHundredPercent 18.3.41" -- unlike draw:opacity, which is always a percentage. @@ -1226,10 +1224,8 @@ function walkDrawPageContent( } } else if (node.tag === "draw:g") { const ownFunctions = readOwnTransformFunctions(node); - const nested = - ownFunctions.length === 0 - ? groupFunctions - : [...ownFunctions, ...groupFunctions]; + // See walkDrawShapes' own identical construction above for why there is no length-0 shortcut here either. + const nested = [...ownFunctions, ...groupFunctions]; walkDrawPageContent( node.children, nested, From a35b0f3659b9748073641cb6602ca3ee6c4e39bf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 14:21:40 +0100 Subject: [PATCH 63/89] test(odf.js): kill typed/draw/shapes.ts's remaining survivors Direct unit coverage for every previously-untested branch in readFrameAltText, readFloatPosition/readDrawImageBlock, readDrawFrameContent's text-box child dispatch, embedded-object residue, readDrawFrame's flowPositioning opt-in, the gradient/hatch draw:style validation and optional angle/rotation fields, and the dash-pattern dots1/dots1-length/distance/dots2 boundary conditions: - An svg:title or svg:desc present but empty falls through exactly like an absent one, distinguishing the length>0 check from a bare presence check on both elements. - A frame's text:anchor-type resolves into a real floatPosition (kept the attribute name itself observable), and is genuinely absent as a key, not present-but-undefined, when there is none. - A draw:text-box child that is neither text:p nor text:list is skipped without consuming a list numId, proven by the numId a following genuine text:list gets. - A chart embedded object's own residue lands on the block's source; every other embedded kind carries no source key at all. - readDrawFrame's flowPositioning parameter defaults to false and only applies when the caller opts in; walkDrawShapes never passes true, so an odp frame with no svg:x/svg:y is dropped rather than read at its own box origin. - A resolved gradient/hatch definition with a missing or unrecognised draw:style still yields no fillPattern, matching an unresolvable name; angleDeg/rotationDeg are omitted as keys, not set to undefined, when draw:angle/draw:rotation are absent. - A dash definition's dots1/dots1-length/distance boundary values (non-positive, negative) each independently blank the whole pattern; a present-but-non-positive dots2 keeps the single-length pattern with dots2/dots2LengthPt genuinely absent. --- packages/odf.js/src/typed/draw/shapes.test.ts | 433 ++++++++++++++++++ 1 file changed, 433 insertions(+) diff --git a/packages/odf.js/src/typed/draw/shapes.test.ts b/packages/odf.js/src/typed/draw/shapes.test.ts index 43549919f..eeb55e83c 100644 --- a/packages/odf.js/src/typed/draw/shapes.test.ts +++ b/packages/odf.js/src/typed/draw/shapes.test.ts @@ -220,6 +220,23 @@ describe("readDrawFrame: content dispatch", () => { ]); }); + it("does not mint a spurious list numId for a draw:text-box child that is neither text:p nor text:list -- only a genuine text:list consumes the numId counter", () => { + const list = el("text:list", {}, [ + el("text:list-item", {}, [el("text:p", {}, [txt("item")])]), + ]); + const frame = el("draw:frame", box, [ + el("draw:text-box", {}, [ + el("text:p", {}, [txt("Hello")]), + el("draw:custom-shape", {}), + list, + ]), + ]); + const shape = readDrawFrame(frame, [], { parts: {} }); + expect( + shape?.blocks.map((b) => (b.kind === "paragraph" ? b.list : undefined)), + ).toEqual([undefined, { numId: "list1", level: 0 }]); + }); + it("reads a draw:image's referenced media part, sniffed and sized to the frame's own resolved box", () => { const pkg: Package = { parts: { @@ -288,6 +305,75 @@ describe("readDrawFrame: content dispatch", () => { ); }); + it("falls back to svg:desc when svg:title is present but empty -- an empty title carries no real alt text", () => { + const pkg: Package = { + parts: { + "Pictures/img1.png": { kind: "binary", base64: tinyPngBase64() }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:image", { "xlink:href": "Pictures/img1.png" }), + el("svg:title", {}, []), + el("svg:desc", {}, [txt("A longer description")]), + ]); + expect(readDrawFrame(frame, [], pkg)?.blocks[0]).toMatchObject({ + kind: "image", + altText: "A longer description", + }); + }); + + it("leaves altText undefined when both svg:title and svg:desc are present but empty", () => { + const pkg: Package = { + parts: { + "Pictures/img1.png": { kind: "binary", base64: tinyPngBase64() }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:image", { "xlink:href": "Pictures/img1.png" }), + el("svg:title", {}, []), + el("svg:desc", {}, []), + ]); + expect(readDrawFrame(frame, [], pkg)?.blocks[0]).not.toHaveProperty( + "altText", + ); + }); + + it("reads a positioned frame's own text:anchor-type into a floatPosition relative to the resolved origin", () => { + const pkg: Package = { + parts: { + "Pictures/img1.png": { kind: "binary", base64: tinyPngBase64() }, + }, + }; + const frame = el("draw:frame", { ...box, "text:anchor-type": "page" }, [ + el("draw:image", { "xlink:href": "Pictures/img1.png" }), + ]); + expect(readDrawFrame(frame, [], pkg)?.blocks[0]).toEqual({ + kind: "image", + format: "png", + base64: tinyPngBase64(), + widthPt: 100, + heightPt: 50, + floatPosition: { + horizontal: { relativeTo: "page", offsetPt: 0 }, + vertical: { relativeTo: "page", offsetPt: 0 }, + }, + }); + }); + + it("leaves floatPosition absent for a frame with no text:anchor-type at all", () => { + const pkg: Package = { + parts: { + "Pictures/img1.png": { kind: "binary", base64: tinyPngBase64() }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:image", { "xlink:href": "Pictures/img1.png" }), + ]); + expect(readDrawFrame(frame, [], pkg)?.blocks[0]).not.toHaveProperty( + "floatPosition", + ); + }); + it("returns no blocks (not a thrown error) for a draw:image whose referenced part is missing", () => { const frame = el("draw:frame", box, [ el("draw:image", { "xlink:href": "Pictures/missing.png" }), @@ -328,6 +414,100 @@ describe("readDrawFrame: content dispatch", () => { }); }); +describe("readDrawFrame: embedded objects (embeddedFormat opt-in)", () => { + const box = { + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "100pt", + "svg:height": "50pt", + }; + + it("attaches a chart embedded object's own residue as the block's source -- the one embedded kind whose sub-reader quarantines presentation-specific XML", () => { + const chartElement = el("chart:chart", {}, [ + el("table:table", {}, [el("table:table-row")]), + ]); + const pkg: Package = { + parts: { + "Object 1/content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [el("office:chart", {}, [chartElement])]), + ]), + ], + }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:object", { "xlink:href": "./Object 1" }), + ]); + const shape = readDrawFrame(frame, [], pkg, undefined, false, "odp"); + expect(shape?.blocks).toHaveLength(1); + const block = shape?.blocks[0]; + expect(block).toMatchObject({ + kind: "embeddedObject", + objectKind: "chart", + }); + expect(block).toHaveProperty("source"); + expect( + block?.kind === "embeddedObject" ? block.source : undefined, + ).not.toBeUndefined(); + }); + + it("carries no source at all for an embedded kind other than chart -- residue is genuinely absent, not an empty placeholder", () => { + const pkg: Package = { + parts: { + "Object 1/content.xml": { + kind: "xml", + nodes: [ + el("office:document-content", {}, [ + el("office:body", {}, [el("office:drawing")]), + ]), + ], + }, + }, + }; + const frame = el("draw:frame", box, [ + el("draw:object", { "xlink:href": "./Object 1" }), + ]); + const shape = readDrawFrame(frame, [], pkg, undefined, false, "odp"); + expect(shape?.blocks[0]).not.toHaveProperty("source"); + }); +}); + +describe("readDrawFrame: flowPositioning opt-in", () => { + const flowBox = { "svg:width": "40pt", "svg:height": "20pt" }; + + it("readDrawFrame's own flowPositioning parameter defaults to false: a frame with no svg:x/svg:y (only svg:width/svg:height) reads as undefined unless the caller opts in", () => { + const frame = el("draw:frame", flowBox, [ + el("draw:text-box", {}, [el("text:p", {}, [txt("Hi")])]), + ]); + expect(readDrawFrame(frame, [], { parts: {} })).toBeUndefined(); + }); + + it("resolves the same frame at the origin of its own box when flowPositioning is explicitly true", () => { + const frame = el("draw:frame", flowBox, [ + el("draw:text-box", {}, [el("text:p", {}, [txt("Hi")])]), + ]); + const shape = readDrawFrame(frame, [], { parts: {} }, undefined, true); + expect(shape?.frame).toEqual({ + xPt: 0, + yPt: 0, + widthPt: 40, + heightPt: 20, + }); + }); + + it("walkDrawShapes never applies flow positioning: a frame with no svg:x/svg:y is dropped, not read at the origin of its own box", () => { + const frame = el("draw:frame", flowBox, [ + el("draw:text-box", {}, [el("text:p", {}, [txt("Hi")])]), + ]); + const out: ContentShape[] = []; + walkDrawShapes([frame], [], { parts: {} }, out); + expect(out).toEqual([]); + }); +}); + describe("readDrawFrame: rotation via draw:transform", () => { it("composes into a center-pivoting frame + rotationDeg -- see transform.test.ts for the pixel-verified geometry this delegates to", () => { const frame = el("draw:frame", { @@ -728,6 +908,122 @@ describe("readDrawPageContent: non-flat fills (gradient/bitmap/hatch) and fill o expect(vector.fill).toBeUndefined(); }); + it("a resolved definition with a missing or unrecognised draw:style leaves fillPattern undefined, same as an unresolvable name", () => { + const gradient = el("draw:gradient", { + "draw:name": "grad1", + "draw:style": "not-a-real-style", + "draw:start-color": "#ff0000", + "draw:end-color": "#0000ff", + }); + const gr1 = graphicStyle("gr1", { + "draw:fill": "gradient", + "draw:fill-gradient-name": "grad1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [gradient]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.fillPattern).toBeUndefined(); + }); + + it("a resolved definition with a missing or unrecognised draw:style leaves fillPattern undefined, same as an unresolvable name", () => { + const hatch = el("draw:hatch", { + "draw:name": "hatch1", + "draw:style": "not-a-real-style", + "draw:color": "#123456", + "draw:distance": "0.1cm", + }); + const gr1 = graphicStyle("gr1", { + "draw:fill": "hatch", + "draw:fill-hatch-name": "hatch1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [hatch]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.fillPattern).toBeUndefined(); + }); + + it("omits angleDeg from the gradient fillPattern entirely (not a present-but-undefined key) when draw:angle is absent", () => { + const gradient = el("draw:gradient", { + "draw:name": "grad1", + "draw:style": "linear", + "draw:start-color": "#ff0000", + "draw:end-color": "#0000ff", + }); + const gr1 = graphicStyle("gr1", { + "draw:fill": "gradient", + "draw:fill-gradient-name": "grad1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [gradient]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.fillPattern).not.toHaveProperty("angleDeg"); + }); + + it("omits rotationDeg from the hatch fillPattern entirely (not a present-but-undefined key) when draw:rotation is absent", () => { + const hatch = el("draw:hatch", { + "draw:name": "hatch1", + "draw:style": "single", + "draw:color": "#123456", + "draw:distance": "0.1cm", + }); + const gr1 = graphicStyle("gr1", { + "draw:fill": "hatch", + "draw:fill-hatch-name": "hatch1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [hatch]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.fillPattern).not.toHaveProperty("rotationDeg"); + }); + it("reads draw:opacity into fillOpacity as a 0..1 fraction", () => { const gr1 = graphicStyle("gr1", { "draw:fill-color": "#ff0000", @@ -914,6 +1210,143 @@ describe("readDrawPageContent: stroke opacity and the real dash run-length patte expect(vector.stroke?.style).toBe("dashed"); expect(vector.stroke?.dashPattern).toBeUndefined(); }); + + it("a resolved dash definition with a non-positive draw:dots1 leaves dashPattern undefined -- dots1/dots1-length/distance are jointly required", () => { + const dash = el("draw:stroke-dash", { + "draw:name": "dash1", + "draw:style": "rect", + "draw:dots1": "0", + "draw:dots1-length": "3pt", + "draw:distance": "2pt", + }); + const gr1 = graphicStyle("gr1", { + "svg:stroke-color": "#000000", + "svg:stroke-width": "1pt", + "draw:stroke": "dash", + "draw:stroke-dash": "dash1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [dash]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.stroke?.style).toBe("dashed"); + expect(vector.stroke?.dashPattern).toBeUndefined(); + }); + + it("a resolved dash definition with a non-positive draw:dots1-length leaves dashPattern undefined", () => { + const dash = el("draw:stroke-dash", { + "draw:name": "dash1", + "draw:style": "rect", + "draw:dots1": "1", + "draw:dots1-length": "0pt", + "draw:distance": "2pt", + }); + const gr1 = graphicStyle("gr1", { + "svg:stroke-color": "#000000", + "svg:stroke-width": "1pt", + "draw:stroke": "dash", + "draw:stroke-dash": "dash1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [dash]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.stroke?.style).toBe("dashed"); + expect(vector.stroke?.dashPattern).toBeUndefined(); + }); + + it("a resolved dash definition with a negative draw:distance leaves dashPattern undefined -- a zero distance is itself valid (dots touching)", () => { + const dash = el("draw:stroke-dash", { + "draw:name": "dash1", + "draw:style": "rect", + "draw:dots1": "1", + "draw:dots1-length": "3pt", + "draw:distance": "-1pt", + }); + const gr1 = graphicStyle("gr1", { + "svg:stroke-color": "#000000", + "svg:stroke-width": "1pt", + "draw:stroke": "dash", + "draw:stroke-dash": "dash1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [dash]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.stroke?.style).toBe("dashed"); + expect(vector.stroke?.dashPattern).toBeUndefined(); + }); + + it("a resolved dash definition whose draw:dots2 is present but non-positive keeps the single-length pattern, with dots2/dots2LengthPt genuinely absent (not present-but-undefined)", () => { + const dash = el("draw:stroke-dash", { + "draw:name": "dash1", + "draw:style": "rect", + "draw:dots1": "4", + "draw:dots1-length": "3pt", + "draw:distance": "1pt", + "draw:dots2": "0", + "draw:dots2-length": "5pt", + }); + const gr1 = graphicStyle("gr1", { + "svg:stroke-color": "#000000", + "svg:stroke-width": "1pt", + "draw:stroke": "dash", + "draw:stroke-dash": "dash1", + }); + const pkg: Package = { + parts: { "content.xml": contentPackageWithResources([gr1], [dash]) }, + }; + const rect = el("draw:rect", { + "draw:style-name": "gr1", + "svg:x": "0pt", + "svg:y": "0pt", + "svg:width": "10pt", + "svg:height": "10pt", + }); + const { vectors } = readDrawPageContent([rect], pkg); + const vector = vectors[0]; + if (vector?.kind !== "rect") { + throw new Error("expected a rect vector"); + } + expect(vector.stroke?.dashPattern).toStrictEqual({ + dots1: 4, + dots1LengthPt: 3, + distancePt: 1, + }); + }); }); describe("readDrawPageContent: draw:line", () => { From 6a9046811c0b4a13254a226e3eb340395a6d89b9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 15:03:38 +0100 Subject: [PATCH 64/89] test(odf.js): cover readOdbInventory's remaining connection/component/table gaps Adds coverage the mutation suite's own incremental cache had been silently skipping for several rounds, spanning formatServerDatabaseUrl, the component-collection walker, and table-name collection in typed/odb/read.ts: - formatServerDatabaseUrl: a hostname with no port omits the port suffix rather than always including one, and a db:server-database with neither a hostname nor a local-socket-name falls through to the bare scheme[:///name] forms rather than the hostname branch. - readConnectionInfo's db:file-based-database and db:server-database branches genuinely omit `url` (not merely leave it undefined) when the underlying href/format helper returns undefined -- asserted with toStrictEqual, since toEqual treats a present-but-undefined property as equivalent to an absent one and so could not actually prove this. - collectTableNames deduplicates a table name that appears in both db:table-representations and db:schema-definition/db:table-definitions, and reads db:schema-definition's own table-definitions path at all -- previously exercised by no test whatsoever. - collectQueryDefinitions' db:escape-processing reads a real "true" value, not just a "false" one indistinguishable from a hardcoded default. - collectComponents skips a stray child that is neither db:component nor db:component-collection instead of misreading it as one. - resolveOdbComponent's thrown message names "(none)" rather than a bare empty string when a .odb declares zero components of the requested kind. --- packages/odf.js/src/typed/odb/read.test.ts | 184 ++++++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) diff --git a/packages/odf.js/src/typed/odb/read.test.ts b/packages/odf.js/src/typed/odb/read.test.ts index 36e3a423d..059b6b00e 100644 --- a/packages/odf.js/src/typed/odb/read.test.ts +++ b/packages/odf.js/src/typed/odb/read.test.ts @@ -133,6 +133,18 @@ describe("resolveOdbComponent", () => { /no report named "SalesForm"/, ); }); + + it('names the available list as "(none)" rather than a bare empty string when the .odb declares no component of that kind at all', () => { + const emptyPkg: Package = { + parts: { + "content.xml": databaseContentPart([]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(() => resolveOdbComponent(emptyPkg, "form", "Anything")).toThrow( + /no form named "Anything" -- available: \(none\)/, + ); + }); }); describe("readOdbInventory: synthetic fully-populated embedded package", () => { @@ -223,6 +235,46 @@ describe("readOdbInventory: synthetic fully-populated embedded package", () => { }); }); +describe("readOdbInventory: table names from db:schema-definition", () => { + it("reads db:schema-definition/db:table-definitions table names alongside db:table-representations, deduplicating a name the two sources share", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:table-representations", {}, [ + el("db:table-representation", { "db:name": "Customers" }), + el("db:table-representation", { "db:name": "Orders" }), + ]), + el("db:schema-definition", {}, [ + el("db:table-definitions", {}, [ + // "Orders" is the same table db:table-representations already named above -- it must appear once in the result, proving the reader actually deduplicates across the two sources rather than merely happening not to repeat within one of them. + el("db:table-definition", { "db:name": "Orders" }), + el("db:table-definition", { "db:name": "Invoices" }), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).tables).toEqual([ + "Customers", + "Orders", + "Invoices", + ]); + }); + + it("reads no table names when office:database has a db:schema-definition with no db:table-definitions child", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:schema-definition", {}, []), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).tables).toEqual([]); + }); +}); + describe("readOdbInventory: query definitions", () => { it("reads db:escape-processing when present, as a real boolean, and omits the field entirely when absent", () => { const pkg: Package = { @@ -232,7 +284,7 @@ describe("readOdbInventory: query definitions", () => { el("db:query", { "db:name": "WithFlag", "db:command": "SELECT 1", - "db:escape-processing": "false", + "db:escape-processing": "true", }), el("db:query", { "db:name": "NoFlag", "db:command": "SELECT 2" }), ]), @@ -242,12 +294,32 @@ describe("readOdbInventory: query definitions", () => { }; const inventory = readOdbInventory(pkg); expect(inventory.queries).toEqual([ - { name: "WithFlag", command: "SELECT 1", escapeProcessing: false }, + { name: "WithFlag", command: "SELECT 1", escapeProcessing: true }, { name: "NoFlag", command: "SELECT 2" }, ]); expect("escapeProcessing" in (inventory.queries[1] ?? {})).toBe(false); }); + it('reads db:escape-processing="false" as a real false, distinguishing it from a bare string comparison against the wrong literal', () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:queries", {}, [ + el("db:query", { + "db:name": "Disabled", + "db:command": "SELECT 1", + "db:escape-processing": "false", + }), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).queries).toEqual([ + { name: "Disabled", command: "SELECT 1", escapeProcessing: false }, + ]); + }); + it("skips a db:query missing its mandatory db:command rather than returning it half-populated", () => { const pkg: Package = { parts: { @@ -388,7 +460,80 @@ describe("readOdbInventory: db:database-description variants (RNG-derived, never "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), }, }; - expect(readOdbInventory(pkg).connection).toEqual({ type: "external" }); + // toStrictEqual, not toEqual: readConnectionInfo's own ternary either omits `url` entirely or sets it to a real string -- it never sets the key to a literal `undefined`. toEqual treats an `undefined`-valued property as equivalent to an absent one, so it cannot tell those two shapes apart; toStrictEqual can, and is what actually proves the key is genuinely missing. + expect(readOdbInventory(pkg).connection).toStrictEqual({ + type: "external", + }); + }); + + it("formats a db:server-database (hostname, no port) into a descriptive url with no port suffix", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:server-database", { + "db:type": "mysql", + "db:hostname": "db.example.com", + "db:database-name": "salesdb", + }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).connection).toEqual({ + type: "external", + url: "mysql://db.example.com/salesdb", + }); + }); + + it("formats a db:server-database with neither a hostname nor a local socket name (only a database name) into a bare scheme-and-name url", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:server-database", { + "db:type": "mysql", + "db:database-name": "salesdb", + }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).connection).toEqual({ + type: "external", + url: "mysql:///salesdb", + }); + }); + + it("formats a db:server-database with neither a hostname/local-socket-name nor a database name into a bare scheme url", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:server-database", { "db:type": "mysql" }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).connection).toEqual({ + type: "external", + url: "mysql://", + }); }); it("reads a db:file-based-database href as an external connection", () => { @@ -416,6 +561,30 @@ describe("readOdbInventory: db:database-description variants (RNG-derived, never url: "../data/", }); }); + + it("treats a db:file-based-database with no xlink:href as a bare external connection with no url", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:data-source", {}, [ + el("db:connection-data", {}, [ + el("db:database-description", {}, [ + el("db:file-based-database", { + "db:media-type": + "application/vnd.oasis.opendocument.spreadsheet", + }), + ]), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + // toStrictEqual: see the "no db:type" test above for why toEqual cannot prove `url` is absent rather than merely undefined. + expect(readOdbInventory(pkg).connection).toStrictEqual({ + type: "external", + }); + }); }); describe("readOdbInventory: malformed db:component handling", () => { @@ -468,6 +637,15 @@ describe("readOdbInventory: malformed db:component handling", () => { ]), ).toEqual([{ name: "Sales & Marketing", href: "forms/A&B" }]); }); + + it("skips a stray child that is neither db:component nor db:component-collection, rather than misreading it as one", () => { + expect( + formsInventory([ + el("db:desc", {}, [txt("Not a real component.")]), + el("db:component", { "db:name": "Good", "xlink:href": "forms/Obj4" }), + ]), + ).toEqual([{ name: "Good", href: "forms/Obj4" }]); + }); }); describe("readOdbInventory: scope boundaries and error paths", () => { From 3f48de46dc9affcc7e3e67388621a5f9394fa1e8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 15:56:22 +0100 Subject: [PATCH 65/89] refactor(odf.js): simplify transform.ts's colon check and drop a redundant self-mapping guard renameQName now tests indexOf's own "not found" sentinel with colon === -1 instead of colon < 0, since indexOf never returns another negative value -- the looser relational check only left an unreachable colon <= 0 case for a genuinely empty declared prefix, which the prefixes map never produces. prefixRenames no longer skips recording a prefix that is already canonical: renaming a declared prefix onto itself is a no-op wherever the map is read (renameQName's own canonical === undefined branch is the only consumer), so the extra comparison bought nothing but an allocation skip. --- packages/odf.js/src/ooo1/transform.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/odf.js/src/ooo1/transform.ts b/packages/odf.js/src/ooo1/transform.ts index 79fcbd3c1..18a63d978 100644 --- a/packages/odf.js/src/ooo1/transform.ts +++ b/packages/odf.js/src/ooo1/transform.ts @@ -234,7 +234,7 @@ function renameQName( prefixes: ReadonlyMap, ): string { const colon = qname.indexOf(":"); - if (colon < 0) { + if (colon === -1) { return qname; } const canonical = prefixes.get(qname.slice(0, colon)); @@ -571,7 +571,8 @@ function prefixRenames(root: XmlElement): Map { } const declared = attribute.name.slice("xmlns:".length); const canonical = CANONICAL_PREFIX_BY_URI.get(attribute.value); - if (canonical !== undefined && canonical !== declared) { + // No "canonical !== declared" guard: recording declared -> declared here is a genuine no-op (renameQName's own canonical === undefined check is the only branch that reads this map, and a self-mapped entry resolves identically to a missing one), so skipping it would only be an allocation micro-optimisation, not a behavioural difference worth a second condition. + if (canonical !== undefined) { renames.set(declared, canonical); } } From 744dd4e62213d50043bc07deb989a9eebdb451bf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 15:56:54 +0100 Subject: [PATCH 66/89] test(odf.js): cover transform.ts's carriesNoLength, href, and reverse-direction gaps Forward direction: an inch-shaped token inside a name-suffixed or xlink:href attribute, a default (unprefixed) xmlns declaration, a non-xmlns attribute whose value coincidentally matches a namespace URI, office:class dropped only on a document root, a paragraph's text:level, a cell's validation-name and a text-value element's value attributes (plus their negative cases outside those elements), a style:column's margin pair, table:sub-table, form:property's boolean flag, an unsplit style:properties for an unclassified family, an unwrapped office:body with no office:class, an XML part with no root element, a manifest entry resolved by skipping a decoy element/non-root entry/attribute, a same-shaped element outside the manifest part itself, mimetype synthesis only when resolvable and only when not already present, and an already-ODF package left untouched by the OpenOffice.org 1.x rules. Reverse direction: the mirrored default-xmlns, rootless-part, and manifest-decoy cases; office:body recursing into its own children when unwrapped by no recognised genre; fo:keep-with-next passed through unmodified for a value outside always/auto; the plain RENAMED_ATTRIBUTES table, form:control-implementation/form:text-style-name, and office:value-type's form:property special case, all reversed by name; text:note-ref and text:notes-configuration split by their own note-class, and a missing note-class defaulting to footnote; a cell's content-validation-name, a text-value element's attributes, and a style:column's indent pair reversed, each with its own negative case; a draw:frame with more than one frame-shaped child only unwrapping the first; a package-internal href that already starts with # never being double-prefixed; an unresolvable text:list left unrenamed; and office:meta's children left alone with no meta:keyword to rewrap. --- packages/odf.js/src/ooo1/transform.test.ts | 450 ++++++++++++++++++++- 1 file changed, 448 insertions(+), 2 deletions(-) diff --git a/packages/odf.js/src/ooo1/transform.test.ts b/packages/odf.js/src/ooo1/transform.test.ts index ef7e0d0d1..b12cc801d 100644 --- a/packages/odf.js/src/ooo1/transform.test.ts +++ b/packages/odf.js/src/ooo1/transform.test.ts @@ -96,11 +96,31 @@ describe("transformOoo1Package: namespaces", () => { it("normalises a non-conventional prefix binding onto the canonical prefix", () => { // Nothing forces a producer to use the conventional prefixes; the URI is what binds. A document binding the text vocabulary to "t:" must still read, because every reader in this package matches on the canonical prefix. const out = transformWhole( - `hi`, + `hi`, ); expect(out).toContain("`); + expect(out).toContain(``); expect(out).not.toContain("`); + // "tz" carries no colon for renameQName to act on and must survive untouched -- chosen so that a broken "no colon" early-return would slice it down to "t" (a real prefix bound above to "text") and wrongly rewrite it to "text:tz". + expect(out).toContain(`tz="unchanged"`); + }); + + it("rewrites a default (unprefixed) xmlns declaration to its OASIS successor, keeping it unprefixed", () => { + const out = transformWhole( + `hi`, + ); + expect(out).toContain(`xmlns="${ODF_NAMESPACES.office}"`); + }); + + it("does not treat a non-xmlns attribute as a namespace declaration even when its value matches a known namespace URI", () => { + // "aaaaaatext" is deliberately not a namespace declaration (no "xmlns:" prefix) but is long enough that a broken skip-check would slice it down to "text" and, seeing its value resolve to the table URI, silently redirect every text:-prefixed name in the document onto table: instead. + const out = transformWhole( + `hi`, + ); + expect(out).toContain(`hi`); + expect(out).not.toContain("table:p"); }); it("leaves a package that is not OpenOffice.org 1.x completely alone", () => { @@ -148,6 +168,39 @@ describe("transformOoo1Package: document structure", () => { ); expect(out).toContain(" { + const out = transformWhole( + `hi`, + ); + expect(out).toContain(`office:version="1.0"`); + expect(out).not.toMatch(/]*office:class=/); + expect(out).toContain(`office:class="not-a-root"`); + }); + + it("leaves office:body unwrapped when there is no office:class to derive a genre from", () => { + const out = transformWhole( + `hi`, + ); + expect(out).toContain(`hi`); + }); + + it("leaves an XML part with no root element completely alone", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml(contentXml("")), + }, + "stray.xml": { kind: "xml", nodes: [{ type: "text", value: "stray" }] }, + }, + }; + const out = transformOoo1Package(pkg); + expect(out.parts["stray.xml"]).toEqual({ + kind: "xml", + nodes: [{ type: "text", value: "stray" }], + }); + }); }); describe("transformOoo1Package: style:properties splitting", () => { @@ -248,6 +301,13 @@ describe("transformOoo1Package: style:properties splitting", () => { expect(out).toContain(`style:family="presentation"`); expect(out).toContain(`style:family="drawing-page"`); }); + + it("leaves a style's own style:properties unsplit when its family is not one this codec classifies", () => { + const out = automaticStyles( + ``, + ); + expect(out).toContain(``); + }); }); describe("transformOoo1Package: text vocabulary", () => { @@ -260,6 +320,15 @@ describe("transformOoo1Package: text vocabulary", () => { ); }); + it("renames a paragraph's text:level to text:outline-level too, not only a heading's", () => { + const out = transformContent( + `Body`, + ); + expect(out).toContain( + `Body`, + ); + }); + it("leaves text:level alone on a list level style, where ODF kept the name", () => { const out = transformWhole( ``, @@ -323,6 +392,78 @@ describe("transformOoo1Package: table and drawing vocabulary", () => { ); }); + it("renames a cell's own validation-name to the content-validation-name ODF spells it with", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(`table:content-validation-name="V1"`); + expect(out).not.toContain("table:validation-name"); + }); + + it("leaves a table:value-type-shaped attribute alone outside a real cell element", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(``); + }); + + it("moves a text field's value-carrying attributes to the office namespace", () => { + const out = transformContent( + `42`, + ); + expect(out).toContain( + `42`, + ); + }); + + it("leaves a text:value-type-shaped attribute alone outside a real text-value element", () => { + const out = transformContent(`hi`); + expect(out).toContain(`hi`); + }); + + it("renames a multi-column layout's own margin attributes to the indent pair, leaving any other attribute alone", () => { + const out = transformContent( + ``, + ); + expect(out).toContain( + ``, + ); + }); + + it("renames table:sub-table to table:table with the sub-table flag", () => { + const out = transformContent( + ``, + ); + expect(out).toContain( + ``, + ); + expect(out).not.toContain("table:sub-table"); + }); + + it("drops the boolean form:property-is-list flag while keeping the property's own other attributes", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(``); + expect(out).not.toContain("form:property-is-list"); + }); + + it("never rewrites an inch-shaped token inside a name-suffixed or xlink:href attribute", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(`draw:name="Logo 2inch"`); + expect(out).toContain(`xlink:href="note 5inch"`); + }); + + it("only rewrites a package-internal xlink:href with a leading '#' on a frame-eligible element, leaving other attributes and non-matching hrefs alone", () => { + const out = transformContent( + ``, + ); + expect(out).toContain(`draw:name="#weird"`); + expect(out).toContain(`xlink:href="Pictures/a.png"`); + }); + it("wraps a bare drawing shape in the draw:frame ODF introduced, moving the frame-level attributes onto it", () => { const out = transformContent( ``, @@ -389,6 +530,101 @@ describe("transformOoo1Package: metadata and package parts", () => { expect(new TextDecoder().decode(base64ToBytes(mimetype.base64))).toBe( "application/vnd.oasis.opendocument.text", ); + // The second, non-root entry's own media type is not a manifest concern of this rewrite -- it must survive untouched. + expect(xml).toContain( + `manifest:media-type="text/xml" manifest:full-path="content.xml"`, + ); + }); + + it("resolves the manifest's own media type from its root entry alone, skipping a decoy element, a non-root entry, and a decoy attribute", () => { + const pkg: Package = { + parts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + `` + + `` + + `` + + `` + + ``, + ), + }, + }, + }; + const out = transformOoo1Package(pkg); + const mimetype = out.parts.mimetype; + if (mimetype?.kind !== "binary") { + throw new Error("mimetype part was not written as a binary part"); + } + expect(new TextDecoder().decode(base64ToBytes(mimetype.base64))).toBe( + "application/vnd.oasis.opendocument.text", + ); + }); + + it("never treats a same-shaped element elsewhere in the package as a manifest entry, even when odfMediaType resolves", () => { + const pkg: Package = { + parts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + "content.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + }, + }; + const out = transformOoo1Package(pkg).parts["content.xml"]; + if (out?.kind !== "xml") { + throw new Error("content.xml did not survive as an XML part"); + } + const xml = selfCloseEmpty(buildXml(out.nodes)); + expect(xml).toContain( + `manifest:media-type="application/vnd.sun.xml.writer"`, + ); + }); + + it("synthesises no mimetype part when the manifest's own root media type can't be resolved", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml(contentXml("")), + }, + }, + }; + expect(transformOoo1Package(pkg).parts.mimetype).toBeUndefined(); + }); + + it("never overwrites a mimetype part the package already carries", () => { + const pkg: Package = { + parts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + "content.xml": { + kind: "xml", + nodes: parseXml(contentXml("")), + }, + mimetype: { kind: "binary", base64: "AAEC" }, + }, + }; + expect(transformOoo1Package(pkg).parts.mimetype).toEqual({ + kind: "binary", + base64: "AAEC", + }); + }); + + it("does not restructure an already-ODF package even where its shapes overlap with an OpenOffice.org 1.x rewrite rule", () => { + const odf = ``; + expect(transformWhole(odf)).toBe(selfCloseEmpty(buildXml(parseXml(odf)))); }); it("carries binary parts through untouched", () => { @@ -504,6 +740,53 @@ describe("transformToOoo1Package: namespaces and package identity", () => { expect(manifestXml).toContain( `manifest:full-path="/" manifest:version="1.3" manifest:media-type="application/vnd.sun.xml.writer"`, ); + // The second, non-root entry's own media type is not this rewrite's concern -- it must survive untouched. + expect(manifestXml).toContain( + `manifest:full-path="content.xml" manifest:media-type="text/xml"`, + ); + }); + + it("reverses a default (unprefixed) xmlns declaration back to its OpenOffice.org 1.x predecessor, keeping it unprefixed", () => { + const pkg = odfPackage(`hi`); + const contentPart = pkg.parts["content.xml"]; + if (contentPart?.kind !== "xml") { + throw new Error("content.xml is not an xml part"); + } + const root = rootElement(contentPart.nodes); + if (root === undefined) { + throw new Error("no root"); + } + root.attributes.push({ name: "xmlns", value: ODF_NAMESPACES.office }); + const out = reversePart(pkg, "content.xml"); + expect(out).toContain(`xmlns="http://openoffice.org/2000/office"`); + }); + + it("never treats a same-shaped element elsewhere in the package as a manifest entry when reversing either", () => { + const pkg = odfPackage(``, { + extraParts: { + "other.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + }, + }); + const out = reversePart(pkg, "other.xml"); + expect(out).toContain(`manifest:media-type="text/plain"`); + }); + + it("leaves an XML part with no root element completely alone in reverse too", () => { + const pkg = odfPackage(``, { + extraParts: { + "stray.xml": { kind: "xml", nodes: [{ type: "text", value: "stray" }] }, + }, + }); + const out = transformToOoo1Package(pkg); + expect(out.parts["stray.xml"]).toEqual({ + kind: "xml", + nodes: [{ type: "text", value: "stray" }], + }); }); it("leaves a package with no mimetype part -- already OpenOffice.org 1.x-shaped, or not a document this module can identify -- completely alone", () => { @@ -537,6 +820,22 @@ describe("transformToOoo1Package: document structure", () => { expect(out).toContain(`office:class="${documentClass}"`); }); + it("recurses into office:body's own children directly when they are not wrapped in a recognised genre element", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml( + `hi`, + ), + }, + }, + }; + writeMimetype(pkg, ODF_MEDIA_TYPES.odt); + const out = reversePart(pkg, "content.xml"); + expect(out).toContain(`hi`); + }); + it("renames the font declaration container and its entries", () => { const pkg = odfPackage(``); const contentPart = pkg.parts["content.xml"]; @@ -627,6 +926,13 @@ describe("transformToOoo1Package: style:*-properties merging", () => { expect(out).toContain(`fo:keep-with-next="false"`); }); + it("leaves fo:keep-with-next alone when its value is neither always nor auto", () => { + const out = automaticStylesReverse( + ``, + ); + expect(out).toContain(`fo:keep-with-next="page"`); + }); + // The forward rename's own inverse, and the one this direction cannot skip: a real consumer resolves a shape's draw:style-name against the plural spelling alone, so an OpenOffice.org 1.x package whose graphic styles still say "graphic" imports with every fill and stroke silently unbound (confirmed against LibreOffice 26.2 -- see the package README's own .sxd verification section). it("renames a drawing style's style:family back from graphic to graphics", () => { const out = automaticStylesReverse( @@ -675,6 +981,62 @@ describe("transformToOoo1Package: text vocabulary", () => { expect(out).not.toContain("text:note-class"); }); + it("splits text:note-ref back into footnote-ref/endnote-ref by its own note-class", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(` { + const out = reverseContent( + ``, + ); + expect(out).toContain(` { + const out = reverseContent( + `1n`, + ); + expect(out).toContain(``); + expect(out).toContain(`1`); + expect(out).toContain( + `n`, + ); + }); + + it("reverses the plain RENAMED_ATTRIBUTES table by explicit name", () => { + const out = reverseContent( + `hi`, + ); + expect(out).toContain(`style:page-master-name="pm1"`); + expect(out).toContain(`style:leader-char="."`); + expect(out).toContain(`text:count-in-floating-frames="true"`); + }); + + it("reverses form:control-implementation and form:text-style-name by explicit name", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain( + `form:service-name="com.sun.star.form.control.TextField"`, + ); + expect(out).toContain(`form:column-style-name="S1"`); + }); + + it("reverses office:value-type on a form:property back to form:property-type, but leaves it as office:value-type on an ordinary element", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(` { const out = reverseContent( `Ada2003-10-16T09:22:13comment`, @@ -719,6 +1081,48 @@ describe("transformToOoo1Package: table and drawing vocabulary", () => { expect(out).toContain(``); }); + it("reverses a cell's own content-validation-name back to validation-name", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(`table:validation-name="V1"`); + expect(out).not.toContain("table:content-validation-name"); + }); + + it("leaves an office:value-type-shaped attribute alone outside a real cell or text-value element in reverse too", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(``); + }); + + it("reverses a text field's value-carrying attributes back to the text namespace", () => { + const out = reverseContent( + `42`, + ); + expect(out).toContain( + `42`, + ); + }); + + it("reverses a multi-column layout's own indent attributes back to the margin pair, leaving any other attribute alone", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain( + ``, + ); + }); + + it("only unwraps the first frame-shaped child when a draw:frame somehow carries more than one, nesting the rest as the chosen shape's own trailing children", () => { + const out = reverseContent( + `t`, + ); + expect(out).toContain( + `t`, + ); + }); + it("unwraps a draw:frame back to the bare shape it wraps, moving the frame attributes onto it and reversing the inch unit", () => { const out = reverseContent( ``, @@ -745,6 +1149,12 @@ describe("transformToOoo1Package: table and drawing vocabulary", () => { expect(out).toContain(`xlink:href="http://example.invalid/a.png"`); expect(out).toContain(`xlink:href="#bookmark"`); }); + + it("does not double-prefix a package-internal href that already starts with #", () => { + const out = reverseContent(``); + expect(out).toContain(`xlink:href="#Pictures/a.png"`); + expect(out).not.toContain(`xlink:href="##Pictures/a.png"`); + }); }); describe("transformToOoo1Package: lists", () => { @@ -793,6 +1203,22 @@ describe("transformToOoo1Package: lists", () => { expect(out).not.toContain(""); }); + + it("leaves a text:list unrenamed when its referenced list-style can't be resolved to ordered or bullet", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml( + `one`, + ), + }, + }, + }; + writeMimetype(pkg, ODF_MEDIA_TYPES.odt); + const out = reversePart(pkg, "content.xml"); + expect(out).toContain(``); + }); }); describe("transformToOoo1Package: metadata", () => { @@ -817,4 +1243,24 @@ describe("transformToOoo1Package: metadata", () => { `Talphabeta`, ); }); + + it("leaves office:meta's children alone when there are no meta:keyword siblings to rewrap", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml(contentXml("")), + }, + "meta.xml": { + kind: "xml", + nodes: parseXml( + `T`, + ), + }, + }, + }; + writeMimetype(pkg, ODF_MEDIA_TYPES.odt); + const out = reversePart(pkg, "meta.xml"); + expect(out).toContain(`T`); + }); }); From 0dfc0e0b9fd2249f84554b872e4ef2bcd30ce007 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:21:03 +0100 Subject: [PATCH 67/89] refactor(odf.js): remove reverse-direction branches the caller's own guard already makes redundant reverseNoteTag and reverseNoteBodyOrCitation are each only ever called with a tag already narrowed to one of their own checked values, so the final if-check and its "return undefined" fallback can never fire -- both now return the last branch unconditionally, and the caller no longer needs a `?? renamedTag` for a value that can't be undefined. reverseTransformElement's own explicit office:body return produced byte-identical output to the generic tag/attribute handling at the bottom of the same function (office:body has no REVERSE_RENAMED_ELEMENTS entry and is not a DOCUMENT_ROOT_ELEMENTS member), so the branch is gone and office:body now falls through to that shared path instead. wrapMetaKeywords's own length-0 early return produced the same result as the general loop below it (every node fails the meta:keyword check and is pushed through unchanged), so the guard is gone too. reverseTransformElement's own genre-child lookup and documentClassOf's now share one firstGenreElement helper instead of two separate inline type predicates, since both wanted the identical "first child that is an element with a recognised genre tag" query. --- packages/odf.js/src/ooo1/transform.ts | 63 +++++++++++---------------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/packages/odf.js/src/ooo1/transform.ts b/packages/odf.js/src/ooo1/transform.ts index 18a63d978..9d1020a2b 100644 --- a/packages/odf.js/src/ooo1/transform.ts +++ b/packages/odf.js/src/ooo1/transform.ts @@ -716,6 +716,16 @@ const CLASS_BY_GENRE_ELEMENT: ReadonlyMap = new Map([ ["office:chart", "chart"], ]); +// The first child that is both an element and a recognised genre tag -- shared by reverseTransformElement's own office:body unwrap and documentClassOf below, the two places this package looks for office:body's genre child. A non-element child is never returned: CLASS_BY_GENRE_ELEMENT has no key for a non-element node's undefined tag, so the type check inside this one shared find() only ever needs proving once rather than twice. +function firstGenreElement( + children: readonly XmlNode[], +): XmlElement | undefined { + return children.find( + (child): child is XmlElement => + child.type === "element" && CLASS_BY_GENRE_ELEMENT.has(child.tag), + ); +} + // Simple, unambiguous element renames reversed by a straight lookup -- every RENAMED_ELEMENTS target EXCEPT the three whose forward mapping is many-to-one (text:list, from text:ordered-list AND text:unordered-list; text:note-body and text:note-citation, each from a footnote/endnote pair), which cannot be inverted by name alone and are handled below through the same context (a resolved list kind, an enclosing note's own class) their forward siblings in NOTE_ELEMENTS already need for the identical reason. const REVERSE_RENAMED_ELEMENTS: ReadonlyMap = new Map([ ["office:font-face-decls", "office:font-decls"], @@ -730,11 +740,8 @@ const REVERSE_RENAMED_ELEMENTS: ReadonlyMap = new Map([ ["table:dependency", "table:dependence"], ]); -// The text:note/text:note-ref/text:notes-configuration family's own reverse: each carries its own text:note-class attribute (added by the forward NOTE_ELEMENTS mapping), so -- unlike text:note-body/text:note-citation below -- this one needs no threaded context at all, just the element's own attribute. Defaults to "footnote" for a malformed/absent class, matching this package's general degrade-gracefully reading posture applied to writing. -function reverseNoteTag( - tag: string, - noteClass: string | undefined, -): string | undefined { +// The text:note/text:note-ref/text:notes-configuration family's own reverse: each carries its own text:note-class attribute (added by the forward NOTE_ELEMENTS mapping), so -- unlike text:note-body/text:note-citation below -- this one needs no threaded context at all, just the element's own attribute. Defaults to "footnote" for a malformed/absent class, matching this package's general degrade-gracefully reading posture applied to writing. Only ever called (see reverseTransformElement below) with tag already narrowed to one of the three checked below, so the last of them needs no guard of its own -- by the time text:note and text:note-ref have both failed, tag can only be text:notes-configuration. +function reverseNoteTag(tag: string, noteClass: string | undefined): string { const isEndnote = noteClass === "endnote"; if (tag === "text:note") { return isEndnote ? "text:endnote" : "text:footnote"; @@ -742,27 +749,21 @@ function reverseNoteTag( if (tag === "text:note-ref") { return isEndnote ? "text:endnote-ref" : "text:footnote-ref"; } - if (tag === "text:notes-configuration") { - return isEndnote - ? "text:endnotes-configuration" - : "text:footnotes-configuration"; - } - return undefined; + return isEndnote + ? "text:endnotes-configuration" + : "text:footnotes-configuration"; } -// text:note-body and text:note-citation carry no note-class of their own in ODF -- only their ENCLOSING text:note does -- so reversing them needs the class threaded down through the recursion from the text:note that contains them (ReverseTransformContext.noteClass, set exactly once, the moment a text:note element is entered). +// text:note-body and text:note-citation carry no note-class of their own in ODF -- only their ENCLOSING text:note does -- so reversing them needs the class threaded down through the recursion from the text:note that contains them (ReverseTransformContext.noteClass, set exactly once, the moment a text:note element is entered). Only ever called (see reverseTransformElement below) with tag already narrowed to one of the two checked below, so the second needs no guard of its own -- once text:note-body has failed, tag can only be text:note-citation. function reverseNoteBodyOrCitation( tag: string, noteClass: "footnote" | "endnote" | undefined, -): string | undefined { +): string { const isEndnote = noteClass === "endnote"; if (tag === "text:note-body") { return isEndnote ? "text:endnote-body" : "text:footnote-body"; } - if (tag === "text:note-citation") { - return isEndnote ? "text:endnote-citation" : "text:footnote-citation"; - } - return undefined; + return isEndnote ? "text:endnote-citation" : "text:footnote-citation"; } // A length written in ODF's "in" unit, reversed to OpenOffice.org 1.x's own "inch" spelling -- the exact inverse of INCH_TOKEN above, matched the same way (a whole whitespace-delimited token, so a compound value like a border shorthand keeps its structure). @@ -962,13 +963,11 @@ function reverseMovedChildren( // meta:keywords was a wrapper ODF removed (see transformElement's own meta:keywords case); reversed here by re-wrapping every meta:keyword sibling office:meta carries into one meta:keywords element, positioned at the first keyword's own place among its siblings -- exactly the shape the forward direction unwraps. Safe to run unconditionally over any element's children (not scoped to office:meta specifically): meta:keyword has no legitimate ODF appearance anywhere else, so the check costs nothing when there is nothing to wrap. function wrapMetaKeywords(nodes: readonly XmlNode[]): XmlNode[] { + // No early return for an empty keywords list: the loop below already reproduces `nodes` unchanged in that case (every node fails the meta:keyword check below and is pushed through as-is), so a length-0 guard would only save the loop's own allocation, never change the result. const keywords = nodes.filter( (node): node is XmlElement => node.type === "element" && node.tag === "meta:keyword", ); - if (keywords.length === 0) { - return [...nodes]; - } const out: XmlNode[] = []; let inserted = false; for (const node of nodes) { @@ -1045,17 +1044,13 @@ function reverseTransformElement( context.prefixes, ); - // office:body's genre child (buildBody's own construction) unwraps: recursing into the GENRE element's children rather than office:body's own single child reproduces the flat body OpenOffice.org 1.x wrote. + // office:body's genre child (buildBody's own construction) unwraps: recursing into the GENRE element's children rather than office:body's own single child reproduces the flat body OpenOffice.org 1.x wrote. office:body itself needs no special-cased return below (unlike draw:frame, the note family, and the rest) -- REVERSE_RENAMED_ELEMENTS has no entry for it and it is not a DOCUMENT_ROOT_ELEMENTS member, so the generic tag/attribute handling at the bottom of this function already reproduces element("office:body", attributes, children) exactly. const genreChild = renamedTag === "office:body" - ? source.children.find( - (child): child is XmlElement => child.type === "element", - ) + ? firstGenreElement(source.children) : undefined; const recurseInto = - genreChild !== undefined && CLASS_BY_GENRE_ELEMENT.has(genreChild.tag) - ? genreChild.children - : source.children; + genreChild === undefined ? source.children : genreChild.children; let childContext = context; if (renamedTag === "text:note") { @@ -1074,10 +1069,6 @@ function reverseTransformElement( reverseTransformNodes(recurseInto, childContext), ); - if (renamedTag === "office:body") { - return [element("office:body", attributes, children)]; - } - if (renamedTag === "draw:frame") { return [ unwrapFrame(attributes, children) ?? @@ -1094,13 +1085,12 @@ function reverseTransformElement( const withoutClass = attributes.filter( (attribute) => attribute.name !== "text:note-class", ); - const tag = reverseNoteTag(renamedTag, noteClassRaw) ?? renamedTag; + const tag = reverseNoteTag(renamedTag, noteClassRaw); return [element(tag, withoutClass, children)]; } if (renamedTag === "text:note-body" || renamedTag === "text:note-citation") { - const tag = - reverseNoteBodyOrCitation(renamedTag, context.noteClass) ?? renamedTag; + const tag = reverseNoteBodyOrCitation(renamedTag, context.noteClass); return [element(tag, attributes, children)]; } @@ -1170,9 +1160,8 @@ function documentClassOf(pkg: Package): string | undefined { root === undefined ? undefined : findChildElement(root.children, "office:body"); - const genre = body?.children.find( - (child): child is XmlElement => child.type === "element", - ); + const genre = + body === undefined ? undefined : firstGenreElement(body.children); return genre === undefined ? undefined : CLASS_BY_GENRE_ELEMENT.get(genre.tag); From de05fd2d560df921c64354ca370941f51964349d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 16:21:23 +0100 Subject: [PATCH 68/89] test(odf.js): cover transform.ts's classAttributeName and remaining reverse-direction gaps Forward: office:class resolved from its own literal attribute when no prefix aliases the office namespace, never through a decoy prefix bound to something else or a coincidentally class-shaped attribute name, and a decoy manifest:file-entry left alone when synthesising the mimetype media type. Reverse: a genre child unwrapped only for office:body itself, not any element whose own first child happens to share a genre tag; listKind threaded down only from a genuine enclosing text:list; an inch-shaped token still protected inside a name-suffixed or xlink:href attribute; text:outline-level, table:is-sub-table, and the style:column indent pair each reversed only on their own real element, never a same-shaped attribute elsewhere; and a decoy manifest entry, tag, or attribute left alone by both the media-type-resolution and rewrite passes. --- packages/odf.js/src/ooo1/transform.test.ts | 116 +++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/packages/odf.js/src/ooo1/transform.test.ts b/packages/odf.js/src/ooo1/transform.test.ts index b12cc801d..dd023adef 100644 --- a/packages/odf.js/src/ooo1/transform.test.ts +++ b/packages/odf.js/src/ooo1/transform.test.ts @@ -107,6 +107,29 @@ describe("transformOoo1Package: namespaces", () => { expect(out).toContain(`tz="unchanged"`); }); + it("never resolves office:class through a decoy prefix bound to something other than the office namespace", () => { + // "t" is bound to the text namespace, never office -- classAttributeName must stay undefined, so a decoy "t:class" attribute placed before the real office:class is never mistaken for it. + const out = transformWhole( + `hi`, + ); + expect(out).toContain(``); + }); + + it("resolves office:class from its own literal attribute name when no prefix is aliased to the office namespace at all", () => { + const out = transformWhole( + `hi`, + ); + expect(out).toContain(``); + }); + + it("never treats a coincidentally 'undefined:class'-named attribute as office:class when no alias prefix is bound", () => { + const out = transformWhole( + `hi`, + ); + expect(out).not.toContain("office:spreadsheet"); + expect(out).toContain(`hi`); + }); + it("rewrites a default (unprefixed) xmlns declaration to its OASIS successor, keeping it unprefixed", () => { const out = transformWhole( `hi`, @@ -536,6 +559,31 @@ describe("transformOoo1Package: metadata and package parts", () => { ); }); + it("only rewrites manifest:file-entry children when writing the synthesised media type, leaving a same-shaped decoy tag alone", () => { + const pkg: Package = { + parts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + }, + }; + const out = transformOoo1Package(pkg); + const manifest = out.parts["META-INF/manifest.xml"]; + if (manifest?.kind !== "xml") { + throw new Error("manifest did not survive as an XML part"); + } + const xml = selfCloseEmpty(buildXml(manifest.nodes)); + expect(xml).toContain( + ``, + ); + expect(xml).toContain( + ``, + ); + }); + it("resolves the manifest's own media type from its root entry alone, skipping a decoy element, a non-root entry, and a decoy attribute", () => { const pkg: Package = { parts: { @@ -746,6 +794,26 @@ describe("transformToOoo1Package: namespaces and package identity", () => { ); }); + it("only rewrites the manifest's own root entry, skipping a same-shaped decoy tag and a decoy attribute placed before the real full-path", () => { + const pkg = odfPackage(``, { + extraParts: { + "META-INF/manifest.xml": { + kind: "xml", + nodes: parseXml( + ``, + ), + }, + }, + }); + const manifestXml = reversePart(pkg, "META-INF/manifest.xml"); + expect(manifestXml).toContain( + ``, + ); + expect(manifestXml).toContain( + `manifest:decoy="nope" manifest:full-path="/" manifest:media-type="application/vnd.sun.xml.writer"`, + ); + }); + it("reverses a default (unprefixed) xmlns declaration back to its OpenOffice.org 1.x predecessor, keeping it unprefixed", () => { const pkg = odfPackage(`hi`); const contentPart = pkg.parts["content.xml"]; @@ -836,6 +904,13 @@ describe("transformToOoo1Package: document structure", () => { expect(out).toContain(`hi`); }); + it("only unwraps a genre child for office:body itself, never for another element whose own first child happens to share a genre tag", () => { + const out = reverseContent( + `trap`, + ); + expect(out).toContain(`trap`); + }); + it("renames the font declaration container and its entries", () => { const pkg = odfPackage(``); const contentPart = pkg.parts["content.xml"]; @@ -963,6 +1038,11 @@ describe("transformToOoo1Package: text vocabulary", () => { ); }); + it("reverses text:outline-level back to text:level only on text:h, leaving it alone elsewhere", () => { + const out = reverseContent(`Body`); + expect(out).toContain(`Body`); + }); + it("renames the inline text:tab back to text:tab-stop", () => { const out = reverseContent(`ab`); expect(out).toContain(`ab`); @@ -1081,6 +1161,11 @@ describe("transformToOoo1Package: table and drawing vocabulary", () => { expect(out).toContain(``); }); + it("only converts table:is-sub-table on a real table:table, never a same-shaped attribute on another element", () => { + const out = reverseContent(`hi`); + expect(out).toContain(`hi`); + }); + it("reverses a cell's own content-validation-name back to validation-name", () => { const out = reverseContent( ``, @@ -1114,6 +1199,19 @@ describe("transformToOoo1Package: table and drawing vocabulary", () => { ); }); + it("leaves the fo:start-indent/fo:end-indent attribute name alone outside a real style:column in reverse too", () => { + const out = reverseContent(`hi`); + expect(out).toContain(`hi`); + }); + + it("never reverses an in-shaped token inside a name-suffixed or xlink:href attribute", () => { + const out = reverseContent( + ``, + ); + expect(out).toContain(`draw:name="Logo 2in"`); + expect(out).toContain(`xlink:href="#note 5in"`); + }); + it("only unwraps the first frame-shaped child when a draw:frame somehow carries more than one, nesting the rest as the chosen shape's own trailing children", () => { const out = reverseContent( `t`, @@ -1219,6 +1317,24 @@ describe("transformToOoo1Package: lists", () => { const out = reversePart(pkg, "content.xml"); expect(out).toContain(``); }); + + it("only threads listKind down from a genuine enclosing text:list, never from another element that merely resolves a list style", () => { + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: parseXml( + `one`, + ), + }, + }, + }; + writeMimetype(pkg, ODF_MEDIA_TYPES.odt); + const out = reversePart(pkg, "content.xml"); + expect(out).toContain(``); + expect(out).not.toContain("text:ordered-list"); + expect(out).not.toContain("text:unordered-list"); + }); }); describe("transformToOoo1Package: metadata", () => { From da5f83def18090bb2744966169ce56ad6957cac8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 17:53:40 +0100 Subject: [PATCH 69/89] docs(odf.js): note ooo1/transform.ts's zero survivors in the breakThreshold comment --- packages/odf.js/stryker.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/odf.js/stryker.config.ts b/packages/odf.js/stryker.config.ts index ad2db00cc..478d194ee 100644 --- a/packages/odf.js/stryker.config.ts +++ b/packages/odf.js/stryker.config.ts @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts"; export default packageStrykerConfig({ vitestConfigFile: "vitest.mutation.config.ts", - // Re-measured after killing every survivor and no-coverage mutant in typed/shared/forms.ts: 76.17% of 8451 valid mutants, timeout share 1.3% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. Still provisional: the bulk of this package (draw/shapes.ts, ooo1/transform.ts, shared/paragraph.ts, ods/write.ts, ods/conditional-format.ts, odb/report.ts, and the rest) still carries its own unkilled mutants -- raise this again once the next batch's own full run confirms the next real floor. shared/constructs.ts has since reached zero survivors and zero no-coverage mutants of its own. + // Re-measured after killing every survivor and no-coverage mutant in typed/shared/forms.ts: 76.17% of 8451 valid mutants, timeout share 1.3% -- break = floor(score) minus the timeout share rounded up to whole points (minimum one), per the derivation rule on PackageStrykerOptions.breakThreshold. Still provisional: a full-package run still finds several hundred survivors outside ooo1/transform.ts -- raise this again once the next batch's own full run confirms the next real floor. shared/constructs.ts and ooo1/transform.ts have each since reached zero survivors and zero no-coverage mutants of their own. breakThreshold: 74, }); From cb6d4afb5ee9f0d556a5c7e60de6b6eb5d53841b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:02:46 +0100 Subject: [PATCH 70/89] fix(odf.js): clamp skipExpression's index advances to text.length Each of the three index-advancing branches (paren/brace recursion, quoted-string close) could overshoot text.length by one when its own terminator search ran to the end of the string, and the function's final return then silently substituted a hardcoded text.length regardless of what index actually held. Clamping each advance and returning index itself instead makes the loop's own boundary arithmetic load-bearing again: a future off-by-one there now surfaces as a wrong return value rather than being masked by the fallback. --- packages/odf.js/src/typed/ods/write.test.ts | 20 +++++++++++++++++++ .../odf.js/src/typed/shared/expression.ts | 12 +++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/odf.js/src/typed/ods/write.test.ts b/packages/odf.js/src/typed/ods/write.test.ts index 08c53afb1..d304abf00 100644 --- a/packages/odf.js/src/typed/ods/write.test.ts +++ b/packages/odf.js/src/typed/ods/write.test.ts @@ -303,6 +303,26 @@ describe("writeOdsContent XML shapes", () => { expect(pkg.parts["Pictures/image1.png"]?.kind).toBe("binary"); }); + it("writes table:table-column and table:table-row with no table:style-name when the column/row carries no width, height, or manual break", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "x" }, + displayText: "x", + }, + ]), + ]), + ); + const table = firstTable(pkg); + const column = childrenWithTag(table, "table:table-column")[0]!; + expect(attrValue(column, "table:style-name")).toBeUndefined(); + const row = childrenWithTag(table, "table:table-row")[0]!; + expect(attrValue(row, "table:style-name")).toBeUndefined(); + }); + describe("the sheet's own master page", () => { it("writes style:master-page-name on the table's own style:style[family='table'], not on table:table itself", () => { const pkg = writeOdsContent(documentOf([sheetOf([])])); diff --git a/packages/odf.js/src/typed/shared/expression.ts b/packages/odf.js/src/typed/shared/expression.ts index ba4f28812..204f459e4 100644 --- a/packages/odf.js/src/typed/shared/expression.ts +++ b/packages/odf.js/src/typed/shared/expression.ts @@ -13,21 +13,25 @@ export function skipExpression( return index; } if (ch === "(") { - index = skipExpression(text, index + 1, ")") + 1; + index = Math.min(skipExpression(text, index + 1, ")") + 1, text.length); continue; } if (ch === "{") { - index = skipExpression(text, index + 1, "}") + 1; + index = Math.min(skipExpression(text, index + 1, "}") + 1, text.length); continue; } if (ch === '"' || ch === "'") { const closeQuote = text.indexOf(ch, index + 1); - index = (closeQuote === -1 ? text.length : closeQuote) + 1; + index = Math.min( + (closeQuote === -1 ? text.length : closeQuote) + 1, + text.length, + ); continue; } index += 1; } - return text.length; + // Every advance above is clamped to text.length, so a natural loop exit always leaves index exactly at text.length -- returning index rather than a hardcoded text.length keeps the loop's own boundary condition load-bearing (an off-by-one there would surface here as a wrong return value) instead of masked by a fallback that would produce the same answer either way. + return index; } // Extracts and trims one expression, advancing past its own terminator. Returns undefined (never an empty string) when the expression is empty, matching both source functions' own "empty means failure" contract for an operand. From b49043d36d650565402bfc106938c168f0c9ab87 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:05:48 +0100 Subject: [PATCH 71/89] test(odf.js): cover sheetCellStyle's decoration-present and no-decoration branches Neither branch of sheetCellStyle had direct coverage through the public write path: a plain cell with no background, borders, or alignment must get no table:style-name at all, and a cell that does carry decoration must actually emit its own style:table-cell-properties on a minted style:style, not just skip the early-return. --- packages/odf.js/src/typed/ods/write.test.ts | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/odf.js/src/typed/ods/write.test.ts b/packages/odf.js/src/typed/ods/write.test.ts index d304abf00..abba57ec3 100644 --- a/packages/odf.js/src/typed/ods/write.test.ts +++ b/packages/odf.js/src/typed/ods/write.test.ts @@ -323,6 +323,54 @@ describe("writeOdsContent XML shapes", () => { expect(attrValue(row, "table:style-name")).toBeUndefined(); }); + it("writes table:table-cell with no table:style-name when the cell carries no background, borders, or alignment", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "plain" }, + displayText: "plain", + }, + ]), + ]), + ); + const cell = firstCell(pkg); + expect(attrValue(cell, "table:style-name")).toBeUndefined(); + }); + + it("writes a background-only cell's style:table-cell-properties on its own minted style:style", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([ + { + row: 0, + column: 0, + value: { kind: "string", value: "coloured" }, + displayText: "coloured", + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }, + ]), + ]), + ); + const cell = firstCell(pkg); + const styleName = attrValue(cell, "table:style-name")!; + expect(styleName).toBeDefined(); + const cellStyle = childrenWithTag( + contentAutomaticStyles(pkg), + "style:style", + ).find( + (styleElement) => attrValue(styleElement, "style:name") === styleName, + )!; + expect(attrValue(cellStyle, "style:family")).toBe("table-cell"); + const properties = childrenWithTag( + cellStyle, + "style:table-cell-properties", + )[0]!; + expect(attrValue(properties, "fo:background-color")).toBe("#ff0000"); + }); + describe("the sheet's own master page", () => { it("writes style:master-page-name on the table's own style:style[family='table'], not on table:table itself", () => { const pkg = writeOdsContent(documentOf([sheetOf([])])); From e9e21e9faeb5dd536d14bbaba0247ef77d4f62ca Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:11:44 +0100 Subject: [PATCH 72/89] test(odf.js): cover data validation message and interning branches writeValidationMessage's display/title/body guards and canonicalValidationKey's showInputMessage/showErrorMessage fingerprint fields had no direct coverage: a rule's help/error messages must appear with the right display/title/message-type only when their underlying fields are actually set, table:allow-empty-cell must appear only for an explicit allowBlank:false, and two rules differing only in showInputMessage or showErrorMessage must intern as separate definitions rather than merging into one. --- packages/odf.js/src/typed/ods/write.test.ts | 188 ++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/packages/odf.js/src/typed/ods/write.test.ts b/packages/odf.js/src/typed/ods/write.test.ts index abba57ec3..6b7174f5d 100644 --- a/packages/odf.js/src/typed/ods/write.test.ts +++ b/packages/odf.js/src/typed/ods/write.test.ts @@ -97,6 +97,27 @@ function firstTable(pkg: Package): XmlElement { return table; } +function contentValidations(pkg: Package): XmlElement { + const body = findChildElement( + partRoot(pkg, "content.xml").children, + "office:body", + ); + const spreadsheet = + body === undefined + ? undefined + : findChildElement(body.children, "office:spreadsheet"); + const container = + spreadsheet === undefined + ? undefined + : findChildElement(spreadsheet.children, "table:content-validations"); + if (container === undefined) { + throw new Error( + "expected office:body/office:spreadsheet/table:content-validations", + ); + } + return container; +} + function contentAutomaticStyles(pkg: Package): XmlElement { const container = findChildElement( partRoot(pkg, "content.xml").children, @@ -371,6 +392,173 @@ describe("writeOdsContent XML shapes", () => { expect(attrValue(properties, "fo:background-color")).toBe("#ff0000"); }); + describe("data validation messages", () => { + it("writes no table:help-message/table:error-message and no table:allow-empty-cell for a bare rule with no message fields and allowBlank left absent", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + expect(attrValue(rule, "table:allow-empty-cell")).toBeUndefined(); + expect(childrenWithTag(rule, "table:help-message")).toHaveLength(0); + expect(childrenWithTag(rule, "table:error-message")).toHaveLength(0); + }); + + it("writes table:allow-empty-cell=false only when allowBlank is explicitly false", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + allowBlank: false, + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + expect(attrValue(rule, "table:allow-empty-cell")).toBe("false"); + }); + + it("writes table:help-message and table:error-message with display/title/body only when the corresponding fields are actually set", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + showInputMessage: true, + promptTitle: "Pick one", + prompt: "Choose a value", + showErrorMessage: true, + errorTitle: "Invalid", + error: "That value is not allowed", + errorStyle: "warning", + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + const help = childrenWithTag(rule, "table:help-message")[0]!; + expect(attrValue(help, "table:display")).toBe("true"); + expect(attrValue(help, "table:title")).toBe("Pick one"); + expect(childrenWithTag(help, "text:p")[0]!.children[0]).toMatchObject({ + type: "text", + value: "Choose a value", + }); + const error = childrenWithTag(rule, "table:error-message")[0]!; + expect(attrValue(error, "table:display")).toBe("true"); + expect(attrValue(error, "table:title")).toBe("Invalid"); + expect(attrValue(error, "table:message-type")).toBe("warning"); + expect(childrenWithTag(error, "text:p")[0]!.children[0]).toMatchObject({ + type: "text", + value: "That value is not allowed", + }); + }); + + it("omits table:display when a message's title/body exist but its own show flag was never set", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + promptTitle: "Pick one", + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + const help = childrenWithTag(rule, "table:help-message")[0]!; + expect(attrValue(help, "table:display")).toBeUndefined(); + expect(attrValue(help, "table:title")).toBe("Pick one"); + }); + + it("interns a rule with showInputMessage/showErrorMessage true as a definition distinct from an otherwise-identical rule with them left unset", () => { + const rangeAt = (row: number) => ({ + startRow: row, + startColumn: 0, + endRow: row, + endColumn: 0, + }); + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [rangeAt(0)], + type: "list", + formula1: '"a,b,c"', + showInputMessage: true, + }, + { + ranges: [rangeAt(1)], + type: "list", + formula1: '"a,b,c"', + }, + { + ranges: [rangeAt(2)], + type: "list", + formula1: '"x,y,z"', + showErrorMessage: true, + }, + { + ranges: [rangeAt(3)], + type: "list", + formula1: '"x,y,z"', + }, + ], + }), + ]), + ); + const rows = childrenWithTag(firstTable(pkg), "table:table-row"); + const nameOfRow = (row: number) => { + const cell = childrenWithTag(rows[row]!, "table:table-cell")[0]!; + return attrValue(cell, "table:content-validation-name"); + }; + expect(nameOfRow(0)).not.toBe(nameOfRow(1)); + expect(nameOfRow(2)).not.toBe(nameOfRow(3)); + }); + }); + describe("the sheet's own master page", () => { it("writes style:master-page-name on the table's own style:style[family='table'], not on table:table itself", () => { const pkg = writeOdsContent(documentOf([sheetOf([])])); From f27d97fd262bd77e843ddaecd9f9b6673a79afb4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:16:20 +0100 Subject: [PATCH 73/89] test(odf.js): cover unsupportedConditionalFormatReason's refusal branches None of containsBlanks/notContainsBlanks, priority, stopIfTrue, aboveAverage's stdDev, iconSet's reverse flag, or an unsupported 'num' cfvo threshold had a test exercising the actual refusal -- each is a distinct reason writeSheet throws for rather than silently dropping, and each also needed a matching "does not refuse" case on the otherwise-identical supported shape to prove the guard is genuinely conditional rather than always tripping. --- packages/odf.js/src/typed/ods/write.test.ts | 163 ++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/packages/odf.js/src/typed/ods/write.test.ts b/packages/odf.js/src/typed/ods/write.test.ts index 6b7174f5d..8fc3bb981 100644 --- a/packages/odf.js/src/typed/ods/write.test.ts +++ b/packages/odf.js/src/typed/ods/write.test.ts @@ -559,6 +559,169 @@ describe("writeOdsContent XML shapes", () => { }); }); + describe("unsupportedConditionalFormatReason refusals", () => { + const rangeOnly = [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ]; + + it("refuses containsBlanks and notContainsBlanks by name", () => { + for (const type of ["containsBlanks", "notContainsBlanks"] as const) { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [{ type, ranges: rangeOnly }], + }), + ]), + ), + ).toThrow(/no spelling for/); + } + }); + + it("refuses a rule carrying a priority", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "containsErrors", + ranges: rangeOnly, + priority: 1, + }, + ], + }), + ]), + ), + ).toThrow(/priority/); + }); + + it("refuses a rule carrying stopIfTrue", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "containsErrors", + ranges: rangeOnly, + stopIfTrue: true, + }, + ], + }), + ]), + ), + ).toThrow(/stopIfTrue/); + }); + + it("does NOT refuse a rule with priority/stopIfTrue left unset", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { type: "containsErrors", ranges: rangeOnly }, + ], + }), + ]), + ), + ).not.toThrow(); + }); + + it("refuses an aboveAverage rule carrying a stdDev count", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "aboveAverage", + ranges: rangeOnly, + stdDev: 2, + }, + ], + }), + ]), + ), + ).toThrow(/standard-deviation/); + }); + + it("does not refuse an aboveAverage rule with no stdDev", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [{ type: "aboveAverage", ranges: rangeOnly }], + }), + ]), + ), + ).not.toThrow(); + }); + + it("refuses a reversed iconSet but not a non-reversed one", () => { + const iconSet = (reverse?: boolean) => ({ + type: "iconSet" as const, + ranges: rangeOnly, + iconSetType: "3TrafficLights1", + thresholds: [ + { type: "percent" as const, value: "33" }, + { type: "percent" as const, value: "67" }, + ], + reverse, + }); + expect(() => + writeOdsContent( + documentOf([sheetOf([], { conditionalFormats: [iconSet(true)] })]), + ), + ).toThrow(/reversed icon set/); + expect(() => + writeOdsContent( + documentOf([sheetOf([], { conditionalFormats: [iconSet(false)] })]), + ), + ).not.toThrow(); + }); + + it("refuses a threshold of the unsupported 'num' cfvo type", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "dataBar", + ranges: rangeOnly, + min: { type: "num", value: "0" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + }, + ], + }), + ]), + ), + ).toThrow(/'num' threshold/); + }); + + it("does not refuse a dataBar whose min/max are both supported cfvo types", () => { + expect(() => + writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "dataBar", + ranges: rangeOnly, + min: { type: "min" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + }, + ], + }), + ]), + ), + ).not.toThrow(); + }); + }); + describe("the sheet's own master page", () => { it("writes style:master-page-name on the table's own style:style[family='table'], not on table:table itself", () => { const pkg = writeOdsContent(documentOf([sheetOf([])])); From 6de776c245b5c9e0746c937fa8f9fe020d8e4565 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:19:09 +0100 Subject: [PATCH 74/89] refactor(odf.js): compute the used range's max row/column via Math.max bumpRow/bumpColumn's own hand-written "is this genuinely bigger" comparison was a redundant guard: reassigning maxRow/maxColumn to a value no larger than what it already holds is a no-op regardless of whether that comparison uses > or >=, so no test can ever distinguish the two. Math.max removes the comparison as a separately mutable expression entirely rather than leaving an unkillable one behind. --- packages/odf.js/src/typed/ods/write.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/odf.js/src/typed/ods/write.ts b/packages/odf.js/src/typed/ods/write.ts index 709e2c548..25aaec7f3 100644 --- a/packages/odf.js/src/typed/ods/write.ts +++ b/packages/odf.js/src/typed/ods/write.ts @@ -667,15 +667,12 @@ interface UsedRange { function computeUsedRange(sheet: ContentSheet): UsedRange { let maxRow: number | undefined; let maxColumn: number | undefined; + // Math.max makes a redundant "is this genuinely bigger" comparison unnecessary: setting maxRow/maxColumn to a value no larger than what it already holds is a no-op regardless of whether that comparison used > or >=, so a hand-written comparison here would be an unkillable equivalent mutant rather than a real behavioural choice. const bumpRow = (row: number): void => { - if (maxRow === undefined || row > maxRow) { - maxRow = row; - } + maxRow = maxRow === undefined ? row : Math.max(maxRow, row); }; const bumpColumn = (column: number): void => { - if (maxColumn === undefined || column > maxColumn) { - maxColumn = column; - } + maxColumn = maxColumn === undefined ? column : Math.max(maxColumn, column); }; for (const cell of sheet.cells) { From 0d856071fee351e539a8d79b18a551554e69b067 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:22:51 +0100 Subject: [PATCH 75/89] test(odf.js): cover dataBar's unset showValue and an empty conditionalFormats array The existing dataBar fixture always set showValue explicitly, so the undefined-vs-defined check that decides whether calcext:show-value gets written at all had no case proving the attribute is genuinely omitted when the field is never set. Separately, writeConditionalFormats' own empty-array guard was only ever exercised via conditionalFormats being undefined, never via an explicit empty array reaching the same early return. --- packages/odf.js/src/typed/ods/write.test.ts | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/odf.js/src/typed/ods/write.test.ts b/packages/odf.js/src/typed/ods/write.test.ts index 8fc3bb981..292f78317 100644 --- a/packages/odf.js/src/typed/ods/write.test.ts +++ b/packages/odf.js/src/typed/ods/write.test.ts @@ -1105,6 +1105,42 @@ describe("writeOdsContent: data validation and conditional formatting", () => { 2, ); }); + + it("omits calcext:show-value on a dataBar rule that never set showValue at all", () => { + const ranges = [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }]; + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + conditionalFormats: [ + { + type: "dataBar", + ranges, + min: { type: "min" }, + max: { type: "max" }, + color: { r: 1, g: 0, b: 0 }, + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + const wrapper = childrenWithTag(table, "calcext:conditional-formats")[0]!; + const dataBar = childrenWithTag( + childrenWithTag(wrapper, "calcext:conditional-format")[0]!, + "calcext:data-bar", + )[0]!; + expect(attrValue(dataBar, "calcext:show-value")).toBeUndefined(); + }); + + it("writes no calcext:conditional-formats element when conditionalFormats is an empty (not undefined) array", () => { + const pkg = writeOdsContent( + documentOf([sheetOf([], { conditionalFormats: [] })]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "calcext:conditional-formats")).toHaveLength( + 0, + ); + }); }); describe("writeOdsContent: a cell's own runs -- bare newline vs. formatted line-break", () => { From 777dec8f18acb8fc1e3736ad3f0c70976f4a9936 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 23:24:31 +0100 Subject: [PATCH 76/89] test(odf.js): recompute readOdbInventory fresh per test and cover its remaining branches readOdbInventory: synthetic fully-populated embedded package and the two real-fixture describe blocks each called readOdbInventory once directly in the describe body, so the call executed exactly once during Vitest's collection phase rather than per test. Stryker's per-test coverage analysis treats that as static, module-load-time code, which makes every mutant inside readOdbInventory unkillable by any individual it() no matter how many assertions exist -- moving the call into a beforeEach ties its execution to whichever test is actually running. That alone killed 5 of 9 survivors. The remaining four needed real coverage: a stray child under db:queries that isn't db:query-collection but contains a nested db:query of its own (proving the recursion check looks at the tag, not just at whether children exist), the equivalent case for db:component under db:forms, and a resolveOdbComponent error with two available names to prove the join separator is ", " rather than a bare concatenation. --- packages/odf.js/src/typed/odb/read.test.ts | 75 ++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/odf.js/src/typed/odb/read.test.ts b/packages/odf.js/src/typed/odb/read.test.ts index 059b6b00e..952c26f46 100644 --- a/packages/odf.js/src/typed/odb/read.test.ts +++ b/packages/odf.js/src/typed/odb/read.test.ts @@ -1,12 +1,13 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import type { XmlNode } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { parsePackage } from "../../package-io/read"; import { readOdbInventory, resolveOdbComponent } from "./read"; +import type { OdbInventory } from "./read"; // This suite reads TWO real, unmodified LibreOffice 26.2-generated .odb fixtures for its genuine-producer-shape assertions, mirroring readOdtContent's and readOdm's own established convention: src/typed/odb/fixtures/embedded-firebird.odb (an embedded-Firebird database document with two live SQL tables and one real query, and deliberately no forms or reports), and src/typed/odb/fixtures/form-and-report.odb (the same engine, plus a real bound form and a real Report Builder report -- see read.ts's own top-of-file note for how it was generated and for the two findings about real form/report registration it produced). A handful of synthetic, hand-built packages (via el/txt) cover shapes neither real fixture exercises -- an external connection, the two defensive db:database-description variants (never empirically observed), and the db:component-collection grouping and malformed-component paths. @@ -51,7 +52,11 @@ const BASE_MANIFEST_ENTRIES = [ ]; describe("readOdbInventory: embedded-firebird.odb (real LibreOffice output)", () => { - const inventory = readOdbInventory(loadFixture("embedded-firebird.odb")); + // Recomputed in beforeEach, not read once at describe-body scope: a describe-body call runs exactly once during Vitest's collection phase, before any individual test executes, which makes Stryker's per-test coverage analysis treat readOdbInventory's own mutants as "static" (unkillable by any single it()) rather than attributing them to the test that actually exercises the resulting assertion. + let inventory: OdbInventory; + beforeEach(() => { + inventory = readOdbInventory(loadFixture("embedded-firebird.odb")); + }); it('reads the real embedded connection info -- an "sdbc:embedded:" href classifies as embedded', () => { expect(inventory.connection).toEqual({ @@ -81,7 +86,11 @@ describe("readOdbInventory: embedded-firebird.odb (real LibreOffice output)", () }); describe("readOdbInventory: form-and-report.odb (real LibreOffice output)", () => { - const inventory = readOdbInventory(loadFixture("form-and-report.odb")); + // See the identical beforeEach note on the embedded-firebird.odb describe above. + let inventory: OdbInventory; + beforeEach(() => { + inventory = readOdbInventory(loadFixture("form-and-report.odb")); + }); it("reads the form's real user-visible name alongside its opaque persistent storage path -- the two genuinely differ in real output", () => { expect(inventory.forms).toEqual([ @@ -145,6 +154,29 @@ describe("resolveOdbComponent", () => { /no form named "Anything" -- available: \(none\)/, ); }); + + it("joins two or more available names with a comma and a space, not concatenated bare", () => { + const twoFormsPkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:forms", {}, [ + el("db:component", { + "db:name": "First", + "xlink:href": "forms/Obj1", + }), + el("db:component", { + "db:name": "Second", + "xlink:href": "forms/Obj2", + }), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(() => resolveOdbComponent(twoFormsPkg, "form", "Nope")).toThrow( + 'no form named "Nope" -- available: First, Second', + ); + }); }); describe("readOdbInventory: synthetic fully-populated embedded package", () => { @@ -200,7 +232,11 @@ describe("readOdbInventory: synthetic fully-populated embedded package", () => { "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), }, }; - const inventory = readOdbInventory(pkg); + // See the identical beforeEach note on the embedded-firebird.odb describe above. + let inventory: OdbInventory; + beforeEach(() => { + inventory = readOdbInventory(pkg); + }); it("reads db:connection-resource", () => { expect(inventory.connection).toEqual({ @@ -331,6 +367,25 @@ describe("readOdbInventory: query definitions", () => { }; expect(readOdbInventory(pkg).queries).toEqual([]); }); + + it("never descends into a stray child that is neither db:query nor db:query-collection, even when it happens to carry a nested db:query of its own", () => { + const pkg: Package = { + parts: { + "content.xml": databaseContentPart([ + el("db:queries", {}, [ + el("db:not-a-collection", {}, [ + el("db:query", { + "db:name": "Hidden", + "db:command": "SELECT 1", + }), + ]), + ]), + ]), + "META-INF/manifest.xml": manifestPart(BASE_MANIFEST_ENTRIES), + }, + }; + expect(readOdbInventory(pkg).queries).toEqual([]); + }); }); describe("readOdbInventory: external datasource", () => { @@ -646,6 +701,18 @@ describe("readOdbInventory: malformed db:component handling", () => { ]), ).toEqual([{ name: "Good", href: "forms/Obj4" }]); }); + + it("skips a stray child tag even when it coincidentally carries a well-formed db:name and xlink:href of its own", () => { + expect( + formsInventory([ + el("db:not-a-component", { + "db:name": "Sneaky", + "xlink:href": "forms/ObjSneaky", + }), + el("db:component", { "db:name": "Good", "xlink:href": "forms/Obj5" }), + ]), + ).toEqual([{ name: "Good", href: "forms/Obj5" }]); + }); }); describe("readOdbInventory: scope boundaries and error paths", () => { From 46e2696afaf248bc8a6f3459caba3eb031a136e9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:07:41 +0100 Subject: [PATCH 77/89] test(odf.js): cover typed/shared/table.ts's read/write decoration and write-side paths readCellStyleDecoration had no direct test coverage at all for verticalAlignment, alignment, or multi-element cascading/accumulation, and the entire write side (tableColumnStyle, tableRowStyle, tableCellStyle, writeCellBlocks, writeOdfTable) had none either -- every mutant in that half of the file was either Survived or NoCoverage. Adds direct tests for both. Also simplifies readRepeatCount: Number.parseInt always returns an integer or NaN, and NaN compared with > is always false like every other NaN comparison, so the separate Number.isInteger guard was checking something > 0 already covers on its own -- removing it drops an unkillable mutation opportunity (isInteger is true whenever parsed isn't NaN, and false only when parsed is NaN, so no reachable state distinguishes && from ||) rather than leaving a redundant condition for a test to fake-cover. --- .../odf.js/src/typed/shared/table.test.ts | 562 +++++++++++++++++- packages/odf.js/src/typed/shared/table.ts | 3 +- 2 files changed, 563 insertions(+), 2 deletions(-) diff --git a/packages/odf.js/src/typed/shared/table.test.ts b/packages/odf.js/src/typed/shared/table.test.ts index 04d730d00..475e84753 100644 --- a/packages/odf.js/src/typed/shared/table.test.ts +++ b/packages/odf.js/src/typed/shared/table.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; import type { XmlElement } from "../../model/node"; +import type { ContentTable, ContentTableCell } from "document-schema.js"; import { el, txt } from "../../xml/fragment"; -import { readOdfTable } from "./table"; +import { attrValue } from "../../xml/query"; +import { StyleRegistry } from "../../styles/registry"; +import { + readOdfTable, + readCellStyleDecoration, + writeOdfTable, + type OdfTableWriteContext, +} from "./table"; // Grammar verified against a real LibreOffice-generated .odp: a presentation's own draw:frame-wrapped table uses table:table/table:table-column/table:table-row/table:table-cell/table:covered-table-cell, column width via table:table-column's own table:style-name -> a style:family="table-column" style:style's style:table-column-properties/@style:column-width, row height the analogous table:family="table-row"/style:table-row-properties/@style:row-height -- and, notably, a real saved table frame carries an EXTRA sibling draw:image (an .svm fallback preview) alongside table:table, which shapes.ts's own readDrawFrameContent (not this module) is responsible for not mistaking for the frame's real content. @@ -376,3 +384,555 @@ describe("readOdfTable: overall shape", () => { }); }); }); + +describe("readOdfTable: repeat-count edge cases (readRepeatCount)", () => { + it("a zero repeated count is invalid and falls back to a single entry, not zero entries", () => { + const table = el("table:table", {}, [ + el("table:table-column", { "table:number-columns-repeated": "0" }), + ]); + expect(readOdfTable(table, { parts: {} }).columnWidthsPt).toEqual([0]); + }); + + it("a negative repeated count is invalid and falls back to a single entry", () => { + const table = el("table:table", {}, [ + el("table:table-column", { "table:number-columns-repeated": "-3" }), + ]); + expect(readOdfTable(table, { parts: {} }).columnWidthsPt).toEqual([0]); + }); + + it("a non-numeric repeated count is invalid and falls back to a single entry", () => { + const table = el("table:table", {}, [ + el("table:table-column", { "table:number-columns-repeated": "abc" }), + ]); + expect(readOdfTable(table, { parts: {} }).columnWidthsPt).toEqual([0]); + }); + + it("a genuinely positive repeated count on a row is honoured in full, not truncated", () => { + const table = el("table:table", {}, [ + el("table:table-row", { "table:number-rows-repeated": "4" }, [cell("x")]), + ]); + expect(readOdfTable(table, { parts: {} }).rows).toHaveLength(4); + }); +}); + +describe("readCellStyleDecoration", () => { + function cellPropsStyle(attrs: Record): XmlElement { + return el("style:table-cell-properties", attrs); + } + + it("returns everything undefined for an empty element list", () => { + expect(readCellStyleDecoration([])).toEqual({ + background: undefined, + borders: undefined, + alignment: undefined, + verticalAlignment: undefined, + }); + }); + + it("returns everything undefined when the style element carries neither a table-cell-properties nor a paragraph-properties child", () => { + const styleElement = el("style:style", {}); + expect(readCellStyleDecoration([styleElement])).toEqual({ + background: undefined, + borders: undefined, + alignment: undefined, + verticalAlignment: undefined, + }); + }); + + it.each(["top", "middle", "bottom"] as const)( + "resolves style:vertical-align=%s", + (value) => { + const styleElement = el("style:style", {}, [ + cellPropsStyle({ "style:vertical-align": value }), + ]); + expect(readCellStyleDecoration([styleElement]).verticalAlignment).toBe( + value, + ); + }, + ); + + it('leaves verticalAlignment undefined for "automatic", the one enumerated ODF value ContentSheetCell has no member for', () => { + const styleElement = el("style:style", {}, [ + cellPropsStyle({ "style:vertical-align": "automatic" }), + ]); + expect( + readCellStyleDecoration([styleElement]).verticalAlignment, + ).toBeUndefined(); + }); + + it.each(["left", "center", "right", "justify"] as const)( + "resolves fo:text-align=%s from a sibling style:paragraph-properties child", + (value) => { + const styleElement = el("style:style", {}, [ + el("style:paragraph-properties", { "fo:text-align": value }), + ]); + expect(readCellStyleDecoration([styleElement]).alignment).toBe(value); + }, + ); + + it('leaves alignment undefined for a fo:text-align value this package does not model (e.g. ODF\'s own "start")', () => { + const styleElement = el("style:style", {}, [ + el("style:paragraph-properties", { "fo:text-align": "start" }), + ]); + expect(readCellStyleDecoration([styleElement]).alignment).toBeUndefined(); + }); + + it("folds background/alignment/verticalAlignment across a multi-element chain, a later element overriding an earlier one", () => { + const base = el("style:style", {}, [ + cellPropsStyle({ + "fo:background-color": "#ff0000", + "style:vertical-align": "top", + }), + el("style:paragraph-properties", { "fo:text-align": "left" }), + ]); + const override = el("style:style", {}, [ + cellPropsStyle({ + "fo:background-color": "#00ff00", + "style:vertical-align": "bottom", + }), + el("style:paragraph-properties", { "fo:text-align": "right" }), + ]); + const decoration = readCellStyleDecoration([base, override]); + expect(decoration.background).toEqual({ + kind: "solid", + color: { r: 0, g: 1, b: 0 }, + }); + expect(decoration.verticalAlignment).toBe("bottom"); + expect(decoration.alignment).toBe("right"); + }); + + it("accumulates per-edge borders across a multi-element chain rather than only keeping the last element's own edges", () => { + const withLeft = el("style:style", {}, [ + cellPropsStyle({ "fo:border-left": "1pt solid #000000" }), + ]); + const withTop = el("style:style", {}, [ + cellPropsStyle({ "fo:border-top": "2pt dashed #ffffff" }), + ]); + const decoration = readCellStyleDecoration([withLeft, withTop]); + expect(decoration.borders?.left).toEqual({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 1, + style: "solid", + }); + expect(decoration.borders?.top).toEqual({ + color: { r: 1, g: 1, b: 1 }, + widthPt: 2, + style: "dashed", + }); + }); +}); + +describe("writeOdfTable", () => { + // Returns the write context alongside the minted elements, read back from the SAME automaticStyles element object registry.intern() pushes into -- the identical pattern styles/registry.test.ts's own automaticStylesOf establishes, rather than reaching into the registry's own private fields. + function writeContext(): { + context: OdfTableWriteContext; + mintedStyles: () => XmlElement[]; + } { + const automaticStyles = el("office:automatic-styles", {}, []); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [el("office:document-content", {}, [automaticStyles])], + }, + }, + }; + const registry = StyleRegistry.forPart(pkg, "content.xml"); + let nextTable = 1; + return { + context: { + registry, + mintTableName: () => `Table${nextTable++}`, + mintListStyleName: (kind) => `L${kind}`, + }, + mintedStyles: () => + automaticStyles.children.filter( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:style", + ), + }; + } + + function paragraphCell(text: string): ContentTableCell { + return { blocks: [{ kind: "paragraph", runs: [{ text }] }] }; + } + + // attrValue itself requires a real XmlElement; every caller here is reading an attribute off a `.find`/array-index result that is legitimately `XmlElement | undefined` under noUncheckedIndexedAccess, so this short-circuits the same way optional chaining does rather than asserting the element is present. + function attr( + element: XmlElement | undefined, + name: string, + ): string | undefined { + return element === undefined ? undefined : attrValue(element, name); + } + + function elementsWithTag(nodes: XmlElement["children"], tag: string) { + return nodes.filter( + (n): n is XmlElement => n.type === "element" && n.tag === tag, + ); + } + + it("mints a document-unique table:name from the context on every call", () => { + const { context } = writeContext(); + const table: ContentTable = { + kind: "table", + rows: [], + columnWidthsPt: [], + }; + const first = writeOdfTable(table, context); + const second = writeOdfTable(table, context); + expect(attrValue(first, "table:name")).toBe("Table1"); + expect(attrValue(second, "table:name")).toBe("Table2"); + }); + + it("writes one table:table-column per column width, with no style-name for a non-positive width", () => { + const table: ContentTable = { + kind: "table", + rows: [], + columnWidthsPt: [0, 100], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const columns = elementsWithTag(written.children, "table:table-column"); + expect(columns).toHaveLength(2); + expect(attr(columns[0], "table:style-name")).toBeUndefined(); + expect(attr(columns[1], "table:style-name")).toBeDefined(); + }); + + it("writes a table:style-name on a row only when it carries a heightPt", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { cells: [paragraphCell("a")] }, + { cells: [paragraphCell("b")], heightPt: 20 }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const rows = elementsWithTag(written.children, "table:table-row"); + expect(attr(rows[0], "table:style-name")).toBeUndefined(); + expect(attr(rows[1], "table:style-name")).toBeDefined(); + }); + + it("writes a style:width on the table's own style only when the columns state a positive total width", () => { + const withWidth: ContentTable = { + kind: "table", + rows: [], + columnWidthsPt: [50, 50], + }; + const withoutWidth: ContentTable = { + kind: "table", + rows: [], + columnWidthsPt: [], + }; + const { context: ctxWith, mintedStyles: stylesWith } = writeContext(); + writeOdfTable(withWidth, ctxWith); + const tableStyleWith = stylesWith().find( + (s) => attrValue(s, "style:family") === "table", + ); + const propsWith = tableStyleWith?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:table-properties", + ); + expect(attr(propsWith, "style:width")).toBeDefined(); + + const { context: ctxWithout, mintedStyles: stylesWithout } = writeContext(); + writeOdfTable(withoutWidth, ctxWithout); + const tableStyleWithout = stylesWithout().find( + (s) => attrValue(s, "style:family") === "table", + ); + const propsWithout = tableStyleWithout?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:table-properties", + ); + expect(attr(propsWithout, "style:width")).toBeUndefined(); + }); + + it("marks a colSpan'd cell's own covered neighbour, writing it as table:covered-table-cell rather than repeating the anchor's content", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [{ kind: "paragraph", runs: [{ text: "anchor" }] }], + colSpan: 2, + }, + paragraphCell("skipped"), + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const rowCells = + row === undefined + ? [] + : row.children.filter((n): n is XmlElement => n.type === "element"); + expect(rowCells[0]?.tag).toBe("table:table-cell"); + expect(attr(rowCells[0], "table:number-columns-spanned")).toBe("2"); + expect(rowCells[1]?.tag).toBe("table:covered-table-cell"); + }); + + it("marks a rowSpan'd cell's own covered neighbour in the row below, writing it as table:covered-table-cell", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [{ kind: "paragraph", runs: [{ text: "anchor" }] }], + rowSpan: 2, + }, + paragraphCell("sibling"), + ], + }, + { cells: [paragraphCell("covered"), paragraphCell("plain")] }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const rows = elementsWithTag(written.children, "table:table-row"); + const row1Cells = + rows[1] === undefined + ? [] + : rows[1].children.filter((n): n is XmlElement => n.type === "element"); + expect(row1Cells[0]?.tag).toBe("table:covered-table-cell"); + expect(row1Cells[1]?.tag).toBe("table:table-cell"); + }); + + it("writes table:number-columns-spanned/table:number-rows-spanned only when the cell actually states a span", () => { + const table: ContentTable = { + kind: "table", + rows: [{ cells: [paragraphCell("plain")] }], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const writtenCell = + row === undefined + ? undefined + : row.children.find((n): n is XmlElement => n.type === "element"); + expect(attr(writtenCell, "table:number-columns-spanned")).toBeUndefined(); + expect(attr(writtenCell, "table:number-rows-spanned")).toBeUndefined(); + }); + + it("writes a table:style-name on a cell only when it carries background or borders", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + paragraphCell("plain"), + { + blocks: [{ kind: "paragraph", runs: [{ text: "filled" }] }], + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const cells = + row === undefined + ? [] + : row.children.filter((n): n is XmlElement => n.type === "element"); + expect(attr(cells[0], "table:style-name")).toBeUndefined(); + expect(attr(cells[1], "table:style-name")).toBeDefined(); + }); + + it("writes each per-edge border only for edges the cell actually states, leaving the others absent", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [{ kind: "paragraph", runs: [{ text: "bordered" }] }], + borders: { + left: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context, mintedStyles } = writeContext(); + writeOdfTable(table, context); + const cellStyleEl = mintedStyles().find( + (s) => attrValue(s, "style:family") === "table-cell", + ); + const props = cellStyleEl?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:table-cell-properties", + ); + expect(attr(props, "fo:border-left")).toBeDefined(); + expect(attr(props, "fo:border-top")).toBeUndefined(); + expect(attr(props, "fo:border-right")).toBeUndefined(); + expect(attr(props, "fo:border-bottom")).toBeUndefined(); + }); + + it("groups consecutive same-list paragraphs into one text:list, closing it when membership changes", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "item1" }], + list: { numId: "bullet:list1", level: 0 }, + }, + { + kind: "paragraph", + runs: [{ text: "item2" }], + list: { numId: "bullet:list1", level: 0 }, + }, + { kind: "paragraph", runs: [{ text: "plain" }] }, + ], + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const writtenCell = + row === undefined + ? undefined + : row.children.find((n): n is XmlElement => n.type === "element"); + const cellChildren = + writtenCell === undefined + ? [] + : writtenCell.children.filter( + (n): n is XmlElement => n.type === "element", + ); + expect(cellChildren).toHaveLength(2); + expect(cellChildren[0]?.tag).toBe("text:list"); + expect(cellChildren[0]?.children).toHaveLength(2); + expect(cellChildren[1]?.tag).toBe("text:p"); + }); + + it("closes an open list and starts a fresh one when membership switches to a different numId", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "bullet:list1", level: 0 }, + }, + { + kind: "paragraph", + runs: [{ text: "b" }], + list: { numId: "ordered:list2", level: 0 }, + }, + ], + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const writtenCell = + row === undefined + ? undefined + : row.children.find((n): n is XmlElement => n.type === "element"); + const cellChildren = + writtenCell === undefined + ? [] + : writtenCell.children.filter( + (n): n is XmlElement => n.type === "element", + ); + expect(cellChildren).toHaveLength(2); + expect(cellChildren[0]?.tag).toBe("text:list"); + expect(cellChildren[1]?.tag).toBe("text:list"); + }); + + it("writes a nested table found inside a cell by recursing into writeOdfTable, minting its own table:name off the same document-wide counter", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "table", + rows: [{ cells: [paragraphCell("nested")] }], + columnWidthsPt: [], + }, + ], + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + const written = writeOdfTable(table, context); + expect(attrValue(written, "table:name")).toBe("Table1"); + const row = elementsWithTag(written.children, "table:table-row")[0]; + const writtenCell = + row === undefined + ? undefined + : row.children.find((n): n is XmlElement => n.type === "element"); + const nestedTable = + writtenCell === undefined + ? undefined + : writtenCell.children.find( + (n): n is XmlElement => + n.type === "element" && n.tag === "table:table", + ); + expect( + nestedTable === undefined + ? undefined + : attrValue(nestedTable, "table:name"), + ).toBe("Table2"); + }); + + it("refuses to write a cell block kind readTableCell could never read back, naming the offending kind", () => { + const table: ContentTable = { + kind: "table", + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "image", + format: "png", + base64: "", + widthPt: 1, + heightPt: 1, + }, + ], + }, + ], + }, + ], + columnWidthsPt: [], + }; + const { context } = writeContext(); + expect(() => writeOdfTable(table, context)).toThrow(/image/); + }); +}); diff --git a/packages/odf.js/src/typed/shared/table.ts b/packages/odf.js/src/typed/shared/table.ts index 3f13527de..09cba17e0 100644 --- a/packages/odf.js/src/typed/shared/table.ts +++ b/packages/odf.js/src/typed/shared/table.ts @@ -51,8 +51,9 @@ function readRepeatCount(element: XmlElement, attrName: string): number { if (raw === undefined) { return 1; } + // Number.parseInt always returns an integer or NaN, and NaN > 0 is false like every other NaN comparison, so a separate Number.isInteger guard adds nothing a plain > 0 check doesn't already cover on its own. const parsed = Number.parseInt(raw, 10); - return Number.isInteger(parsed) && parsed > 0 ? parsed : 1; + return parsed > 0 ? parsed : 1; } // A column with no resolvable width (no table:style-name, no matching style, or a style with no style:table-column-properties/@style:column-width) defaults to 0pt, mirroring ooxml.js's own readTable (`emuToPt(Number(attr(col, 'w') ?? '0'))`) -- an established, deliberate sibling-reader convention, not a fallback invented here. From ac31917f690587608adff69ac02228b05dea31be Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:11:45 +0100 Subject: [PATCH 78/89] test(odf.js): add direct unit coverage for canonicalRun canonicalRun had no dedicated tests at all -- every canonicalParagraph test exercised it only through a single-field RUN fixture, so none of its seven per-field guards (bold/italic/underline/strike/fontFamily/ sizePt/color/hyperlink) or the canonicalColor delegation ever had a test proving that field actually passes through. Adds one test per field plus an all-fields-at-once case proving none clobbers another. --- .../src/typed/shared/canonicalise.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/odf.js/src/typed/shared/canonicalise.test.ts b/packages/odf.js/src/typed/shared/canonicalise.test.ts index c44151072..441b92eb9 100644 --- a/packages/odf.js/src/typed/shared/canonicalise.test.ts +++ b/packages/odf.js/src/typed/shared/canonicalise.test.ts @@ -15,6 +15,7 @@ import { canonicalImage, canonicalMetadata, canonicalParagraph, + canonicalRun, canonicalTable, } from "./canonicalise"; @@ -28,6 +29,96 @@ function paragraph( return { kind: "paragraph", runs: [RUN], ...overrides }; } +describe("canonicalRun", () => { + it("keeps only text when no other field is stated", () => { + expect(canonicalRun({ text: "plain" })).toEqual({ text: "plain" }); + }); + + it("carries bold through when stated", () => { + expect(canonicalRun({ text: "x", bold: true })).toEqual({ + text: "x", + bold: true, + }); + }); + + it("carries italic through when stated", () => { + expect(canonicalRun({ text: "x", italic: true })).toEqual({ + text: "x", + italic: true, + }); + }); + + it("carries underline through when stated", () => { + expect(canonicalRun({ text: "x", underline: true })).toEqual({ + text: "x", + underline: true, + }); + }); + + it("carries strike through when stated", () => { + expect(canonicalRun({ text: "x", strike: true })).toEqual({ + text: "x", + strike: true, + }); + }); + + it("carries fontFamily through when stated", () => { + expect(canonicalRun({ text: "x", fontFamily: "Arial" })).toEqual({ + text: "x", + fontFamily: "Arial", + }); + }); + + it("carries sizePt through when stated", () => { + expect(canonicalRun({ text: "x", sizePt: 12 })).toEqual({ + text: "x", + sizePt: 12, + }); + }); + + it("quantises color through canonicalColor's own hex-pair round trip when stated", () => { + expect(canonicalRun({ text: "x", color: { r: 0.9, g: 0, b: 0 } })).toEqual({ + text: "x", + color: { r: 230 / 255, g: 0, b: 0 }, + }); + }); + + it("carries hyperlink through when stated", () => { + expect( + canonicalRun({ text: "x", hyperlink: "https://example.com" }), + ).toEqual({ + text: "x", + hyperlink: "https://example.com", + }); + }); + + it("carries every field at once, none clobbering another", () => { + expect( + canonicalRun({ + text: "x", + bold: true, + italic: true, + underline: true, + strike: true, + fontFamily: "Arial", + sizePt: 12, + color: { r: 0, g: 0, b: 0 }, + hyperlink: "https://example.com", + }), + ).toEqual({ + text: "x", + bold: true, + italic: true, + underline: true, + strike: true, + fontFamily: "Arial", + sizePt: 12, + color: { r: 0, g: 0, b: 0 }, + hyperlink: "https://example.com", + }); + }); +}); + describe("canonicalParagraph", () => { it("keeps only the run text when no other field is stated", () => { expect(canonicalParagraph(paragraph(), undefined)).toEqual({ From 0ecd8a2429cbaf92f0be9447207bdf8c6e5213bf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:17:30 +0100 Subject: [PATCH 79/89] test(odf.js): add direct XML-structure coverage for odb/write.ts writeOdb's own suite was entirely round-trip tests against real fixtures (write then read back, compare to the original inventory), which cannot observe a mutation that changes what gets WRITTEN in a way the reader's own inverse tolerates or the fixture never exercises -- the same failure mode typed/shared/canonicalise.ts's own top-of- file note describes for write/read pairs in general. Adds direct, one-sided assertions against the raw written XML for: the two connection-type names in the no-url error message, the four optional child elements staying entirely absent when every list is empty, db:as-template/db:escape-processing each writing the exact true/false the caller stated (and nothing when absent), table names writing in order, and a connection's url landing verbatim in db:connection-resource regardless of connection type. --- packages/odf.js/src/typed/odb/write.test.ts | 145 ++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/packages/odf.js/src/typed/odb/write.test.ts b/packages/odf.js/src/typed/odb/write.test.ts index ca95d0bd0..5be3f4cea 100644 --- a/packages/odf.js/src/typed/odb/write.test.ts +++ b/packages/odf.js/src/typed/odb/write.test.ts @@ -3,14 +3,56 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import type { Package } from "../../model/package"; +import type { XmlElement } from "../../model/node"; import { decodePackage, encodePackage } from "../../codec"; import { readMimetype } from "../../mimetype"; import { validateManifest } from "../../manifest"; import { parsePackage } from "../../package-io/read"; +import { rootElement, childrenWithTag } from "../../xml/query"; +import { attrValue } from "../../xml/query"; import { readOdbInventory } from "./read"; import type { OdbInventory } from "./read"; import { writeOdb } from "./write"; +// Direct, one-sided structural checks against the raw written XML, alongside the round-trip suite below: a round trip (write then read back, compare to the original inventory) cannot observe a mutation that changes what gets WRITTEN in a way the reader's own inverse tolerates or a fixture never exercises (see typed/shared/canonicalise.ts's own top-of-file note on this exact failure mode) -- e.g. a fixture whose components all share one asTemplate value can't distinguish "always writes true" from "writes the real value". +function databaseElement(pkg: Package): XmlElement { + const part = pkg.parts["content.xml"]; + if (part?.kind !== "xml") { + throw new Error("expected an xml content.xml part"); + } + const root = rootElement(part.nodes); + if (root === undefined) { + throw new Error("expected a root element"); + } + const body = childrenWithTag(root, "office:body")[0]; + if (body === undefined) { + throw new Error("expected an office:body element"); + } + const database = childrenWithTag(body, "office:database")[0]; + if (database === undefined) { + throw new Error("expected an office:database element"); + } + return database; +} + +function emptyInventory(): OdbInventory { + return { + connection: undefined, + tables: [], + queries: [], + forms: [], + reports: [], + }; +} + +// attrValue itself requires a real XmlElement; every caller here reads an attribute off a `[n]` array-index result that is legitimately `XmlElement | undefined` under noUncheckedIndexedAccess, so this short-circuits the same way optional chaining does rather than asserting the element is present. +function attr( + element: XmlElement | undefined, + name: string, +): string | undefined { + return element === undefined ? undefined : attrValue(element, name); +} + const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); function loadFixture(name: string): Package { @@ -80,4 +122,107 @@ describe("writeOdb", () => { }), ).toThrow(/no url/); }); + + it("names the connection kind that carries no url in the thrown message", () => { + expect(() => + writeOdb({ + ...emptyInventory(), + connection: { type: "embedded" }, + }), + ).toThrow(/an embedded connection/); + expect(() => + writeOdb({ + ...emptyInventory(), + connection: { type: "external" }, + }), + ).toThrow(/an external connection/); + }); + + it("writes no db:forms/db:reports/db:queries/db:table-representations element at all when every list is empty", () => { + const database = databaseElement(writeOdb(emptyInventory())); + expect(childrenWithTag(database, "db:forms")).toHaveLength(0); + expect(childrenWithTag(database, "db:reports")).toHaveLength(0); + expect(childrenWithTag(database, "db:queries")).toHaveLength(0); + expect(childrenWithTag(database, "db:table-representations")).toHaveLength( + 0, + ); + expect(childrenWithTag(database, "db:data-source")).toHaveLength(0); + }); + + it("writes db:as-template only when the component actually states it, verbatim true or false", () => { + const database = databaseElement( + writeOdb({ + ...emptyInventory(), + forms: [ + { name: "NoFlag", href: "forms/Obj1" }, + { name: "FlagTrue", href: "forms/Obj2", asTemplate: true }, + { name: "FlagFalse", href: "forms/Obj3", asTemplate: false }, + ], + }), + ); + const forms = childrenWithTag(database, "db:forms")[0]; + const components = + forms === undefined ? [] : childrenWithTag(forms, "db:component"); + expect(attr(components[0], "db:as-template")).toBeUndefined(); + expect(attr(components[1], "db:as-template")).toBe("true"); + expect(attr(components[2], "db:as-template")).toBe("false"); + }); + + it("writes db:escape-processing only when the query actually states it, verbatim true or false", () => { + const database = databaseElement( + writeOdb({ + ...emptyInventory(), + queries: [ + { name: "NoFlag", command: "SELECT 1" }, + { name: "FlagTrue", command: "SELECT 1", escapeProcessing: true }, + { name: "FlagFalse", command: "SELECT 1", escapeProcessing: false }, + ], + }), + ); + const queries = childrenWithTag(database, "db:queries")[0]; + if (queries === undefined) { + throw new Error("expected a db:queries element"); + } + const writtenQueries = childrenWithTag(queries, "db:query"); + expect(attr(writtenQueries[0], "db:escape-processing")).toBeUndefined(); + expect(attr(writtenQueries[1], "db:escape-processing")).toBe("true"); + expect(attr(writtenQueries[2], "db:escape-processing")).toBe("false"); + }); + + it("writes each table name as its own db:table-representation, in order", () => { + const database = databaseElement( + writeOdb({ ...emptyInventory(), tables: ["Customers", "Orders"] }), + ); + const representations = childrenWithTag( + database, + "db:table-representations", + )[0]; + if (representations === undefined) { + throw new Error("expected a db:table-representations element"); + } + const rows = childrenWithTag(representations, "db:table-representation"); + expect(rows.map((row) => attrValue(row, "db:name"))).toEqual([ + "Customers", + "Orders", + ]); + }); + + it("writes the connection's own url verbatim into db:connection-resource, regardless of connection type", () => { + const database = databaseElement( + writeOdb({ + ...emptyInventory(), + connection: { type: "embedded", url: "sdbc:embedded:hsqldb" }, + }), + ); + const dataSource = childrenWithTag(database, "db:data-source")[0]; + if (dataSource === undefined) { + throw new Error("expected a db:data-source element"); + } + const connectionData = childrenWithTag(dataSource, "db:connection-data")[0]; + const resource = + connectionData === undefined + ? undefined + : childrenWithTag(connectionData, "db:connection-resource")[0]; + expect(attr(resource, "xlink:href")).toBe("sdbc:embedded:hsqldb"); + }); }); From 9bdcc80898c78709bf3658a4efbdd0d25967548d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:24:00 +0100 Subject: [PATCH 80/89] test(odf.js): add the first direct unit coverage for typed/draw/write-vectors.ts writeDrawVector/canonicalDrawVector had no dedicated test file at all -- only indirect exercise through typed/odg/write.test.ts's own round-trip suite, which cannot observe a mutation that changes what gets WRITTEN in a way the reader's own inverse tolerates or the round-trip fixtures never happen to exercise (the same failure mode typed/shared/canonicalise.ts's own top-of-file note describes). Adds direct, one-sided assertions against the raw written XML for vectorGraphicStyleName's fill/fillRule/stroke branches (including the two refusal paths: a dotted stroke style and a non-positive stroke width), zIndexAttrs' resolved-vs-fallback paint order, each of the four writeDrawVector element shapes (including the path element's two refusal paths: no subpaths, and a non-positive frame dimension), and writeDrawVectors' own baseIndex-plus-position arithmetic -- plus direct coverage of canonicalDrawVector's own per-field canonicalising rules (rotationDeg-zero collapse, color quantisation, absent-stroke- style defaulting, fillRule/subpath carrying, and per-kind field identity). --- .../src/typed/draw/write-vectors.test.ts | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 packages/odf.js/src/typed/draw/write-vectors.test.ts diff --git a/packages/odf.js/src/typed/draw/write-vectors.test.ts b/packages/odf.js/src/typed/draw/write-vectors.test.ts new file mode 100644 index 000000000..c7c81d432 --- /dev/null +++ b/packages/odf.js/src/typed/draw/write-vectors.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it } from "vitest"; +import type { ContentVector } from "document-schema.js"; +import type { Package } from "../../model/package"; +import type { XmlElement } from "../../model/node"; +import { el } from "../../xml/fragment"; +import { attrValue } from "../../xml/query"; +import { StyleRegistry } from "../../styles/registry"; +import { + createDrawShapeWriteState, + type DrawShapeWriteState, +} from "./write-shapes"; +import { + writeDrawVector, + writeDrawVectors, + canonicalDrawVector, +} from "./write-vectors"; + +// writeDrawVector/canonicalDrawVector had no direct unit tests at all -- only indirect exercise through typed/odg/write.test.ts's own round-trip suite, which (see typed/shared/canonicalise.ts's own top-of-file note) cannot observe a mutation that changes what gets WRITTEN in a way the reader's own inverse tolerates. These tests assert directly against the raw written XML and against canonicalDrawVector's own return value. + +function writeState(): DrawShapeWriteState { + const automaticStyles = el("office:automatic-styles", {}, []); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [el("office:document-content", {}, [automaticStyles])], + }, + }, + }; + const registry = StyleRegistry.forPart(pkg, "content.xml"); + return createDrawShapeWriteState(pkg, registry, automaticStyles); +} + +function attr(element: XmlElement, name: string): string | undefined { + return attrValue(element, name); +} + +function graphicPropsOf( + written: XmlElement, + state: DrawShapeWriteState, +): XmlElement { + const styleName = attr(written, "draw:style-name"); + const style = state.contentAutomaticStyles.children.find( + (c): c is XmlElement => + c.type === "element" && + c.tag === "style:style" && + attrValue(c, "style:name") === styleName, + ); + if (style === undefined) { + throw new Error(`expected a minted style named ${styleName}`); + } + const props = style.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:graphic-properties", + ); + if (props === undefined) { + throw new Error("expected a style:graphic-properties child"); + } + return props; +} + +// `satisfies` rather than `: ContentVector`, so each fixture keeps its own literal "rect"/"line"/"path" member type -- annotating with the full union would widen it back to the union and lose the narrowing canonicalDrawVector's own per-kind assertions below rely on. +const RECT = { + kind: "rect", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, +} satisfies ContentVector; + +describe("writeDrawVector: paint (vectorGraphicStyleName)", () => { + it('writes draw:fill="none" when the vector states no fill', () => { + const state = writeState(); + const written = writeDrawVector(RECT, state, 0); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:fill")).toBe("none"); + expect(attr(props, "draw:fill-color")).toBeUndefined(); + }); + + it('writes draw:fill="solid" and draw:fill-color when the vector states a fill', () => { + const state = writeState(); + const written = writeDrawVector( + { ...RECT, fill: { r: 1, g: 0, b: 0 } }, + state, + 0, + ); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:fill")).toBe("solid"); + expect(attr(props, "draw:fill-color")).toBe("#ff0000"); + }); + + it("writes svg:fill-rule only when the path vector states one", () => { + const path: ContentVector = { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths: [ + { + start: { xPt: 0, yPt: 0 }, + segments: [{ kind: "line", to: { xPt: 1, yPt: 1 } }], + closed: false, + }, + ], + }; + const state = writeState(); + const withoutRule = graphicPropsOf(writeDrawVector(path, state, 0), state); + expect(attr(withoutRule, "svg:fill-rule")).toBeUndefined(); + + const state2 = writeState(); + const withRule = graphicPropsOf( + writeDrawVector({ ...path, fillRule: "evenodd" }, state2, 0), + state2, + ); + expect(attr(withRule, "svg:fill-rule")).toBe("evenodd"); + }); + + it('writes draw:stroke="none" and no stroke-color/width when the vector states no stroke', () => { + const state = writeState(); + const written = writeDrawVector(RECT, state, 0); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:stroke")).toBe("none"); + expect(attr(props, "svg:stroke-color")).toBeUndefined(); + expect(attr(props, "svg:stroke-width")).toBeUndefined(); + }); + + it("writes a solid stroke's colour and width, and draw:stroke=solid for an absent or explicit solid style", () => { + const state = writeState(); + const written = writeDrawVector( + { + ...RECT, + stroke: { color: { r: 0, g: 0, b: 1 }, widthPt: 2 }, + }, + state, + 0, + ); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:stroke")).toBe("solid"); + expect(attr(props, "svg:stroke-color")).toBe("#0000ff"); + expect(attr(props, "svg:stroke-width")).toBe("2pt"); + }); + + it('writes draw:stroke="dash" for a dashed stroke style', () => { + const state = writeState(); + const written = writeDrawVector( + { + ...RECT, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "dashed" }, + }, + state, + 0, + ); + const props = graphicPropsOf(written, state); + expect(attr(props, "draw:stroke")).toBe("dash"); + }); + + it("refuses a 'dotted' stroke style, naming it and ODF's own none/solid/dash enumeration", () => { + const state = writeState(); + expect(() => + writeDrawVector( + { + ...RECT, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "dotted" }, + }, + state, + 0, + ), + ).toThrow(/dotted.*none\/solid\/dash/s); + }); + + it("refuses a stroke whose widthPt is not positive, naming the actual width", () => { + const state = writeState(); + expect(() => + writeDrawVector( + { ...RECT, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 0 } }, + state, + 0, + ), + ).toThrow(/width 0pt/); + }); +}); + +describe("writeDrawVector: draw:z-index (zIndexAttrs)", () => { + it("uses the caller's documentIndex when the vector states no ODF-spellable paintOrder", () => { + const state = writeState(); + const written = writeDrawVector(RECT, state, 3); + expect(attr(written, "draw:z-index")).toBe("3"); + }); + + it("uses the vector's own resolvable paintOrder over the caller's documentIndex", () => { + const state = writeState(); + const written = writeDrawVector({ ...RECT, paintOrder: 5 }, state, 3); + expect(attr(written, "draw:z-index")).toBe("5"); + }); +}); + +describe("writeDrawVector: per-kind element shape", () => { + it("writes a 'line' as draw:line with its own four endpoint coordinates and no frame geometry", () => { + const state = writeState(); + const line: ContentVector = { + kind: "line", + from: { xPt: 1, yPt: 2 }, + to: { xPt: 3, yPt: 4 }, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }; + const written = writeDrawVector(line, state, 0); + expect(written.tag).toBe("draw:line"); + expect(attr(written, "svg:x1")).toBe("1pt"); + expect(attr(written, "svg:y1")).toBe("2pt"); + expect(attr(written, "svg:x2")).toBe("3pt"); + expect(attr(written, "svg:y2")).toBe("4pt"); + }); + + it("writes 'rect' as draw:rect and 'ellipse' as draw:ellipse", () => { + const state = writeState(); + expect(writeDrawVector(RECT, state, 0).tag).toBe("draw:rect"); + expect(writeDrawVector({ ...RECT, kind: "ellipse" }, state, 0).tag).toBe( + "draw:ellipse", + ); + }); + + it("writes 'path' as draw:path with svg:viewBox and svg:d", () => { + const state = writeState(); + const path: ContentVector = { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths: [ + { + start: { xPt: 0, yPt: 0 }, + segments: [{ kind: "line", to: { xPt: 1, yPt: 1 } }], + closed: false, + }, + ], + }; + const written = writeDrawVector(path, state, 0); + expect(written.tag).toBe("draw:path"); + expect(attr(written, "svg:viewBox")).toBeDefined(); + expect(attr(written, "svg:d")).toBeDefined(); + }); + + it("refuses a 'path' with no subpaths at all", () => { + const state = writeState(); + expect(() => + writeDrawVector( + { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths: [], + }, + state, + 0, + ), + ).toThrow(/no subpaths/); + }); + + it("refuses a 'path' whose frame has a non-positive width or height, naming the actual dimensions", () => { + const state = writeState(); + const subpaths: Extract["subpaths"] = [ + { + start: { xPt: 0, yPt: 0 }, + segments: [{ kind: "line", to: { xPt: 1, yPt: 1 } }], + closed: false, + }, + ]; + expect(() => + writeDrawVector( + { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 0, heightPt: 10 }, + subpaths, + }, + state, + 0, + ), + ).toThrow(/0pt x 10pt/); + expect(() => + writeDrawVector( + { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: -1 }, + subpaths, + }, + state, + 0, + ), + ).toThrow(/10pt x -1pt/); + }); +}); + +describe("writeDrawVectors", () => { + it("writes each vector at baseIndex plus its own array position", () => { + const state = writeState(); + const written = writeDrawVectors([RECT, RECT], state, 5); + expect(written).toHaveLength(2); + const [first, second] = written; + if (first === undefined || second === undefined) { + throw new Error("expected two written vectors"); + } + expect(attr(first, "draw:z-index")).toBe("5"); + expect(attr(second, "draw:z-index")).toBe("6"); + }); +}); + +describe("canonicalDrawVector", () => { + it("resolves paintOrder to documentIndex when the vector states none", () => { + expect(canonicalDrawVector(RECT, 7).paintOrder).toBe(7); + }); + + it("resolves paintOrder to the vector's own value when it states one", () => { + expect(canonicalDrawVector({ ...RECT, paintOrder: 2 }, 7).paintOrder).toBe( + 2, + ); + }); + + it("collapses rotationDeg === 0 to absent, but keeps a genuine non-zero rotation", () => { + const zero = canonicalDrawVector({ ...RECT, rotationDeg: 0 }, 0); + const nonZero = canonicalDrawVector({ ...RECT, rotationDeg: 45 }, 0); + if (zero.kind === "line" || nonZero.kind === "line") { + throw new Error("expected 'rect' results, not 'line'"); + } + expect(zero.rotationDeg).toBeUndefined(); + expect(nonZero.rotationDeg).toBe(45); + }); + + it("quantises fill through canonicalColor and leaves an absent fill absent", () => { + const absent = canonicalDrawVector(RECT, 0); + const stated = canonicalDrawVector( + { ...RECT, fill: { r: 0.9, g: 0, b: 0 } }, + 0, + ); + if (absent.kind === "line" || stated.kind === "line") { + throw new Error("expected 'rect' results, not 'line'"); + } + expect(absent.fill).toBeUndefined(); + expect(stated.fill).toEqual({ r: 230 / 255, g: 0, b: 0 }); + }); + + it("canonicalises an absent stroke style to 'solid' and leaves an absent stroke absent", () => { + expect(canonicalDrawVector(RECT, 0).stroke).toBeUndefined(); + const result = canonicalDrawVector( + { ...RECT, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 } }, + 0, + ); + expect(result.stroke).toEqual({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 1, + style: "solid", + }); + }); + + it("carries fillRule through for a 'path' only when stated", () => { + const path = { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths: [], + } satisfies ContentVector; + expect(canonicalDrawVector(path, 0)).not.toHaveProperty("fillRule"); + const withRule = canonicalDrawVector({ ...path, fillRule: "nonzero" }, 0); + if (withRule.kind !== "path") { + throw new Error("expected a 'path' result"); + } + expect(withRule.fillRule).toBe("nonzero"); + }); + + it("carries every subpath's own start/segments/closed through for a 'path', as a fresh array", () => { + const subpaths: NonNullable< + Extract["subpaths"] + > = [ + { + start: { xPt: 1, yPt: 2 }, + segments: [{ kind: "line", to: { xPt: 3, yPt: 4 } }], + closed: true, + }, + ]; + const path: ContentVector = { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + subpaths, + }; + const result = canonicalDrawVector(path, 0); + expect(result.kind).toBe("path"); + if (result.kind === "path") { + expect(result.subpaths).toEqual(subpaths); + expect(result.subpaths).not.toBe(subpaths); + } + }); + + it("carries kind/frame through unchanged for 'line', 'rect', and 'ellipse'", () => { + const line: ContentVector = { + kind: "line", + from: { xPt: 0, yPt: 0 }, + to: { xPt: 1, yPt: 1 }, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }; + const result = canonicalDrawVector(line, 0); + expect(result.kind).toBe("line"); + if (result.kind === "line") { + expect(result.from).toEqual({ xPt: 0, yPt: 0 }); + expect(result.to).toEqual({ xPt: 1, yPt: 1 }); + } + const rectResult = canonicalDrawVector(RECT, 0); + if (rectResult.kind === "line") { + throw new Error("expected a 'rect' result, not 'line'"); + } + expect(rectResult.frame).toEqual(RECT.frame); + }); +}); From 2e045c6b6be72e3c0f3a298e6a4be04bfa581362 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:26:21 +0100 Subject: [PATCH 81/89] test(odf.js): cover paragraph.ts's note/annotation sequential minting nextNoteOrdinal/nextAnnotationOrdinal each only ever had a single unnamed note/annotation in any existing test, so the counter's own post-increment could run backwards (or not advance at all) without any test noticing -- the minted name a SINGLE call produces is identical either way. Adds a two-construct case for each counter, asserting the second unnamed note/annotation gets a genuinely higher-numbered name than the first, not the same name reused or a decreasing one. --- .../odf.js/src/typed/shared/paragraph.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/odf.js/src/typed/shared/paragraph.test.ts b/packages/odf.js/src/typed/shared/paragraph.test.ts index 31c7ceeb8..984758073 100644 --- a/packages/odf.js/src/typed/shared/paragraph.test.ts +++ b/packages/odf.js/src/typed/shared/paragraph.test.ts @@ -826,6 +826,26 @@ describe("readOdfParagraph: run-level construct extents (fields, bookmarks)", () }); }); + it("mints sequentially INCREASING names across multiple unnamed notes, not the same name reused or a decreasing counter", () => { + const note = (text: string) => + el("text:note", { "text:note-class": "footnote" }, [ + el("text:note-citation", {}, [txt(text)]), + ]); + const p = el("text:p", {}, [note("1"), note("2")]); + const sink: OdfDefinitionsSink = { + entries: {}, + nextNoteOrdinal: 1, + nextAnnotationOrdinal: 1, + }; + const paragraph = readOdfParagraph(p, { parts: {} }, { definitions: sink }); + expect( + paragraph.constructs?.map((c) => + c.descriptor.kind === "anchor" ? c.descriptor.name : undefined, + ), + ).toEqual(["note1", "note2"]); + expect(Object.keys(sink.entries)).toEqual(["note:note1", "note:note2"]); + }); + it("reads an unnamed office:annotation as a point comment anchor at its run position, with its body and author in the definitions sink", () => { const annotation = el("office:annotation", {}, [ el("dc:creator", {}, [txt("C. Reviewer")]), @@ -862,6 +882,22 @@ describe("readOdfParagraph: run-level construct extents (fields, bookmarks)", () }); }); + it("mints sequentially INCREASING names across multiple unnamed annotations, not the same name reused or a decreasing counter", () => { + const annotation = (text: string) => + el("office:annotation", {}, [el("text:p", {}, [txt(text)])]); + const p = el("text:p", {}, [annotation("first"), annotation("second")]); + const sink: OdfDefinitionsSink = { + entries: {}, + nextNoteOrdinal: 1, + nextAnnotationOrdinal: 1, + }; + readOdfParagraph(p, { parts: {} }, { definitions: sink }); + expect(Object.keys(sink.entries)).toEqual([ + "comment:annotation1", + "comment:annotation2", + ]); + }); + it("assembles an annotation body's paragraphs and list items in document order, not paragraphs-then-lists", () => { const annotation = el("office:annotation", {}, [ el("dc:creator", {}, [txt("C. Reviewer")]), From 0f15ae9211d8e247bc9b3f3141bb6062d3604277 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:28:47 +0100 Subject: [PATCH 82/89] test(odf.js): add the first direct unit coverage for ooo1/properties.ts This module (the OpenOffice.org 1.x style:properties splitter/merger) had no dedicated test file at all -- every reader that touches it only exercises it indirectly through a whole-document round trip. Adds direct tests for propertyTypesForContainer's own routing branches (container-tag lookup, style:style/style:default-style family lookup, and each of its three undefined-returning cases), splitStyleProperties' first-match-wins routing and its fallback to the first candidate for an unrecognised attribute, its own per- candidate element emission (including omitting an empty candidate), its non-element-child skip, its child-element routing, both OpenOffice.org compound-attribute expansions (style:text-underline and style:text-crossing-out, each with a listed and an unlisted value), the fo:keep-with-next boolean-to-keyword rewrite, and mergeStyleProperties' found/not-found result and its multi-child concatenation and rest-preservation behaviour. --- packages/odf.js/src/ooo1/properties.test.ts | 257 ++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 packages/odf.js/src/ooo1/properties.test.ts diff --git a/packages/odf.js/src/ooo1/properties.test.ts b/packages/odf.js/src/ooo1/properties.test.ts new file mode 100644 index 000000000..6a853c94b --- /dev/null +++ b/packages/odf.js/src/ooo1/properties.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from "vitest"; +import type { XmlElement } from "../model/node"; +import { el, txt } from "../xml/fragment"; +import { + propertyTypesForContainer, + splitStyleProperties, + mergeStyleProperties, +} from "./properties"; + +// This module had no direct unit tests at all -- every reader/writer that touches it only exercises it indirectly through a whole-document round trip. Direct coverage below targets propertyTypesForContainer's own routing branches, splitStyleProperties' first-match-wins/fallback-to-first-candidate routing (including the two OpenOffice.org compound-attribute expansions), and mergeStyleProperties' found/not-found and multi-child concatenation behaviour. + +describe("propertyTypesForContainer", () => { + it("resolves a container tag with no style:family attribute at all, e.g. style:page-master", () => { + expect(propertyTypesForContainer(el("style:page-master"))).toEqual([ + "page-layout", + ]); + }); + + it("resolves a number:*-style container tag to ['text']", () => { + expect(propertyTypesForContainer(el("number:date-style"))).toEqual([ + "text", + ]); + }); + + it("resolves style:style by its own style:family attribute", () => { + expect( + propertyTypesForContainer( + el("style:style", { "style:family": "table-cell" }), + ), + ).toEqual(["table-cell", "paragraph", "text"]); + }); + + it("resolves style:default-style by its own style:family attribute exactly like style:style", () => { + expect( + propertyTypesForContainer( + el("style:default-style", { "style:family": "paragraph" }), + ), + ).toEqual(["paragraph", "text"]); + }); + + it("returns undefined for style:style with no style:family attribute at all", () => { + expect(propertyTypesForContainer(el("style:style"))).toBeUndefined(); + }); + + it("returns undefined for style:style whose style:family this module does not recognise", () => { + expect( + propertyTypesForContainer( + el("style:style", { "style:family": "unknown-family" }), + ), + ).toBeUndefined(); + }); + + it("returns undefined for a container tag this module recognises neither by tag nor as a family-bearing style element", () => { + expect(propertyTypesForContainer(el("office:styles"))).toBeUndefined(); + }); +}); + +describe("splitStyleProperties: attribute routing", () => { + it("routes an attribute to the first candidate whose vocabulary claims it", () => { + // fo:background-color is claimed by table-cell, paragraph, and graphic alike; with table-cell first in the candidate list it must win, not paragraph. + const properties = el("style:properties", { + "fo:background-color": "#ff0000", + }); + const split = splitStyleProperties(properties, [ + "table-cell", + "paragraph", + "text", + ]); + expect(split).toHaveLength(1); + expect(split[0]?.tag).toBe("style:table-cell-properties"); + }); + + it("routes the identical attribute to paragraph instead, when paragraph is first in the candidate list", () => { + const properties = el("style:properties", { + "fo:background-color": "#ff0000", + }); + const split = splitStyleProperties(properties, ["paragraph", "text"]); + expect(split).toHaveLength(1); + expect(split[0]?.tag).toBe("style:paragraph-properties"); + }); + + it("falls back to the first candidate for an attribute no candidate's vocabulary claims", () => { + const properties = el("style:properties", { + "draw:some-unlisted-attribute": "x", + }); + const split = splitStyleProperties(properties, ["graphic", "paragraph"]); + expect(split).toHaveLength(1); + expect(split[0]?.tag).toBe("style:graphic-properties"); + }); + + it("emits one typed element per candidate that actually received something, in candidate order, and none for a candidate that received nothing", () => { + const properties = el("style:properties", { + "fo:font-family": "Arial", // text-only + "fo:margin-top": "2pt", // paragraph-only + }); + const split = splitStyleProperties(properties, [ + "table-cell", + "paragraph", + "text", + ]); + expect(split.map((s) => s.tag)).toEqual([ + "style:paragraph-properties", + "style:text-properties", + ]); + }); + + it("skips non-element children (whitespace/comment text nodes) without filing them into any family", () => { + const properties = el("style:properties", {}, [txt(" ")]); + const split = splitStyleProperties(properties, ["paragraph", "text"]); + expect(split).toHaveLength(0); + }); + + it("routes a style:properties child element the identical first-match way attributes are routed", () => { + const tabStops = el("style:tab-stops"); + const properties = el("style:properties", {}, [tabStops]); + const split = splitStyleProperties(properties, ["paragraph", "text"]); + expect(split).toHaveLength(1); + expect(split[0]?.tag).toBe("style:paragraph-properties"); + expect(split[0]?.children).toEqual([tabStops]); + }); +}); + +describe("splitStyleProperties: style:text-underline expansion", () => { + function underlineStyleOf(value: string): XmlElement | undefined { + const properties = el("style:properties", { + "style:text-underline": value, + }); + const split = splitStyleProperties(properties, ["text"]); + return split[0]; + } + + it('"single" expands to style: "solid" alone, no width or type attribute', () => { + const result = underlineStyleOf("single"); + expect(result?.attributes).toEqual([ + { name: "style:text-underline-style", value: "solid" }, + ]); + }); + + it('"double" expands to style: "solid" plus type: "double"', () => { + const result = underlineStyleOf("double"); + expect(result?.attributes).toEqual([ + { name: "style:text-underline-style", value: "solid" }, + { name: "style:text-underline-type", value: "double" }, + ]); + }); + + it('"bold" expands to style: "solid" plus width: "bold"', () => { + const result = underlineStyleOf("bold"); + expect(result?.attributes).toEqual([ + { name: "style:text-underline-style", value: "solid" }, + { name: "style:text-underline-width", value: "bold" }, + ]); + }); + + it("a value with no listed expansion passes straight through as the style itself", () => { + const result = underlineStyleOf("dotted"); + expect(result?.attributes).toEqual([ + { name: "style:text-underline-style", value: "dotted" }, + ]); + }); +}); + +describe("splitStyleProperties: style:text-crossing-out expansion", () => { + function lineThroughStyleOf(value: string): XmlElement | undefined { + const properties = el("style:properties", { + "style:text-crossing-out": value, + }); + const split = splitStyleProperties(properties, ["text"]); + return split[0]; + } + + it('"single-line" expands to style: "solid" alone', () => { + expect(lineThroughStyleOf("single-line")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + ]); + }); + + it('"slash" expands to style: "solid" plus text: "/"', () => { + expect(lineThroughStyleOf("slash")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + { name: "style:text-line-through-text", value: "/" }, + ]); + }); + + it('"X" expands to style: "solid" plus text: "X"', () => { + expect(lineThroughStyleOf("X")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + { name: "style:text-line-through-text", value: "X" }, + ]); + }); + + it("a value with no listed expansion passes straight through as the style itself", () => { + expect(lineThroughStyleOf("dash")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "dash" }, + ]); + }); +}); + +describe("splitStyleProperties: fo:keep-with-next boolean-to-keyword rewrite", () => { + it('"true" becomes "always"', () => { + const properties = el("style:properties", { "fo:keep-with-next": "true" }); + const split = splitStyleProperties(properties, ["paragraph"]); + expect(split[0]?.attributes).toEqual([ + { name: "fo:keep-with-next", value: "always" }, + ]); + }); + + it('anything other than "true" (e.g. "false") becomes "auto"', () => { + const properties = el("style:properties", { + "fo:keep-with-next": "false", + }); + const split = splitStyleProperties(properties, ["paragraph"]); + expect(split[0]?.attributes).toEqual([ + { name: "fo:keep-with-next", value: "auto" }, + ]); + }); +}); + +describe("mergeStyleProperties", () => { + it("returns merged: undefined and the input untouched when no typed properties child is present", () => { + const other = el("style:map"); + const result = mergeStyleProperties([other]); + expect(result.merged).toBeUndefined(); + expect(result.rest).toEqual([other]); + }); + + it("concatenates a single typed properties element's own attributes and children into one style:properties", () => { + const textProps = el("style:text-properties", { "fo:color": "#000000" }, [ + el("style:some-child"), + ]); + const result = mergeStyleProperties([textProps]); + expect(result.merged).toMatchObject({ + tag: "style:properties", + attributes: [{ name: "fo:color", value: "#000000" }], + }); + expect(result.merged?.children).toEqual([el("style:some-child")]); + }); + + it("concatenates MULTIPLE typed properties elements' own attributes/children together, in encounter order", () => { + const textProps = el("style:text-properties", { "fo:color": "#000000" }); + const paraProps = el("style:paragraph-properties", { + "fo:text-align": "center", + }); + const result = mergeStyleProperties([textProps, paraProps]); + expect(result.merged?.attributes).toEqual([ + { name: "fo:color", value: "#000000" }, + { name: "fo:text-align", value: "center" }, + ]); + }); + + it("keeps a non-properties child in rest, in its original relative position", () => { + const textProps = el("style:text-properties", { "fo:color": "#000000" }); + const map = el("style:map"); + const result = mergeStyleProperties([map, textProps]); + expect(result.rest).toEqual([map]); + }); +}); From 5ac43b2f53f807835f38bfb461ead47393541b99 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:30:03 +0100 Subject: [PATCH 83/89] test(odf.js): add the first direct unit coverage for model/node.ts isXmlNode is a hand-written recursive structural guard (z.custom's own validator, since a genuinely recursive schema collapses to unknown under z.lazy in this pinned zod version) with no direct tests at all -- every place it runs is exercised only as a side effect of parsing a real XML document, which never constructs the malformed shapes a mutant here would need a test to distinguish. Covers every branch: non-object/unrecognised-type rejection, each of text/cdata/comment/declaration/pi/element's own required-field checks, an element's own attributes/children array-type and per-item validity checks, and two levels of recursive descent (a valid deeply nested tree, and one whose innermost grandchild is malformed). --- packages/odf.js/src/model/node.test.ts | 156 +++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 packages/odf.js/src/model/node.test.ts diff --git a/packages/odf.js/src/model/node.test.ts b/packages/odf.js/src/model/node.test.ts new file mode 100644 index 000000000..326f0a710 --- /dev/null +++ b/packages/odf.js/src/model/node.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { isXmlNode } from "./node"; + +// isXmlNode is a hand-written recursive structural guard (used via z.custom, since a genuinely recursive Zod schema collapses to `unknown` under z.lazy in this pinned version) with no direct unit tests at all -- every place it runs is exercised only as a side effect of parsing a real XML document, which never constructs the malformed shapes below. + +describe("isXmlNode: non-object/malformed input", () => { + it("rejects null, a primitive, and an array outright", () => { + expect(isXmlNode(null)).toBe(false); + expect(isXmlNode("a string")).toBe(false); + expect(isXmlNode(42)).toBe(false); + expect(isXmlNode([])).toBe(false); + }); + + it("rejects a plain object with no type field", () => { + expect(isXmlNode({})).toBe(false); + }); + + it("rejects an unrecognised type value", () => { + expect(isXmlNode({ type: "bogus" })).toBe(false); + }); +}); + +describe("isXmlNode: text/cdata/comment", () => { + it.each(["text", "cdata", "comment"] as const)( + "accepts type %s with a string value", + (type) => { + expect(isXmlNode({ type, value: "hello" })).toBe(true); + }, + ); + + it.each(["text", "cdata", "comment"] as const)( + "rejects type %s when value is not a string", + (type) => { + expect(isXmlNode({ type, value: 5 })).toBe(false); + expect(isXmlNode({ type })).toBe(false); + }, + ); +}); + +describe("isXmlNode: declaration", () => { + it("accepts a declaration with an empty attributes array", () => { + expect(isXmlNode({ type: "declaration", attributes: [] })).toBe(true); + }); + + it("accepts a declaration whose every attribute is a valid {name, value} pair", () => { + expect( + isXmlNode({ + type: "declaration", + attributes: [{ name: "version", value: "1.0" }], + }), + ).toBe(true); + }); + + it("rejects a declaration whose attributes is not an array", () => { + expect(isXmlNode({ type: "declaration", attributes: "nope" })).toBe(false); + }); + + it("rejects a declaration with one malformed attribute among otherwise-valid ones", () => { + expect( + isXmlNode({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding" }, // missing value + ], + }), + ).toBe(false); + }); +}); + +describe("isXmlNode: pi", () => { + it("accepts a pi with string target and content", () => { + expect( + isXmlNode({ type: "pi", target: "xml-stylesheet", content: "foo" }), + ).toBe(true); + }); + + it("rejects a pi missing either target or content", () => { + expect(isXmlNode({ type: "pi", target: "x" })).toBe(false); + expect(isXmlNode({ type: "pi", content: "x" })).toBe(false); + }); + + it("rejects a pi whose target or content is not a string", () => { + expect(isXmlNode({ type: "pi", target: 1, content: "x" })).toBe(false); + expect(isXmlNode({ type: "pi", target: "x", content: 1 })).toBe(false); + }); +}); + +describe("isXmlNode: element", () => { + function validElement(overrides: Record = {}) { + return { + type: "element", + tag: "text:p", + attributes: [], + children: [], + ...overrides, + }; + } + + it("accepts a leaf element with no attributes or children", () => { + expect(isXmlNode(validElement())).toBe(true); + }); + + it("rejects an element whose tag is not a string", () => { + expect(isXmlNode(validElement({ tag: 5 }))).toBe(false); + }); + + it("rejects an element whose attributes is not an array", () => { + expect(isXmlNode(validElement({ attributes: "nope" }))).toBe(false); + }); + + it("rejects an element with one malformed attribute", () => { + expect(isXmlNode(validElement({ attributes: [{ name: "x" }] }))).toBe( + false, + ); + }); + + it("rejects an element whose children is not an array", () => { + expect(isXmlNode(validElement({ children: "nope" }))).toBe(false); + }); + + it("accepts an element whose children are all valid nodes, recursively", () => { + const child = { type: "text", value: "hi" }; + expect(isXmlNode(validElement({ children: [child] }))).toBe(true); + }); + + it("rejects an element with one malformed child among otherwise-valid ones", () => { + const goodChild = { type: "text", value: "hi" }; + const badChild = { type: "text", value: 5 }; + expect(isXmlNode(validElement({ children: [goodChild, badChild] }))).toBe( + false, + ); + }); + + it("rejects an element nested two levels deep whose innermost grandchild is malformed", () => { + const malformedGrandchild = { type: "comment", value: 5 }; + const child = { + type: "element", + tag: "text:span", + attributes: [], + children: [malformedGrandchild], + }; + expect(isXmlNode(validElement({ children: [child] }))).toBe(false); + }); + + it("accepts an element nested two levels deep whose every descendant is well-formed", () => { + const grandchild = { type: "text", value: "deep" }; + const child = { + type: "element", + tag: "text:span", + attributes: [], + children: [grandchild], + }; + expect(isXmlNode(validElement({ children: [child] }))).toBe(true); + }); +}); From 85f3cceb1b0523a84d8960b35e2d5973204fa153 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 00:31:45 +0100 Subject: [PATCH 84/89] test(odf.js): add the first direct unit coverage for xml/parse.ts parseXml had no direct unit tests at all -- every reader test in this package exercises it only indirectly, through a whole XML document string, which never isolates a single node kind's own mapping. Adds direct assertions, each against a minimal real XML string, for every reachable parseNode branch: a bare element, an element's text content, a comment, a CDATA section, the leading declaration and its attributes, a non-xml processing instruction, attribute parsing and ordering, nested-element recursion with mixed element/text children, and that entities and whitespace are carried through unencoded and untrimmed (this package's own lossless-round-trip contract). --- packages/odf.js/src/xml/parse.test.ts | 103 ++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/odf.js/src/xml/parse.test.ts diff --git a/packages/odf.js/src/xml/parse.test.ts b/packages/odf.js/src/xml/parse.test.ts new file mode 100644 index 000000000..010f78c7d --- /dev/null +++ b/packages/odf.js/src/xml/parse.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { parseXml } from "./parse"; + +// parseXml had no direct unit tests at all -- every reader test in this package exercises it only indirectly, through a whole XML document string, which never isolates a single node kind's own mapping. These pin each of parseNode's own branches (text/comment/cdata/declaration/pi/element) directly against a minimal real XML string, plus attribute parsing and nested-element recursion. + +describe("parseXml: node kinds", () => { + it("parses a bare element with no children, attributes, or text", () => { + expect(parseXml("")).toEqual([ + { type: "element", tag: "a", attributes: [], children: [] }, + ]); + }); + + it("parses an element's own text content as a text node child", () => { + const [node] = parseXml("hello"); + expect(node).toMatchObject({ + type: "element", + tag: "a", + children: [{ type: "text", value: "hello" }], + }); + }); + + it("parses a comment as its own comment node, not folded into surrounding text", () => { + const [node] = parseXml(""); + expect(node).toMatchObject({ + type: "element", + tag: "a", + children: [{ type: "comment", value: "a note" }], + }); + }); + + it("parses a CDATA section as its own cdata node with the raw text preserved", () => { + const [node] = parseXml("]]>"); + expect(node).toMatchObject({ + type: "element", + tag: "a", + children: [{ type: "cdata", value: "raw " }], + }); + }); + + it("parses the leading as a declaration node carrying its own attributes", () => { + const [node] = parseXml(''); + expect(node).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + ], + }); + }); + + it("parses a non-xml processing instruction with its own target", () => { + const [, pi] = parseXml(''); + if (pi?.type !== "pi") { + throw new Error("expected a pi node"); + } + expect(pi.target).toBe("xml-stylesheet"); + expect(typeof pi.content).toBe("string"); + }); + + it("parses an element's own attributes, stripping the @_ prefix and preserving order", () => { + const [node] = parseXml(''); + expect(node).toMatchObject({ + type: "element", + tag: "a", + attributes: [ + { name: "x", value: "1" }, + { name: "y", value: "2" }, + ], + }); + }); + + it("parses an element with no attributes to an empty attributes array, not undefined", () => { + const [node] = parseXml(""); + expect(node).toMatchObject({ attributes: [] }); + }); + + it("recurses into nested elements, preserving document order across mixed element and text children", () => { + const [node] = parseXml("onetwo"); + expect(node).toMatchObject({ + type: "element", + tag: "a", + children: [ + { type: "text", value: "one" }, + { type: "element", tag: "b", attributes: [], children: [] }, + { type: "text", value: "two" }, + ], + }); + }); + + it("does not re-encode entities -- an already-encoded & comes back exactly as written", () => { + const [node] = parseXml("x & y"); + expect(node).toMatchObject({ + children: [{ type: "text", value: "x & y" }], + }); + }); + + it("does not trim leading/trailing whitespace out of text content", () => { + const [node] = parseXml(" padded "); + expect(node).toMatchObject({ + children: [{ type: "text", value: " padded " }], + }); + }); +}); From 9f3b731ea1cc8adad264948f47af3815486e9f82 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 01:20:19 +0100 Subject: [PATCH 85/89] test(odf.js): add the first direct unit coverage for typed/draw/write-shapes.ts Every function here (planShapeContent, frameGeometryAttrs, odfZIndexOf, writeDrawFrame, canonicalDrawShape, writeDrawShapes) was previously only exercised indirectly through odp/write.test.ts and odg/write.test.ts's own whole-document round-trip suites, which cannot observe a mutation that changes what gets written in a way the reader's own inverse tolerates. --- .../src/typed/draw/write-shapes.test.ts | 466 ++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 packages/odf.js/src/typed/draw/write-shapes.test.ts diff --git a/packages/odf.js/src/typed/draw/write-shapes.test.ts b/packages/odf.js/src/typed/draw/write-shapes.test.ts new file mode 100644 index 000000000..457922ab0 --- /dev/null +++ b/packages/odf.js/src/typed/draw/write-shapes.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, it } from "vitest"; +import type { ContentShape, ContentBlock } from "document-schema.js"; +import type { Package } from "../../model/package"; +import type { XmlElement } from "../../model/node"; +import { el } from "../../xml/fragment"; +import { attrValue } from "../../xml/query"; +import { StyleRegistry } from "../../styles/registry"; +import type { ListPlanState } from "../shared/list"; +import { + planShapeContent, + frameGeometryAttrs, + odfZIndexOf, + writeDrawFrame, + canonicalDrawShape, + writeDrawShapes, + createDrawShapeWriteState, + type DrawShapeWriteState, +} from "./write-shapes"; + +// This module had no direct unit tests at all -- every function here was only exercised indirectly through typed/odp/write.test.ts and typed/odg/write.test.ts's own whole-document round-trip suites, which (see typed/shared/canonicalise.ts's own top-of-file note) cannot observe a mutation that changes what gets WRITTEN in a way the reader's own inverse tolerates. + +function freshListState(): ListPlanState { + return { next: 1 }; +} + +function writeState(): { + state: DrawShapeWriteState; + mintedStyles: () => XmlElement[]; +} { + const automaticStyles = el("office:automatic-styles", {}, []); + const pkg: Package = { + parts: { + "content.xml": { + kind: "xml", + nodes: [el("office:document-content", {}, [automaticStyles])], + }, + }, + }; + const registry = StyleRegistry.forPart(pkg, "content.xml"); + return { + state: createDrawShapeWriteState(pkg, registry, automaticStyles), + mintedStyles: () => + automaticStyles.children.filter( + (c): c is XmlElement => c.type === "element" && c.tag === "style:style", + ), + }; +} + +function attr( + element: XmlElement | undefined, + name: string, +): string | undefined { + return element === undefined ? undefined : attrValue(element, name); +} + +const ZERO_INSETS = { + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, +}; + +function shape(overrides: Partial = {}): ContentShape { + return { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 50 }, + ...ZERO_INSETS, + blocks: [], + ...overrides, + }; +} + +function paragraphBlock(text: string): ContentBlock { + return { kind: "paragraph", runs: [{ text }] }; +} + +describe("planShapeContent", () => { + it("resolves a single table block to kind: table", () => { + const table: ContentBlock = { kind: "table", rows: [], columnWidthsPt: [] }; + const plan = planShapeContent([table], freshListState()); + expect(plan.kind).toBe("table"); + }); + + it("resolves a single image block to kind: image", () => { + const image: ContentBlock = { + kind: "image", + format: "png", + base64: "", + widthPt: 1, + heightPt: 1, + }; + const plan = planShapeContent([image], freshListState()); + expect(plan.kind).toBe("image"); + }); + + it("resolves a single embeddedObject block to kind: embedded", () => { + const object: ContentBlock = { + kind: "embeddedObject", + objectKind: "wordprocessing", + document: { kind: "wordprocessing", metadata: {}, sections: [] }, + frame: { xPt: 0, yPt: 0, widthPt: 1, heightPt: 1 }, + }; + const plan = planShapeContent([object], freshListState()); + expect(plan.kind).toBe("embedded"); + }); + + it("resolves any list of paragraphs (including a single one) to kind: text", () => { + const plan = planShapeContent([paragraphBlock("hi")], freshListState()); + expect(plan.kind).toBe("text"); + if (plan.kind === "text") { + expect(plan.paragraphs).toHaveLength(1); + } + }); + + it("refuses a table alongside another block", () => { + const table: ContentBlock = { kind: "table", rows: [], columnWidthsPt: [] }; + expect(() => + planShapeContent([table, paragraphBlock("x")], freshListState()), + ).toThrow(/a table alongside other content/); + }); + + it("refuses an image alongside another block", () => { + const image: ContentBlock = { + kind: "image", + format: "png", + base64: "", + widthPt: 1, + heightPt: 1, + }; + expect(() => + planShapeContent([image, paragraphBlock("x")], freshListState()), + ).toThrow(/an image alongside other content/); + }); + + it("refuses a page break", () => { + const pageBreak: ContentBlock = { kind: "pageBreak" }; + expect(() => planShapeContent([pageBreak], freshListState())).toThrow( + /a page break/, + ); + }); + + it("refuses an embedded object alongside another block", () => { + const object: ContentBlock = { + kind: "embeddedObject", + objectKind: "wordprocessing", + document: { kind: "wordprocessing", metadata: {}, sections: [] }, + frame: { xPt: 0, yPt: 0, widthPt: 1, heightPt: 1 }, + }; + expect(() => + planShapeContent([object, paragraphBlock("x")], freshListState()), + ).toThrow(/an embedded object alongside other content/); + }); + + it("refuses a construct boundary marker", () => { + const marker: ContentBlock = { + kind: "constructStart", + descriptor: { kind: "division" }, + }; + expect(() => planShapeContent([marker], freshListState())).toThrow( + /a construct boundary marker/, + ); + }); + + it("refuses a heading paragraph", () => { + const heading: ContentBlock = { + kind: "paragraph", + runs: [{ text: "H" }], + headingLevel: 1, + }; + expect(() => planShapeContent([heading], freshListState())).toThrow( + /a heading/, + ); + }); + + it("force-closes the plan's currently open run unconditionally, even when the next shape's own blocks carry no list membership at all", () => { + const listState = freshListState(); + planShapeContent( + [ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "bullet:l1", level: 0 }, + }, + ], + listState, + ); + expect(listState.openNumId).toBeDefined(); + planShapeContent([paragraphBlock("b")], listState); + expect(listState.openNumId).toBeUndefined(); + }); + + it("canonicalises list membership onto a fresh numId for a new shape's first paragraph, even when its incoming numId string happens to match a still-open run from before this call", () => { + const listState = freshListState(); + const first = planShapeContent( + [ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "bullet:l1", level: 0 }, + }, + ], + listState, + ); + const second = planShapeContent( + [ + { + kind: "paragraph", + runs: [{ text: "b" }], + list: { numId: "bullet:l1", level: 0 }, + }, + ], + listState, + ); + if (first.kind !== "text" || second.kind !== "text") { + throw new Error("expected text plans"); + } + expect(first.paragraphs[0]?.list?.numId).not.toBe( + second.paragraphs[0]?.list?.numId, + ); + }); +}); + +describe("frameGeometryAttrs", () => { + const frame = { xPt: 10, yPt: 20, widthPt: 100, heightPt: 50 }; + + it("writes plain svg:x/y/width/height with no draw:transform when rotationDeg is undefined", () => { + const attrs = frameGeometryAttrs(frame, undefined); + expect(attrs["svg:x"]).toBe("10pt"); + expect(attrs["svg:y"]).toBe("20pt"); + expect(attrs["svg:width"]).toBe("100pt"); + expect(attrs["svg:height"]).toBe("50pt"); + expect(attrs["draw:transform"]).toBeUndefined(); + }); + + it("treats rotationDeg === 0 identically to undefined", () => { + expect(frameGeometryAttrs(frame, 0)).toEqual( + frameGeometryAttrs(frame, undefined), + ); + }); + + it("writes svg:width/height plus draw:transform (no svg:x/y) for a genuinely rotated frame", () => { + const attrs = frameGeometryAttrs(frame, 90); + expect(attrs["svg:width"]).toBe("100pt"); + expect(attrs["svg:height"]).toBe("50pt"); + expect(attrs["svg:x"]).toBeUndefined(); + expect(attrs["svg:y"]).toBeUndefined(); + expect(attrs["draw:transform"]).toMatch( + /^rotate\(.+\) translate\(.+ .+\)$/, + ); + }); + + it("a 90-degree rotation's own transform matches the documented algebraic derivation exactly", () => { + const attrs = frameGeometryAttrs(frame, 90); + const angleRad = (-90 * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const halfW = frame.widthPt / 2; + const halfH = frame.heightPt / 2; + const txPt = frame.xPt + halfW - halfW * cos - halfH * sin; + const tyPt = frame.yPt + halfH - halfH * cos + halfW * sin; + expect(attrs["draw:transform"]).toBe( + `rotate(${angleRad}) translate(${txPt}pt ${tyPt}pt)`, + ); + }); +}); + +describe("odfZIndexOf", () => { + it("returns undefined for an undefined paintOrder", () => { + expect(odfZIndexOf(undefined)).toBeUndefined(); + }); + + it("returns undefined for a negative paintOrder", () => { + expect(odfZIndexOf(-1)).toBeUndefined(); + }); + + it("returns undefined for a fractional paintOrder", () => { + expect(odfZIndexOf(1.5)).toBeUndefined(); + }); + + it("returns undefined for a paintOrder beyond Number.isSafeInteger's own bound", () => { + expect(odfZIndexOf(Number.MAX_SAFE_INTEGER + 2)).toBeUndefined(); + }); + + it("returns the value unchanged for a genuine non-negative safe integer, including zero", () => { + expect(odfZIndexOf(0)).toBe(0); + expect(odfZIndexOf(5)).toBe(5); + }); +}); + +describe("writeDrawFrame", () => { + it("uses the shape's own resolvable paintOrder over documentIndex, and falls back to documentIndex otherwise", () => { + const { state } = writeState(); + const withPaintOrder = writeDrawFrame( + shape({ paintOrder: 9 }), + freshListState(), + state, + 2, + ); + expect(attr(withPaintOrder, "draw:z-index")).toBe("9"); + const withoutPaintOrder = writeDrawFrame( + shape(), + freshListState(), + state, + 2, + ); + expect(attr(withoutPaintOrder, "draw:z-index")).toBe("2"); + }); + + it("writes draw:name only when the shape actually states one", () => { + const { state } = writeState(); + expect( + attr(writeDrawFrame(shape(), freshListState(), state, 0), "draw:name"), + ).toBeUndefined(); + expect( + attr( + writeDrawFrame(shape({ name: "Rect 1" }), freshListState(), state, 0), + "draw:name", + ), + ).toBe("Rect 1"); + }); + + it("writes a draw:text-box for an all-paragraph shape", () => { + const { state } = writeState(); + const written = writeDrawFrame( + shape({ blocks: [paragraphBlock("hi")] }), + freshListState(), + state, + 0, + ); + expect(written.children[0]).toMatchObject({ tag: "draw:text-box" }); + }); + + it("writes a table:table for a single-table shape", () => { + const { state } = writeState(); + const written = writeDrawFrame( + shape({ + blocks: [{ kind: "table", rows: [], columnWidthsPt: [] }], + }), + freshListState(), + state, + 0, + ); + expect(written.children[0]).toMatchObject({ tag: "table:table" }); + }); + + it("writes a draw:image for a single-image shape", () => { + const { state } = writeState(); + const written = writeDrawFrame( + shape({ + blocks: [ + { + kind: "image", + format: "png", + base64: "abc", + widthPt: 1, + heightPt: 1, + }, + ], + }), + freshListState(), + state, + 0, + ); + expect( + written.children.some( + (c) => c.type === "element" && c.tag === "draw:image", + ), + ).toBe(true); + }); + + it("mints a graphic-family style stating explicit no-fill/no-stroke, with padding attributes only when an inset is non-zero", () => { + const { state, mintedStyles } = writeState(); + writeDrawFrame(shape(), freshListState(), state, 0); + const zeroInsetStyle = mintedStyles().find( + (s) => attrValue(s, "style:family") === "graphic", + ); + const zeroProps = zeroInsetStyle?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:graphic-properties", + ); + expect(attr(zeroProps, "draw:fill")).toBe("none"); + expect(attr(zeroProps, "draw:stroke")).toBe("none"); + expect(attr(zeroProps, "fo:padding-left")).toBeUndefined(); + + const { state: state2, mintedStyles: mintedStyles2 } = writeState(); + writeDrawFrame(shape({ insetLeftPt: 3 }), freshListState(), state2, 0); + const withInsetStyle = mintedStyles2().find( + (s) => attrValue(s, "style:family") === "graphic", + ); + const withProps = withInsetStyle?.children.find( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:graphic-properties", + ); + expect(attr(withProps, "fo:padding-left")).toBe("3pt"); + expect(attr(withProps, "fo:padding-top")).toBe("0pt"); + }); +}); + +describe("canonicalDrawShape", () => { + it("resolves paintOrder to documentIndex when the shape states none, and to its own value when it does", () => { + expect(canonicalDrawShape(shape(), 4, freshListState()).paintOrder).toBe(4); + expect( + canonicalDrawShape(shape({ paintOrder: 7 }), 4, freshListState()) + .paintOrder, + ).toBe(7); + }); + + it("collapses rotationDeg === 0 to absent, but keeps a genuine non-zero rotation", () => { + expect( + canonicalDrawShape(shape({ rotationDeg: 0 }), 0, freshListState()) + .rotationDeg, + ).toBeUndefined(); + expect( + canonicalDrawShape(shape({ rotationDeg: 30 }), 0, freshListState()) + .rotationDeg, + ).toBe(30); + }); + + it("carries name through only when stated", () => { + expect( + canonicalDrawShape(shape(), 0, freshListState()).name, + ).toBeUndefined(); + expect( + canonicalDrawShape(shape({ name: "X" }), 0, freshListState()).name, + ).toBe("X"); + }); + + it("overrides an image block's own widthPt/heightPt with the enclosing shape's frame size", () => { + const result = canonicalDrawShape( + shape({ + frame: { xPt: 0, yPt: 0, widthPt: 200, heightPt: 80 }, + blocks: [ + { + kind: "image", + format: "png", + base64: "abc", + widthPt: 1, + heightPt: 1, + }, + ], + }), + 0, + freshListState(), + ); + expect(result.blocks[0]).toMatchObject({ widthPt: 200, heightPt: 80 }); + }); + + it("carries frame/insets through unchanged", () => { + const s = shape({ insetLeftPt: 5 }); + const result = canonicalDrawShape(s, 0, freshListState()); + expect(result.frame).toEqual(s.frame); + expect(result.insetLeftPt).toBe(5); + }); +}); + +describe("writeDrawShapes", () => { + it("writes each shape at its own array index as documentIndex, in order", () => { + const { state } = writeState(); + const written = writeDrawShapes( + [shape(), shape()], + freshListState(), + state, + ); + expect(attr(written[0], "draw:z-index")).toBe("0"); + expect(attr(written[1], "draw:z-index")).toBe("1"); + }); +}); From 0b6690e59e9849a18d0816cbe90c641480734984 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:50:59 +0100 Subject: [PATCH 86/89] refactor(odf.js): share one file-entry parse between readManifest and validateManifest Extracts parseFileEntryElements as the single place that walks a manifest:manifest root's manifest:file-entry children and enforces the required manifest:full-path/manifest:media-type attributes. validateManifest's own encryption-data scan now reuses it instead of independently re-walking and re-validating the same elements, so its fullPath is a plain string rather than needing its own redundant "already surfaced above" undefined check. Also drops two other redundant conditions found the same way: resolvePartMediaType's extension === "" fallback (mediaTypeForExtension never has "" as a key, so skipping the lookup when there is no dot at all was always equivalent, and does it without the empty-string placeholder ever risking a nameless part being read as one named literally after its own extension), and validateManifest's fullPath === "/" check (a bare "/" already satisfies its own endsWith("/") check). --- packages/odf.js/src/manifest.test.ts | 236 ++++++++++++++++++++++++++- packages/odf.js/src/manifest.ts | 73 +++++---- 2 files changed, 275 insertions(+), 34 deletions(-) diff --git a/packages/odf.js/src/manifest.test.ts b/packages/odf.js/src/manifest.test.ts index 6f5361c47..c8ba1757b 100644 --- a/packages/odf.js/src/manifest.test.ts +++ b/packages/odf.js/src/manifest.test.ts @@ -25,6 +25,12 @@ const JPEG_BYTES: Uint8Array = new Uint8Array([ const WMF_BYTES: Uint8Array = new Uint8Array([ 0xd7, 0xcd, 0xc6, 0x9a, 1, 2, 3, ]); +const GIF_BYTES: Uint8Array = new Uint8Array([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 2, 3, +]); +const SVG_BYTES: Uint8Array = new TextEncoder().encode( + '', +); function binaryPart(bytes: Uint8Array): { kind: "binary"; @@ -104,6 +110,21 @@ describe("buildManifest", () => { ).toBe("image/jpeg"); }); + it("sniffs GIF/SVG bytes for a binary part with no recognised extension", () => { + const pkg = baseOdtPackage(); + pkg.parts["Thumbnails/gifthumb"] = binaryPart(GIF_BYTES); + pkg.parts["Thumbnails/svgthumb"] = binaryPart(SVG_BYTES); + const manifest = buildManifest(pkg); + expect( + manifest.entries.find((e) => e.fullPath === "Thumbnails/gifthumb") + ?.mediaType, + ).toBe("image/gif"); + expect( + manifest.entries.find((e) => e.fullPath === "Thumbnails/svgthumb") + ?.mediaType, + ).toBe("image/svg+xml"); + }); + it('falls back to empty string -- not "application/octet-stream" -- for a part it cannot classify by name or by sniffing', () => { const manifest = buildManifest(baseOdtPackage()); expect( @@ -124,6 +145,17 @@ describe("buildManifest", () => { ).toBe(ODT_MEDIA_TYPE); }); + it("never treats a part with no dot at all as having an extension, even when its whole basename spells a real ODF extension", () => { + const pkg = baseOdtPackage(); + pkg.parts.odt = binaryPart( + new TextEncoder().encode("not xml, not an image"), + ); + const manifest = buildManifest(pkg); + expect(manifest.entries.find((e) => e.fullPath === "odt")?.mediaType).toBe( + "", + ); + }); + it("an explicit mediaTypeOverrides entry wins over every automatic resolution rule", () => { const pkg = baseOdtPackage(); const manifest = buildManifest(pkg, { @@ -174,6 +206,26 @@ describe("writeManifest / readManifest round trip", () => { expect(readManifest(pkg)).toEqual(manifest); }); + it("omits the version property entirely (not just as undefined) for an entry whose file-entry element carries no manifest:version attribute", () => { + const pkg: Package = { + parts: { + [MANIFEST_PART]: { + kind: "xml", + nodes: [ + el("manifest:manifest", { "manifest:version": "1.3" }, [ + el("manifest:file-entry", { + "manifest:full-path": "content.xml", + "manifest:media-type": "text/xml", + }), + ]), + ], + }, + }, + }; + const entry = readManifest(pkg).entries[0]; + expect(entry && "version" in entry).toBe(false); + }); + it("serializes manifest:file-entry attributes in full-path, version, media-type order, matching real-world ODF output", () => { const pkg = baseOdtPackage(); writeManifest(pkg, { @@ -192,8 +244,48 @@ describe("writeManifest / readManifest round trip", () => { ); }); - it("readManifest throws for a package with no manifest part", () => { - expect(() => readManifest({ parts: {} })).toThrow(); + it('opens with the standard declaration', () => { + const pkg = baseOdtPackage(); + writeManifest(pkg, { + version: "1.3", + entries: [{ fullPath: "/", mediaType: ODT_MEDIA_TYPE, version: "1.3" }], + }); + const part = pkg.parts[MANIFEST_PART]; + if (part?.kind !== "xml") { + throw new Error("expected an xml part"); + } + const xml = buildXml(part.nodes); + expect(xml.startsWith('')).toBe(true); + }); + + it("readManifest throws for a package with no manifest part, naming the missing part", () => { + expect(() => readManifest({ parts: {} })).toThrow( + new RegExp(MANIFEST_PART.replace(/\//g, "\\/")), + ); + }); + + it("readManifest skips non-file-entry children (text nodes, other elements) while still reading real entries around them", () => { + const pkg: Package = { + parts: { + [MANIFEST_PART]: { + kind: "xml", + nodes: [ + el("manifest:manifest", { "manifest:version": "1.3" }, [ + txt("\n "), + el("manifest:not-a-file-entry"), + el("manifest:file-entry", { + "manifest:full-path": "/", + "manifest:media-type": ODT_MEDIA_TYPE, + }), + ]), + ], + }, + }, + }; + expect(readManifest(pkg)).toEqual({ + version: "1.3", + entries: [{ fullPath: "/", mediaType: ODT_MEDIA_TYPE }], + }); }); it("readManifest throws when the manifest XML has no manifest:manifest root element", () => { @@ -339,7 +431,7 @@ describe("validateManifest", () => { ).toBe(true); }); - it("reports an error when the root entry media type disagrees with the mimetype part", () => { + it("reports an error when the root entry media type disagrees with the mimetype part, naming both media types in the message", () => { const pkg = baseOdtPackage(); syncManifest(pkg); writeManifest(pkg, { @@ -353,9 +445,29 @@ describe("validateManifest", () => { ], }); const problems = validateManifest(pkg); - expect(problems.some((p) => p.severity === "error" && p.path === "/")).toBe( - true, + const problem = problems.find( + (p) => p.severity === "error" && p.path === "/", ); + expect(problem?.message).toContain( + "application/vnd.oasis.opendocument.spreadsheet", + ); + expect(problem?.message).toContain(ODT_MEDIA_TYPE); + }); + + it("does not report a media-type mismatch when the package has no mimetype part at all, regardless of the manifest root entry's own media type", () => { + const pkg: Package = { parts: {} }; + writeManifest(pkg, { + version: "1.3", + entries: [ + { + fullPath: "/", + mediaType: "application/vnd.oasis.opendocument.spreadsheet", + version: "1.3", + }, + ], + }); + const problems = validateManifest(pkg); + expect(problems.some((p) => p.path === "/")).toBe(false); }); it("reports a warning for a manifest entry with no corresponding part", () => { @@ -385,6 +497,62 @@ describe("validateManifest", () => { expect(problem?.message).toContain("settings.xml"); }); + it('never flags a directory entry (fullPath ending in "/") as a ghost part, since a directory has no literal corresponding zip part', () => { + const pkg = baseOdtPackage(); + syncManifest(pkg); + const manifest = readManifest(pkg); + writeManifest(pkg, { + ...manifest, + entries: [...manifest.entries, { fullPath: "Object 1/", mediaType: "" }], + }); + const problems = validateManifest(pkg); + expect(problems.some((p) => p.path === "Object 1/")).toBe(false); + }); + + it("reports no problems for a manifest whose only non-file-entry content is an unrelated sibling element, proving that element is genuinely skipped rather than mistaken for a malformed file-entry", () => { + const pkg: Package = { + parts: { + [MANIFEST_PART]: { + kind: "xml", + nodes: [ + el("manifest:manifest", { "manifest:version": "1.3" }, [ + el("manifest:file-entry", { + "manifest:full-path": "/", + "manifest:media-type": ODT_MEDIA_TYPE, + }), + el("manifest:unrelated-sibling"), + ]), + ], + }, + }, + }; + expect(validateManifest(pkg)).toEqual([]); + }); + + it("does not mistake an unrelated element sibling of manifest:file-entry, or an unrelated child of one, for manifest:encryption-data", () => { + const pkg = baseOdtPackage(); + const root = el("manifest:manifest", { "manifest:version": "1.3" }, [ + el("manifest:file-entry", { + "manifest:full-path": "/", + "manifest:media-type": ODT_MEDIA_TYPE, + }), + el( + "manifest:file-entry", + { + "manifest:full-path": "content.xml", + "manifest:media-type": "text/xml", + }, + [el("manifest:not-encryption-data")], + ), + el("manifest:not-a-file-entry"), + ]); + pkg.parts[MANIFEST_PART] = { kind: "xml", nodes: [root] }; + const problems = validateManifest(pkg); + expect(problems.some((p) => p.message.includes("encryption-data"))).toBe( + false, + ); + }); + it("detects manifest:encryption-data on a file-entry and reports it as a warning", () => { const pkg = baseOdtPackage(); const encryptedEntry = el( @@ -435,6 +603,37 @@ describe("validateManifest", () => { expect(problem?.severity).toBe("warning"); expect(problem?.message).toContain("manifest:encryption-data"); }); + + it("skips a manifest:encryption-data entry with no manifest:full-path attribute, since readManifest's own required-attribute check already surfaces it", () => { + const pkg = baseOdtPackage(); + const encryptedEntryMissingPath = el("manifest:file-entry", {}, [ + el("manifest:encryption-data", { + "manifest:checksum-type": "SHA1/1K", + "manifest:checksum": "abc==", + }), + ]); + const root = el( + "manifest:manifest", + { + "xmlns:manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0", + "manifest:version": "1.3", + }, + [ + el("manifest:file-entry", { + "manifest:full-path": "/", + "manifest:version": "1.3", + "manifest:media-type": ODT_MEDIA_TYPE, + }), + encryptedEntryMissingPath, + ], + ); + pkg.parts[MANIFEST_PART] = { kind: "xml", nodes: [root] }; + + const problems = validateManifest(pkg); + expect( + problems.some((p) => p.message.includes("manifest:encryption-data")), + ).toBe(false); + }); }); describe("setDocumentMediaType", () => { @@ -447,6 +646,19 @@ describe("setDocumentMediaType", () => { }); }); + it("replaces the existing root entry in place rather than appending a second one, when a root entry already exists", () => { + const pkg = baseOdtPackage(); + syncManifest(pkg); + setDocumentMediaType( + pkg, + "application/vnd.oasis.opendocument.text-template", + ); + const rootEntries = readManifest(pkg).entries.filter( + (e) => e.fullPath === "/", + ); + expect(rootEntries).toHaveLength(1); + }); + it("atomically updates both the mimetype part and the manifest root entry", () => { const pkg = baseOdtPackage(); syncManifest(pkg); @@ -477,6 +689,20 @@ describe("setDocumentMediaType", () => { expect(after).toEqual(before); }); + it("prepends a root entry when an existing manifest has none yet", () => { + const pkg: Package = { parts: {} }; + writeManifest(pkg, { + version: "1.3", + entries: [{ fullPath: "content.xml", mediaType: "text/xml" }], + }); + setDocumentMediaType(pkg, ODT_MEDIA_TYPE); + const manifest = readManifest(pkg); + expect(manifest.entries).toEqual([ + { fullPath: "/", mediaType: ODT_MEDIA_TYPE, version: "1.3" }, + { fullPath: "content.xml", mediaType: "text/xml" }, + ]); + }); + it("keeps manifest:manifest's own version and the root entry's version in step with the version argument", () => { const pkg = baseOdtPackage(); syncManifest(pkg); diff --git a/packages/odf.js/src/manifest.ts b/packages/odf.js/src/manifest.ts index 36e3a59c7..1c764b1a8 100644 --- a/packages/odf.js/src/manifest.ts +++ b/packages/odf.js/src/manifest.ts @@ -60,6 +60,38 @@ function attrValue(element: XmlElement, name: string): string | undefined { return element.attributes.find((attribute) => attribute.name === name)?.value; } +// One parsed manifest:file-entry, still carrying its own source element -- so a caller that also needs the raw XML (validateManifest's own encryption-data scan, below) can inspect it without a second, independent full-path/media-type re-validation of the same element. +interface ParsedFileEntry { + element: XmlElement; + fullPath: string; + mediaType: string; + version: string | undefined; +} + +// The one place that walks a manifest:manifest root's manifest:file-entry children and enforces the ODF spec's required attributes on each -- both readManifest (which only needs the resulting ManifestEntry values) and validateManifest's own encryption-data scan (which additionally needs each element's own children) build on this single parse rather than repeating the required-attribute check a second time. Throws under the identical condition readManifest documents. +function parseFileEntryElements(root: XmlElement): ParsedFileEntry[] { + const result: ParsedFileEntry[] = []; + for (const child of root.children) { + if (child.type !== "element" || child.tag !== "manifest:file-entry") { + continue; + } + const fullPath = attrValue(child, "manifest:full-path"); + const mediaType = attrValue(child, "manifest:media-type"); + if (fullPath === undefined || mediaType === undefined) { + throw new Error( + `${MANIFEST_PART} has a manifest:file-entry missing manifest:full-path or manifest:media-type`, + ); + } + result.push({ + element: child, + fullPath, + mediaType, + version: attrValue(child, "manifest:version"), + }); + } + return result; +} + // Reads META-INF/manifest.xml into a structured Manifest. Throws for a package that has no manifest part, or one whose XML does not carry the elements/attributes the ODF spec requires (no manifest:manifest root, or a manifest:file-entry missing its required manifest:full-path/manifest:media-type) -- unlike validateManifest, this is a strict parse, not a diagnostics collector. export function readManifest(pkg: Package): Manifest { const part = pkg.parts[MANIFEST_PART]; @@ -77,25 +109,12 @@ export function readManifest(pkg: Package): Manifest { ); } - const entries: ManifestEntry[] = []; - for (const child of root.children) { - if (child.type !== "element" || child.tag !== "manifest:file-entry") { - continue; - } - const fullPath = attrValue(child, "manifest:full-path"); - const mediaType = attrValue(child, "manifest:media-type"); - if (fullPath === undefined || mediaType === undefined) { - throw new Error( - `${MANIFEST_PART} has a manifest:file-entry missing manifest:full-path or manifest:media-type`, - ); - } - const entryVersion = attrValue(child, "manifest:version"); - entries.push( + const entries: ManifestEntry[] = parseFileEntryElements(root).map( + ({ fullPath, mediaType, version: entryVersion }) => entryVersion === undefined ? { fullPath, mediaType } : { fullPath, mediaType, version: entryVersion }, - ); - } + ); return { version, entries }; } @@ -126,9 +145,11 @@ function resolvePartMediaType( } const dotIndex = baseName.lastIndexOf("."); - const extension = dotIndex === -1 ? "" : baseName.slice(dotIndex + 1); + // No intermediate "extension === '' ? undefined : ..." fallback: mediaTypeForExtension("") already returns undefined on its own (the empty string is never a key in ODF_MEDIA_TYPES), so that check was always redundant. Skipping the lookup entirely when there is no dot at all -- rather than computing an empty-string placeholder and feeding it through the same lookup -- keeps a nameless part from ever being mistaken for one whose whole basename happens to spell a real ODF extension (e.g. a part literally named "odt" with no dot). const byExtension = - extension === "" ? undefined : mediaTypeForExtension(extension); + dotIndex === -1 + ? undefined + : mediaTypeForExtension(baseName.slice(dotIndex + 1)); if (byExtension !== undefined) { return byExtension; } @@ -322,8 +343,8 @@ export function validateManifest(pkg: Package): ManifestProblem[] { ); for (const entry of manifest.entries) { - // Root and directory entries have no literal corresponding zip part -- "/" is the package itself, and a directory entry describes a prefix, not a physical entry. - if (entry.fullPath === "/" || entry.fullPath.endsWith("/")) { + // Root and directory entries have no literal corresponding zip part -- "/" is the package itself (and, being a single "/" character, trivially satisfies endsWith("/") on its own, so it needs no separate check), and a directory entry describes a prefix, not a physical entry. + if (entry.fullPath.endsWith("/")) { continue; } if (!partPaths.has(entry.fullPath)) { @@ -344,11 +365,9 @@ export function validateManifest(pkg: Package): ManifestProblem[] { } } - for (const child of root.children) { - if (child.type !== "element" || child.tag !== "manifest:file-entry") { - continue; - } - const hasEncryptionData = child.children.some( + // Reusing readManifest's own parseFileEntryElements rather than a second, independent full-path re-check: the try/catch above already proved every manifest:file-entry here has both required attributes, so this parse is guaranteed to succeed identically and each fullPath below is a plain string, not string | undefined. + for (const { element, fullPath } of parseFileEntryElements(root)) { + const hasEncryptionData = element.children.some( (grandchild) => grandchild.type === "element" && grandchild.tag === "manifest:encryption-data", @@ -356,10 +375,6 @@ export function validateManifest(pkg: Package): ManifestProblem[] { if (!hasEncryptionData) { continue; } - const fullPath = attrValue(child, "manifest:full-path"); - if (fullPath === undefined) { - continue; // already surfaced above by readManifest's own required-attribute check - } problems.push({ severity: "warning", message: `entry "${fullPath}" carries manifest:encryption-data -- odf.js does not implement ODF encryption/decryption`, From 7be3661d708de715dca6def5b745decfc40db0bf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:51:10 +0100 Subject: [PATCH 87/89] fix(odf.js): drop parseLineDecoration's hardcoded no-op companion placeholder parseLineDecoration's strike call site passed a hardcoded (undefined, "") companion-B pair to represent "no second companion attribute" -- a placeholder no test could ever observe, since strike genuinely has only one companion (type). companionB is now a single, properly optional { value, onValue } parameter, so a call site with no second companion (strike) omits it entirely instead of feeding the function a value that doesn't correspond to any real ODF attribute. Also simplifies parsePercentageMultiplier: PERCENTAGE_PATTERN anchors the numeric portion between ^ and a trailing "%$", so a successful match's numeric text is always exactly the input with its last character removed -- reading it back out of a capture group needed a second, provably-always-true undefined check the type system can't see through on its own. --- packages/odf.js/src/styles/properties.test.ts | 94 +++++++++++++++++++ packages/odf.js/src/styles/properties.ts | 27 ++---- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/packages/odf.js/src/styles/properties.test.ts b/packages/odf.js/src/styles/properties.test.ts index 89f0cf9a9..9b08dddd2 100644 --- a/packages/odf.js/src/styles/properties.test.ts +++ b/packages/odf.js/src/styles/properties.test.ts @@ -174,6 +174,72 @@ describe("parseTextProperties", () => { expect(result.hasUnknown).toBe(true); }); + it("flags hasUnknown for an underline companion attribute (width) present with no style:text-underline-style at all", () => { + const element = el("style:text-properties", { + "style:text-underline-width": "auto", + }); + const result = parseTextProperties(element); + expect(result.properties.underline).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + + it("flags hasUnknown for an underline companion attribute (color) present with no style:text-underline-style at all", () => { + const element = el("style:text-properties", { + "style:text-underline-color": "font-color", + }); + const result = parseTextProperties(element); + expect(result.properties.underline).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + + it("flags hasUnknown for a canonical-looking underline whose width does not match the on-value, even when its colour does", () => { + const element = el("style:text-properties", { + "style:text-underline-style": "solid", + "style:text-underline-width": "bold", + "style:text-underline-color": "font-color", + }); + const result = parseTextProperties(element); + expect(result.properties.underline).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + + it('flags hasUnknown for style:text-underline-style="none" accompanied by a companion attribute, since a real "off" underline never carries one', () => { + const withWidth = parseTextProperties( + el("style:text-properties", { + "style:text-underline-style": "none", + "style:text-underline-width": "auto", + }), + ); + expect(withWidth.properties.underline).toBeUndefined(); + expect(withWidth.hasUnknown).toBe(true); + + const withColor = parseTextProperties( + el("style:text-properties", { + "style:text-underline-style": "none", + "style:text-underline-color": "font-color", + }), + ); + expect(withColor.properties.underline).toBeUndefined(); + expect(withColor.hasUnknown).toBe(true); + }); + + it('flags hasUnknown for style:text-line-through-style="none" accompanied by style:text-line-through-type', () => { + const result = parseTextProperties( + el("style:text-properties", { + "style:text-line-through-style": "none", + "style:text-line-through-type": "single", + }), + ); + expect(result.properties.strike).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + + it("sets no underline/strike property at all (not even as undefined) when neither has any attribute present", () => { + const result = parseTextProperties(el("style:text-properties")); + expect("underline" in result.properties).toBe(false); + expect("strike" in result.properties).toBe(false); + }); + it("returns an empty, non-unknown result for an element with no attributes at all", () => { const element = el("style:text-properties"); expect(parseTextProperties(element)).toEqual({ @@ -338,6 +404,14 @@ describe("parseParagraphProperties", () => { } }); + it("flags hasUnknown for an fo:break-after value the boolean model cannot hold, the identical way fo:break-before does", () => { + const result = parseParagraphProperties( + el("style:paragraph-properties", { "fo:break-after": "column" }), + ); + expect(result.properties.pageBreakAfter).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + // fo:border-* parsing, added for ExaDev/documents.js#1086 -- the odt half of #1082's own docx w:pBdr reading, so a border-only paragraph (Word's AutoCorrect "---" horizontal rule, or LibreOffice's own equivalent) is detected the same way regardless of source format. it("parses a bottom-only border -- the exact shape a border-only horizontal rule takes", () => { const element = el("style:paragraph-properties", { @@ -433,6 +507,15 @@ describe("parseParagraphProperties", () => { expect(result.hasUnknown).toBe(false); }); + it("flags hasUnknown for a malformed fo:border shorthand value, the identical way a malformed per-edge value does", () => { + const element = el("style:paragraph-properties", { + "fo:border": "not-three-tokens", + }); + const result = parseParagraphProperties(element); + expect(result.properties.borderLeft).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + it("flags hasUnknown and leaves the field untouched for a malformed fo:border-* value (wrong token count, unparseable length/colour)", () => { for (const value of [ "not-three-tokens", @@ -487,6 +570,17 @@ describe("parseStyleElementProperties", () => { expect(result.hasUnknown).toBe(true); }); + it("never routes an unrecognised child tag through the paragraph-properties parser, even when it happens to carry an attribute name paragraph-properties would otherwise recognise", () => { + const styleElement = el( + "style:style", + { "style:name": "ta1", "style:family": "table" }, + [el("style:table-properties", { "fo:text-align": "center" })], + ); + const result = parseStyleElementProperties(styleElement); + expect(result.properties.alignment).toBeUndefined(); + expect(result.hasUnknown).toBe(true); + }); + it("flags hasUnknown for style:master-page-name even with otherwise fully-modelled properties (real LibreOffice output, style P1)", () => { // Real attributes: style:name="P1" style:family="paragraph" style:parent-style-name="Text_20_body" style:master-page-name="HTML", with paragraph-properties fo:text-align="left" (plus style:justify-single-word/fo:text-indent/style:auto-text-indent/style:page-number, themselves already unmodelled). const styleElement = el( diff --git a/packages/odf.js/src/styles/properties.ts b/packages/odf.js/src/styles/properties.ts index 062da93ff..9c5f7ed0e 100644 --- a/packages/odf.js/src/styles/properties.ts +++ b/packages/odf.js/src/styles/properties.ts @@ -119,15 +119,11 @@ const PERCENTAGE_PATTERN = /^(-?(?:\d+(?:\.\d+)?|\.\d+))%$/; // fo:line-height as a percentage (e.g. "150%") maps to document-schema.js's ContentParagraph.lineSpacing, which is a multiplier (1.5), not a percentage (150) -- see ooxml.js's own docx/pptx line-spacing readers, which establish this convention (`expect(props.lineSpacing).toBe(1.5)` for what OOXML calls 360/240). An absolute-length fo:line-height (e.g. "12pt") or the literal value "normal" is valid ODF but outside this multiplier-only model, so it parses as undefined here (triggering the caller's hasUnknown, not a silent misinterpretation). function parsePercentageMultiplier(value: string): number | undefined { - const match = PERCENTAGE_PATTERN.exec(value); - if (match === null) { + // No capture-group extraction: the pattern anchors the numeric portion between ^ and a trailing "%$", so a successful match's numeric text is always exactly the input with its last character (the "%") removed -- reading it back out of a capture group would need a second, provably-always-true undefined check the type system can't see through on its own. + if (!PERCENTAGE_PATTERN.test(value)) { return undefined; } - const numeric = match[1]; - if (numeric === undefined) { - return undefined; - } - return Number(numeric) / 100; + return Number(value.slice(0, -1)) / 100; } export function formatPercentageMultiplier(multiplier: number): string { @@ -136,32 +132,32 @@ export function formatPercentageMultiplier(multiplier: number): string { // The canonical ODF colour parse/format pair now lives in ../typed/shared/color.ts, shared with every other reader in this package rather than duplicated here -- see that module's own top-of-file note on the text:color datatype. This module calls parseOdfColor/formatOdfColor directly (see parseTextProperties/textPropertiesToAttributes below) rather than through a local alias. -// Reads a boolean tri-state (true/false/absent) plus an "unrecognised combination" outcome from ODF's compound line-decoration attributes (underline: style+width+color; strike: style+type). Ground truth (LibreOffice 26.2): underline "on" is `style:text-underline-style="solid" style:text-underline-width="auto" style:text-underline-color="font-color"`; strike "on" is `style:text-line-through-style="solid" style:text-line-through-type="single"`. Only that exact canonical "on" shape, or a plain "none" with no companion attributes, parses cleanly -- anything else (a custom underline colour, a dotted style, a companion attribute present alongside "none") is real formatting information this boolean model cannot represent, so it comes back as 'unknown' rather than being silently approximated. +// Reads a boolean tri-state (true/false/absent) plus an "unrecognised combination" outcome from ODF's compound line-decoration attributes (underline: style+width+color; strike: style+type). Ground truth (LibreOffice 26.2): underline "on" is `style:text-underline-style="solid" style:text-underline-width="auto" style:text-underline-color="font-color"`; strike "on" is `style:text-line-through-style="solid" style:text-line-through-type="single"`. Only that exact canonical "on" shape, or a plain "none" with no companion attributes, parses cleanly -- anything else (a custom underline colour, a dotted style, a companion attribute present alongside "none") is real formatting information this boolean model cannot represent, so it comes back as 'unknown' rather than being silently approximated. companionB is genuinely optional at the type level, not just always undefined at runtime: strike has only one companion attribute (type), so its call site omits companionB entirely rather than passing a hardcoded "no on-value for the companion that doesn't exist" placeholder that no test could ever observe. function parseLineDecoration( style: string | undefined, companionA: string | undefined, companionAOnValue: string, - companionB: string | undefined, - companionBOnValue: string, + companionB?: { readonly value: string | undefined; readonly onValue: string }, ): boolean | undefined | "unknown" { + const companionBValue = companionB?.value; if ( style === undefined && companionA === undefined && - companionB === undefined + companionBValue === undefined ) { return undefined; } if ( style === "solid" && (companionA === undefined || companionA === companionAOnValue) && - (companionB === undefined || companionB === companionBOnValue) + (companionBValue === undefined || companionBValue === companionB?.onValue) ) { return true; } if ( style === "none" && companionA === undefined && - companionB === undefined + companionBValue === undefined ) { return false; } @@ -207,8 +203,7 @@ export function parseTextProperties(element: XmlElement): ParsedProperties { attrs.get(ATTR.underlineStyle), attrs.get(ATTR.underlineWidth), "auto", - attrs.get(ATTR.underlineColor), - "font-color", + { value: attrs.get(ATTR.underlineColor), onValue: "font-color" }, ); if (underline === "unknown") { hasUnknown = true; @@ -220,8 +215,6 @@ export function parseTextProperties(element: XmlElement): ParsedProperties { attrs.get(ATTR.lineThroughStyle), attrs.get(ATTR.lineThroughType), "single", - undefined, - "", ); if (strike === "unknown") { hasUnknown = true; From d1341e25b4a36bf95e82481fa3e34d2dc8068c64 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 07:51:18 +0100 Subject: [PATCH 88/89] test(odf.js): cover propertyTypesForContainer and splitStyleProperties edge cases Adds direct coverage for propertyTypesForContainer ignoring a recognised style:family attribute on a tag that isn't style:style or default-style, splitStyleProperties throwing on an empty property-type candidate list, and lineThroughStyleOf's "double-line"/"thick-line" expansions alongside the values already covered. --- packages/odf.js/src/ooo1/properties.test.ts | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/odf.js/src/ooo1/properties.test.ts b/packages/odf.js/src/ooo1/properties.test.ts index 6a853c94b..a1d245029 100644 --- a/packages/odf.js/src/ooo1/properties.test.ts +++ b/packages/odf.js/src/ooo1/properties.test.ts @@ -53,6 +53,14 @@ describe("propertyTypesForContainer", () => { it("returns undefined for a container tag this module recognises neither by tag nor as a family-bearing style element", () => { expect(propertyTypesForContainer(el("office:styles"))).toBeUndefined(); }); + + it("returns undefined for a non-style:style/default-style tag even when it happens to carry a recognised style:family attribute, since only style:style/default-style consult it", () => { + expect( + propertyTypesForContainer( + el("office:styles", { "style:family": "paragraph" }), + ), + ).toBeUndefined(); + }); }); describe("splitStyleProperties: attribute routing", () => { @@ -118,6 +126,13 @@ describe("splitStyleProperties: attribute routing", () => { expect(split[0]?.tag).toBe("style:paragraph-properties"); expect(split[0]?.children).toEqual([tabStops]); }); + + it("throws rather than silently dropping an attribute when the candidate list is empty, since a family with zero property types is a caller programming error, not a routable input", () => { + const properties = el("style:properties", { "fo:color": "#000000" }); + expect(() => splitStyleProperties(properties, [])).toThrow( + "a style family must have at least one property type", + ); + }); }); describe("splitStyleProperties: style:text-underline expansion", () => { @@ -189,6 +204,20 @@ describe("splitStyleProperties: style:text-crossing-out expansion", () => { ]); }); + it('"double-line" expands to style: "solid" plus type: "double"', () => { + expect(lineThroughStyleOf("double-line")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + { name: "style:text-line-through-type", value: "double" }, + ]); + }); + + it('"thick-line" expands to style: "solid" plus width: "bold"', () => { + expect(lineThroughStyleOf("thick-line")?.attributes).toEqual([ + { name: "style:text-line-through-style", value: "solid" }, + { name: "style:text-line-through-width", value: "bold" }, + ]); + }); + it("a value with no listed expansion passes straight through as the style itself", () => { expect(lineThroughStyleOf("dash")?.attributes).toEqual([ { name: "style:text-line-through-style", value: "dash" }, From 26d130e8308bc33c91e144e7ec1d40c1850fafac Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 12:27:01 +0100 Subject: [PATCH 89/89] test(odf.js): cover ods write's used-range extension and validation deduping computeUsedRange had no direct test coverage for six of its independent grid-extension sources (columns/rows/images/embeddedObjects/data-validation ranges/printSettings), so mutants deleting any one of those loop bodies or calls went undetected as long as the sheet's cells alone already drove the grid large enough. Adds one test per source, each with an otherwise-empty cells array, proving that source alone extends the correct axis and never the other. Also covers internContentValidation's dedup/naming (two rules with identical written content share one definition; three distinct rules mint val1/val2/val3 in encounter order; allowBlank left unset and allowBlank explicitly true dedupe to the same definition since both mean the same written content) and writeValidationMessage's no-message and multi-line body cases. --- packages/odf.js/src/typed/ods/write.test.ts | 347 ++++++++++++++++++++ 1 file changed, 347 insertions(+) diff --git a/packages/odf.js/src/typed/ods/write.test.ts b/packages/odf.js/src/typed/ods/write.test.ts index 292f78317..b883d9cc1 100644 --- a/packages/odf.js/src/typed/ods/write.test.ts +++ b/packages/odf.js/src/typed/ods/write.test.ts @@ -392,6 +392,205 @@ describe("writeOdsContent XML shapes", () => { expect(attrValue(properties, "fo:background-color")).toBe("#ff0000"); }); + describe("used range: each independent source extends its own axis, never the other", () => { + it("a column past the last cell extends table:table-column but not table:table-row", () => { + const pkg = writeOdsContent( + documentOf([sheetOf([], { columns: [{ index: 3, hidden: false }] })]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(4); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(0); + }); + + it("a row past the last cell extends table:table-row but not table:table-column", () => { + const pkg = writeOdsContent( + documentOf([sheetOf([], { rows: [{ index: 2, hidden: false }] })]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(3); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(0); + }); + + it("an image past the last cell extends both axes to its own anchor position", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + images: [ + { + kind: "image", + format: "png", + base64: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + widthPt: 30, + heightPt: 20, + anchorRow: 4, + anchorColumn: 2, + offsetXPt: 0, + offsetYPt: 0, + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(5); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(3); + }); + + it("an embedded object past the last cell extends both axes to its own anchor position", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + embeddedObjects: [ + { + objectKind: "wordprocessing", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + anchorRow: 3, + anchorColumn: 1, + document: { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { + topPt: 72, + rightPt: 72, + bottomPt: 72, + leftPt: 72, + }, + blocks: [{ kind: "paragraph", runs: [{ text: "x" }] }], + }, + ], + }, + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(4); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(2); + }); + + it("an embedded object with no anchorRow/anchorColumn defaults to position (0,0), not undefined-driven NaN cells", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + embeddedObjects: [ + { + objectKind: "wordprocessing", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + document: { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { + topPt: 72, + rightPt: 72, + bottomPt: 72, + leftPt: 72, + }, + blocks: [{ kind: "paragraph", runs: [{ text: "x" }] }], + }, + ], + }, + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(1); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(1); + }); + + it("a data-validation rule's range past the last cell extends the grid, unlike a conditional-format rule's range", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 6, endColumn: 4 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + ], + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(7); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(5); + }); + + it("printSettings.repeatColumns/repeatRows each extend only their own axis (wrapped in their own table:table-header-columns/-rows)", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + repeatColumns: { start: 0, end: 2 }, + repeatRows: { start: 0, end: 5 }, + }, + }), + ]), + ); + const table = firstTable(pkg); + // repeatColumns/repeatRows wrap every column/row 0..end inside their own table:table-header-columns/-rows element (ODF's own repeated-header spelling), rather than leaving them as direct table:table children -- see wrapHeaderRange. + const columnHeader = childrenWithTag( + table, + "table:table-header-columns", + )[0]!; + const rowHeader = childrenWithTag(table, "table:table-header-rows")[0]!; + expect(childrenWithTag(columnHeader, "table:table-column")).toHaveLength( + 3, + ); + expect(childrenWithTag(rowHeader, "table:table-row")).toHaveLength(6); + }); + + it("printSettings.printRange extends both axes to its own end position", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + printRange: { + startRow: 0, + startColumn: 0, + endRow: 8, + endColumn: 3, + }, + }, + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(9); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(4); + }); + + it("printSettings.manualBreaks rows/columns each extend only their own axis", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + printSettings: { + ...DEFAULT_PRINT_SETTINGS, + manualBreaks: { rows: [7], columns: [3] }, + }, + }), + ]), + ); + const table = firstTable(pkg); + expect(childrenWithTag(table, "table:table-row")).toHaveLength(8); + expect(childrenWithTag(table, "table:table-column")).toHaveLength(4); + }); + }); + describe("data validation messages", () => { it("writes no table:help-message/table:error-message and no table:allow-empty-cell for a bare rule with no message fields and allowBlank left absent", () => { const pkg = writeOdsContent( @@ -557,6 +756,154 @@ describe("writeOdsContent XML shapes", () => { expect(nameOfRow(0)).not.toBe(nameOfRow(1)); expect(nameOfRow(2)).not.toBe(nameOfRow(3)); }); + + it("interns two rules with identical written content to the SAME name, not a fresh one each time", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + { + ranges: [ + { startRow: 1, startColumn: 0, endRow: 1, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + ], + }), + ]), + ); + expect( + childrenWithTag(contentValidations(pkg), "table:content-validation"), + ).toHaveLength(1); + const rows = childrenWithTag(firstTable(pkg), "table:table-row"); + const nameOf = (row: number) => + attrValue( + childrenWithTag(rows[row]!, "table:table-cell")[0]!, + "table:content-validation-name", + ); + expect(nameOf(0)).toBe(nameOf(1)); + }); + + it("mints names in first-encounter order across three genuinely distinct rules: val1, val2, val3", () => { + const rangeAt = (row: number) => ({ + startRow: row, + startColumn: 0, + endRow: row, + endColumn: 0, + }); + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { ranges: [rangeAt(0)], type: "list", formula1: '"a,b,c"' }, + { ranges: [rangeAt(1)], type: "list", formula1: '"x,y,z"' }, + { ranges: [rangeAt(2)], type: "list", formula1: '"p,q,r"' }, + ], + }), + ]), + ); + const names = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + ).map((rule) => attrValue(rule, "table:name")); + expect(names).toEqual(["val1", "val2", "val3"]); + }); + + it("treats allowBlank left unset and allowBlank explicitly true as the same written content, deduping to one definition", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + { + ranges: [ + { startRow: 1, startColumn: 0, endRow: 1, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + allowBlank: true, + }, + ], + }), + ]), + ); + expect( + childrenWithTag(contentValidations(pkg), "table:content-validation"), + ).toHaveLength(1); + }); + + it("writes no table:help-message/table:error-message element at all when display, title, and body are all absent", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + expect(childrenWithTag(rule, "table:help-message")).toHaveLength(0); + expect(childrenWithTag(rule, "table:error-message")).toHaveLength(0); + }); + + it("splits a multi-line help/error message body into one text:p per line", () => { + const pkg = writeOdsContent( + documentOf([ + sheetOf([], { + dataValidations: [ + { + ranges: [ + { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 }, + ], + type: "list", + formula1: '"a,b,c"', + showInputMessage: true, + prompt: "Line one\nLine two", + }, + ], + }), + ]), + ); + const rule = childrenWithTag( + contentValidations(pkg), + "table:content-validation", + )[0]!; + const help = childrenWithTag(rule, "table:help-message")[0]!; + const paragraphs = childrenWithTag(help, "text:p"); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]!.children[0]).toMatchObject({ + type: "text", + value: "Line one", + }); + expect(paragraphs[1]!.children[0]).toMatchObject({ + type: "text", + value: "Line two", + }); + }); }); describe("unsupportedConditionalFormatReason refusals", () => {