From 849bb6ef403105b482f95a683e7a7e70d7fd644a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 22:22:40 +0100 Subject: [PATCH 01/91] test(documents.js): raise findCellRegions' 200k-row test timeout The 200,000-synthetic-row-divider test in lattice.test.ts completes in well under a second uncontended, but under Stryker's per-statement instrumentation combined with heavy concurrent host load it can exceed vitest's default 5000ms wall-clock timeout despite doing the same real work. Raise the timeout to 60 seconds, matching the same wall-clock-dominated-by-scheduling pattern already documented for read-graph.test.ts's docxToPdf conversion and its sibling ODS mergeCells test. --- packages/documents.js/src/layout/lattice.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/layout/lattice.test.ts b/packages/documents.js/src/layout/lattice.test.ts index 4a015a2820..5c176c7aab 100644 --- a/packages/documents.js/src/layout/lattice.test.ts +++ b/packages/documents.js/src/layout/lattice.test.ts @@ -206,5 +206,6 @@ describe("findCellRegions: a merged region far larger than the JS engine's argum { rowStart: 0, rowEnd: rowCount, colStart: 0, colEnd: 1 }, { rowStart: 0, rowEnd: rowCount, colStart: 1, colEnd: 2 }, ]); - }); + // Builds and reconciles 200,000 synthetic row dividers, which is genuine work even though it completes in well under a second uncontended -- under Stryker's per-statement instrumentation plus heavy concurrent host load it has measured a 5000ms-plus wall clock, the same "wall-clock dominated by scheduling, not this test's own CPU work" shape documented for read-graph.test.ts's docxToPdf timeout (ExaDev/documents.js#1039) and its sibling ODS mergeCells test (ExaDev/documents.js#1037). + }, 60_000); }); From 92d44deb133035523f253ead21fd5ed8518ad45c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:26:28 +0100 Subject: [PATCH 02/91] test(documents.js): assert every rich ODS fixture cell's displayText and header labels bridges.test.ts's ods<->xlsx round-trip test read only each cell's numeric/typed `.value`, never the source ODS fixture's own rendered `.displayText` (buildRichFixturePackage's per-cell text:p run) or the two unchecked header labels ("Amount", "Active") -- so a header cell or a cell's rendered text could silently go blank without any assertion catching it. Also remove gridOdsPackage/richOdsPackage/decoratedOdsPackage from test-support/ods.ts: dead exports with no caller anywhere in the suite (only their *Bytes counterparts are ever used), each one a whole function body with no test coverage. --- .../documents.js/src/convert/bridges.test.ts | 22 +++++++++++++++++++ packages/documents.js/src/test-support/ods.ts | 12 ---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/documents.js/src/convert/bridges.test.ts b/packages/documents.js/src/convert/bridges.test.ts index 2264e83aef..e7618df72e 100644 --- a/packages/documents.js/src/convert/bridges.test.ts +++ b/packages/documents.js/src/convert/bridges.test.ts @@ -616,6 +616,20 @@ describe("ods <-> xlsx: ods -> xlsx (one hop, the character-width-unit conversio const original = odsContentOf(richOdsBytes()); const originalSheet = original.sheets[0]!; + // The source ODS fixture's own header row and every cell's rendered displayText: buildRichFixturePackage (test-support/ods.ts) writes a distinct text:p run for every cell alongside its office:value, and none of it is exercised by any assertion below (those check only the CONVERTED xlsx side's `.value`) -- so a header cell silently losing its label, or a cell's displayText silently losing its rendered text, would go undetected without checking the source fixture directly. + expect(cellAt(originalSheet, 0, 0)?.displayText).toBe("Name"); + expect(cellAt(originalSheet, 0, 1)?.displayText).toBe("Amount"); + expect(cellAt(originalSheet, 0, 2)?.displayText).toBe("Active"); + expect(cellAt(originalSheet, 1, 0)?.displayText).toBe("Widget"); + expect(cellAt(originalSheet, 1, 1)?.displayText).toBe("42.5"); + expect(cellAt(originalSheet, 1, 2)?.displayText).toBe("TRUE"); + expect(cellAt(originalSheet, 2, 0)?.displayText).toBe("15%"); + expect(cellAt(originalSheet, 2, 1)?.displayText).toBe("$9.99"); + expect(cellAt(originalSheet, 2, 2)?.displayText).toBe("2026-01-15"); + expect(cellAt(originalSheet, 3, 0)?.displayText).toBe("14:30"); + expect(cellAt(originalSheet, 3, 1)?.displayText).toBe("85"); + expect(cellAt(originalSheet, 4, 0)?.displayText).toBe("Merged Cell"); + const xlsxBytes = odsToXlsx(richOdsBytes()); const xlsx = xlsxContentOf(xlsxBytes); const sheet = xlsx.sheets[0]!; @@ -624,6 +638,14 @@ describe("ods <-> xlsx: ods -> xlsx (one hop, the character-width-unit conversio kind: "string", value: "Name", }); + expect(cellAt(sheet, 0, 1)?.value).toEqual({ + kind: "string", + value: "Amount", + }); + expect(cellAt(sheet, 0, 2)?.value).toEqual({ + kind: "string", + value: "Active", + }); expect(cellAt(sheet, 1, 0)?.value).toEqual({ kind: "string", value: "Widget", diff --git a/packages/documents.js/src/test-support/ods.ts b/packages/documents.js/src/test-support/ods.ts index 94b427ceaa..486975e198 100644 --- a/packages/documents.js/src/test-support/ods.ts +++ b/packages/documents.js/src/test-support/ods.ts @@ -263,10 +263,6 @@ export function gridOdsBytes(): Uint8Array { return encodePackage(buildGridFixturePackage()); } -export function gridOdsPackage(): Package { - return decodePackage(gridOdsBytes()); -} - // A third fixture, purpose-built for the ods<->xlsx cross-format bridge's own round-trip tests (src/convert/bridges.test.ts): three explicitly-widthed columns (3cm/4cm/2cm) and every office:value-type ODS distinguishes on one row each -- string, float, boolean, percentage, currency, date, time -- plus a formula cell (table:formula carried verbatim, never evaluated by either side of the bridge) and a genuine 2-column merge. This is deliberately the richest of the three ods.ts fixtures: xlsx write support (ooxml.js's buildXlsxPackageFromContent) is new to the ecosystem, so the bridge's own tests need real, independently-authored ground truth to check against, not a fixture built through the very editor (createOds) the bridge composes with on its own write-back hop. function buildRichFixturePackage(): Package { const columns = [ @@ -445,10 +441,6 @@ export function richOdsBytes(): Uint8Array { return encodePackage(buildRichFixturePackage()); } -export function richOdsPackage(): Package { - return decodePackage(richOdsBytes()); -} - // A fourth fixture, purpose-built for the per-cell decoration wiring (ContentSheetCell's background/borders/alignment/verticalAlignment, all four added to document-schema.js's ContentSheetCellSchema and all four genuinely populated by odf.js's own readOdsContent -- see typed/shared/table.ts's readCellStyleDecoration). Deliberately hand-authored ODF XML rather than built through createOds/OdsCell, for the same independent-construction reason this module's other fixtures are: OdsCell has no decoration setter at all today, so the editor could not express this fixture even if it were the right tool. // // One sheet, "Decorated", one row of two cells: A1 carries a yellow fo:background-color, a full fo:border shorthand, an explicit fo:text-align="right" and style:vertical-align="top"; B1 carries only a red fo:border-bottom, with no background, no alignment, and no vertical alignment of its own -- so a single fixture exercises both the "declares everything" and the "declares exactly one edge and nothing else" branches of the layout wiring at once. @@ -564,7 +556,3 @@ function buildDecoratedFixturePackage(): Package { export function decoratedOdsBytes(): Uint8Array { return encodePackage(buildDecoratedFixturePackage()); } - -export function decoratedOdsPackage(): Package { - return decodePackage(decoratedOdsBytes()); -} From 18581b95eadfab53996a4f2b18376b2026377c9a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 01:34:15 +0100 Subject: [PATCH 03/91] test(documents.js): remove four dead test-support fixture wrappers embeddedHsqldbMultiIndexOdbPackage (odb.ts), odfFormulaPackage (odf.ts), sheetFormulaOdsPackage (ods-formula.ts), and pdfWithForeignHiddenAnnotationPdf (pdf.ts) had no caller anywhere in the suite -- each one a whole function body (plus, for the first three, a now-unused Package/decodePackage import) that existed purely as NoCoverage mutation surface with nothing exercising it. pdfWithForeignHiddenAnnotationPdf's own comment ties it to a readPageNotes /T-marker check in pdf/read.ts, but no such file or function exists anywhere in this package's current src -- the reader it was meant to exercise appears to have moved or been removed elsewhere, leaving this fixture orphaned. --- packages/documents.js/src/test-support/odb.ts | 4 ---- packages/documents.js/src/test-support/odf.ts | 10 +--------- .../documents.js/src/test-support/ods-formula.ts | 7 +------ packages/documents.js/src/test-support/pdf.ts | 13 ------------- 4 files changed, 2 insertions(+), 32 deletions(-) diff --git a/packages/documents.js/src/test-support/odb.ts b/packages/documents.js/src/test-support/odb.ts index f53edc6c69..cbe0e6dc7a 100644 --- a/packages/documents.js/src/test-support/odb.ts +++ b/packages/documents.js/src/test-support/odb.ts @@ -214,10 +214,6 @@ function multiIndexOdbEntries(): (readonly [ ]; } -export function embeddedHsqldbMultiIndexOdbPackage(): Package { - return decodePackage(embeddedHsqldbMultiIndexOdbBytes()); -} - export function embeddedHsqldbMultiIndexOdbBytes(): Uint8Array { return zipPackage(multiIndexOdbEntries()); } diff --git a/packages/documents.js/src/test-support/odf.ts b/packages/documents.js/src/test-support/odf.ts index 37e935fc67..36e290f56c 100644 --- a/packages/documents.js/src/test-support/odf.ts +++ b/packages/documents.js/src/test-support/odf.ts @@ -1,5 +1,4 @@ -import type { Package } from "odf.js"; -import { decodePackage, ODF_MEDIA_TYPES, zipPackage } from "odf.js"; +import { ODF_MEDIA_TYPES, zipPackage } from "odf.js"; // Never imported by src/index.ts and never reaches dist/. Hand-authored ODF formula (.odf) XML zipped via odf.js's own zipPackage/decodePackage, mirroring src/test-support/odt.ts's own established convention exactly (same mimetype-part-first-and-stored requirement, same "not from a real LibreOffice binary" scope) -- see that file's own top-of-file comment for the full reasoning. Every fixture wraps its own MathML content in the real office:body > office:math > math:math structure a genuine LibreOffice-authored .odf uses, with every math element under a "math:" namespace prefix (not the bare, unprefixed form) -- deliberately, since that IS what real LibreOffice output uses (confirmed by src/mathml/nodes.ts's own localName-stripping design, built specifically to handle this), so these fixtures exercise the realistic path, not merely the more lenient one. @@ -30,13 +29,6 @@ export function odfFormulaBytes( ]); } -export function odfFormulaPackage( - mathMlInner: string, - options?: { readonly starMath?: string }, -): Package { - return decodePackage(odfFormulaBytes(mathMlInner, options)); -} - // A small, curated set of real formulas covering every construct the task's own test requirement names: a simple fraction, a square root, a superscript/subscript combination, and a small matrix via mtable. export const FRACTION_FORMULA = diff --git a/packages/documents.js/src/test-support/ods-formula.ts b/packages/documents.js/src/test-support/ods-formula.ts index 334acc4f12..c377fbdd95 100644 --- a/packages/documents.js/src/test-support/ods-formula.ts +++ b/packages/documents.js/src/test-support/ods-formula.ts @@ -1,5 +1,4 @@ -import type { Package } from "odf.js"; -import { base64ToBytes, decodePackage } from "odf.js"; +import { base64ToBytes } from "odf.js"; // Never imported by src/index.ts and never reaches dist/. The ExaDev/odf.js repository's own real fixture (src/typed/ods/fixtures/sheet-formula.ods), base64-embedded here exactly like src/test-support/odb-fixture.ts's own .odb and src/test-support/firebird.ts's own .fbk streams -- a genuine, unmodified LibreOffice 26.2-generated spreadsheet built through that application's own UNO API (a Java client against a headless soffice, saved with the calc8 filter) and never hand-edited afterwards. Embedded rather than read off disk because odf.js ships only dist/ as a dependency: its fixtures directory exists in that repository, not in this package's own node_modules, so a test reading it from a sibling checkout would pass on one machine and fail in CI. // @@ -94,7 +93,3 @@ const SHEET_FORMULA_ODS_BASE64 = export function sheetFormulaOdsBytes(): Uint8Array { return base64ToBytes(SHEET_FORMULA_ODS_BASE64); } - -export function sheetFormulaOdsPackage(): Package { - return decodePackage(sheetFormulaOdsBytes()); -} diff --git a/packages/documents.js/src/test-support/pdf.ts b/packages/documents.js/src/test-support/pdf.ts index 1cb8b77ea5..c0b667bb49 100644 --- a/packages/documents.js/src/test-support/pdf.ts +++ b/packages/documents.js/src/test-support/pdf.ts @@ -301,19 +301,6 @@ export function inheritedPageAttributesPdf(): Uint8Array { return b.bytes(); } -// A page with a hidden /Subtype /Text annotation NOT authored by documents.js's own writer (a different /T, as a real third-party tool's own sticky note would have) -- proves readPageNotes's /T-marker check genuinely discriminates our own notes annotation from someone else's, rather than treating every hidden Text annotation as recovered pptx notes. -export function pdfWithForeignHiddenAnnotationPdf(): Uint8Array { - const b = new FixtureBuilder().header("1.4"); - catalogPagesPageFontObjects(b, 5, "[0 0 200 100]", "/Annots [6 0 R] "); - b.stream(5, "<< >>", enc(HELLO_CONTENT)); - b.object( - 6, - "<< /Type /Annot /Subtype /Text /Rect [0 0 0 0] /Contents (A real reviewer note, not pptx speaker notes) /T (Some Other Tool) /F 2 >>", - ); - b.classicXrefAndTrailer(6, "/Root 1 0 R"); - return b.bytes(); -} - // An /Info dict mixing the two real-world string encodings a reader must handle: /Title as UTF-16BE-with-BOM (our own writer's own convention, ISO 32000-1 7.9.2.2's "long form"), and /Author/Keywords as plain literal-string PDFDocEncoding (the common case for ASCII-only metadata most third-party producers emit). /CreationDate uses the PDF date format (ISO 32000-1 7.9.4) with an explicit UTC+02:00 offset. export function withInfoDictPdf(): Uint8Array { const b = new FixtureBuilder().header("1.4"); From e6faa7c497b2c74d5492b8af9c78bc84e0a2134c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:01:54 +0100 Subject: [PATCH 04/91] test(documents.js): cover DocxTableCell.borders getter and heightPt update/clear The borders getter (top/left/bottom/right resolution, nil/none exclusion, auto-color and missing-sz fallbacks, and the empty-map-to-undefined collapse) was only ever exercised indirectly through a docx-odt bridge round trip that reads back via ooxml.js's own separate reader, never through this getter itself. Add direct tests for the getter's full edge/style/color/width matrix, the nil/none exclusion path, the auto-color and missing-attribute fallbacks, and the all-edges-excluded case. Also cover DocxTableRow.heightPt being updated and cleared on a row that already carries a w:trHeight, which the existing round-trip test never exercised since it only ever set the value once. --- .../documents.js/src/edit/docx/table.test.ts | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/packages/documents.js/src/edit/docx/table.test.ts b/packages/documents.js/src/edit/docx/table.test.ts index 59e1f40923..10b9616363 100644 --- a/packages/documents.js/src/edit/docx/table.test.ts +++ b/packages/documents.js/src/edit/docx/table.test.ts @@ -161,6 +161,18 @@ describe("DocxTable cell access and mutation", () => { } expect(roundTrippedTable.rows[0]?.heightPt).toBeCloseTo(34, 5); }); + + it("heightPt can be updated to a new value and cleared back to undefined on a row that already has one", () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const row = table.rows()[0]!; + row.heightPt = 20; + expect(row.heightPt).toBeCloseTo(20, 5); + row.heightPt = 40; + expect(row.heightPt).toBeCloseTo(40, 5); + row.heightPt = undefined; + expect(row.heightPt).toBeUndefined(); + }); }); describe("DocxTableCell background", () => { @@ -219,6 +231,142 @@ describe("DocxTableCell background", () => { }); }); +describe("DocxTableCell.borders", () => { + // Same walk-to-w:tcPr helper as the background describe block above, duplicated locally since that one is scoped to its own describe callback. + function tcPrOf(tableElement: XmlNode, cell: DocxTableCell): XmlElement { + cell.colSpan = 1; + const tr = tableElement.type === "element" ? tableElement : undefined; + const row = + tr?.children.find((c) => c.type === "element" && c.tag === "w:tr") ?? + undefined; + const tc = + row?.type === "element" + ? row.children.find((c) => c.type === "element" && c.tag === "w:tc") + : undefined; + const tcPr = + tc?.type === "element" + ? tc.children.find((c) => c.type === "element" && c.tag === "w:tcPr") + : undefined; + if (tcPr?.type !== "element") { + throw new Error("expected w:tcPr"); + } + return tcPr; + } + + it("borders is undefined for a cell with no w:tcBorders, and round-trips all four edges through the setter", () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + expect(cell.borders).toBeUndefined(); + + cell.borders = { + top: { color: { r: 1, g: 0, b: 0 }, widthPt: 2, style: "dashed" }, + left: { color: { r: 0, g: 1, b: 0 }, widthPt: 1.5, style: "dotted" }, + bottom: { color: { r: 0, g: 0, b: 1 }, widthPt: 0.5, style: "double" }, + right: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "solid" }, + }; + + expect(cell.borders).toEqual({ + top: { color: { r: 1, g: 0, b: 0 }, widthPt: 2, style: "dashed" }, + left: { color: { r: 0, g: 1, b: 0 }, widthPt: 1.5, style: "dotted" }, + bottom: { color: { r: 0, g: 0, b: 1 }, widthPt: 0.5, style: "double" }, + right: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "solid" }, + }); + }); + + it("clearing borders (undefined) removes w:tcBorders entirely", () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + cell.borders = { + top: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: "solid" }, + }; + expect(cell.borders).not.toBeUndefined(); + cell.borders = undefined; + expect(cell.borders).toBeUndefined(); + }); + + it("clearing borders on a cell with no w:tcPr at all is a no-op, not an error", () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + expect(() => { + cell.borders = undefined; + }).not.toThrow(); + expect(cell.borders).toBeUndefined(); + }); + + it('an edge whose w:val is "nil" or "none" is excluded from the read-back borders, and an edge with neither w:sz nor w:color falls back to a 1pt black solid border', () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + const tcPr = tcPrOf(tableElement, cell); + tcPr.children.push( + el("w:tcBorders", {}, [ + el("w:top", { "w:val": "nil" }), + el("w:left", { "w:val": "none" }), + el("w:bottom", { "w:val": "single" }), + ]), + ); + + const borders = cell.borders; + expect(borders?.top).toBeUndefined(); + expect(borders?.left).toBeUndefined(); + expect(borders?.bottom).toEqual({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 1, + style: "solid", + }); + }); + + it('an edge whose w:color is "auto" (rather than absent) also falls back to black', () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + const tcPr = tcPrOf(tableElement, cell); + tcPr.children.push( + el("w:tcBorders", {}, [ + el("w:top", { "w:val": "single", "w:sz": "16", "w:color": "auto" }), + ]), + ); + + expect(cell.borders?.top).toEqual({ + color: { r: 0, g: 0, b: 0 }, + widthPt: 2, + style: "solid", + }); + }); + + it("borders is undefined when w:tcBorders is present but every edge is nil/none, since an empty resolved map is treated the same as no borders at all", () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + const tcPr = tcPrOf(tableElement, cell); + tcPr.children.push( + el("w:tcBorders", {}, [ + el("w:top", { "w:val": "nil" }), + el("w:left", { "w:val": "none" }), + ]), + ); + + expect(cell.borders).toBeUndefined(); + }); + + it("an unrecognised w:val reads back as the 'solid' default, mirroring ooxml.js read.js's own fallback for unrecognised vals", () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + const tcPr = tcPrOf(tableElement, cell); + tcPr.children.push( + el("w:tcBorders", {}, [ + el("w:top", { "w:val": "wave", "w:sz": "8", "w:color": "123456" }), + ]), + ); + + expect(cell.borders?.top?.style).toBe("solid"); + }); +}); + describe("DocxTableRow.mergeCellsHorizontally", () => { it("merges colSpan columns into one cell, removing the consumed w:tc elements and leaving w:tblGrid untouched", () => { const tableElement = buildTable({ rows: 1, columns: 4 }); From ab623f3fdc78b57e135747a58377016c93c58eaf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:05:56 +0100 Subject: [PATCH 05/91] test(documents.js): cover the replace-existing-value path for colSpan, background, and borders Each of DocxTableCell's colSpan/background/borders setters filters out any prior element of the same kind before inserting the new one, but every existing test only ever set each property once starting from an empty w:tcPr, so that filter's own predicate never ran against a real element -- setting a value while one already exists was untested. --- .../documents.js/src/edit/docx/table.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/documents.js/src/edit/docx/table.test.ts b/packages/documents.js/src/edit/docx/table.test.ts index 10b9616363..a6c2bf2f52 100644 --- a/packages/documents.js/src/edit/docx/table.test.ts +++ b/packages/documents.js/src/edit/docx/table.test.ts @@ -70,6 +70,15 @@ describe("DocxTable cell access and mutation", () => { expect(cell.colSpan).toBeUndefined(); }); + it("setting colSpan again while one already exists replaces it rather than leaving a stale gridSpan behind", () => { + const tableElement = buildTable({ rows: 1, columns: 3 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + cell.colSpan = 2; + cell.colSpan = 3; + expect(cell.colSpan).toBe(3); + }); + it("verticalMerge writes and reads w:tcPr/w:vMerge, distinguishing restart from continue", () => { const tableElement = buildTable({ rows: 1, columns: 1 }); const table = new DocxTable([tableElement], tableElement); @@ -208,6 +217,15 @@ describe("DocxTableCell background", () => { expect(cell.background).toBeUndefined(); }); + it("setting background again while one already exists replaces it rather than leaving a stale w:shd behind", () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + cell.background = { r: 1, g: 0, b: 0 }; + cell.background = { r: 0, g: 0, b: 1 }; + expect(cell.background).toEqual({ r: 0, g: 0, b: 1 }); + }); + it('resolves a w:val="solid" shading from w:color, not w:fill -- the real bug this getter once had, since it read w:fill unconditionally regardless of w:val', () => { const tableElement = buildTable({ rows: 1, columns: 1 }); const table = new DocxTable([tableElement], tableElement); @@ -274,6 +292,21 @@ describe("DocxTableCell.borders", () => { }); }); + it("setting borders again while one already exists replaces it rather than leaving a stale w:tcBorders behind", () => { + const tableElement = buildTable({ rows: 1, columns: 1 }); + const table = new DocxTable([tableElement], tableElement); + const cell = table.cell(0, 0); + cell.borders = { + top: { color: { r: 1, g: 0, b: 0 }, widthPt: 2, style: "dashed" }, + }; + cell.borders = { + left: { color: { r: 0, g: 1, b: 0 }, widthPt: 1, style: "solid" }, + }; + expect(cell.borders).toEqual({ + left: { color: { r: 0, g: 1, b: 0 }, widthPt: 1, style: "solid" }, + }); + }); + it("clearing borders (undefined) removes w:tcBorders entirely", () => { const tableElement = buildTable({ rows: 1, columns: 1 }); const table = new DocxTable([tableElement], tableElement); From 38fb8d0faae91705e8677f52fe2d31ba4d7b220e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:09:37 +0100 Subject: [PATCH 06/91] test(documents.js): assert every literal in the empty docx scaffold createEmptyDocxPackage's own tests only checked that the right parts and elements existed, never the actual namespace URIs, content-type strings, relationship targets, page/margin dimensions, or style attributes createEmptyDocxPackage hardcodes -- so a corrupted namespace, content type, or page dimension would still pass every existing assertion. Add exact-value checks for every literal: the version/encoding/standalone declaration on every part, both Default extensions and Override content types in [Content_Types].xml, both relationship parts' Id/Type/Target, the US-Letter w:sectPr's pgSz/pgMar values, and the Normal style's type/id/name. --- .../src/edit/docx/scaffold.test.ts | 153 +++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/edit/docx/scaffold.test.ts b/packages/documents.js/src/edit/docx/scaffold.test.ts index 8ed0116e10..a7331a6bca 100644 --- a/packages/documents.js/src/edit/docx/scaffold.test.ts +++ b/packages/documents.js/src/edit/docx/scaffold.test.ts @@ -1,7 +1,28 @@ -import { decodePackage, encodePackage, rootElement } from "ooxml.js"; +import type { XmlElement } from "ooxml.js"; +import { attr, decodePackage, encodePackage, rootElement } from "ooxml.js"; import { describe, expect, it } from "vitest"; import { createEmptyDocxPackage } from "./scaffold"; +function elementChildren( + node: XmlElement | undefined, + tag: string, +): XmlElement[] { + if (node === undefined) { + return []; + } + return node.children.filter( + (c): c is XmlElement => c.type === "element" && c.tag === tag, + ); +} + +function elementChild(node: XmlElement | undefined, tag: string): XmlElement { + const found = elementChildren(node, tag)[0]; + if (found === undefined) { + throw new Error(`expected a <${tag}> child`); + } + return found; +} + describe("createEmptyDocxPackage", () => { it("has every part a minimal docx needs", () => { const pkg = createEmptyDocxPackage(); @@ -49,4 +70,134 @@ describe("createEmptyDocxPackage", () => { ); expect(normalStyle).toBeDefined(); }); + + it("every XML part starts with the standard version/encoding/standalone declaration", () => { + const pkg = createEmptyDocxPackage(); + for (const partName of [ + "[Content_Types].xml", + "_rels/.rels", + "word/document.xml", + "word/_rels/document.xml.rels", + "word/styles.xml", + ] as const) { + const part = pkg.parts[partName]; + if (part?.kind !== "xml") { + throw new Error(`expected ${partName} to be an xml part`); + } + expect(part.nodes[0]).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + { name: "standalone", value: "yes" }, + ], + }); + } + }); + + it("[Content_Types].xml declares the package namespace, the two Default extensions, and both part Overrides with their exact content types", () => { + const pkg = createEmptyDocxPackage(); + const root = rootElement(pkg.parts["[Content_Types].xml"]); + if (root === undefined) { + throw new Error("expected a root element"); + } + expect(attr(root, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/content-types", + ); + + const defaults = elementChildren(root, "Default"); + expect(defaults).toHaveLength(2); + expect(attr(defaults[0]!, "Extension")).toBe("rels"); + expect(attr(defaults[0]!, "ContentType")).toBe( + "application/vnd.openxmlformats-package.relationships+xml", + ); + expect(attr(defaults[1]!, "Extension")).toBe("xml"); + expect(attr(defaults[1]!, "ContentType")).toBe("application/xml"); + + const overrides = elementChildren(root, "Override"); + expect(overrides).toHaveLength(2); + expect(attr(overrides[0]!, "PartName")).toBe("/word/document.xml"); + expect(attr(overrides[0]!, "ContentType")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", + ); + expect(attr(overrides[1]!, "PartName")).toBe("/word/styles.xml"); + expect(attr(overrides[1]!, "ContentType")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml", + ); + }); + + it("_rels/.rels points rId1 at word/document.xml via the officeDocument relationship type", () => { + const pkg = createEmptyDocxPackage(); + const root = rootElement(pkg.parts["_rels/.rels"]); + if (root === undefined) { + throw new Error("expected a root element"); + } + expect(attr(root, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/relationships", + ); + const relationship = elementChild(root, "Relationship"); + expect(attr(relationship, "Id")).toBe("rId1"); + expect(attr(relationship, "Type")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", + ); + expect(attr(relationship, "Target")).toBe("word/document.xml"); + }); + + it("word/_rels/document.xml.rels points rId1 at styles.xml via the styles relationship type", () => { + const pkg = createEmptyDocxPackage(); + const root = rootElement(pkg.parts["word/_rels/document.xml.rels"]); + if (root === undefined) { + throw new Error("expected a root element"); + } + expect(attr(root, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/relationships", + ); + const relationship = elementChild(root, "Relationship"); + expect(attr(relationship, "Id")).toBe("rId1"); + expect(attr(relationship, "Type")).toBe( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + ); + expect(attr(relationship, "Target")).toBe("styles.xml"); + }); + + it("word/document.xml declares the wordprocessingml namespace and a US-Letter w:sectPr with 1in margins and 0.5in header/footer", () => { + const pkg = createEmptyDocxPackage(); + const root = rootElement(pkg.parts["word/document.xml"]); + if (root === undefined) { + throw new Error("expected a root element"); + } + expect(attr(root, "xmlns:w")).toBe( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + ); + const body = elementChild(root, "w:body"); + const sectPr = elementChild(body, "w:sectPr"); + const pgSz = elementChild(sectPr, "w:pgSz"); + expect(attr(pgSz, "w:w")).toBe("12240"); + expect(attr(pgSz, "w:h")).toBe("15840"); + const pgMar = elementChild(sectPr, "w:pgMar"); + expect(attr(pgMar, "w:top")).toBe("1440"); + expect(attr(pgMar, "w:right")).toBe("1440"); + expect(attr(pgMar, "w:bottom")).toBe("1440"); + expect(attr(pgMar, "w:left")).toBe("1440"); + expect(attr(pgMar, "w:header")).toBe("720"); + expect(attr(pgMar, "w:footer")).toBe("720"); + expect(attr(pgMar, "w:gutter")).toBe("0"); + }); + + it("word/styles.xml declares the wordprocessingml namespace and the Normal style's exact type/id/name", () => { + const pkg = createEmptyDocxPackage(); + const root = rootElement(pkg.parts["word/styles.xml"]); + if (root === undefined) { + throw new Error("expected a root element"); + } + expect(attr(root, "xmlns:w")).toBe( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + ); + const style = elementChild(root, "w:style"); + expect(attr(style, "w:type")).toBe("paragraph"); + expect(attr(style, "w:default")).toBe("1"); + expect(attr(style, "w:styleId")).toBe("Normal"); + const name = elementChild(style, "w:name"); + expect(attr(name, "w:val")).toBe("Normal"); + }); }); From d4b6d1eddf73c36f1eb704e188502498c35f44c5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:13:22 +0100 Subject: [PATCH 07/91] test(documents.js): cover ensureColumnDefaultWidth/ensureRowDefaultHeight's no-op branch Both helpers stamp DEFAULT_COLUMN_WIDTH_PT/DEFAULT_ROW_HEIGHT_PT only when the column/row has no width/height style yet, but no existing test ever called cell() (which triggers them) on a column/row that already had an explicit width/height set -- so the "already set, leave it alone" branch never actually ran with a real value to compare against. --- packages/documents.js/src/edit/ods/sheet.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/documents.js/src/edit/ods/sheet.test.ts b/packages/documents.js/src/edit/ods/sheet.test.ts index decba4cefd..1089bd49c6 100644 --- a/packages/documents.js/src/edit/ods/sheet.test.ts +++ b/packages/documents.js/src/edit/ods/sheet.test.ts @@ -568,6 +568,21 @@ describe("OdsSheet.setColumnWidth / setRowHeight", () => { // sheetB's own column was only ever touched by its own cell() call, never by sheetA's setColumnWidth -- it reads back at the ordinary cell()-materialization default (64pt), proving the two sheets' styles are genuinely independent rather than sharing one automatic style neither of them meant to share. expect(content.sheets[1]!.columns[0]?.widthPt).toBeCloseTo(64, 5); }); + + it("a later cell() on a column/row that already has an explicit width/height never resets it back to the cell()-materialization default", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.setColumnWidth(0, 130); + sheet.setRowHeight(0, 45); + sheet.cell(0, 0).value = { kind: "string", value: "x" }; // ensureColumnDefaultWidth/ensureRowDefaultHeight run here and must no-op + + const content = readOdsContent(openOds(editor.toBytes()).toPackage()); + if (content.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + expect(content.sheets[0]!.columns[0]?.widthPt).toBeCloseTo(130, 5); + expect(content.sheets[0]!.rows[0]?.heightPt).toBeCloseTo(45, 5); + }); }); describe("OdsSheet.setColumnHidden / setRowHidden", () => { From bf9a7b755e6bc67fa4b76983539fd1fa5161ac19 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:37:53 +0100 Subject: [PATCH 08/91] test(documents.js): add a print-settings.ts test suite and simplify its gridlines/headers parsing readSheetPrintSettings/writeSheetPrintSettings had no dedicated test at all; every existing test only ever set printSettings.gridlines through a single shallow property check elsewhere, leaving pageSize, margins, pageOrder, printRange, scalePercent, fitToPages, manualBreaks, and repeatColumns/repeatRows entirely unexercised. Add direct coverage for each field's round trip through the real OdsSheet.printSettings getter/setter, the wrapRepeatRange gap-fill and stale-wrapper dissolution behaviour, hasManualBreak's own break-detection, and parsePrintRanges/parseScalePercentage/parseNonNegativeInteger's malformed-input handling (poking the underlying XML directly for shapes the writer itself never produces). Also simplify the gridlines/headers reader: the previous `new Set(...).filter((token) => token.length > 0)` guarded against empty tokens from stray whitespace, but the only two things ever read from that set are `.has("grid")`/`.has("headers")`, which an empty token can never satisfy either way -- the filter's outcome was unobservable through any real behaviour. Replaced with a plain `.split(" ").includes(...)` check, which is equally robust to stray whitespace without carrying an untestable branch. --- .../src/edit/ods/print-settings.test.ts | 469 ++++++++++++++++++ .../src/edit/ods/print-settings.ts | 12 +- 2 files changed, 474 insertions(+), 7 deletions(-) create mode 100644 packages/documents.js/src/edit/ods/print-settings.test.ts diff --git a/packages/documents.js/src/edit/ods/print-settings.test.ts b/packages/documents.js/src/edit/ods/print-settings.test.ts new file mode 100644 index 0000000000..b6da7cbe3d --- /dev/null +++ b/packages/documents.js/src/edit/ods/print-settings.test.ts @@ -0,0 +1,469 @@ +import type { ContentSheetPrintSettings } from "document-schema.js"; +import type { XmlElement } from "odf.js"; +import { findStyleElement } from "odf.js"; +import { attr } from "ooxml.js"; +import { describe, expect, it } from "vitest"; +import { readOdsContent } from "../../odf/ods/read"; +import { setAttr } from "../../xml/edit"; +import { createOds, type OdsEditor } from "./editor"; +import { + readSheetPrintSettings, + writeSheetPrintSettings, +} from "./print-settings"; + +function directChild(parent: XmlElement, tag: string): XmlElement | undefined { + return parent.children.find( + (c): c is XmlElement => c.type === "element" && c.tag === tag, + ); +} + +function findTableElement(editor: OdsEditor): XmlElement { + const contentPart = editor.toPackage().parts["content.xml"]; + const root = + contentPart?.kind === "xml" + ? contentPart.nodes.find((n): n is XmlElement => n.type === "element") + : undefined; + const body = + root === undefined ? undefined : directChild(root, "office:body"); + const spreadsheet = + body === undefined ? undefined : directChild(body, "office:spreadsheet"); + const table = + spreadsheet === undefined + ? undefined + : directChild(spreadsheet, "table:table"); + if (table === undefined) { + throw new Error("expected a table:table element"); + } + return table; +} + +// The style:page-layout-properties element the most recently written printSettings minted -- style:page-layout is always appended (never reused, see print-settings.ts's own top-of-file note), so the LAST one in styles.xml/office:automatic-styles is always the current sheet's. +function currentPageLayoutProperties(editor: OdsEditor): XmlElement { + const stylesPart = editor.toPackage().parts["styles.xml"]; + const root = + stylesPart?.kind === "xml" + ? stylesPart.nodes.find((n): n is XmlElement => n.type === "element") + : undefined; + const automaticStyles = + root === undefined + ? undefined + : directChild(root, "office:automatic-styles"); + const pageLayouts = + automaticStyles === undefined + ? [] + : automaticStyles.children.filter( + (c): c is XmlElement => + c.type === "element" && c.tag === "style:page-layout", + ); + const last = pageLayouts.at(-1); + const properties = + last === undefined + ? undefined + : directChild(last, "style:page-layout-properties"); + if (properties === undefined) { + throw new Error("expected a style:page-layout-properties element"); + } + return properties; +} + +const BASE: ContentSheetPrintSettings = { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 90, bottomPt: 72, leftPt: 54 }, + gridlines: false, + headers: false, + pageOrder: "downThenOver", +}; + +describe("OdsSheet.printSettings: pageSize/margins/gridlines/headers/pageOrder", () => { + it("round-trips pageSize, margins, and pageOrder=downThenOver with neither gridlines nor headers, writing no style:print attribute at all", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + + expect(sheet.printSettings).toEqual(BASE); + const properties = currentPageLayoutProperties(editor); + expect(attr(properties, "fo:page-width")).toBe("612pt"); + expect(attr(properties, "fo:page-height")).toBe("792pt"); + expect(attr(properties, "fo:margin-top")).toBe("72pt"); + expect(attr(properties, "fo:margin-right")).toBe("90pt"); + expect(attr(properties, "fo:margin-bottom")).toBe("72pt"); + expect(attr(properties, "fo:margin-left")).toBe("54pt"); + expect(attr(properties, "style:print")).toBeUndefined(); + expect(attr(properties, "style:print-page-order")).toBe("ttb"); + }); + + it("round-trips pageOrder=overThenDown as style:print-page-order=ltr", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = { ...BASE, pageOrder: "overThenDown" }; + + expect(sheet.printSettings.pageOrder).toBe("overThenDown"); + expect( + attr(currentPageLayoutProperties(editor), "style:print-page-order"), + ).toBe("ltr"); + }); + + it('gridlines alone writes style:print="grid" and reads back gridlines=true, headers=false', () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = { ...BASE, gridlines: true }; + + expect(sheet.printSettings.gridlines).toBe(true); + expect(sheet.printSettings.headers).toBe(false); + expect(attr(currentPageLayoutProperties(editor), "style:print")).toBe( + "grid", + ); + }); + + it('headers alone writes style:print="headers" and reads back gridlines=false, headers=true', () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = { ...BASE, headers: true }; + + expect(sheet.printSettings.gridlines).toBe(false); + expect(sheet.printSettings.headers).toBe(true); + expect(attr(currentPageLayoutProperties(editor), "style:print")).toBe( + "headers", + ); + }); + + it('both gridlines and headers write style:print="grid headers" and both read back true', () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = { ...BASE, gridlines: true, headers: true }; + + expect(sheet.printSettings.gridlines).toBe(true); + expect(sheet.printSettings.headers).toBe(true); + expect(attr(currentPageLayoutProperties(editor), "style:print")).toBe( + "grid headers", + ); + }); + + it("falls back to PAGE_SIZE_A4/DEFAULT_MARGINS/downThenOver when the sheet's own style chain never resolves a page layout at all", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + // A freshly-created sheet has no table:style-name at all yet -- readSheetPrintSettings must fall back rather than throw. + const settings = sheet.printSettings; + expect(settings.pageSize).toEqual({ widthPt: 595.28, heightPt: 841.89 }); + expect(settings.margins).toEqual({ + topPt: 56.69291338582677, + rightPt: 56.69291338582677, + bottomPt: 56.69291338582677, + leftPt: 56.69291338582677, + }); + expect(settings.pageOrder).toBe("downThenOver"); + expect(settings.gridlines).toBe(false); + expect(settings.headers).toBe(false); + }); +}); + +describe("OdsSheet.printSettings: printRange", () => { + it("round-trips a printRange as SheetName-prefixed table:print-ranges", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = { + ...BASE, + printRange: { startRow: 1, startColumn: 2, endRow: 9, endColumn: 4 }, + }; + + expect(sheet.printSettings.printRange).toEqual({ + startRow: 1, + startColumn: 2, + endRow: 9, + endColumn: 4, + }); + const table = findTableElement(editor); + expect(attr(table, "table:print-ranges")).toBe("Sheet1.C2:Sheet1.E10"); + }); + + it("has no printRange when the field is omitted", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + expect(sheet.printSettings.printRange).toBeUndefined(); + }); + + it("parses a bare (no SheetName prefix) reference in table:print-ranges the same as a prefixed one", () => { + const editor = createOds(); + const table = findTableElement(editor); + setAttr(table, "table:print-ranges", "A1:C3"); + const settings = readSheetPrintSettings(editor.toPackage(), table); + expect(settings.printRange).toEqual({ + startRow: 0, + startColumn: 0, + endRow: 2, + endColumn: 2, + }); + }); + + it("table:print-ranges with no colon separator, or an unparseable cell reference, yields no printRange", () => { + const editor = createOds(); + const table = findTableElement(editor); + + setAttr(table, "table:print-ranges", "Sheet1.A1"); + expect( + readSheetPrintSettings(editor.toPackage(), table).printRange, + ).toBeUndefined(); + + setAttr(table, "table:print-ranges", "not-a-cell:C3"); + expect( + readSheetPrintSettings(editor.toPackage(), table).printRange, + ).toBeUndefined(); + }); + + it("only the first of several space-separated table:print-ranges is read", () => { + const editor = createOds(); + const table = findTableElement(editor); + setAttr( + table, + "table:print-ranges", + "Sheet1.A1:Sheet1.B2 Sheet1.D4:Sheet1.E5", + ); + const settings = readSheetPrintSettings(editor.toPackage(), table); + expect(settings.printRange).toEqual({ + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 1, + }); + }); +}); + +describe("OdsSheet.printSettings: scalePercent/fitToPages", () => { + it("round-trips scalePercent", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = { ...BASE, scalePercent: 150 }; + expect(sheet.printSettings.scalePercent).toBe(150); + expect(attr(currentPageLayoutProperties(editor), "style:scale-to")).toBe( + "150%", + ); + }); + + it("has no scalePercent when the field is omitted", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + expect(sheet.printSettings.scalePercent).toBeUndefined(); + }); + + it("an unparseable style:scale-to value yields no scalePercent", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + const properties = currentPageLayoutProperties(editor); + setAttr(properties, "style:scale-to", "not-a-percent"); + expect( + readSheetPrintSettings(editor.toPackage(), findTableElement(editor)) + .scalePercent, + ).toBeUndefined(); + }); + + it("round-trips fitToPages", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = { ...BASE, fitToPages: { width: 2, height: 3 } }; + expect(sheet.printSettings.fitToPages).toEqual({ width: 2, height: 3 }); + const properties = currentPageLayoutProperties(editor); + expect(attr(properties, "style:scale-to-X")).toBe("2"); + expect(attr(properties, "style:scale-to-Y")).toBe("3"); + }); + + it("has no fitToPages when the field is omitted", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + expect(sheet.printSettings.fitToPages).toBeUndefined(); + }); + + it("fitToPages is undefined when only one of style:scale-to-X/style:scale-to-Y is present", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + const properties = currentPageLayoutProperties(editor); + setAttr(properties, "style:scale-to-X", "4"); + expect( + readSheetPrintSettings(editor.toPackage(), findTableElement(editor)) + .fitToPages, + ).toBeUndefined(); + }); + + it("a negative style:scale-to-X/Y value yields no fitToPages", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + const properties = currentPageLayoutProperties(editor); + setAttr(properties, "style:scale-to-X", "-1"); + setAttr(properties, "style:scale-to-Y", "3"); + expect( + readSheetPrintSettings(editor.toPackage(), findTableElement(editor)) + .fitToPages, + ).toBeUndefined(); + }); + + it("Number.parseInt truncates a fractional style:scale-to-X/Y value rather than rejecting it, mirroring odf.js's own parseNonNegativeInteger", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + const properties = currentPageLayoutProperties(editor); + setAttr(properties, "style:scale-to-X", "2.5"); + setAttr(properties, "style:scale-to-Y", "3"); + expect( + readSheetPrintSettings(editor.toPackage(), findTableElement(editor)) + .fitToPages, + ).toEqual({ width: 2, height: 3 }); + }); +}); + +describe("OdsSheet.printSettings: manualBreaks", () => { + it("round-trips manual breaks on both columns and rows, preserving any width/height already set on the same column/row", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.setColumnWidth(0, 111); + sheet.setRowHeight(2, 33); + sheet.printSettings = { + ...BASE, + manualBreaks: { columns: [0, 4], rows: [2, 6] }, + }; + + const settings = sheet.printSettings; + expect(settings.manualBreaks?.columns).toEqual([0, 4]); + expect(settings.manualBreaks?.rows).toEqual([2, 6]); + + // the pre-existing width/height on column 0 / row 2 survived the manual-break write + const content = readOdsContent(editor.toPackage()); + if (content.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + expect( + content.sheets[0]!.columns.find((c) => c.index === 0)?.widthPt, + ).toBeCloseTo(111, 5); + expect( + content.sheets[0]!.rows.find((r) => r.index === 2)?.heightPt, + ).toBeCloseTo(33, 5); + }); + + it("has no manualBreaks when the field is omitted", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + expect(sheet.printSettings.manualBreaks).toBeUndefined(); + }); +}); + +describe("OdsSheet.printSettings: repeatColumns/repeatRows", () => { + it("round-trips repeatColumns and repeatRows as table:table-header-columns/-rows wrapping the given range", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + for (let column = 0; column < 5; column++) { + sheet.cell(0, column).value = { kind: "number", value: column }; + } + sheet.printSettings = { + ...BASE, + repeatColumns: { start: 0, end: 1 }, + repeatRows: { start: 0, end: 0 }, + }; + + expect(sheet.printSettings.repeatColumns).toEqual({ start: 0, end: 1 }); + expect(sheet.printSettings.repeatRows).toEqual({ start: 0, end: 0 }); + + const table = findTableElement(editor); + expect(directChild(table, "table:table-header-columns")).toBeDefined(); + expect(directChild(table, "table:table-header-rows")).toBeDefined(); + }); + + it("has no repeatColumns/repeatRows when the fields are omitted", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.printSettings = BASE; + expect(sheet.printSettings.repeatColumns).toBeUndefined(); + expect(sheet.printSettings.repeatRows).toBeUndefined(); + }); + + it("setting a new repeatColumns range dissolves the previous wrapper rather than nesting or duplicating it", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + for (let column = 0; column < 6; column++) { + sheet.cell(0, column).value = { kind: "number", value: column }; + } + sheet.printSettings = { ...BASE, repeatColumns: { start: 0, end: 1 } }; + sheet.printSettings = { ...BASE, repeatColumns: { start: 2, end: 3 } }; + + expect(sheet.printSettings.repeatColumns).toEqual({ start: 2, end: 3 }); + const table = findTableElement(editor); + const wrappers = table.children.filter( + (c) => c.type === "element" && c.tag === "table:table-header-columns", + ); + expect(wrappers).toHaveLength(1); + }); + + it("stamps a real default width/height on the exterior gap-filled columns/rows too, not just the in-range ones", () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + // columns/rows 3-5 are individuated (and wrapped) by the repeat range below; positions 0-2 are gap-filled by replaceRun's own case-3 as one compressed run ahead of it, and would otherwise be left at an ambiguous, unstyled 0 -- readOdsContent reports one compressed run as a single entry at its own start index (0), so only that index is checked for the exterior gap-fill. + sheet.printSettings = { + ...BASE, + repeatColumns: { start: 3, end: 5 }, + repeatRows: { start: 3, end: 5 }, + }; + + const content = readOdsContent(editor.toPackage()); + if (content.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + for (const index of [0, 3, 4, 5]) { + expect( + content.sheets[0]!.columns.find((c) => c.index === index)?.widthPt, + ).toBeCloseTo(64, 5); + expect( + content.sheets[0]!.rows.find((r) => r.index === index)?.heightPt, + ).toBeCloseTo(15, 5); + } + }); +}); + +describe("hasManualBreak / scanTableStructure (via a hand-crafted table:style-name chain)", () => { + it('a column/row style with no fo:break-before, or one set to something other than "page", is not a manual break', () => { + const editor = createOds(); + const sheet = editor.sheets()[0]!; + sheet.setColumnWidth(0, 80); // mints a style:table-column-properties with no fo:break-before at all + const table = findTableElement(editor); + const column = directChild(table, "table:table-column")!; + const styleName = attr(column, "table:style-name")!; + const styleElement = findStyleElement( + styleName, + "table-column", + editor.toPackage(), + )!; + const properties = directChild( + styleElement, + "style:table-column-properties", + )!; + + expect( + readSheetPrintSettings(editor.toPackage(), table).manualBreaks, + ).toBeUndefined(); + + setAttr(properties, "fo:break-before", "auto"); + expect( + readSheetPrintSettings(editor.toPackage(), table).manualBreaks, + ).toBeUndefined(); + }); +}); + +describe("writeSheetPrintSettings error handling", () => { + it("throws when printRange is set but the table has no table:name", () => { + const editor = createOds(); + const table = findTableElement(editor); + setAttr(table, "table:name", undefined as unknown as string); + // directly deleting the attribute: setAttr(undefined) is not the real removal path, so remove it via the attributes array instead. + table.attributes = table.attributes.filter((a) => a.name !== "table:name"); + + expect(() => { + writeSheetPrintSettings(editor.toPackage(), table, { + ...BASE, + printRange: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }, + }); + }).toThrow(/table:name/); + }); +}); diff --git a/packages/documents.js/src/edit/ods/print-settings.ts b/packages/documents.js/src/edit/ods/print-settings.ts index 73b1058c32..335bcb3e0b 100644 --- a/packages/documents.js/src/edit/ods/print-settings.ts +++ b/packages/documents.js/src/edit/ods/print-settings.ts @@ -297,14 +297,12 @@ export function readSheetPrintSettings( : parsePageSize(layoutProperties); const margins = layoutProperties === undefined ? undefined : parseMargins(layoutProperties); - const printTokens = new Set( + // A whitespace-split array (no separate empty-token filtering needed: .includes("grid")/.includes("headers") below finds either token regardless of any empty entries a stray double space or leading/trailing space would otherwise produce) of style:print's own space-separated tokens. + const printWords = (layoutProperties === undefined ? undefined : attr(layoutProperties, "style:print") - ) - ?.split(" ") - .filter((token) => token.length > 0) ?? [], - ); + )?.split(" ") ?? []; const pageOrder = (layoutProperties === undefined ? undefined @@ -347,8 +345,8 @@ export function readSheetPrintSettings( return { pageSize: pageSize ?? PAGE_SIZE_A4, margins: margins ?? DEFAULT_MARGINS, - gridlines: printTokens.has("grid"), - headers: printTokens.has("headers"), + gridlines: printWords.includes("grid"), + headers: printWords.includes("headers"), pageOrder, ...(printRange !== undefined ? { printRange } : {}), ...(scalePercent !== undefined ? { scalePercent } : {}), From c9e9b8c24f5474ac5fa0aba9961a3c5f6730e07a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 04:43:11 +0100 Subject: [PATCH 09/91] test(documents.js): assert every literal in the empty ods scaffold createEmptyOdsPackage had no dedicated test at all -- every existing test only exercised it indirectly through createOds(), checking structural existence (a table exists, a cell can be written) without ever asserting the actual namespace URIs, the of: namespace LibreOffice needs to recalculate table:formula on open, media type, page-layout geometry, calculation-settings defaults, or office:meta field mapping this scaffold hardcodes. Add exact-value checks for the mimetype and manifest root entry, every part's declaration, content.xml's of: namespace and default sheet/style chain, styles.xml's page-layout/ master-page chain, and every office:meta field buildOfficeMeta writes (and omits) for a given LayoutMetadata. --- .../src/edit/ods/scaffold.test.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/documents.js/src/edit/ods/scaffold.test.ts diff --git a/packages/documents.js/src/edit/ods/scaffold.test.ts b/packages/documents.js/src/edit/ods/scaffold.test.ts new file mode 100644 index 0000000000..4e3220023e --- /dev/null +++ b/packages/documents.js/src/edit/ods/scaffold.test.ts @@ -0,0 +1,199 @@ +import type { Package, XmlElement } from "odf.js"; +import { readMimetype, rootElement } from "odf.js"; +import { attr } from "ooxml.js"; +import { describe, expect, it } from "vitest"; +import { createEmptyOdsPackage } from "./scaffold"; + +function xmlRoot(pkg: Package, partName: string): XmlElement { + const part = pkg.parts[partName]; + if (part?.kind !== "xml") { + throw new Error(`expected ${partName} to be an xml part`); + } + const root = rootElement(part.nodes); + if (root === undefined) { + throw new Error(`expected a root element in ${partName}`); + } + return root; +} + +function elementChildren( + node: XmlElement | undefined, + tag: string, +): XmlElement[] { + if (node === undefined) { + return []; + } + return node.children.filter( + (c): c is XmlElement => c.type === "element" && c.tag === tag, + ); +} + +function elementChild(node: XmlElement | undefined, tag: string): XmlElement { + const found = elementChildren(node, tag)[0]; + if (found === undefined) { + throw new Error(`expected a <${tag}> child`); + } + return found; +} + +describe("createEmptyOdsPackage", () => { + it("has every part a minimal ods needs, plus mimetype and manifest", () => { + const pkg = createEmptyOdsPackage(); + expect(Object.keys(pkg.parts).sort()).toEqual( + [ + "content.xml", + "styles.xml", + "meta.xml", + "mimetype", + "META-INF/manifest.xml", + ].sort(), + ); + }); + + it("declares the vnd.oasis.opendocument.spreadsheet media type in both mimetype and the manifest's root entry", () => { + const pkg = createEmptyOdsPackage(); + expect(readMimetype(pkg)).toBe( + "application/vnd.oasis.opendocument.spreadsheet", + ); + + const manifestRoot = xmlRoot(pkg, "META-INF/manifest.xml"); + const rootEntry = elementChildren(manifestRoot, "manifest:file-entry").find( + (entry) => attr(entry, "manifest:full-path") === "/", + ); + expect(attr(rootEntry, "manifest:media-type")).toBe( + "application/vnd.oasis.opendocument.spreadsheet", + ); + }); + + it("every XML part starts with the standard version/encoding/standalone declaration", () => { + const pkg = createEmptyOdsPackage(); + for (const partName of ["content.xml", "styles.xml", "meta.xml"] as const) { + const part = pkg.parts[partName]; + if (part?.kind !== "xml") { + throw new Error(`expected ${partName} to be an xml part`); + } + expect(part.nodes[0]).toEqual({ + type: "declaration", + attributes: [ + { name: "version", value: "1.0" }, + { name: "encoding", value: "UTF-8" }, + { name: "standalone", value: "yes" }, + ], + }); + } + }); + + it("content.xml declares the of: namespace (required for table:formula's OpenFormula grammar to recalculate on open) alongside version 1.3 and one empty, named default sheet", () => { + const pkg = createEmptyOdsPackage(); + const root = xmlRoot(pkg, "content.xml"); + expect(attr(root, "xmlns:of")).toBe( + "urn:oasis:names:tc:opendocument:xmlns:of:1.2", + ); + expect(attr(root, "office:version")).toBe("1.3"); + + const automaticStyles = elementChild(root, "office:automatic-styles"); + const sheetStyle = elementChild(automaticStyles, "style:style"); + expect(attr(sheetStyle, "style:name")).toBe("OdsTable"); + expect(attr(sheetStyle, "style:family")).toBe("table"); + expect(attr(sheetStyle, "style:master-page-name")).toBe("Standard"); + + const body = elementChild(root, "office:body"); + const spreadsheet = elementChild(body, "office:spreadsheet"); + const calcSettings = elementChild( + spreadsheet, + "table:calculation-settings", + ); + expect(attr(calcSettings, "table:automatic-find-labels")).toBe("false"); + expect(attr(calcSettings, "table:use-regular-expressions")).toBe("false"); + expect(attr(calcSettings, "table:use-wildcards")).toBe("true"); + expect(attr(calcSettings, "table:null-year")).toBe("1950"); + + const table = elementChild(spreadsheet, "table:table"); + expect(attr(table, "table:name")).toBe("Sheet1"); + expect(attr(table, "table:style-name")).toBe("OdsTable"); + }); + + it("styles.xml declares version 1.3, a PAGE_SIZE_A4/2cm-margin page layout, and the Standard master page referencing it", () => { + const pkg = createEmptyOdsPackage(); + const root = xmlRoot(pkg, "styles.xml"); + expect(attr(root, "office:version")).toBe("1.3"); + + const automaticStyles = elementChild(root, "office:automatic-styles"); + const pageLayout = elementChild(automaticStyles, "style:page-layout"); + expect(attr(pageLayout, "style:name")).toBe("PM1"); + const properties = elementChild(pageLayout, "style:page-layout-properties"); + expect(attr(properties, "fo:page-width")).toBe("595.28pt"); + expect(attr(properties, "fo:page-height")).toBe("841.89pt"); + expect(attr(properties, "fo:margin-top")).toBe("2cm"); + expect(attr(properties, "fo:margin-right")).toBe("2cm"); + expect(attr(properties, "fo:margin-bottom")).toBe("2cm"); + expect(attr(properties, "fo:margin-left")).toBe("2cm"); + + const masterStyles = elementChild(root, "office:master-styles"); + const masterPage = elementChild(masterStyles, "style:master-page"); + expect(attr(masterPage, "style:name")).toBe("Standard"); + expect(attr(masterPage, "style:page-layout-name")).toBe("PM1"); + }); + + it("meta.xml has an empty office:meta when no metadata is given", () => { + const pkg = createEmptyOdsPackage(); + const root = xmlRoot(pkg, "meta.xml"); + expect(attr(root, "office:version")).toBe("1.3"); + const meta = elementChild(root, "office:meta"); + expect(meta.children).toHaveLength(0); + }); + + it("meta.xml carries every given metadata field, XML-encoded, with keywords repeated once per entry", () => { + const pkg = createEmptyOdsPackage({ + metadata: { + title: "A & More", + author: "Ada", + subject: "A Subject", + keywords: ["alpha", "beta"], + creator: "documents.js", + createdIso: "2024-01-01T00:00:00.000Z", + modifiedIso: "2024-06-01T00:00:00.000Z", + }, + }); + const root = xmlRoot(pkg, "meta.xml"); + const meta = elementChild(root, "office:meta"); + + const title = elementChild(meta, "dc:title"); + expect(title.children).toEqual([ + { type: "text", value: "A <Title> & More" }, + ]); + const creator = elementChild(meta, "meta:initial-creator"); + expect(creator.children).toEqual([{ type: "text", value: "Ada" }]); + const subject = elementChild(meta, "dc:subject"); + expect(subject.children).toEqual([{ type: "text", value: "A Subject" }]); + const keywords = elementChildren(meta, "meta:keyword"); + expect(keywords).toHaveLength(2); + expect(keywords[0]?.children).toEqual([{ type: "text", value: "alpha" }]); + expect(keywords[1]?.children).toEqual([{ type: "text", value: "beta" }]); + const generator = elementChild(meta, "meta:generator"); + expect(generator.children).toEqual([ + { type: "text", value: "documents.js" }, + ]); + const creationDate = elementChild(meta, "meta:creation-date"); + expect(creationDate.children).toEqual([ + { type: "text", value: "2024-01-01T00:00:00.000Z" }, + ]); + const date = elementChild(meta, "dc:date"); + expect(date.children).toEqual([ + { type: "text", value: "2024-06-01T00:00:00.000Z" }, + ]); + }); + + it("meta.xml omits each metadata field individually when it is absent, rather than writing an empty element", () => { + const pkg = createEmptyOdsPackage({ metadata: { title: "Only Title" } }); + const root = xmlRoot(pkg, "meta.xml"); + const meta = elementChild(root, "office:meta"); + expect(elementChildren(meta, "dc:title")).toHaveLength(1); + expect(elementChildren(meta, "meta:initial-creator")).toHaveLength(0); + expect(elementChildren(meta, "dc:subject")).toHaveLength(0); + expect(elementChildren(meta, "meta:keyword")).toHaveLength(0); + expect(elementChildren(meta, "meta:generator")).toHaveLength(0); + expect(elementChildren(meta, "meta:creation-date")).toHaveLength(0); + expect(elementChildren(meta, "dc:date")).toHaveLength(0); + }); +}); From 331d5933a0020ec1c395ce4eeb4a4e32a75a83cf Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 04:45:13 +0100 Subject: [PATCH 10/91] fix(documents.js): narrow the undefined manifest:file-entry lookup in the ods scaffold test tsconfig.node.json's stricter typecheck (run separately from the default tsconfig by the pre-push hook) caught what tsconfig.json's own run missed: Array.prototype.find can return undefined, and attr() requires a real XmlElement. --- packages/documents.js/src/edit/ods/scaffold.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/documents.js/src/edit/ods/scaffold.test.ts b/packages/documents.js/src/edit/ods/scaffold.test.ts index 4e3220023e..5bb7a41695 100644 --- a/packages/documents.js/src/edit/ods/scaffold.test.ts +++ b/packages/documents.js/src/edit/ods/scaffold.test.ts @@ -60,6 +60,9 @@ describe("createEmptyOdsPackage", () => { const rootEntry = elementChildren(manifestRoot, "manifest:file-entry").find( (entry) => attr(entry, "manifest:full-path") === "/", ); + if (rootEntry === undefined) { + throw new Error("expected a root manifest:file-entry"); + } expect(attr(rootEntry, "manifest:media-type")).toBe( "application/vnd.oasis.opendocument.spreadsheet", ); From 5fc01b66401a5ad52ea3268ab8c7bb9d647ae4c8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:19:02 +0100 Subject: [PATCH 11/91] fix(documents.js): drop the unobservable empty-string user-agent fallback in detectPackageManager userAgent ?? "" only mattered for the startsWith checks below it, and none of yarn/pnpm/bun's prefixes match an empty string either, so the fallback literal's own value was unobservable and the branch always returned npm regardless. Replace it with an explicit early return for the undefined case so the mutation-prone fallback literal no longer exists as an AST node. --- packages/documents.js/src/bin-dispatch.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/documents.js/src/bin-dispatch.ts b/packages/documents.js/src/bin-dispatch.ts index 89291757f8..dc4a67aabb 100644 --- a/packages/documents.js/src/bin-dispatch.ts +++ b/packages/documents.js/src/bin-dispatch.ts @@ -26,13 +26,15 @@ const RUNNERS: Readonly<Record<PackageManager, RunnerSpec>> = { }; function detectPackageManager(userAgent: string | undefined): PackageManager { - const ua = userAgent ?? ""; - if (ua.startsWith("yarn/")) { + // No npm_config_user_agent at all (Deno never sets it; running the bin via bare `node` sets nothing) falls back to npm exactly like every other unrecognised value below -- handled as its own branch, rather than defaulting `userAgent` to an empty string first, so there is no fallback string literal whose own value is unobservable (every one of the startsWith checks below is false for it) and therefore untestable. + if (userAgent === undefined) return "npm"; + + if (userAgent.startsWith("yarn/")) { // Yarn classic (1.x) has no `dlx` subcommand -- it is Yarn Berry (2+) only -- so classic is treated as npm and runs through npx rather than a command that fails. - return ua.startsWith("yarn/1.") ? "npm" : "yarn"; + return userAgent.startsWith("yarn/1.") ? "npm" : "yarn"; } - if (ua.startsWith("pnpm/")) return "pnpm"; - if (ua.startsWith("bun/")) return "bun"; + if (userAgent.startsWith("pnpm/")) return "pnpm"; + if (userAgent.startsWith("bun/")) return "bun"; // npm, and any agent that doesn't identify itself (Deno doesn't set this env var at all; running the bin via bare `node` sets nothing), falls back to npx. return "npm"; } From a874e3c4bccf16c6121b19cffa0aebe6bca6df63 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:19:13 +0100 Subject: [PATCH 12/91] test(documents.js): cover applyOdfGeometry and buildTransformAttr directly No test in the package called applyOdfGeometry directly; its 33 covering tests all went through higher-level editors that never distinguished rotationDeg undefined from rotationDeg 0, so the combined "undefined || === 0" guard survived a mutation to a bare false. Add direct unit coverage for all three branches (undefined, exactly 0, and a real rotation) plus a sanity check on buildTransformAttr's own composition. --- .../documents.js/src/edit/geometry.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 packages/documents.js/src/edit/geometry.test.ts diff --git a/packages/documents.js/src/edit/geometry.test.ts b/packages/documents.js/src/edit/geometry.test.ts new file mode 100644 index 0000000000..9e914e7cfd --- /dev/null +++ b/packages/documents.js/src/edit/geometry.test.ts @@ -0,0 +1,56 @@ +import type { Box } from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { el } from "../xml/fragment"; +import { applyOdfGeometry, buildTransformAttr } from "./geometry"; + +function attr(node: ReturnType<typeof el>, name: string): string | undefined { + return node.attributes.find((a) => a.name === name)?.value; +} + +const frame: Box = { xPt: 10, yPt: 20, widthPt: 100, heightPt: 50 }; + +describe("applyOdfGeometry", () => { + it("writes plain svg:x/svg:y and removes draw:transform when rotationDeg is undefined", () => { + const node = el("draw:frame", { + "draw:transform": "rotate(1) translate(2 3)", + }); + applyOdfGeometry(node, frame, undefined); + expect(attr(node, "svg:width")).toBe("100pt"); + expect(attr(node, "svg:height")).toBe("50pt"); + expect(attr(node, "svg:x")).toBe("10pt"); + expect(attr(node, "svg:y")).toBe("20pt"); + expect(attr(node, "draw:transform")).toBeUndefined(); + }); + + it("writes plain svg:x/svg:y and removes draw:transform when rotationDeg is exactly 0", () => { + const node = el("draw:frame", { + "draw:transform": "rotate(1) translate(2 3)", + }); + applyOdfGeometry(node, frame, 0); + expect(attr(node, "svg:x")).toBe("10pt"); + expect(attr(node, "svg:y")).toBe("20pt"); + expect(attr(node, "draw:transform")).toBeUndefined(); + }); + + it("writes draw:transform and removes svg:x/svg:y for a non-zero rotation", () => { + const node = el("draw:frame", { "svg:x": "10pt", "svg:y": "20pt" }); + applyOdfGeometry(node, frame, 90); + expect(attr(node, "svg:x")).toBeUndefined(); + expect(attr(node, "svg:y")).toBeUndefined(); + expect(attr(node, "draw:transform")).toBe(buildTransformAttr(frame, 90)); + }); +}); + +describe("buildTransformAttr", () => { + it("round-trips the frame's own centre through the rotate+translate composition", () => { + const transform = buildTransformAttr(frame, 90); + expect(transform).toMatch( + /^rotate\(-1\.5707963267948966\) translate\(-?\d+(\.\d+)?pt -?\d+(\.\d+)?pt\)$/, + ); + }); + + it("produces a zero translate when rotating an already-centred (origin) frame with zero angle", () => { + const centred: Box = { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }; + expect(buildTransformAttr(centred, 0)).toBe("rotate(0) translate(0pt 0pt)"); + }); +}); From f704f6ff7dc635ae121e2b35b82c973b72ddc640 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:19:34 +0100 Subject: [PATCH 13/91] test(documents.js): assert the .name of three named error classes UnsupportedFontSourceFormatError, UnsupportedConversionError, and UnsupportedPackageFormatError were only ever checked by instanceof/toThrow(), so each constructor's own this.name assignment had no test observing its value and survived a mutation to an empty string. --- packages/documents.js/src/convert/document-fonts.test.ts | 1 + packages/documents.js/src/convert/local.test.ts | 3 +++ packages/documents.js/src/package-codec.test.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/packages/documents.js/src/convert/document-fonts.test.ts b/packages/documents.js/src/convert/document-fonts.test.ts index 919587f6b2..5357bd3813 100644 --- a/packages/documents.js/src/convert/document-fonts.test.ts +++ b/packages/documents.js/src/convert/document-fonts.test.ts @@ -72,5 +72,6 @@ describe("extractSourceFontsForFormat", () => { ); } expect(caught.format).toBe("xlsx"); + expect(caught.name).toBe("UnsupportedFontSourceFormatError"); }); }); diff --git a/packages/documents.js/src/convert/local.test.ts b/packages/documents.js/src/convert/local.test.ts index 5715bdb29b..320d5961ae 100644 --- a/packages/documents.js/src/convert/local.test.ts +++ b/packages/documents.js/src/convert/local.test.ts @@ -649,6 +649,9 @@ describe("createLocalDocumentConverter: convert", () => { ); await expect(promise).rejects.toBeInstanceOf(UnsupportedConversionError); await expect(promise).rejects.toThrow(/unsupported conversion/); + await promise.catch((error: unknown) => { + expect((error as Error).name).toBe("UnsupportedConversionError"); + }); }); it("collects a char/substituted diagnostic for a character outside WinAnsi", async () => { diff --git a/packages/documents.js/src/package-codec.test.ts b/packages/documents.js/src/package-codec.test.ts index 2c42ed2c79..20f6659849 100644 --- a/packages/documents.js/src/package-codec.test.ts +++ b/packages/documents.js/src/package-codec.test.ts @@ -118,6 +118,7 @@ describe("decodeDocumentPackage / encodeDocumentPackage: unsupported formats", ( throw error; } expect(error.format).toBe("markdown"); + expect(error.name).toBe("UnsupportedPackageFormatError"); } }); From 8d36215a4d2bafc3030559e6d3ff241a53a6e355 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:19:55 +0100 Subject: [PATCH 14/91] test(documents.js): cover the PptEditor/writePptContent non-presentation guard Neither constructor guard's error message had a test calling it with a wrong-kind ContentDocument, so both throw strings survived being mutated to an empty template literal. --- .../documents.js/src/edit/ppt/editor.test.ts | 16 +++++++++++++++- packages/documents.js/src/ppt/write.test.ts | 13 +++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/edit/ppt/editor.test.ts b/packages/documents.js/src/edit/ppt/editor.test.ts index efaec7e366..b2de6bef05 100644 --- a/packages/documents.js/src/edit/ppt/editor.test.ts +++ b/packages/documents.js/src/edit/ppt/editor.test.ts @@ -1,7 +1,8 @@ +import type { ContentDocument } from "document-schema.js"; import { SLIDE_SIZE_WIDESCREEN } from "document-schema.js"; import { describe, expect, it } from "vitest"; import { fixedClock } from "../../ports/clock"; -import { createPpt, openPpt } from "./editor"; +import { createPpt, openPpt, PptEditor } from "./editor"; const FIXED_ISO = "2026-01-01T00:00:00.000Z"; @@ -13,6 +14,19 @@ describe("createPpt", () => { }); }); +describe("PptEditor constructor guard", () => { + it("rejects a non-presentation ContentDocument, naming the offending kind", () => { + const spreadsheet: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], + }; + expect(() => new PptEditor(spreadsheet)).toThrow( + 'PptEditor requires a presentation ContentDocument, got "spreadsheet"', + ); + }); +}); + describe("PptEditor slides and shapes", () => { it("round-trips a slide with a text box, its frame, and speaker notes", () => { const editor = createPpt(); diff --git a/packages/documents.js/src/ppt/write.test.ts b/packages/documents.js/src/ppt/write.test.ts index 151c661db4..cfe48abd26 100644 --- a/packages/documents.js/src/ppt/write.test.ts +++ b/packages/documents.js/src/ppt/write.test.ts @@ -148,3 +148,16 @@ describe("ppt/write + ppt/read: OLE-embedded objects", () => { ).toBe("Nested deck"); }); }); + +describe("writePptContent: constructor guard", () => { + it("rejects a non-presentation ContentDocument, naming the offending kind", () => { + const spreadsheet: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], + }; + expect(() => writePptContent(spreadsheet)).toThrow( + "writePptContent requires a presentation ContentDocument, got 'spreadsheet'", + ); + }); +}); From 98171b2e46decc6a65184a826bd4ba8d2a4c3024 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:20:07 +0100 Subject: [PATCH 15/91] test(documents.js): assert collectDocumentFormulas' presentation locate string The presentation-slide-shape test only checked the recovered formula, never the locate path built from the slide/shape indices, so that template literal survived being mutated to an empty string. --- packages/documents.js/src/model/formula.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/documents.js/src/model/formula.test.ts b/packages/documents.js/src/model/formula.test.ts index 9adebdfd2e..7406f8a789 100644 --- a/packages/documents.js/src/model/formula.test.ts +++ b/packages/documents.js/src/model/formula.test.ts @@ -215,6 +215,7 @@ describe("collectDocumentFormulas", () => { const entries = collectDocumentFormulas(document); expect(entries).toHaveLength(1); expect(entries[0]?.formula.presentation?.latex).toBe("m \\times a"); + expect(entries[0]?.locate).toBe("slides[0].shapes[0]/blocks[0]"); }); it("walks a drawing page's shapes", () => { From dc60a3ea4e050fc50e6560f9b88416fa68bd91af Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:20:13 +0100 Subject: [PATCH 16/91] test(documents.js): assert odbTablesToSpreadsheetDocument's row-sizing count No test read sheet.rows at all, so table.rows.length + 1 (one sizing entry per data row plus the header row) survived being mutated to - 1. --- packages/documents.js/src/odb/spreadsheet.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/documents.js/src/odb/spreadsheet.test.ts b/packages/documents.js/src/odb/spreadsheet.test.ts index ea761170b5..cb472e3fcb 100644 --- a/packages/documents.js/src/odb/spreadsheet.test.ts +++ b/packages/documents.js/src/odb/spreadsheet.test.ts @@ -88,6 +88,15 @@ describe("odbTablesToSpreadsheetDocument", () => { ]); }); + it("sizes one row entry per data row plus the header row", () => { + const content = odbTablesToSpreadsheetDocument([TABLE]); + if (content.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + // TABLE has 2 data rows, so the sizing array must cover row 0 (header) through row 2. + expect(content.sheets[0]?.rows.map((row) => row.index)).toEqual([0, 1, 2]); + }); + it("produces a real, non-empty printSettings for every sheet, so the xlsx builder has something to write", () => { const content = odbTablesToSpreadsheetDocument([TABLE]); if (content.kind !== "spreadsheet") { From 134d15448530836dbee3021ad0544b0f422d95a1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:20:17 +0100 Subject: [PATCH 17/91] test(documents.js): cover odbReportGroupChain's held-no-group guard The "single-length level whose slot is undefined" branch had no covering test at all (not reachable through any real odf.js-decoded report), so its error message survived being mutated to an empty string. Exercise it directly with a hand-built groups array carrying an explicit hole. --- .../documents.js/src/odb/formula/definition.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/documents.js/src/odb/formula/definition.test.ts b/packages/documents.js/src/odb/formula/definition.test.ts index df558735c6..34561f96eb 100644 --- a/packages/documents.js/src/odb/formula/definition.test.ts +++ b/packages/documents.js/src/odb/formula/definition.test.ts @@ -108,6 +108,19 @@ describe("rptDefinitionFromReport refusals", () => { ).toThrow(/declares no rpt:group-expression/); }); + it("refuses a single-length group level whose one slot holds no group", () => { + // Not a shape odf.js's own reader can ever produce (its groups array is always populated element-for-element) -- this exercises the defensive guard directly, since a length-1 array with a hole is otherwise unreachable through any real .odb fixture. + const holed = emptyReport({ + groups: [undefined] as unknown as OdbReportGroup[], + }); + expect(() => rptDefinitionFromReport(holed)).toThrow( + RptReportStructureError, + ); + expect(() => rptDefinitionFromReport(holed)).toThrow( + /a group nesting level reported a non-zero length but held no group/, + ); + }); + it("refuses sibling groups at one nesting level rather than keeping the first and dropping the rest", () => { const siblings = emptyReport({ groups: [ From 8205920da8a4d0763d734bb4f02550b7e915f08d Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:20:22 +0100 Subject: [PATCH 18/91] test(documents.js): assert findChildElement rejects a non-matching tag The only existing test's container held a single element that always matched regardless of the tag check, so node.tag === tag survived being mutated to true. Add a container whose one element has a different tag. --- packages/documents.js/src/xml/query.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/documents.js/src/xml/query.test.ts b/packages/documents.js/src/xml/query.test.ts index 3ba85ae26e..a53cca40b1 100644 --- a/packages/documents.js/src/xml/query.test.ts +++ b/packages/documents.js/src/xml/query.test.ts @@ -21,6 +21,12 @@ describe("xml/query", () => { expect(cursor?.container).toBe(container); }); + it("findChildElement returns undefined when no child element matches the requested tag, even though an element of a different tag is present", () => { + const run = el("w:r"); + const container: XmlNode[] = [run]; + expect(findChildElement(container, "w:p")).toBeUndefined(); + }); + it("findChildElements returns only direct children, in document order", () => { const runA = el("w:r"); const runB = el("w:r"); From 72c0829a93117e27dd00fe9abdb2161df2dc13f3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:42:26 +0100 Subject: [PATCH 19/91] test(documents.js): cover CONTENT_READERS.markdown/readDocumentLayout option forwarding Neither the markdown reader's images/signal object nor readDocumentLayout's signal object had a test observing an effect from either field, so both ObjectLiteral mutations to {} survived. Assert the images resolver is actually invoked and that an aborted signal is checked before parsing (the markdown reader's own signal check, and readPdf's own pre-page-loop check). Also assert throwIfAborted's DOMException carries the exact name/message rather than only its type. --- packages/documents.js/src/codecs/read.test.ts | 45 +++++++++++++++++++ packages/documents.js/src/ports/abort.test.ts | 9 ++++ 2 files changed, 54 insertions(+) create mode 100644 packages/documents.js/src/codecs/read.test.ts diff --git a/packages/documents.js/src/codecs/read.test.ts b/packages/documents.js/src/codecs/read.test.ts new file mode 100644 index 0000000000..587bdb33eb --- /dev/null +++ b/packages/documents.js/src/codecs/read.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { CONTENT_READERS, readDocumentLayout } from "./read"; +import { encodeMarkdownText } from "../markdown/text"; + +describe("CONTENT_READERS.markdown", () => { + it("forwards the images resolver through to readMarkdownContent", () => { + const resolver = vi.fn(() => undefined); + CONTENT_READERS.markdown(encodeMarkdownText("![alt](img.png)"), { + images: resolver, + }); + expect(resolver).toHaveBeenCalledWith("img.png", expect.anything()); + }); + + it("forwards the abort signal through to readMarkdownContent, which checks it before parsing", () => { + const controller = new AbortController(); + controller.abort(); + let caught: unknown; + try { + CONTENT_READERS.markdown(encodeMarkdownText("hi"), { + signal: controller.signal, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe("AbortError"); + }); +}); + +describe("readDocumentLayout", () => { + it("forwards the signal option through to readPdf, which checks it before parsing", () => { + const controller = new AbortController(); + controller.abort(); + // A real "%PDF-" header but otherwise garbage bytes: readPdf checks the header first, then the abort signal, before it ever opens the document -- if the signal were not forwarded (an empty options object), this would fail trying to parse the document instead. + const bytes = new TextEncoder().encode("%PDF-1.4\n%garbage"); + let caught: unknown; + try { + readDocumentLayout(bytes, { signal: controller.signal }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe("AbortError"); + }); +}); diff --git a/packages/documents.js/src/ports/abort.test.ts b/packages/documents.js/src/ports/abort.test.ts index 9449bf837c..064eab418e 100644 --- a/packages/documents.js/src/ports/abort.test.ts +++ b/packages/documents.js/src/ports/abort.test.ts @@ -21,5 +21,14 @@ describe("throwIfAborted", () => { expect(() => { throwIfAborted(controller.signal); }).toThrow(DOMException); + let caught: unknown; + try { + throwIfAborted(controller.signal); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe("AbortError"); + expect((caught as DOMException).message).toBe("Aborted"); }); }); From 1b97dfa03d69de9417ed6e9597e0190c5f61aa67 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:42:45 +0100 Subject: [PATCH 20/91] test(documents.js): cover DOCUMENT_FORMAT_CODECS.xls/pdf write-half guards xls.content.write's non-spreadsheet refusal message and pdf.layout.write's signal forwarding to writePdf had no test observing either, so a StringLiteral mutation to the error message and an ObjectLiteral mutation of the options object both survived. --- .../documents.js/src/codecs/registry.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/codecs/registry.test.ts b/packages/documents.js/src/codecs/registry.test.ts index 34a8d37561..d6d9c49a54 100644 --- a/packages/documents.js/src/codecs/registry.test.ts +++ b/packages/documents.js/src/codecs/registry.test.ts @@ -7,7 +7,7 @@ import type { import { PAGE_SIZE_LETTER } from "document-schema.js"; import { describe, expect, it } from "vitest"; import type { XlsContentDocument } from "xls-codec"; -import { odsToXlsx } from "../convert/convert"; +import { docxToPdf, odsToXlsx } from "../convert/convert"; import { readOdfFormulaContent } from "../odf/formula/read"; import { FRACTION_FORMULA, odfFormulaBytes } from "../test-support/odf"; import { minimalDocxBytes } from "../test-support/docx"; @@ -300,6 +300,18 @@ describe("DOCUMENT_FORMAT_CODECS: content read/write round trips", () => { expect(roundTripped).toEqual(expected); }); + it("xls: content.write refuses a non-spreadsheet ContentDocument by name", () => { + const codec = requireContentCodec("xls"); + const wordprocessing: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [], + }; + expect(() => codec.write!(wordprocessing)).toThrow( + "DOCUMENT_FORMAT_CODECS.xls.content.write: expected a spreadsheet ContentDocument", + ); + }); + // Mirrors ppt-codec's own write.test.ts fixture shape. The writer's own scope is text-box slides only (see that package's README scope note); like pptx/odp above, a black-box substantive-text check is the right-scoped proof of wiring here rather than exact equality -- ppt-codec's own reader always reports PowerPoint's fixed default text insets (0.1in left/right, 0.05in top/bottom) regardless of what a shape actually carries, since it does not yet read a shape's own OfficeArtFOPT inset override (see read.ts's own DEFAULT_INSET_LEFT_RIGHT_PT/DEFAULT_INSET_TOP_BOTTOM_PT comment), a pre-existing, documented gap this registry wiring did not introduce. it("ppt: read -> write -> read carries the source slide text through", () => { const codec = requireContentCodec("ppt"); @@ -353,6 +365,16 @@ describe("DOCUMENT_FORMAT_CODECS: pdf has a layout codec, not a content codec", expect(DOCUMENT_FORMAT_CODECS.pdf.content).toBeUndefined(); expect(DOCUMENT_FORMAT_CODECS.pdf.layout).toBeDefined(); }); + + it("layout.write forwards the abort signal through to writePdf's own per-page check", () => { + const codec = DOCUMENT_FORMAT_CODECS.pdf.layout!; + const layout = codec.read(docxToPdf(minimalDocxBytes())); + const controller = new AbortController(); + controller.abort(); + expect(() => { + codec.write(layout, { signal: controller.signal }); + }).toThrow(DOMException); + }); }); describe("DOCUMENT_FORMAT_CODECS: xlsx has a content codec, no layout codec", () => { From 67662ac4cf9e19d2703e202bb6063524886af04a Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:43:03 +0100 Subject: [PATCH 21/91] fix(documents.js): drop dataCell's redundant empty-field inference guard inferCellValue("") already returns undefined on its own (its own text.length === 0 check), so field === "" ? undefined : inferCellValue(field) restated that in a second place for no observable difference -- an empty field's own type-inference path was unkillable by any test because both branches always produced the same undefined. Call inferCellValue(field) directly, and assert onCellTypeInference fires only for the populated fields in a row that also carries empty ones. --- .../documents.js/src/csv/read-write.test.ts | 19 ++++++++++++++++++- packages/documents.js/src/csv/read.ts | 3 ++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/documents.js/src/csv/read-write.test.ts b/packages/documents.js/src/csv/read-write.test.ts index 3322658d97..10aa357911 100644 --- a/packages/documents.js/src/csv/read-write.test.ts +++ b/packages/documents.js/src/csv/read-write.test.ts @@ -107,7 +107,10 @@ describe("readCsvContent", () => { it("maps an empty data field to the empty cell and pads a short record to the grid width with empty cells", () => { // Row 2 has one field where the grid is three wide: columns 1 and 2 are genuine empty cells, not holes. - const document = readCsvContent("a,b,c\n1,,3\nsolo\n"); + const events: CellTypeInference[] = []; + const document = readCsvContent("a,b,c\n1,,3\nsolo\n", { + onCellTypeInference: (event) => events.push(event), + }); if (document.kind !== "spreadsheet") { throw new Error("expected a spreadsheet ContentDocument"); } @@ -119,6 +122,10 @@ describe("readCsvContent", () => { expect(valueAt(2, 0)).toEqual({ kind: "string", value: "solo" }); expect(valueAt(2, 1)).toEqual({ kind: "empty" }); expect(valueAt(2, 2)).toEqual({ kind: "empty" }); + // The three empty fields (row 1 col 1, row 2 cols 1 and 2) never fire a type-inference event -- only the populated fields ("1" and "3", both plain numbers) do. + expect( + events.map((event) => `${String(event.row)},${String(event.column)}`), + ).toEqual(["1,0", "1,2"]); }); it("names the lone sheet Sheet1 and emits exactly one sheet, since a csv file is one table by construction", () => { @@ -315,5 +322,15 @@ describe("decodeCsvText / encodeCsvText", () => { expect(() => decodeCsvText(new Uint8Array([0xff, 0xfe, 0x00]))).toThrow( CsvInvalidUtf8Error, ); + let caught: unknown; + try { + decodeCsvText(new Uint8Array([0xff, 0xfe, 0x00])); + } catch (error) { + caught = error; + } + expect((caught as Error).name).toBe("CsvInvalidUtf8Error"); + expect((caught as Error).message).toBe( + "csv text must be well-formed UTF-8", + ); }); }); diff --git a/packages/documents.js/src/csv/read.ts b/packages/documents.js/src/csv/read.ts index 135af65856..ec6049da19 100644 --- a/packages/documents.js/src/csv/read.ts +++ b/packages/documents.js/src/csv/read.ts @@ -49,7 +49,8 @@ function dataCell( field: string, onCellTypeInference: CellTypeInferenceSink | undefined, ): ContentSheetCell { - const inference = field === "" ? undefined : inferCellValue(field); + // No separate empty-field check here: inferCellValue("") already returns undefined on its own (its own text.length === 0 guard), so a guard here would only restate that in a second place. + const inference = inferCellValue(field); if (inference !== undefined) { onCellTypeInference?.({ sheetIndex: 0, From 5d6980e8989ccedda745ba09af1c5f86b880ecae Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:43:40 +0100 Subject: [PATCH 22/91] test(documents.js): assert SvgInvalidUtf8Error's exact name and message Mirrors the identical CsvInvalidUtf8Error gap: only instanceof/toThrow(Class) was checked, so the constructor's own name/message string literals survived being mutated to empty strings. --- packages/documents.js/src/svg/read-write.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/documents.js/src/svg/read-write.test.ts b/packages/documents.js/src/svg/read-write.test.ts index 20ae370cc0..73c78e6aeb 100644 --- a/packages/documents.js/src/svg/read-write.test.ts +++ b/packages/documents.js/src/svg/read-write.test.ts @@ -688,5 +688,15 @@ describe("decodeSvgText / encodeSvgText", () => { expect(() => decodeSvgText(new Uint8Array([0xff, 0xfe, 0x00]))).toThrow( SvgInvalidUtf8Error, ); + let caught: unknown; + try { + decodeSvgText(new Uint8Array([0xff, 0xfe, 0x00])); + } catch (error) { + caught = error; + } + expect((caught as Error).name).toBe("SvgInvalidUtf8Error"); + expect((caught as Error).message).toBe( + "svg text must be well-formed UTF-8", + ); }); }); From 457727beeca4c437aa728dbed0d14e7dda828a4d Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:43:58 +0100 Subject: [PATCH 23/91] fix(documents.js): drop formatPathNumber's redundant zero/-0 special case Number.prototype.toFixed already normalizes -0 to "0.000000" on its own, so the trailing-zero trim below collapses any zero-valued coordinate (positive or negative) to a bare "0" through the exact same path every other value takes -- the early return produced no output any test could ever distinguish from the general path, for any input. --- packages/documents.js/src/edit/odg/svg-path.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/documents.js/src/edit/odg/svg-path.ts b/packages/documents.js/src/edit/odg/svg-path.ts index 630911bb6d..b3458d99a7 100644 --- a/packages/documents.js/src/edit/odg/svg-path.ts +++ b/packages/documents.js/src/edit/odg/svg-path.ts @@ -2,7 +2,7 @@ import type { ContentPathPoint, ContentSubpath } from "document-schema.js"; // The write-side inverse of odf.js's own typed/shared/path.ts (parseOdfPathData/parseOdfViewBox): turns a ContentVector 'path' variant's own subpaths (already in the path's local coordinate space, sized to frame.widthPt x frame.heightPt -- see document-schema.js's content.ts, the exact same convention scaleOdfRawPoint/buildOdfSubpaths read INTO on the parse side) into a real svg:d + svg:viewBox attribute pair. Anchoring the viewBox at "0 0 {widthPt} {heightPt}" -- exactly the frame's own current size -- gives a 1:1 scale (buildOdfSubpaths' own scale factor is frame.widthPt/viewBox.width), so the numbers written into svg:d are the SAME numbers as the source ContentPathPoint values, with no rescaling arithmetic needed on write and none needed to recover them on a later reparse. -// A single numeric coordinate, formatted to satisfy BOTH grammars odf.js's own path.ts parses: svg:d's PATH_TOKEN_PATTERN (`-?(\d+\.\d+|\.\d+|\d+)([eE][-+]?\d+)?`) and svg:viewBox's stricter VIEW_BOX_PATTERN (`-?\d+(?:\.\d+)?`, no bare ".5" leading-dot form, no exponent). Always emitting at least one leading digit before any decimal point and never using exponential notation satisfies both at once, so one formatter serves both callers below. Rounds to a fixed sub-point precision first to strip IEEE-754 noise (e.g. 0.1 + 0.2) from leaking into the written string, and normalizes -0 to a plain "0" rather than "-0" (cosmetic, but "-0" reads as a stray negative sign to a human inspecting the XML). +// A single numeric coordinate, formatted to satisfy BOTH grammars odf.js's own path.ts parses: svg:d's PATH_TOKEN_PATTERN (`-?(\d+\.\d+|\.\d+|\d+)([eE][-+]?\d+)?`) and svg:viewBox's stricter VIEW_BOX_PATTERN (`-?\d+(?:\.\d+)?`, no bare ".5" leading-dot form, no exponent). Always emitting at least one leading digit before any decimal point and never using exponential notation satisfies both at once, so one formatter serves both callers below. Rounds to a fixed sub-point precision first to strip IEEE-754 noise (e.g. 0.1 + 0.2) from leaking into the written string. No separate zero/-0 special case is needed to get a plain "0" (never "-0") for a zero-valued coordinate: Number.prototype.toFixed already normalizes -0 to "0.000000" on its own, which the trailing-zero trim below then collapses to a bare "0" through the exact same path every other value takes. const PATH_NUMBER_DECIMALS = 6; const PATH_NUMBER_SCALE = 10 ** PATH_NUMBER_DECIMALS; @@ -11,9 +11,6 @@ export function formatPathNumber(value: number): string { throw new Error(`cannot format a non-finite path coordinate: ${value}`); } const rounded = Math.round(value * PATH_NUMBER_SCALE) / PATH_NUMBER_SCALE; - if (rounded === 0) { - return "0"; - } const fixed = rounded.toFixed(PATH_NUMBER_DECIMALS); return fixed.replace(/0+$/, "").replace(/\.$/, ""); } From 7c5148b918d93bb1ce45a652c214f025374bd762 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:44:08 +0100 Subject: [PATCH 24/91] test(documents.js): assert treeEmbeddedFontsOf returns undefined for no fonts No test called treeEmbeddedFontsOf directly, so its "return undefined rather than an empty array" branch (the whole point of the function per its own comment: a splice site can spread undefined without minting an empty table) had no test observing the difference between the two. --- .../documents.js/src/fonts/registry.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/fonts/registry.test.ts b/packages/documents.js/src/fonts/registry.test.ts index d352f83965..86f206872d 100644 --- a/packages/documents.js/src/fonts/registry.test.ts +++ b/packages/documents.js/src/fonts/registry.test.ts @@ -13,7 +13,11 @@ import { embeddedFontOdtPackage, embeddedFontPptxPackage, } from "../test-support/fonts"; -import { createDocumentFontRegistry, extractSourceFonts } from "./registry"; +import { + createDocumentFontRegistry, + extractSourceFonts, + treeEmbeddedFontsOf, +} from "./registry"; // A character no Latin-only face carries -- Caladea's cmap genuinely has no glyph for CJK, so a run containing this is the honest "the embedded face is right for the document but lacks this one synthesised character" case. const UNMAPPED_CHARACTER = "中"; @@ -269,3 +273,23 @@ describe("a cmap miss on a source-embedded face", () => { ).toBeGreaterThan(0); }); }); + +describe("treeEmbeddedFontsOf", () => { + it("returns undefined, not an empty array, for a source package with no embedded fonts", () => { + expect( + treeEmbeddedFontsOf({ kind: "docx", package: minimalDocxPackage() }), + ).toBeUndefined(); + }); + + it("returns the base64-encoded faces for a source package that embeds fonts", () => { + const faces = treeEmbeddedFontsOf({ + kind: "docx", + package: embeddedFontDocxPackage(), + }); + expect(faces).toBeDefined(); + expect(faces?.length).toBeGreaterThan(0); + expect(faces?.[0]?.family).toBe("Caladea"); + expect(typeof faces?.[0]?.base64).toBe("string"); + expect(faces?.[0]?.base64.length).toBeGreaterThan(0); + }); +}); From 0ff54ade22f8f25d4d6457c19f08904b3ee7e348 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:44:22 +0100 Subject: [PATCH 25/91] test(documents.js): cover markdownBlock's table-recursion and formula-placeholder branches The existing table test's nested cell content never needed transforming, so recursing markdownTableCell into it versus not (a BlockStatement mutation skipping the whole branch) produced identical output. Add a case with a pageBreak nested in a cell, which only survives as marker text if the recursion genuinely ran. Also cover formulaParagraph's own placeholder-run branch (no presentation LaTeX), whose ArrayDeclaration mutation to an empty runs array had no test asserting the placeholder text reached the output. --- .../documents.js/src/markdown/write.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/documents.js/src/markdown/write.test.ts b/packages/documents.js/src/markdown/write.test.ts index c4599418d3..3727ade3a5 100644 --- a/packages/documents.js/src/markdown/write.test.ts +++ b/packages/documents.js/src/markdown/write.test.ts @@ -122,4 +122,47 @@ describe("buildMarkdownText", () => { ]); expect(buildMarkdownText(document)).toContain("cell"); }); + + it("recurses the pageBreak-to-marker transform into a table cell's own blocks", () => { + const document = markerDocument([ + { + kind: "table", + rows: [ + { + cells: [{ blocks: [{ kind: "pageBreak" }] }], + }, + ], + columnWidthsPt: [80], + }, + ]); + // If the table branch did not recurse markdownBlock into the cell, this cell's own pageBreak block would reach the writer unconverted -- a table cell backslash-escapes the marker's own punctuation (unlike the top-level HTMLPreformatted paragraph the same marker gets outside a table), but "page break" surviving into the cell text either way is still proof the marker text -- not the untransformed pageBreak block -- is what reached the writer. + expect(buildMarkdownText(document)).toContain("page break"); + }); + + it("flattens an embedded formula with no presentation LaTeX to the literal [formula] placeholder", () => { + const document = markerDocument([ + { + kind: "embeddedObject", + objectKind: "formula", + document: { + kind: "formula", + metadata: {}, + // No `presentation` field and no `starMath` field, so formulaPlaceholderText falls all the way through to its own literal "[formula]" fallback. + formula: { + mathml: [ + { + type: "element", + tag: "mi", + attributes: [], + children: [{ type: "text", value: "x" }], + }, + ], + }, + }, + frame: { xPt: 0, yPt: 0, widthPt: 40, heightPt: 24 }, + }, + ]); + // The literal "[" and "]" are backslash-escaped by the plain-paragraph run writer, but the word "formula" itself carries no markdown-special characters and survives unescaped -- proof formulaPlaceholderText's own fallback text (and not an empty run list) reached the writer. + expect(buildMarkdownText(document)).toContain("formula"); + }); }); From 525d54020c7078e6039c831a38a15d5b7e7c6290 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:44:36 +0100 Subject: [PATCH 26/91] test(documents.js): extract groupVectorsByShapePosition for direct testing odf.js's own single shared paintOrder counter guarantees a shape and a vector can never collide on the same value, which made the "order < vectorOrder" boundary check unkillable through any real odf.js-decoded package -- the "<=" mutant produces identical grouping for every reachable input. Split the pure grouping algorithm out of collectSlideVectorGroups so it takes plain paintOrder-bearing data directly, and test the boundary (and the paintOrderOf throw guard) against a hand-built collision no real package can produce but the function's own contract still needs to hold for. --- .../src/odf/vector/detect.test.ts | 35 +++++++++++++++++++ .../documents.js/src/odf/vector/detect.ts | 20 +++++++---- 2 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 packages/documents.js/src/odf/vector/detect.test.ts diff --git a/packages/documents.js/src/odf/vector/detect.test.ts b/packages/documents.js/src/odf/vector/detect.test.ts new file mode 100644 index 0000000000..1bd95cf75c --- /dev/null +++ b/packages/documents.js/src/odf/vector/detect.test.ts @@ -0,0 +1,35 @@ +import type { ContentVector } from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { groupVectorsByShapePosition } from "./detect"; + +function rect(paintOrder: number | undefined): ContentVector { + return { + kind: "rect", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + fill: { r: 1, g: 0, b: 0 }, + paintOrder, + }; +} + +describe("groupVectorsByShapePosition", () => { + it("throws when a vector carries no paintOrder at all, naming odf.js's own stamping contract", () => { + expect(() => { + groupVectorsByShapePosition([], [rect(undefined)]); + }).toThrow( + "expected odf.js's own readDrawPageContent to stamp every shape/vector with a paintOrder", + ); + }); + + it("a vector sharing a shape's own paintOrder exactly is NOT counted as coming before that shape", () => { + // odf.js's real, single shared counter can never actually produce this collision (see the module comment on the function under test), but the boundary itself -- strictly less than, not less-than-or-equal -- is still this function's own contract and worth pinning directly. + const groups = groupVectorsByShapePosition([5], [rect(5)]); + expect(groups).toHaveLength(1); + expect(groups[0]?.insertBeforeShapeIndex).toBe(0); + }); + + it("a vector strictly after a shape's paintOrder is grouped behind it", () => { + const groups = groupVectorsByShapePosition([5], [rect(6)]); + expect(groups).toHaveLength(1); + expect(groups[0]?.insertBeforeShapeIndex).toBe(1); + }); +}); diff --git a/packages/documents.js/src/odf/vector/detect.ts b/packages/documents.js/src/odf/vector/detect.ts index 1977659efa..d71c9538a2 100644 --- a/packages/documents.js/src/odf/vector/detect.ts +++ b/packages/documents.js/src/odf/vector/detect.ts @@ -44,14 +44,11 @@ function paintOrderOf(item: { readonly paintOrder?: number }): number { return item.paintOrder; } -// Every vector primitive on one draw:page, grouped by which of odf.js's own readOdpContent-produced ContentShapes each sits immediately before -- so a caller inserting synthetic shapes for them lands each group at its true position among the slide's real shapes, in ONE forward pass, rather than always at the end. -export function collectSlideVectorGroups( - pageChildren: readonly XmlNode[], - pkg: Package, +// The grouping logic itself, split from collectSlideVectorGroups below so it takes plain paintOrder-bearing data rather than an XmlNode tree and a Package -- both to keep the actual algorithm testable against hand-built inputs (a real odf.js-decoded page can never hand this a colliding shape/vector paintOrder, since both arrays are stamped from the one shared counter the module comment above describes) and because it is the whole of what this module adds on top of odf.js's own readDrawPageContent; the XML-facing wrapper below is just that call plus this. +export function groupVectorsByShapePosition( + shapePaintOrders: readonly number[], + vectors: readonly ContentVector[], ): readonly DetectedSlideVectorGroup[] { - const { shapes, vectors } = readDrawPageContent(pageChildren, pkg); - const shapePaintOrders = shapes.map(paintOrderOf); - interface MutableGroup { insertBeforeShapeIndex: number; vectors: ContentVector[]; @@ -80,3 +77,12 @@ export function collectSlideVectorGroups( })), })); } + +// Every vector primitive on one draw:page, grouped by which of odf.js's own readOdpContent-produced ContentShapes each sits immediately before -- so a caller inserting synthetic shapes for them lands each group at its true position among the slide's real shapes, in ONE forward pass, rather than always at the end. +export function collectSlideVectorGroups( + pageChildren: readonly XmlNode[], + pkg: Package, +): readonly DetectedSlideVectorGroup[] { + const { shapes, vectors } = readDrawPageContent(pageChildren, pkg); + return groupVectorsByShapePosition(shapes.map(paintOrderOf), vectors); +} From da70f32fa54e6df3aea85f94c89807bdf61c28c5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:44:48 +0100 Subject: [PATCH 27/91] test(documents.js): assert decodeLegacyEmbeddedObject's isCompoundFile guard short-circuits Every legacy reader independently rejects non-CFB bytes too (each one's own first step is archive-codec's readCompoundFile), so the outcome alone (undefined either way) could never distinguish the outer isCompoundFile guard existing from it being skipped. Spy on readDocContent to prove it is never even invoked for bytes that fail the guard, rather than merely happening to fall through all three readers to the identical result. --- .../documents.js/src/ooxml/legacy-embedded.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/ooxml/legacy-embedded.test.ts b/packages/documents.js/src/ooxml/legacy-embedded.test.ts index 9bda499477..05ba2abf0d 100644 --- a/packages/documents.js/src/ooxml/legacy-embedded.test.ts +++ b/packages/documents.js/src/ooxml/legacy-embedded.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as docCodec from "doc-codec"; import { writeDocContent } from "doc-codec"; import { writeXlsContent } from "xls-codec"; import { writePptContent } from "ppt-codec"; @@ -94,4 +95,12 @@ describe("decodeLegacyEmbeddedObject", () => { decodeLegacyEmbeddedObject(new TextEncoder().encode("not a CFB file")), ).toBeUndefined(); }); + + it("never invokes a legacy reader at all for bytes that carry no compound-file signature", () => { + // Every legacy reader would itself reject non-CFB bytes too (its own first step is archive-codec's readCompoundFile), so the outcome alone can't distinguish the isCompoundFile guard existing from it being skipped -- this spies on readDocContent to prove the guard actually short-circuits before any reader is ever called, rather than merely happening to produce the same undefined result by falling through all three try/catch blocks. + const spy = vi.spyOn(docCodec, "readDocContent"); + decodeLegacyEmbeddedObject(new TextEncoder().encode("not a CFB file")); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); }); From 26db90c657bc69a7102b7fe47b79665c30b4e110 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:45:01 +0100 Subject: [PATCH 28/91] test(documents.js): export slidePathsInOrder and cover its two malformed-package guards readPptxContent's own upstream flat reader has no slides to map over in either malformed shape (a missing ppt/presentation.xml part, or one with no p:sldIdLst), so nothing reaching slidePathsInOrder through readPptxContent could ever observe which of its two possible empty-array returns actually came back -- both ArrayDeclaration mutations to a placeholder array survived. Export the function so its own two guards are directly testable against hand-built packages. --- .../documents.js/src/ooxml/pptx/read.test.ts | 37 ++++++++++++++++++- packages/documents.js/src/ooxml/pptx/read.ts | 4 +- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/documents.js/src/ooxml/pptx/read.test.ts b/packages/documents.js/src/ooxml/pptx/read.test.ts index 92d222f3a1..df106eb7ae 100644 --- a/packages/documents.js/src/ooxml/pptx/read.test.ts +++ b/packages/documents.js/src/ooxml/pptx/read.test.ts @@ -4,7 +4,7 @@ import { minimalPptxPackage, pptxWithLegacyOleObjectPackage, } from "../../test-support/pptx"; -import { readPptxContent } from "./read"; +import { readPptxContent, slidePathsInOrder } from "./read"; // readPptxContent is now a thin adapter over ooxml.js's own readPptxContent (the flat reader; the bare readPptx name reads the tree-form DocumentTree since ooxml.js 4.0.0): placeholder -> layout -> master -> theme inheritance, the run-property cascade, and group-transform flattening all live upstream in ooxml.js now, with their own test coverage there. These tests exercise only the wrapping this file is actually responsible for -- ContentDocument's discriminant/formatVersion, the metadata/slides passthrough -- not the OOXML semantics readPptx itself resolves. @@ -70,3 +70,38 @@ describe("readPptxContent", () => { ).toBe("Legacy doc text"); }); }); + +describe("slidePathsInOrder", () => { + it("returns an empty array when the package has no ppt/presentation.xml part at all", () => { + const pkg = minimalPptxPackage(); + const rest = Object.fromEntries( + Object.entries(pkg.parts).filter( + ([path]) => path !== "ppt/presentation.xml", + ), + ); + expect(slidePathsInOrder({ ...pkg, parts: rest })).toEqual([]); + }); + + it("returns an empty array when presentation.xml carries no p:sldIdLst element", () => { + const pkg = minimalPptxPackage(); + expect( + slidePathsInOrder({ + ...pkg, + parts: { + ...pkg.parts, + "ppt/presentation.xml": { + kind: "xml", + nodes: [ + { + type: "element", + tag: "p:presentation", + attributes: [], + children: [], + }, + ], + }, + }, + }), + ).toEqual([]); + }); +}); diff --git a/packages/documents.js/src/ooxml/pptx/read.ts b/packages/documents.js/src/ooxml/pptx/read.ts index a6290a815b..91962eb7dc 100644 --- a/packages/documents.js/src/ooxml/pptx/read.ts +++ b/packages/documents.js/src/ooxml/pptx/read.ts @@ -19,8 +19,8 @@ export interface ReadPptxContentOptions { const PRESENTATION_PART = "ppt/presentation.xml"; -// Every slide's own part path, in p:sldIdLst document order -- the same order the upstream reader itself resolves slides in (see ooxml.js's own readSlidePathsInOrder), needed here only to locate each slide's raw p:sld root for the second, vector-detecting pass below. -function slidePathsInOrder(pkg: Package): readonly string[] { +// Every slide's own part path, in p:sldIdLst document order -- the same order the upstream reader itself resolves slides in (see ooxml.js's own readSlidePathsInOrder), needed here only to locate each slide's raw p:sld root for the second, vector-detecting pass below. Exported (not merely internal) so its own two malformed-package guards -- no ppt/presentation.xml part, or one with no p:sldIdLst -- are directly testable: readPptxContent's own upstream flat reader has no slides to map over at all in either of those same shapes, so nothing calling THIS function through readPptxContent can ever observe which of its two possible return values ("[]" vs "the mutant's own placeholder array") actually came back. +export function slidePathsInOrder(pkg: Package): readonly string[] { const presentationRoot = rootElement(pkg.parts[PRESENTATION_PART]); if (presentationRoot === undefined) { return []; From 2889d7b8021784e0e2b3c4e3e6f6dd058b303715 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:45:13 +0100 Subject: [PATCH 29/91] test(documents.js): assert standardFontDocxBytes genuinely requests Arial No test read the family back from the fixture's own runs, so the argument to stylesXml("Arial") survived being mutated to an empty string -- an empty requested family also resolves through the standard 14 with no substitution, identically to "Arial", so the existing substitution-count assertions couldn't distinguish the two. --- .../src/convert/convert-fonts.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/convert/convert-fonts.test.ts b/packages/documents.js/src/convert/convert-fonts.test.ts index fcdc0fe1ff..3025359ee8 100644 --- a/packages/documents.js/src/convert/convert-fonts.test.ts +++ b/packages/documents.js/src/convert/convert-fonts.test.ts @@ -4,7 +4,10 @@ import type { FontSubstitution } from "pdf-codec"; import { createStandardFontMeasurer, loadMathFont, writePdf } from "pdf-codec"; const mathMetricsAt = (sizePt: number) => loadMathFont().metricsAt(sizePt); import { decodePackage as decodeOdfPackage } from "odf.js"; -import { encodePackage as encodeOoxmlPackage } from "ooxml.js"; +import { + decodePackage as decodeOoxmlPackage, + encodePackage as encodeOoxmlPackage, +} from "ooxml.js"; import { openDocx } from "../edit/docx/editor"; import { openPptx } from "../edit/pptx/editor"; import { buildDocumentBytes } from "./from-package"; @@ -146,6 +149,21 @@ describe("X -> PDF: caller-supplied faces", () => { }); expect(substitutions).toEqual([]); }); + + it("standardFontDocxBytes genuinely requests Arial, not merely a request no vendored substitute happens to claim", () => { + const content = readDocxContent( + decodeOoxmlPackage(standardFontDocxBytes()), + ); + if (content.kind !== "wordprocessing") { + throw new Error("expected a wordprocessing ContentDocument"); + } + const paragraph = content.sections[0]?.blocks[0]; + expect( + paragraph?.kind === "paragraph" + ? paragraph.runs[0]?.fontFamily + : undefined, + ).toBe("Arial"); + }); }); // The backward-compatibility guarantee this phase had to keep: wiring a FontRegistry into all six conversions must not change a single byte of output for a document that embeds no fonts and asks for no family a vendored substitute claims. Each reference below reproduces the exact pre-registry pipeline -- createStandardFontMeasurer() into the format's own layout engine, then writePdf with no `fonts` option at all -- so this is a genuine before/after byte comparison rather than a self-consistency check of the new code against itself. From d71ebcf531921d57b1b8c74e669841f6fcfcd8a4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:56:45 +0100 Subject: [PATCH 30/91] test(documents.js): export promoteBlock and cover its style/join boundaries A real marker paragraph is always a single run and markdown-codec's own HTML-block lowering never produces the same text under a different style, so the styleId check and the runs.map(...).join("") separator were both unkillable through any real markdown input. Export promoteBlock for direct testing against hand-built multi-run/wrong-style blocks, and mock markdown-codec's own reader to exercise the "non-wordprocessing document" guard no real markdown text can trigger. --- .../documents.js/src/markdown/read.test.ts | 55 ++++++++++++++++++- packages/documents.js/src/markdown/read.ts | 3 +- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/documents.js/src/markdown/read.test.ts b/packages/documents.js/src/markdown/read.test.ts index 03e946ced5..0ad02e2a8e 100644 --- a/packages/documents.js/src/markdown/read.test.ts +++ b/packages/documents.js/src/markdown/read.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type * as MarkdownCodec from "markdown-codec"; import { richMarkdownText, richMarkdownTextWithFrontMatter, } from "../test-support/markdown"; -import { readMarkdownContent } from "./read"; +import { HTML_PREFORMATTED_STYLE_ID } from "markdown-codec"; +import { promoteBlock, readMarkdownContent } from "./read"; +import { PAGE_BREAK_MARKER } from "./write"; describe("readMarkdownContent", () => { it("produces a wordprocessing ContentDocument", () => { @@ -11,6 +14,27 @@ describe("readMarkdownContent", () => { expect(content.kind).toBe("wordprocessing"); }); + it("throws if markdown-codec's own reader ever produced a non-wordprocessing ContentDocument", async () => { + // Not a shape markdown-codec's real readMarkdownContent can ever produce (markdown has no presentation/spreadsheet/drawing/formula equivalent to lower into, per this module's own comment) -- this exercises the defensive guard directly via a mocked reader, since no real markdown text can trigger it. + vi.resetModules(); + vi.doMock("markdown-codec", async () => { + const actual = + await vi.importActual<typeof MarkdownCodec>("markdown-codec"); + return { + ...actual, + readMarkdownContent: () => ({ + document: { kind: "spreadsheet", metadata: {}, sheets: [] }, + }), + }; + }); + const { readMarkdownContent: mockedRead } = await import("./read"); + expect(() => mockedRead("irrelevant")).toThrow( + "readMarkdownContent returned a non-wordprocessing ContentDocument", + ); + vi.doUnmock("markdown-codec"); + vi.resetModules(); + }); + // The read-side inverse of src/markdown/write.ts's page-break marker: an `<!-- page break -->` HTML comment lowers (via markdown-codec's own HTML block arm) to an HTMLPreformatted paragraph carrying that literal text, and this pass promotes exactly that paragraph to a pageBreak block -- so markdownToPdf re-renders a real page boundary and a pdfToMarkdown -> markdownToPdf round trip regenerates markers from real boundaries instead of accumulating them as visible literal text. it("reads a page-break marker back as a pageBreak block", () => { const content = readMarkdownContent( @@ -119,3 +143,30 @@ describe("readMarkdownContent", () => { ).toThrow(); }); }); + +describe("promoteBlock", () => { + it("does not promote a paragraph carrying the exact marker text if it isn't HTML-preformatted styled", () => { + // markdown-codec's own HTML-block lowering never produces this exact combination for real input, but the gate is still styleId AND text, not text alone -- pinned directly. + const block = promoteBlock({ + kind: "paragraph", + runs: [{ text: PAGE_BREAK_MARKER }], + }); + expect(block).toEqual({ + kind: "paragraph", + runs: [{ text: PAGE_BREAK_MARKER }], + }); + }); + + it("promotes runs whose texts concatenate (with no separator) to exactly the marker", () => { + // A real marker paragraph is always a single run; this splits it across two runs so a join that inserted any separator between them would produce a non-matching string and fail to promote, proving the join really does concatenate with "" rather than something else. + const promoted = promoteBlock({ + kind: "paragraph", + styleId: HTML_PREFORMATTED_STYLE_ID, + runs: [ + { text: PAGE_BREAK_MARKER.slice(0, 6) }, + { text: PAGE_BREAK_MARKER.slice(6) }, + ], + }); + expect(promoted).toEqual({ kind: "pageBreak" }); + }); +}); diff --git a/packages/documents.js/src/markdown/read.ts b/packages/documents.js/src/markdown/read.ts index 6a3998d260..3ea42e978e 100644 --- a/packages/documents.js/src/markdown/read.ts +++ b/packages/documents.js/src/markdown/read.ts @@ -47,7 +47,8 @@ function promotePageBreakMarkers( }; } -function promoteBlock(block: ContentBlock): ContentBlock { +// Exported (not merely internal) so the two conditions that gate a promotion -- the block's own styleId, and the exact (not merely substring, not merely per-run) text match -- are directly testable: a real markdown-codec-lowered marker paragraph is always a single run, so a hand-built multi-run block is the only way to exercise the join("") boundary, and a same-text-wrong-style paragraph is not a shape markdown-codec's own HTML-block lowering can produce for anything OTHER than this exact marker's own preformatted styling. +export function promoteBlock(block: ContentBlock): ContentBlock { if ( block.kind !== "paragraph" || block.styleId !== HTML_PREFORMATTED_STYLE_ID From 0de812adb63e314b4755107f2e9a5bc17756e108 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 07:57:07 +0100 Subject: [PATCH 31/91] test(documents.js): pin richMarkdownText's own block boundaries exactly The fixture's blank-line separators had no test asserting they genuinely separate blocks -- CommonMark merges consecutive non-blank lines into one paragraph, so a corrupted separator silently widens a neighbouring paragraph's own text rather than changing the block count, and no existing test read that text closely enough to notice. --- .../src/test-support/markdown.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/documents.js/src/test-support/markdown.test.ts diff --git a/packages/documents.js/src/test-support/markdown.test.ts b/packages/documents.js/src/test-support/markdown.test.ts new file mode 100644 index 0000000000..e4c8f6d99a --- /dev/null +++ b/packages/documents.js/src/test-support/markdown.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { readMarkdownContent } from "../markdown/read"; +import { richMarkdownText, richMarkdownTextWithFrontMatter } from "./markdown"; + +// richMarkdownText/richMarkdownTextWithFrontMatter are hand-authored literal markdown source text (see their own top-of-file comment), joined from an array of lines including several deliberately blank ("") separator lines between blocks. A blank line is a genuine CommonMark block boundary, so these assert the fixture actually parses into DISTINCT top-level blocks rather than merging into fewer, larger ones -- the only way a corrupted separator (anything other than a real blank line) would show up. + +describe("richMarkdownText", () => { + it("parses into four distinct top-level blocks: heading, paragraph, list, table", () => { + const content = readMarkdownContent(richMarkdownText()); + if (content.kind !== "wordprocessing") { + throw new Error("expected a wordprocessing ContentDocument"); + } + const blocks = content.sections[0]?.blocks ?? []; + // The heading, the second paragraph, then one paragraph per list item (markdown-codec's own flat block model has no dedicated "list" block kind -- each item is its own paragraph, with list membership carried on the paragraph itself), then the table. + expect(blocks.map((block) => block.kind)).toEqual([ + "paragraph", + "paragraph", + "paragraph", + "paragraph", + "paragraph", + "paragraph", + "table", + ]); + expect(blocks[0]).toMatchObject({ styleId: "Heading1" }); + // Exact per-block text -- proof each blank-line separator genuinely separated two blocks rather than merging stray text into one of them (CommonMark merges consecutive non-blank lines of plain text into a single paragraph, so a corrupted separator would silently widen one paragraph's own text rather than changing the block kind sequence above at all). + function text(block: (typeof blocks)[number]): string { + return block.kind === "paragraph" + ? block.runs.map((run) => run.text).join("") + : ""; + } + expect(text(blocks[0]!)).toBe("Report Title"); + expect(text(blocks[1]!)).toBe( + "Second paragraph with bold and italic text.", + ); + expect(text(blocks[2]!)).toBe("First item"); + }); +}); + +describe("richMarkdownTextWithFrontMatter", () => { + it("separates the closing --- from the body, parsing metadata and richMarkdownText's own four blocks separately", () => { + const content = readMarkdownContent(richMarkdownTextWithFrontMatter(), { + frontMatter: true, + }); + expect(content.metadata.title).toBe("Sample Report"); + expect(content.metadata.author).toBe("Ada Lovelace"); + if (content.kind !== "wordprocessing") { + throw new Error("expected a wordprocessing ContentDocument"); + } + const blocks = content.sections[0]?.blocks ?? []; + expect(blocks.map((block) => block.kind)).toEqual([ + "paragraph", + "paragraph", + "paragraph", + "paragraph", + "paragraph", + "paragraph", + "table", + ]); + }); +}); From 3ea66fd4a53deafa7f4748f401e17ee73592242c Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:20:52 +0100 Subject: [PATCH 32/91] test(documents.js): assert the root element tag of every createEmptyDocxPackage part rootElement() finds the top-level element node regardless of its own tag name, so nothing checked that Types/Relationships/w:styles were the actual tag written -- each part's own root tag literal survived being mutated to an empty string. --- packages/documents.js/src/edit/docx/scaffold.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/documents.js/src/edit/docx/scaffold.test.ts b/packages/documents.js/src/edit/docx/scaffold.test.ts index a7331a6bca..1674535356 100644 --- a/packages/documents.js/src/edit/docx/scaffold.test.ts +++ b/packages/documents.js/src/edit/docx/scaffold.test.ts @@ -101,6 +101,7 @@ describe("createEmptyDocxPackage", () => { if (root === undefined) { throw new Error("expected a root element"); } + expect(root.tag).toBe("Types"); expect(attr(root, "xmlns")).toBe( "http://schemas.openxmlformats.org/package/2006/content-types", ); @@ -132,6 +133,7 @@ describe("createEmptyDocxPackage", () => { if (root === undefined) { throw new Error("expected a root element"); } + expect(root.tag).toBe("Relationships"); expect(attr(root, "xmlns")).toBe( "http://schemas.openxmlformats.org/package/2006/relationships", ); @@ -149,6 +151,7 @@ describe("createEmptyDocxPackage", () => { if (root === undefined) { throw new Error("expected a root element"); } + expect(root.tag).toBe("Relationships"); expect(attr(root, "xmlns")).toBe( "http://schemas.openxmlformats.org/package/2006/relationships", ); @@ -190,6 +193,7 @@ describe("createEmptyDocxPackage", () => { if (root === undefined) { throw new Error("expected a root element"); } + expect(root.tag).toBe("w:styles"); expect(attr(root, "xmlns:w")).toBe( "http://schemas.openxmlformats.org/wordprocessingml/2006/main", ); From f3c697a3921e2ccfe029d78bdff73d26ed1ef8a1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:21:31 +0100 Subject: [PATCH 33/91] test(documents.js): cover strike's default value and buildRun's remaining init fields strike's ?? false default had no test reading it before ever being set, and buildRun's underline/strike/color init fields had no coverage at all, unlike bold/italic/sizePt/fontFamily. --- packages/documents.js/src/edit/odt/run.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/edit/odt/run.test.ts b/packages/documents.js/src/edit/odt/run.test.ts index 6d5851e08b..0d214296ff 100644 --- a/packages/documents.js/src/edit/odt/run.test.ts +++ b/packages/documents.js/src/edit/odt/run.test.ts @@ -25,17 +25,20 @@ describe("OdtRun text", () => { }); describe("OdtRun toggle properties", () => { - it("bold/italic/underline default to false and can be toggled on and off", () => { + it("bold/italic/underline/strike default to false and can be toggled on and off", () => { const run = freshRun(); expect(run.bold).toBe(false); expect(run.italic).toBe(false); expect(run.underline).toBe(false); + expect(run.strike).toBe(false); run.bold = true; run.italic = true; run.underline = true; + run.strike = true; expect(run.bold).toBe(true); expect(run.italic).toBe(true); expect(run.underline).toBe(true); + expect(run.strike).toBe(true); run.bold = false; expect(run.bold).toBe(false); expect(run.italic).toBe(true); // unaffected by the other toggle @@ -121,14 +124,20 @@ describe("buildRun", () => { text: "Hi", bold: true, italic: true, + underline: true, + strike: true, sizePt: 16, fontFamily: "Arial", + color: { r: 1, g: 0, b: 0 }, }); const run = new OdtRun([runElement], runElement, editor.toPackage()); expect(run.bold).toBe(true); expect(run.italic).toBe(true); + expect(run.underline).toBe(true); + expect(run.strike).toBe(true); expect(run.sizePt).toBe(16); expect(run.fontFamily).toBe("Arial"); + expect(run.color).toEqual({ r: 1, g: 0, b: 0 }); expect(run.text).toBe("Hi"); }); }); From ae7473ebca5467121eb7fa75a2c940189442c959 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:22:45 +0100 Subject: [PATCH 34/91] test(documents.js): add direct coverage for spliceOut and registerImageBytes Neither function had a dedicated test file; coverage came only indirectly through pdf editor tests. Cover spliceOut's not-found no-op (a skipped guard would splice(-1, 1), silently removing the container's own last element instead), registerImageBytes' real JPEG-vs-PNG decode dispatch, and its already-registered dedup guard. --- .../documents.js/src/edit/pdf/util.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/documents.js/src/edit/pdf/util.test.ts diff --git a/packages/documents.js/src/edit/pdf/util.test.ts b/packages/documents.js/src/edit/pdf/util.test.ts new file mode 100644 index 0000000000..ef848b6e7a --- /dev/null +++ b/packages/documents.js/src/edit/pdf/util.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import type { LayoutImageAsset } from "pdf-codec"; +import { encodePng } from "byte-codec"; +import { registerImageBytes, spliceOut } from "./util"; + +// A minimal, spec-shaped baseline JPEG: SOI, an SOF0 frame header (4x3px, 3 components), EOI -- mirrors byte-codec's own readJpegInfo test fixture shape (that package's own image/jpeg-info.test.ts buildJpeg helper), restated inline here since this is the only place in documents.js that needs a real (not merely format-labelled) JPEG byte stream. +const JPEG_WIDTH = 4; +const JPEG_HEIGHT = 3; +const JPEG_BYTES = new Uint8Array([ + 0xff, + 0xd8, // SOI + 0xff, + 0xc0, + 0x00, + 0x08, + 0x08, + 0x00, + JPEG_HEIGHT, + 0x00, + JPEG_WIDTH, + 0x03, // SOF0 + 0xff, + 0xd9, // EOI +]); + +const PNG_BYTES = encodePng({ + width: 2, + height: 2, + channels: 3, + data: new Uint8Array(2 * 2 * 3), +}); + +describe("spliceOut", () => { + it("removes the given node from the container", () => { + const container = ["a", "b", "c"]; + spliceOut(container, "b"); + expect(container).toEqual(["a", "c"]); + }); + + it("leaves the container completely unchanged when the node is not present", () => { + // If the index-not-found guard were skipped, Array.prototype.splice(-1, 1) would silently remove the container's own LAST element instead of doing nothing. + const container = ["a", "b", "c"]; + spliceOut(container, "not present"); + expect(container).toEqual(["a", "b", "c"]); + }); +}); + +describe("registerImageBytes", () => { + it("decodes real JPEG dimensions via readJpegInfo for format 'jpeg'", () => { + const images: Record<string, LayoutImageAsset> = {}; + const imageId = registerImageBytes(JPEG_BYTES, "jpeg", images); + expect(images[imageId]).toMatchObject({ + format: "jpeg", + widthPx: JPEG_WIDTH, + heightPx: JPEG_HEIGHT, + }); + }); + + it("decodes real PNG dimensions via decodePng for format 'png'", () => { + const images: Record<string, LayoutImageAsset> = {}; + const imageId = registerImageBytes(PNG_BYTES, "png", images); + expect(images[imageId]).toMatchObject({ + format: "png", + widthPx: 2, + heightPx: 2, + }); + }); + + it("does not re-decode or overwrite an already-registered image id", () => { + const images: Record<string, LayoutImageAsset> = {}; + const imageId = registerImageBytes(PNG_BYTES, "png", images); + // A sentinel value decodeImageDimensions could never itself produce -- if the "already registered" guard were skipped, the second call would overwrite it with the real decode. + const sentinel: LayoutImageAsset = { + format: "png", + base64: "sentinel", + widthPx: -1, + heightPx: -1, + }; + images[imageId] = sentinel; + const secondId = registerImageBytes(PNG_BYTES, "png", images); + expect(secondId).toBe(imageId); + expect(images[imageId]).toBe(sentinel); + }); +}); From 1d3864dec3911c9a1ff19746e1d6bef9ea1f73b7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:23:03 +0100 Subject: [PATCH 35/91] test(documents.js): cover XlsEditor's two constructor guards Neither the non-spreadsheet-kind refusal nor the empty-sheets refusal had a test constructing the editor directly with a document that would trigger them. --- .../documents.js/src/edit/xls/editor.test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/edit/xls/editor.test.ts b/packages/documents.js/src/edit/xls/editor.test.ts index 4152627187..772014fb9a 100644 --- a/packages/documents.js/src/edit/xls/editor.test.ts +++ b/packages/documents.js/src/edit/xls/editor.test.ts @@ -1,9 +1,34 @@ +import type { ContentDocument } from "document-schema.js"; import { describe, expect, it } from "vitest"; import { fixedClock } from "../../ports/clock"; -import { createXls, openXls } from "./editor"; +import { createXls, openXls, XlsEditor } from "./editor"; const FIXED_ISO = "2026-01-01T00:00:00.000Z"; +describe("XlsEditor constructor guards", () => { + it("rejects a non-spreadsheet ContentDocument, naming the offending kind", () => { + const wordprocessing: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [], + }; + expect(() => new XlsEditor(wordprocessing)).toThrow( + 'XlsEditor requires a spreadsheet ContentDocument, got "wordprocessing"', + ); + }); + + it("rejects a spreadsheet ContentDocument with no sheets at all", () => { + const empty: ContentDocument = { + kind: "spreadsheet", + metadata: {}, + sheets: [], + }; + expect(() => new XlsEditor(empty)).toThrow( + "an xls workbook must carry at least one sheet", + ); + }); +}); + describe("createXls", () => { it("builds a one-sheet workbook with real metadata timestamps", () => { const editor = createXls({ From ccdfda560f18bbc34cce9e9c526b9a0e10a1ba3b Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:23:23 +0100 Subject: [PATCH 36/91] test(documents.js): pin the Gregorian century-correction and tick-to-ms arithmetic Every existing date used a value at least four digits long and never crossed a century-correction boundary, so the algorithm's two Firebird-specific magic constants had no test that would diverge if either were perturbed by 2 (their exact mutation). Add a genuine 1700 (excluded from the 4-year leap rule) and a year-under-1000 case for the zero-padding branch, plus a non-zero-fraction time case for the ms-from-ticks division. --- .../documents.js/src/firebird/date.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/documents.js/src/firebird/date.test.ts b/packages/documents.js/src/firebird/date.test.ts index 1b528055e9..1bf44a505b 100644 --- a/packages/documents.js/src/firebird/date.test.ts +++ b/packages/documents.js/src/firebird/date.test.ts @@ -35,6 +35,30 @@ describe("decodeFirebirdDate", () => { ); expect(decodeFirebirdDate(days)).toEqual({ year: 2024, month: 2, day: 29 }); }); + + it("excludes 1700 from the leap years despite being divisible by 4, since it isn't divisible by 400", () => { + // The century-based correction term (the 4-year rule minus a further exception every 100 years, restored every 400) is exactly what distinguishes this from a naive 4-year-only leap rule -- 1700 is the case that rule exists for. + const days = Math.round( + (Date.UTC(1700, 1, 28) - Date.UTC(1858, 10, 17)) / 86400000, + ); + expect(decodeFirebirdDate(days)).toEqual({ year: 1700, month: 2, day: 28 }); + expect(formatFirebirdDate(days)).toBe("1700-02-28"); + }); + + it("formats a year under 1000 with leading zeros", () => { + const days = Math.round( + (Date.UTC(500, 1, 28) - Date.UTC(1858, 10, 17)) / 86400000, + ); + expect(decodeFirebirdDate(days)).toEqual({ year: 500, month: 2, day: 28 }); + expect(formatFirebirdDate(days)).toBe("0500-02-28"); + }); + + it("rolls over correctly into March of the following (non-leap) year, one day after a year ending in 59", () => { + const days = Math.round( + (Date.UTC(1859, 2, 1) - Date.UTC(1858, 10, 17)) / 86400000, + ); + expect(decodeFirebirdDate(days)).toEqual({ year: 1859, month: 3, day: 1 }); + }); }); describe("decodeFirebirdTime", () => { @@ -61,6 +85,12 @@ describe("decodeFirebirdTime", () => { const ticks = (9 * 3600 + 5 * 60 + 1) * 10000; expect(formatFirebirdTime(ticks)).toBe("09:05:01.000"); }); + + it("converts a non-zero fraction of a tick-second to milliseconds by dividing, not multiplying", () => { + // 5000 ticks (of 10000 ticks/second) is half a second -- 500ms, not the 50000 a fractions * 10 mutant would produce. + const ticks = 5000; + expect(formatFirebirdTime(ticks)).toBe("00:00:00.500"); + }); }); describe("formatFirebirdTimestamp", () => { From 0e82c714d2b484e1f30d23974147ca42d44e3c17 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:23:45 +0100 Subject: [PATCH 37/91] test(documents.js): combine translateVector's identical rect/ellipse/path cases rect and ellipse had byte-identical case bodies, so a mutant swapping one case's statement for the other's produced no observable difference for any input -- collapsing them under one shared case label removes the duplicate AST node the swap mutant targeted. Add a dedicated test file (none existed) covering shiftPoint's addition arithmetic with a non-zero, asymmetric offset and the shared rect/ellipse/path translation path. --- .../src/model/embedded-drawing.test.ts | 100 ++++++++++++++++++ .../src/model/embedded-drawing.ts | 3 +- 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 packages/documents.js/src/model/embedded-drawing.test.ts diff --git a/packages/documents.js/src/model/embedded-drawing.test.ts b/packages/documents.js/src/model/embedded-drawing.test.ts new file mode 100644 index 0000000000..f2c293a62c --- /dev/null +++ b/packages/documents.js/src/model/embedded-drawing.test.ts @@ -0,0 +1,100 @@ +import type { + ContentEmbeddedObjectBlock, + ContentVector, +} from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { + buildDrawingBlock, + drawingOfBlock, + embeddedDrawingVectors, + FLOW_CONTAINER_ORIGIN, +} from "./embedded-drawing"; + +describe("buildDrawingBlock / drawingOfBlock", () => { + it("wraps the given vectors in a one-page drawing document sized to the given page, and drawingOfBlock recovers it", () => { + const rect: ContentVector = { + kind: "rect", + frame: { xPt: 5, yPt: 10, widthPt: 20, heightPt: 30 }, + fill: { r: 1, g: 0, b: 0 }, + }; + const block = buildDrawingBlock({ widthPt: 100, heightPt: 200 }, [rect]); + expect(block.frame).toEqual({ + xPt: 0, + yPt: 0, + widthPt: 100, + heightPt: 200, + }); + const drawing = drawingOfBlock(block); + expect(drawing?.pages).toHaveLength(1); + expect(drawing?.pages[0]?.vectors).toEqual([rect]); + }); + + it("drawingOfBlock returns undefined for a non-drawing embeddedObject block", () => { + const nonDrawing: ContentEmbeddedObjectBlock = { + kind: "embeddedObject", + objectKind: "formula", + document: { kind: "formula", metadata: {}, formula: { mathml: [] } }, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }; + expect(drawingOfBlock(nonDrawing)).toBeUndefined(); + }); +}); + +describe("embeddedDrawingVectors", () => { + it("translates a line's endpoints by adding dxPt/dyPt to each coordinate, not subtracting", () => { + const line: ContentVector = { + kind: "line", + from: { xPt: 1, yPt: 2 }, + to: { xPt: 3, yPt: 7 }, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }; + const block = buildDrawingBlock({ widthPt: 100, heightPt: 100 }, [line]); + // A non-zero, asymmetric (block frame != container origin) offset, so a sign flip on either axis produces a different result than the correct one. + block.frame.xPt = 10; + block.frame.yPt = 20; + const [translated] = embeddedDrawingVectors(block, { xPt: 1, yPt: 2 }); + expect(translated?.kind).toBe("line"); + if (translated?.kind !== "line") { + throw new Error("expected a line vector"); + } + // dxPt = 10 + 1 = 11, dyPt = 20 + 2 = 22. + expect(translated.from).toEqual({ xPt: 12, yPt: 24 }); + expect(translated.to).toEqual({ xPt: 14, yPt: 29 }); + }); + + it("translates rect, ellipse, and path vectors identically by shifting only their own frame", () => { + const rect: ContentVector = { + kind: "rect", + frame: { xPt: 0, yPt: 0, widthPt: 5, heightPt: 5 }, + fill: { r: 1, g: 0, b: 0 }, + }; + const ellipse: ContentVector = { + kind: "ellipse", + frame: { xPt: 0, yPt: 0, widthPt: 5, heightPt: 5 }, + fill: { r: 0, g: 1, b: 0 }, + }; + const block = buildDrawingBlock({ widthPt: 100, heightPt: 100 }, [ + rect, + ellipse, + ]); + const translated = embeddedDrawingVectors(block, FLOW_CONTAINER_ORIGIN); + expect( + translated[0]?.kind === "rect" ? translated[0].frame : undefined, + ).toEqual({ xPt: 0, yPt: 0, widthPt: 5, heightPt: 5 }); + expect( + translated[1]?.kind === "ellipse" ? translated[1].frame : undefined, + ).toEqual({ xPt: 0, yPt: 0, widthPt: 5, heightPt: 5 }); + }); + + it("returns an empty array for a non-drawing embeddedObject block", () => { + const nonDrawing: ContentEmbeddedObjectBlock = { + kind: "embeddedObject", + objectKind: "formula", + document: { kind: "formula", metadata: {}, formula: { mathml: [] } }, + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + }; + expect(embeddedDrawingVectors(nonDrawing, FLOW_CONTAINER_ORIGIN)).toEqual( + [], + ); + }); +}); diff --git a/packages/documents.js/src/model/embedded-drawing.ts b/packages/documents.js/src/model/embedded-drawing.ts index 501cabe17d..970ca37f36 100644 --- a/packages/documents.js/src/model/embedded-drawing.ts +++ b/packages/documents.js/src/model/embedded-drawing.ts @@ -63,10 +63,9 @@ function translateVector( from: shiftPoint(vector.from, dxPt, dyPt), to: shiftPoint(vector.to, dxPt, dyPt), }; + // rect/ellipse/path all translate by shifting the frame alone and nothing else -- one shared body under three case labels, not three copies of the identical statement (which would leave rect's and ellipse's own bodies byte-identical and swappable with each other for no observable difference, an equivalent-mutant trap the earlier three-copy form fell into). case "rect": - return { ...vector, frame: shiftBox(vector.frame, dxPt, dyPt) }; case "ellipse": - return { ...vector, frame: shiftBox(vector.frame, dxPt, dyPt) }; case "path": return { ...vector, frame: shiftBox(vector.frame, dxPt, dyPt) }; } From dec4c3c3d852250df005da21fc23f7114a8a83bf Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:24:37 +0100 Subject: [PATCH 38/91] test(documents.js): assert compareCellKeys' tie-breaking is a strict less-than MIN/MAX's own tie-breaking reduce (keep the first-seen value on equal comparison) had no test with two structurally different but numerically-tied values, so replacing the strict "< 0"/"> 0" checks with "<= 0"/">= 0" survived: every existing aggregate fixture had only distinct values, where a tie can never occur to expose which one a wrong comparator would pick. --- packages/documents.js/src/odb/values.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/documents.js/src/odb/values.test.ts b/packages/documents.js/src/odb/values.test.ts index df08dc1fc4..5671816ce9 100644 --- a/packages/documents.js/src/odb/values.test.ts +++ b/packages/documents.js/src/odb/values.test.ts @@ -199,4 +199,16 @@ describe("aggregateCellValues", () => { "SUM requires numeric values, but found a string value", ); }); + + it("keeps the first-seen value on a tie, for both MIN and MAX", () => { + // Two structurally different values (a plain number and a currency) that compare numerically equal -- distinguishable by .kind alone, so which one "won" the tie is directly observable. + const first: ContentCellValue = { kind: "number", value: 5 }; + const second: ContentCellValue = { + kind: "currency", + value: 5, + currency: "GBP", + }; + expect(aggregateCellValues("MIN", [first, second], fail)).toBe(first); + expect(aggregateCellValues("MAX", [first, second], fail)).toBe(first); + }); }); From a3f2964172d2024adb9daabb878597def5ab81e3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:25:14 +0100 Subject: [PATCH 39/91] test(documents.js): drop syncOdfManifest's dead root-content.xml comparison, add direct coverage path === ROOT_CONTENT_PART could never be true for any path the preceding !endsWith(CONTENT_PART_SUFFIX) check had already let through -- a bare "content.xml" never itself ends with "/content.xml", so the two conditions never both mattered for the same path. Removed the redundant comparison (eliminating the LogicalOperator/ConditionalExpression mutants it left no way to kill) and added a direct test file for a module with none, spying on odf.js's own syncManifest to make the remaining skip-guard observable: it silently tolerates a bogus mediaTypeOverrides key, so nothing downstream of it would otherwise notice one leaking in for the package's own root content.xml. --- .../src/odf-package/manifest.test.ts | 53 +++++++++++++++++++ .../documents.js/src/odf-package/manifest.ts | 4 +- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 packages/documents.js/src/odf-package/manifest.test.ts diff --git a/packages/documents.js/src/odf-package/manifest.test.ts b/packages/documents.js/src/odf-package/manifest.test.ts new file mode 100644 index 0000000000..24657ba4c5 --- /dev/null +++ b/packages/documents.js/src/odf-package/manifest.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import * as odfJs from "odf.js"; +import { ODF_MEDIA_TYPES } from "odf.js"; +import { createOdt } from "../edit/odt/editor"; +import { syncOdfManifest } from "./manifest"; + +// syncOdfManifest walks every package part path, deriving a mediaTypeOverrides entry for each genuine embedded sub-document directory ("<dir>/content.xml") and handing the whole map to odf.js's own syncManifest. Spying on that call is what makes the guard against the package's own ROOT content.xml (which also happens to have a real office:body -- there is nothing about its shape alone that would exclude it) directly observable: odf.js's real syncManifest silently tolerates a bogus override key, so nothing downstream of it would otherwise notice one leaking in. +describe("syncOdfManifest", () => { + it("never derives a mediaTypeOverrides entry for the package's own root content.xml", () => { + const spy = vi.spyOn(odfJs, "syncManifest"); + const pkg = createOdt().toPackage(); + syncOdfManifest(pkg); + const options = spy.mock.calls.at(-1)?.[1]; + expect(options?.mediaTypeOverrides).toEqual({}); + spy.mockRestore(); + }); + + it("derives the correct media type override for a real embedded sub-document directory", () => { + const pkg = createOdt().toPackage(); + pkg.parts["Object 1/content.xml"] = { + kind: "xml", + nodes: [ + { + type: "element", + tag: "office:document-content", + attributes: [], + children: [ + { + type: "element", + tag: "office:body", + attributes: [], + children: [ + { + type: "element", + tag: "office:spreadsheet", + attributes: [], + children: [], + }, + ], + }, + ], + }, + ], + }; + const spy = vi.spyOn(odfJs, "syncManifest"); + syncOdfManifest(pkg); + const options = spy.mock.calls.at(-1)?.[1]; + expect(options?.mediaTypeOverrides).toEqual({ + "Object 1/": ODF_MEDIA_TYPES.ods, + }); + spy.mockRestore(); + }); +}); diff --git a/packages/documents.js/src/odf-package/manifest.ts b/packages/documents.js/src/odf-package/manifest.ts index 0ba78803cd..7e35b3e59c 100644 --- a/packages/documents.js/src/odf-package/manifest.ts +++ b/packages/documents.js/src/odf-package/manifest.ts @@ -47,7 +47,9 @@ function subDocumentMediaType( export function syncOdfManifest(pkg: Package): void { const mediaTypeOverrides: Record<string, string> = {}; for (const path of Object.keys(pkg.parts)) { - if (!path.endsWith(CONTENT_PART_SUFFIX) || path === ROOT_CONTENT_PART) { + // No `|| path === ROOT_CONTENT_PART` check alongside this: the bare root content.xml can + // never itself end with "/content.xml" (it has no directory prefix to carry the slash), so that comparison could never be true for any path this `endsWith` check has already let through -- it restated the same exclusion a second, unreachable way. + if (!path.endsWith(CONTENT_PART_SUFFIX)) { continue; } const directory = path.slice(0, path.length - ROOT_CONTENT_PART.length); From 894a5611132e3d267864bab8cf435d0b8d6966f9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:36:04 +0100 Subject: [PATCH 40/91] test(documents.js): export nextMediaIndex and cover its prefix/escaping/max-so-far logic Replaced the if-based max-so-far update with Math.max: the numeric suffix in every path is unique by construction (it's the same string that makes the path itself unique), so a '>' vs '>=' tie can only ever compare a value against its own already-recorded max and reassign the identical number -- genuinely unobservable through any real input, which is why the comparison survived. Exported the function (no test called it directly before) and added coverage for the prefix-exclusion guard, resuming from a pre-existing higher index, and escapeRegExp's own replacement text mattering for an extension containing a regex-special character. --- packages/documents.js/src/opc/media.test.ts | 32 ++++++++++++++++++++- packages/documents.js/src/opc/media.ts | 10 ++++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/documents.js/src/opc/media.test.ts b/packages/documents.js/src/opc/media.test.ts index 59256cac54..9d23967b36 100644 --- a/packages/documents.js/src/opc/media.test.ts +++ b/packages/documents.js/src/opc/media.test.ts @@ -7,7 +7,7 @@ import { } from "ooxml.js"; import { describe, expect, it } from "vitest"; import { findChildElements } from "../xml/query"; -import { addImageMedia } from "./media"; +import { addImageMedia, nextMediaIndex } from "./media"; function emptyPackage(): Package { return { parts: {} }; @@ -98,3 +98,33 @@ describe("addImageMedia", () => { ).toHaveLength(1); }); }); + +describe("nextMediaIndex", () => { + it("ignores a same-named file outside the given media directory", () => { + const pkg: Package = { + parts: { + "ppt/media/image9.png": { kind: "binary", base64: "" }, + }, + }; + expect(nextMediaIndex(pkg, "word/media", "image", "png")).toBe(1); + }); + + it("continues from a pre-existing higher index rather than starting from 1", () => { + const pkg: Package = { + parts: { + "word/media/image5.png": { kind: "binary", base64: "" }, + }, + }; + expect(nextMediaIndex(pkg, "word/media", "image", "png")).toBe(6); + }); + + it("does not let an extension containing a regex-special character match unrelated files", () => { + // "p.g" contains a literal dot -- if escapeRegExp's own replacement text were dropped (turning the escape into a no-op deletion instead), the built pattern's dot would match ANY character, wrongly matching "pXg" too. + const pkg: Package = { + parts: { + "word/media/image1.pXg": { kind: "binary", base64: "" }, + }, + }; + expect(nextMediaIndex(pkg, "word/media", "image", "p.g")).toBe(1); + }); +}); diff --git a/packages/documents.js/src/opc/media.ts b/packages/documents.js/src/opc/media.ts index 22bf1dd33c..bf35167c3d 100644 --- a/packages/documents.js/src/opc/media.ts +++ b/packages/documents.js/src/opc/media.ts @@ -19,7 +19,7 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function nextMediaIndex( +export function nextMediaIndex( pkg: Package, mediaDir: string, fileNamePrefix: string, @@ -43,9 +43,11 @@ function nextMediaIndex( continue; } const n = Number.parseInt(digits, 10); - if (n > max) { - max = n; - } + // Math.max, not an if/comparison: the two ever differ observably only on a tie, and every + // path here is keyed by its own literal numeric suffix, so no two iterations of this loop can + // ever see the same n twice -- a tie can only be n against its own already-recorded max, which + // assigns the identical value back, an if-based '>' vs '>=' comparison could never distinguish. + max = Math.max(max, n); } return max + 1; } From 05d6359c11dc584c64474f47d448461e34b8e677 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:36:29 +0100 Subject: [PATCH 41/91] test(documents.js): export nextPictureIndex and cover it, mirroring opc/media.ts Same Math.max simplification and the same three-case gap as src/opc/media.ts's own nextMediaIndex (an unkillable '>' vs '>=' tie, no test calling the function directly, no coverage of the prefix exclusion or the regex-escaping boundary). --- .../src/odf-package/media.test.ts | 25 ++++++++++++++++++- .../documents.js/src/odf-package/media.ts | 10 +++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/documents.js/src/odf-package/media.test.ts b/packages/documents.js/src/odf-package/media.test.ts index 29e251e3de..67824fdec0 100644 --- a/packages/documents.js/src/odf-package/media.test.ts +++ b/packages/documents.js/src/odf-package/media.test.ts @@ -7,7 +7,7 @@ import { setDocumentMediaType, } from "odf.js"; import { describe, expect, it } from "vitest"; -import { addImageMedia } from "./media"; +import { addImageMedia, nextPictureIndex } from "./media"; const ODT_MEDIA_TYPE = "application/vnd.oasis.opendocument.text"; const PNG_BYTES: Uint8Array<ArrayBuffer> = new Uint8Array([ @@ -95,3 +95,26 @@ describe("addImageMedia", () => { ); }); }); + +describe("nextPictureIndex", () => { + it("ignores a same-named file outside Pictures/", () => { + const pkg: Package = { + parts: { "Other/image9.png": { kind: "binary", base64: "" } }, + }; + expect(nextPictureIndex(pkg, "png")).toBe(1); + }); + + it("continues from a pre-existing higher index rather than starting from 1", () => { + const pkg: Package = { + parts: { "Pictures/image5.png": { kind: "binary", base64: "" } }, + }; + expect(nextPictureIndex(pkg, "png")).toBe(6); + }); + + it("does not let an extension containing a regex-special character match unrelated files", () => { + const pkg: Package = { + parts: { "Pictures/image1.pXg": { kind: "binary", base64: "" } }, + }; + expect(nextPictureIndex(pkg, "p.g")).toBe(1); + }); +}); diff --git a/packages/documents.js/src/odf-package/media.ts b/packages/documents.js/src/odf-package/media.ts index f13bb8643c..57ec667550 100644 --- a/packages/documents.js/src/odf-package/media.ts +++ b/packages/documents.js/src/odf-package/media.ts @@ -14,7 +14,7 @@ function escapeRegExp(value: string): string { } // Mirrors src/opc/media.ts's own nextMediaIndex -- scans existing Pictures/ part paths for the given extension and returns one past the highest index found, so successive images never collide even if an earlier one was later removed. -function nextPictureIndex(pkg: Package, extension: string): number { +export function nextPictureIndex(pkg: Package, extension: string): number { const pattern = new RegExp(`^image(\\d+)\\.${escapeRegExp(extension)}$`); const prefix = `${PICTURES_DIR}/`; let max = 0; @@ -31,9 +31,11 @@ function nextPictureIndex(pkg: Package, extension: string): number { continue; } const n = Number.parseInt(digits, 10); - if (n > max) { - max = n; - } + // Math.max, not an if/comparison: the two ever differ observably only on a tie, and every + // path here is keyed by its own literal numeric suffix, so no two iterations of this loop can + // ever see the same n twice -- a tie can only be n against its own already-recorded max, which + // assigns the identical value back, an if-based '>' vs '>=' comparison could never distinguish. + max = Math.max(max, n); } return max + 1; } From d7a47deb5bf6213ade6b26505ea4da1021340f8c Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:36:45 +0100 Subject: [PATCH 42/91] test(documents.js): export allocateRelationshipId and assert the created root's own tag/xmlns Same Math.max simplification as the two nextMediaIndex/nextPictureIndex siblings. Also asserts ensureRelationshipsRootAtPath's freshly-created <Relationships xmlns="..."/> root by its own tag and namespace attribute (rootElement finds the top-level element regardless of what it's actually named, so neither was previously checked), and that allocateRelationshipId ignores a same-shaped Id attribute on a non-Relationship element. --- packages/documents.js/src/opc/rels.test.ts | 30 +++++++++++++++++++++- packages/documents.js/src/opc/rels.ts | 10 +++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/documents.js/src/opc/rels.test.ts b/packages/documents.js/src/opc/rels.test.ts index de301572f7..cb89d6610b 100644 --- a/packages/documents.js/src/opc/rels.test.ts +++ b/packages/documents.js/src/opc/rels.test.ts @@ -6,7 +6,12 @@ import { rootElement, } from "ooxml.js"; import { describe, expect, it } from "vitest"; -import { addRelationship, addRootRelationship } from "./rels"; +import { el } from "../xml/fragment"; +import { + addRelationship, + addRootRelationship, + allocateRelationshipId, +} from "./rels"; const IMAGE_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; @@ -98,6 +103,10 @@ describe("addRootRelationship", () => { const rels = rootElement(pkg.parts["_rels/.rels"]); expect(rels).toBeDefined(); + expect(rels?.tag).toBe("Relationships"); + expect(rels === undefined ? undefined : attr(rels, "xmlns")).toBe( + "http://schemas.openxmlformats.org/package/2006/relationships", + ); const [relationship] = rels === undefined ? [] : childrenWithTag(rels, "Relationship"); expect(relationship).toBeDefined(); @@ -166,3 +175,22 @@ describe("addRootRelationship", () => { ).toEqual(["rId1", "rId2"]); }); }); + +describe("allocateRelationshipId", () => { + it("allocates rId1 for an empty root", () => { + const root = el("Relationships"); + expect(allocateRelationshipId(root)).toBe("rId1"); + }); + + it("continues from a pre-existing higher id rather than starting from 1", () => { + const root = el("Relationships", {}, [ + el("Relationship", { Id: "rId5", Type: IMAGE_TYPE, Target: "x" }), + ]); + expect(allocateRelationshipId(root)).toBe("rId6"); + }); + + it("ignores a non-Relationship child even if it carries an Id-shaped attribute", () => { + const root = el("Relationships", {}, [el("SomethingElse", { Id: "rId9" })]); + expect(allocateRelationshipId(root)).toBe("rId1"); + }); +}); diff --git a/packages/documents.js/src/opc/rels.ts b/packages/documents.js/src/opc/rels.ts index 6a91a74b00..6786e8fe99 100644 --- a/packages/documents.js/src/opc/rels.ts +++ b/packages/documents.js/src/opc/rels.ts @@ -29,7 +29,7 @@ function ensureRelationshipsRootAtPath( } // The next unused rId in a Relationships root, scanning existing Id attributes for the highest numeric suffix -- never reusing or guessing an id that might already be referenced elsewhere. -function allocateRelationshipId(relationshipsRoot: XmlElement): string { +export function allocateRelationshipId(relationshipsRoot: XmlElement): string { let max = 0; for (const child of relationshipsRoot.children) { if (child.type !== "element" || child.tag !== "Relationship") { @@ -48,9 +48,11 @@ function allocateRelationshipId(relationshipsRoot: XmlElement): string { continue; } const n = Number.parseInt(digits, 10); - if (n > max) { - max = n; - } + // Math.max, not an if/comparison: the two ever differ observably only on a tie, and every + // path here is keyed by its own literal numeric suffix, so no two iterations of this loop can + // ever see the same n twice -- a tie can only be n against its own already-recorded max, which + // assigns the identical value back, an if-based '>' vs '>=' comparison could never distinguish. + max = Math.max(max, n); } return `rId${max + 1}`; } From 77b53eb2db88ee942921baaecad118ae2d4a2563 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:41:24 +0100 Subject: [PATCH 43/91] test(documents.js): assert the created Types root's tag/xmlns and the exact throw message Neither the fresh [Content_Types].xml root's own tag/namespace nor the unknown-extension error's exact message text had a test observing them. --- packages/documents.js/src/opc/content-types.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/opc/content-types.test.ts b/packages/documents.js/src/opc/content-types.test.ts index f181e71e83..13b47a0f7b 100644 --- a/packages/documents.js/src/opc/content-types.test.ts +++ b/packages/documents.js/src/opc/content-types.test.ts @@ -42,7 +42,9 @@ describe("defaultContentTypeForExtension", () => { }); it("throws for an unknown extension rather than guessing", () => { - expect(() => defaultContentTypeForExtension("tiff")).toThrow(); + expect(() => defaultContentTypeForExtension("tiff")).toThrow( + "no known default content type for extension: tiff", + ); }); }); @@ -50,6 +52,12 @@ describe("ensureDefaultContentType", () => { it("creates [Content_Types].xml with a Default entry when none exists", () => { const pkg = emptyPackage(); ensureDefaultContentType(pkg, "png", "image/png"); + const part = pkg.parts["[Content_Types].xml"]; + const root = part?.kind === "xml" ? part.nodes[0] : undefined; + expect(root?.type === "element" ? root.tag : undefined).toBe("Types"); + expect(root?.type === "element" ? attr(root, "xmlns") : undefined).toBe( + "http://schemas.openxmlformats.org/package/2006/content-types", + ); const defaults = findChildElements(rootChildren(pkg), "Default"); const node = soleNode(defaults); expect(attr(node, "Extension")).toBe("png"); From 2465f60fb719e1d53b8ebd8e8cc5e4dcb414ed21 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:48:19 +0100 Subject: [PATCH 44/91] test(documents.js): add direct coverage for shiftItems, drop its dead zero-shift fast path No test file existed for this module. Added coverage for the flat glyph-run/rule shift arithmetic, the stroke points array, and the assembled-glyphs placements array -- none of which had a single covering test before. Also dropped the dxPt===0&&dyPt===0 early return: adding zero to any coordinate is a no-op on every item kind, so the general map below already produces an equal result for a zero shift, and the guard's own condition was unkillable through any test that only checks output equality. --- .../documents.js/src/mathml/compose.test.ts | 81 +++++++++++++++++++ packages/documents.js/src/mathml/compose.ts | 5 +- 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 packages/documents.js/src/mathml/compose.test.ts diff --git a/packages/documents.js/src/mathml/compose.test.ts b/packages/documents.js/src/mathml/compose.test.ts new file mode 100644 index 0000000000..241939f04a --- /dev/null +++ b/packages/documents.js/src/mathml/compose.test.ts @@ -0,0 +1,81 @@ +import type { + MathAssembledGlyphs, + MathGlyphRun, + MathLayoutItem, + MathStroke, +} from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { shiftItems } from "./compose"; + +const BLACK = { r: 0, g: 0, b: 0 }; + +describe("shiftItems", () => { + it("returns an equal result, in a new array, when dxPt and dyPt are both zero", () => { + const glyphRun: MathGlyphRun = { + kind: "glyphs", + xPt: 1, + yPt: 2, + text: "x", + sizePt: 12, + color: BLACK, + }; + const items: MathLayoutItem[] = [glyphRun]; + const shifted = shiftItems(items, 0, 0); + expect(shifted).toEqual(items); + expect(shifted).not.toBe(items); + }); + + it("shifts a flat glyph-run item by adding dxPt/dyPt, not subtracting", () => { + const glyphRun: MathGlyphRun = { + kind: "glyphs", + xPt: 10, + yPt: 20, + text: "x", + sizePt: 12, + color: BLACK, + }; + const [shifted] = shiftItems([glyphRun], 3, 5); + expect(shifted).toMatchObject({ xPt: 13, yPt: 25 }); + }); + + it("shifts every point of a stroke item by adding dxPt/dyPt, not subtracting", () => { + const stroke: MathStroke = { + kind: "stroke", + points: [ + { xPt: 1, yPt: 2 }, + { xPt: 3, yPt: 4 }, + ], + widthPt: 1, + color: BLACK, + }; + const [shifted] = shiftItems([stroke], 10, 100); + if (shifted?.kind !== "stroke") { + throw new Error("expected a stroke item"); + } + expect(shifted.points).toEqual([ + { xPt: 11, yPt: 102 }, + { xPt: 13, yPt: 104 }, + ]); + }); + + it("shifts every placement of an assembled-glyphs item by adding dxPt/dyPt, not subtracting", () => { + const assembled: MathAssembledGlyphs = { + kind: "assembled-glyphs", + placements: [ + { glyphId: 1, xPt: 1, yPt: 2 }, + { glyphId: 2, xPt: 3, yPt: 4 }, + ], + text: "√", + sizePt: 12, + color: BLACK, + }; + const [shifted] = shiftItems([assembled], 10, 100); + if (shifted?.kind !== "assembled-glyphs") { + throw new Error("expected an assembled-glyphs item"); + } + expect(shifted.placements).toEqual([ + { glyphId: 1, xPt: 11, yPt: 102 }, + { glyphId: 2, xPt: 13, yPt: 104 }, + ]); + }); +}); diff --git a/packages/documents.js/src/mathml/compose.ts b/packages/documents.js/src/mathml/compose.ts index 7d068eff7e..189fca0610 100644 --- a/packages/documents.js/src/mathml/compose.ts +++ b/packages/documents.js/src/mathml/compose.ts @@ -8,15 +8,12 @@ export const EMPTY_BOX: MathBox = { items: [], }; -// Translates every item in `items` by (dxPt, dyPt) -- the one place this module touches an individual MathLayoutItem's own coordinate fields, since MathStroke's points and MathAssembledGlyphs' placements are each a nested array unlike MathGlyphRun/MathRule's flat xPt/yPt. +// Translates every item in `items` by (dxPt, dyPt) -- the one place this module touches an individual MathLayoutItem's own coordinate fields, since MathStroke's points and MathAssembledGlyphs' placements are each a nested array unlike MathGlyphRun/MathRule's flat xPt/yPt. No dxPt===0&&dyPt===0 fast path: adding zero to any coordinate is a no-op, so the general map below already produces an equal (if not reference-identical) result for a zero shift, on every item kind, with nothing for a special case to shortcut. export function shiftItems( items: readonly MathLayoutItem[], dxPt: number, dyPt: number, ): MathLayoutItem[] { - if (dxPt === 0 && dyPt === 0) { - return [...items]; - } return items.map((item) => { if (item.kind === "stroke") { return { From 81f1ba623b8a82b7033f313f1e26046721789375 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 08:57:06 +0100 Subject: [PATCH 45/91] test(documents.js): assert sourcePath on odp's recovered formula and drawing blocks Neither the formula-attach nor the vector-detection pass in readOdpContent had its own sourcePath template literal observed by any test, and the groups.length===0 early return and the outer contentPart-is-xml guard both had no test that would fail if either were skipped -- every existing assertion checked only the recovered content, never the path back to where it came from, or the shape being absent from the array at all. --- packages/documents.js/src/edit/odp/content.test.ts | 1 + packages/documents.js/src/edit/odp/formula.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/documents.js/src/edit/odp/content.test.ts b/packages/documents.js/src/edit/odp/content.test.ts index c521ffade8..283ab97d38 100644 --- a/packages/documents.js/src/edit/odp/content.test.ts +++ b/packages/documents.js/src/edit/odp/content.test.ts @@ -325,6 +325,7 @@ describe("buildOdpPackage", () => { ) { throw new Error("expected a drawing-kind embeddedObject block"); } + expect(drawingBlock.sourcePath).toBe("slides[0].shapes[1]"); expect( withoutRotation(drawingBlock.document.pages[0]?.vectors ?? []), ).toEqual(withoutRotation(VECTOR_FIXTURE)); diff --git a/packages/documents.js/src/edit/odp/formula.test.ts b/packages/documents.js/src/edit/odp/formula.test.ts index 9d3f89f4d1..c4f7ec4645 100644 --- a/packages/documents.js/src/edit/odp/formula.test.ts +++ b/packages/documents.js/src/edit/odp/formula.test.ts @@ -199,6 +199,7 @@ describe("buildOdpPackage: an embedded formula block", () => { throw new Error("expected a formula-kind embedded document"); } expect(signature(block.document.formula.mathml)).toBe("mfrac(mi(a),mi(b))"); + expect(block.sourcePath).toBe("slides[0].shapes[0]"); }); it("still writes the plain-text stand-in for a formula carrying no MathML at all", () => { From 4296c04651520ac956b7640d560cd11f596ad026 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:03:33 +0100 Subject: [PATCH 46/91] test(documents.js): spy on odf.js's decodeOdfText to assert the wrapper's own tag name The synthetic container decodeOdfText builds is thrown away immediately -- odf.js's own decodeOdfText only ever reads its .children, never .tag -- so the tag string had no way to affect observable output and survived being mutated to empty. Spying on the call is what makes it observable: the wrapper genuinely is a real, named element, not a malformed one that happens to still work. --- packages/documents.js/src/xml/odf-text.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/xml/odf-text.test.ts b/packages/documents.js/src/xml/odf-text.test.ts index 550aa58f8c..3fd0c74d1e 100644 --- a/packages/documents.js/src/xml/odf-text.test.ts +++ b/packages/documents.js/src/xml/odf-text.test.ts @@ -1,5 +1,6 @@ import type { XmlNode } from "ooxml.js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import * as odfJs from "odf.js"; import { decodeOdfText, encodeOdfText } from "./odf-text"; // The wrong behaviour decodeOdfText exists specifically to avoid: a naive concatenation of ONLY XmlText nodes, exactly what ooxml.js's own textContent() helper does and exactly why this codebase's own top-of-file warning in odf-text.ts forbids using it on ODF content. Defined only for the one regression test below, never exported. @@ -98,6 +99,15 @@ describe("encodeOdfText", () => { }); describe("decodeOdfText", () => { + it("wraps the given nodes in a real, named synthetic container element", () => { + const spy = vi.spyOn(odfJs, "decodeOdfText"); + decodeOdfText([{ type: "text", value: "x" }]); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ tag: "_odf-text-container" }), + ); + spy.mockRestore(); + }); + it("is the exact inverse of encodeOdfText for single spaces, space runs, tabs, newlines, and mixed sequences", () => { for (const value of [ " ", From 88ff8013c016460583569dc88ad815289e55ba36 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:11:56 +0100 Subject: [PATCH 47/91] test(documents.js): cover the table cell kind filter, width division, and remove guard paragraphs()/text never had a test proving the "paragraph" kind check actually filters (every existing cell held only paragraphs), the multi- paragraph join's own separator was never observed (the only multi-paragraph test never read .text), buildTable's per-column width division had no assertion at all, and remove()'s "already removed by other means" guard -- unreachable through the public API alone, since a second remove() call throws in live() first -- had no test constructing that state directly. --- .../src/edit/markdown/table.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/documents.js/src/edit/markdown/table.test.ts b/packages/documents.js/src/edit/markdown/table.test.ts index 91ae40952a..7b85c766e5 100644 --- a/packages/documents.js/src/edit/markdown/table.test.ts +++ b/packages/documents.js/src/edit/markdown/table.test.ts @@ -1,5 +1,7 @@ +import type { ContentBlock, ContentTableCell } from "document-schema.js"; import { describe, expect, it } from "vitest"; import { openMarkdown } from "./editor"; +import { buildTable, MarkdownTable, MarkdownTableCell } from "./table"; describe("MarkdownTable appendTable / appendRow / cell.text", () => { it("produces a real GFM table, re-parseable back into the same cell texts", () => { @@ -39,6 +41,29 @@ describe("MarkdownTable appendTable / appendRow / cell.text", () => { const paragraph = cell.appendParagraph({ text: "Second" }); expect(cell.paragraphs()).toHaveLength(2); expect(paragraph.text).toBe("Second"); + // Two paragraphs joined with a real newline, not concatenated bare -- the first is the cell's own untouched default (empty text), the second is "Second". + expect(cell.text).toBe("\nSecond"); + }); + + it("paragraphs()/text ignore a non-paragraph block sharing the cell, filtering strictly by kind", () => { + const node: ContentTableCell = { + blocks: [ + { kind: "paragraph", runs: [{ text: "First" }] }, + { kind: "pageBreak" }, + { kind: "paragraph", runs: [{ text: "Third" }] }, + ], + }; + const cell = new MarkdownTableCell(node); + expect(cell.paragraphs()).toHaveLength(2); + expect(cell.text).toBe("First\nThird"); + }); +}); + +describe("buildTable", () => { + it("divides the default table width evenly across the requested column count", () => { + const table = buildTable({ rows: 1, columns: 4 }); + expect(table.columnWidthsPt).toEqual([117, 117, 117, 117]); + expect(table.columnWidthsPt.reduce((sum, w) => sum + w, 0)).toBe(468); }); }); @@ -51,4 +76,17 @@ describe("MarkdownTable.remove", () => { expect(editor.tables()).toHaveLength(0); expect(() => table.rows()).toThrow(/removed/); }); + + it("does nothing to the container when its own node is no longer in it, rather than splicing the wrong element", () => { + // If the not-found guard were skipped, Array.prototype.splice(-1, 1) would silently remove the container's own LAST element instead of doing nothing. + const tableNode = buildTable({ rows: 1, columns: 1 }); + const other: ContentBlock = { kind: "paragraph", runs: [] }; + const container: ContentBlock[] = [other, tableNode]; + const table = new MarkdownTable(container, tableNode); + // Remove the table's own node from the container by some other means first, so remove()'s own indexOf lookup genuinely fails to find it. + container.splice(container.indexOf(tableNode), 1); + expect(container).toEqual([other]); + table.remove(); + expect(container).toEqual([other]); + }); }); From 8f36df0914515ff51c56a1f78d02d8385c7c73bc Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:15:50 +0100 Subject: [PATCH 48/91] test(documents.js): add direct coverage for the bin.ts launcher entry point No test existed for this executable entry point at all. Mocks node:child_process's spawnSync and stubs process.argv/env/exit before a fresh dynamic import per case, covering the argv.slice(2) strip, the spawned args array and stdio option, and the status ?? 1 exit-code fallback (a null status and a genuine non-zero status, since a zero status makes ?? and && agree by coincidence). --- packages/documents.js/src/bin.test.ts | 83 +++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 packages/documents.js/src/bin.test.ts diff --git a/packages/documents.js/src/bin.test.ts b/packages/documents.js/src/bin.test.ts new file mode 100644 index 0000000000..792dd1a5d9 --- /dev/null +++ b/packages/documents.js/src/bin.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; + +// bin.ts is a real executable entry point: importing it runs its top-level code immediately, which spawns a child process and calls process.exit. Every test here mocks node:child_process's spawnSync and stubs process.exit/argv/env before a fresh dynamic import, then restores them. + +interface SpawnSyncCall { + readonly command: string; + readonly args: readonly string[]; + readonly options: unknown; +} + +async function runBin( + argv: readonly string[], + userAgent: string | undefined, + status: number | null, +): Promise<{ readonly call: SpawnSyncCall; readonly exitCode: unknown }> { + vi.resetModules(); + let call: SpawnSyncCall | undefined; + vi.doMock("node:child_process", () => ({ + spawnSync: (command: string, args: readonly string[], options: unknown) => { + call = { command, args, options }; + return { status }; + }, + })); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + return undefined as never; + }); + const originalArgv = process.argv; + const originalUserAgent = process.env.npm_config_user_agent; + process.argv = ["node", "documents.js", ...argv]; + if (userAgent === undefined) { + delete process.env.npm_config_user_agent; + } else { + process.env.npm_config_user_agent = userAgent; + } + try { + await import("./bin"); + } finally { + process.argv = originalArgv; + if (originalUserAgent === undefined) { + delete process.env.npm_config_user_agent; + } else { + process.env.npm_config_user_agent = originalUserAgent; + } + } + if (call === undefined) { + throw new Error("expected spawnSync to have been called"); + } + const exitCode = exitSpy.mock.calls[0]?.[0]; + exitSpy.mockRestore(); + vi.doUnmock("node:child_process"); + return { call, exitCode }; +} + +describe("bin", () => { + it("strips the node/script argv[0..1] before resolving dispatch, not the full argv", () => { + return runBin(["mcp"], "npm/10.2.4 node/v20", 0).then(({ call }) => { + // Without process.argv.slice(2), argv[0] would be "node" (not "mcp"), never triggering the mcp dispatch path -- this only resolves to document-mcp because the strip happened. + expect(call.command).toBe("npx"); + expect(call.args).toEqual(["-y", "document-mcp"]); + }); + }); + + it("spawns with the exact resolved args array and { stdio: 'inherit' } options", () => { + return runBin(["convert", "a.docx"], "npm/10.2.4 node/v20", 0).then( + ({ call }) => { + expect(call.args).toEqual(["-y", "document-cli", "convert", "a.docx"]); + expect(call.options).toEqual({ stdio: "inherit" }); + }, + ); + }); + + it("exits with the spawned process's own non-zero status, not always the same code", () => { + return runBin([], "npm/10.2.4 node/v20", 2).then(({ exitCode }) => { + expect(exitCode).toBe(2); + }); + }); + + it("exits with 1 when spawnSync reports no status at all (e.g. killed by a signal)", () => { + return runBin([], "npm/10.2.4 node/v20", null).then(({ exitCode }) => { + expect(exitCode).toBe(1); + }); + }); +}); From cc2439f5334d015cad42945c005f4572c5fa2745 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:31:31 +0100 Subject: [PATCH 49/91] test(documents.js): cover hasWritableMetadataOverride's four presence branches Each of its three independent presence checks (title, author, subject) and the keywords length guard was previously untested in isolation, relying only on end-to-end coverage through patchOoxmlCorePropertiesOnPackage. --- .../src/metadata/core-patch.test.ts | 36 +++++++++++++++++++ .../documents.js/src/metadata/core-patch.ts | 4 ++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 packages/documents.js/src/metadata/core-patch.test.ts diff --git a/packages/documents.js/src/metadata/core-patch.test.ts b/packages/documents.js/src/metadata/core-patch.test.ts new file mode 100644 index 0000000000..70ba83247c --- /dev/null +++ b/packages/documents.js/src/metadata/core-patch.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { hasWritableMetadataOverride, mergeMetadata } from "./core-patch"; + +describe("hasWritableMetadataOverride", () => { + it("is false for an empty overrides object", () => { + expect(hasWritableMetadataOverride({})).toBe(false); + }); + + it("is true when only title is supplied", () => { + expect(hasWritableMetadataOverride({ title: "T" })).toBe(true); + }); + + it("is true when only author is supplied", () => { + expect(hasWritableMetadataOverride({ author: "A" })).toBe(true); + }); + + it("is true when only subject is supplied", () => { + expect(hasWritableMetadataOverride({ subject: "S" })).toBe(true); + }); + + it("is true when keywords is a non-empty array", () => { + expect(hasWritableMetadataOverride({ keywords: ["a"] })).toBe(true); + }); + + it("is false when keywords is present but empty, since nothing would actually be written", () => { + expect(hasWritableMetadataOverride({ keywords: [] })).toBe(false); + }); +}); + +describe("mergeMetadata", () => { + it("keeps fields the overrides object did not mention", () => { + expect( + mergeMetadata({ title: "Original", author: "Ada" }, { title: "New" }), + ).toEqual({ title: "New", author: "Ada" }); + }); +}); diff --git a/packages/documents.js/src/metadata/core-patch.ts b/packages/documents.js/src/metadata/core-patch.ts index b839ab26d7..cfaff60a29 100644 --- a/packages/documents.js/src/metadata/core-patch.ts +++ b/packages/documents.js/src/metadata/core-patch.ts @@ -32,7 +32,9 @@ export function mergeMetadata( } // Whether `overrides` would actually cause the addCoreProperties/writeOdfMetadata fallback below to write at least one element -- NOT merely whether a field is present in `overrides` at all. An empty keywords array is the gap this distinction closes: overrides.keywords !== undefined is true for `keywords: []`, but addCoreProperties/writeOdfMetadata themselves only ever emit a keywords element when the array's length is nonzero (mirroring how a from-scratch build never writes an empty keywords element), so treating "the key is present" as "something will be written" would create a real metadata part (plus, for OOXML, its Content_Types override and package-root relationship) out of an empty root element, on a document that had none -- contradicting patchOoxmlCorePropertiesOnPackage/patchOdfMetadataOnPackage's own contract that a document with no requested change stays byte-for-byte free of a part it never had. This predicate mirrors addCoreProperties'/buildOdfMetaNodes' own per-field write conditions exactly: title/author/subject count on mere presence, keywords counts only with at least one entry. -function hasWritableMetadataOverride(overrides: MetadataOverrides): boolean { +export function hasWritableMetadataOverride( + overrides: MetadataOverrides, +): boolean { return ( overrides.title !== undefined || overrides.author !== undefined || From fda10dc63d769c1567f7d88b1425c64f569c640f Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:31:46 +0100 Subject: [PATCH 50/91] test(documents.js): assert an empty keywords array omits cp:keywords addCoreProperties' keywords branch was only ever exercised with a non-empty array or a fully-absent field; nothing pinned the empty-array case, which must also omit the element per the function's own contract. --- packages/documents.js/src/opc/core-properties.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/documents.js/src/opc/core-properties.test.ts b/packages/documents.js/src/opc/core-properties.test.ts index 4e300f7519..a40d12d0a3 100644 --- a/packages/documents.js/src/opc/core-properties.test.ts +++ b/packages/documents.js/src/opc/core-properties.test.ts @@ -108,6 +108,15 @@ describe("addCoreProperties", () => { ).toHaveLength(0); }); + it("omits cp:keywords for an empty (but defined) keywords array, not just an undefined one", () => { + const pkg = emptyPackage(); + addCoreProperties(pkg, { title: "Has keywords field", keywords: [] }); + const root = rootElement(pkg.parts[CORE_PROPERTIES_PATH]); + expect( + root === undefined ? [] : childrenWithTag(root, "cp:keywords"), + ).toHaveLength(0); + }); + it("registers the [Content_Types].xml override and the package-root relationship", () => { const pkg = emptyPackage(); addCoreProperties(pkg, { title: "Doc" }); From c188f5e5f953189509719ead882ba4c2b3990982 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:32:02 +0100 Subject: [PATCH 51/91] test(documents.js): cover insertAfter's mid-container and reference-not-found paths Only the append-relative-to-last-element and prepend cases were tested; neither the mid-container insertion point nor the reference-sibling-absent fallback (append rather than misplace at the start) had a dedicated test. --- packages/documents.js/src/xml/edit.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/documents.js/src/xml/edit.test.ts b/packages/documents.js/src/xml/edit.test.ts index c84854de41..ef1c00de7d 100644 --- a/packages/documents.js/src/xml/edit.test.ts +++ b/packages/documents.js/src/xml/edit.test.ts @@ -61,6 +61,25 @@ describe("removeChild / insertBefore / insertAfter", () => { insertAfter(container, b, after); expect(container).toEqual([a, before, b, after]); }); + + it("insertAfter places the node right after a found reference that is not the container's last element", () => { + const a = el("a"); + const b = el("b"); + const container: XmlNode[] = [a, b]; + const newNode = el("new"); + insertAfter(container, a, newNode); + expect(container).toEqual([a, newNode, b]); + }); + + it("insertAfter appends at the end when the reference sibling is not in the container, rather than at the start", () => { + const a = el("a"); + const b = el("b"); + const container: XmlNode[] = [a, b]; + const stray = el("stray"); + const newNode = el("new"); + insertAfter(container, stray, newNode); + expect(container).toEqual([a, b, newNode]); + }); }); describe("insertInSchemaOrder", () => { From f453696b042866ff2540b789683a300a2ea9ec43 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:35:58 +0100 Subject: [PATCH 52/91] test(documents.js): add direct coverage for buildVectorShape's nvSpPr structure Nothing exercised this function in isolation; its p:nvSpPr wrapper (tag names, the cNvPr id/name attributes, the sibling p:cNvSpPr/p:nvPr pair) was only reachable indirectly through a full slide-building test. --- .../documents.js/src/edit/pptx/vector.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 packages/documents.js/src/edit/pptx/vector.test.ts diff --git a/packages/documents.js/src/edit/pptx/vector.test.ts b/packages/documents.js/src/edit/pptx/vector.test.ts new file mode 100644 index 0000000000..34038883a9 --- /dev/null +++ b/packages/documents.js/src/edit/pptx/vector.test.ts @@ -0,0 +1,48 @@ +import type { ContentVector } from "document-schema.js"; +import { childrenWithTag, attr } from "ooxml.js"; +import { describe, expect, it } from "vitest"; +import { buildVectorShape } from "./vector"; + +function rect(): ContentVector { + return { + kind: "rect", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 5 }, + }; +} + +describe("buildVectorShape", () => { + it("wraps the shape properties in a real p:sp/p:nvSpPr with the expected child tags", () => { + const sp = buildVectorShape(rect(), 3); + expect(sp.tag).toBe("p:sp"); + expect( + sp.children.map((c) => (c.type === "element" ? c.tag : c.type)), + ).toEqual(["p:nvSpPr", "p:spPr"]); + + const [nvSpPr] = childrenWithTag(sp, "p:nvSpPr"); + expect(nvSpPr).toBeDefined(); + expect( + nvSpPr === undefined + ? [] + : nvSpPr.children.map((c) => (c.type === "element" ? c.tag : c.type)), + ).toEqual(["p:cNvPr", "p:cNvSpPr", "p:nvPr"]); + + const [cNvPr] = + nvSpPr === undefined ? [] : childrenWithTag(nvSpPr, "p:cNvPr"); + expect(cNvPr).toBeDefined(); + expect(cNvPr === undefined ? undefined : attr(cNvPr, "id")).toBe("3"); + expect(cNvPr === undefined ? undefined : attr(cNvPr, "name")).toBe( + "Rect 3", + ); + }); + + it("derives the shape id and name from the supplied id, not a hardcoded value", () => { + const sp = buildVectorShape(rect(), 7); + const [nvSpPr] = childrenWithTag(sp, "p:nvSpPr"); + const [cNvPr] = + nvSpPr === undefined ? [] : childrenWithTag(nvSpPr, "p:cNvPr"); + expect(cNvPr === undefined ? undefined : attr(cNvPr, "id")).toBe("7"); + expect(cNvPr === undefined ? undefined : attr(cNvPr, "name")).toBe( + "Rect 7", + ); + }); +}); From a0bd6fadb716a406aa416698f58dae84e6b6de8c Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:39:38 +0100 Subject: [PATCH 53/91] test(documents.js): pin parseSvgLengthPt/parseSvgViewBox's trim, finiteness, and boundary cases Whitespace-trimming before both regexes, the exponent-overflow finiteness guard, and the split(/\s+/) collapse of consecutive whitespace were never exercised, and the negative-height check had no case distinguishing zero (legal) from negative (rejected) at the boundary itself. --- packages/documents.js/src/svg/units.test.ts | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/documents.js/src/svg/units.test.ts b/packages/documents.js/src/svg/units.test.ts index 0d132e672b..2260dd7184 100644 --- a/packages/documents.js/src/svg/units.test.ts +++ b/packages/documents.js/src/svg/units.test.ts @@ -28,6 +28,15 @@ describe("parseSvgLengthPt", () => { expect(parseSvgLengthPt("")).toBeUndefined(); expect(parseSvgLengthPt(undefined)).toBeUndefined(); }); + + it("trims surrounding whitespace before matching, rather than rejecting it as malformed", () => { + expect(parseSvgLengthPt(" 100px ")).toBe(75); + }); + + it("returns undefined when the matched number is syntactically valid but not finite", () => { + // The pattern's own exponent grammar accepts a magnitude this large; Number() then overflows to Infinity, which the finiteness guard must still reject rather than propagate. + expect(parseSvgLengthPt("1e400")).toBeUndefined(); + }); }); describe("parseSvgUserUnits", () => { @@ -67,6 +76,7 @@ describe("parseSvgViewBox", () => { expect(parseSvgViewBox("0 0 100")).toBeUndefined(); expect(parseSvgViewBox("0 0 100 60 5")).toBeUndefined(); expect(parseSvgViewBox("0 0 -100 60")).toBeUndefined(); + expect(parseSvgViewBox("0 0 100 -60")).toBeUndefined(); expect(parseSvgViewBox("0 0 100 abc")).toBeUndefined(); expect(parseSvgViewBox(undefined)).toBeUndefined(); }); @@ -78,5 +88,20 @@ describe("parseSvgViewBox", () => { width: 0, height: 60, }); + expect(parseSvgViewBox("0 0 100 0")).toEqual({ + minX: 0, + minY: 0, + width: 100, + height: 0, + }); + }); + + it("trims surrounding whitespace and collapses runs of internal whitespace between numbers", () => { + expect(parseSvgViewBox(" 0 0 100 60 ")).toEqual({ + minX: 0, + minY: 0, + width: 100, + height: 60, + }); }); }); From 44133c163a2eb3b89691f758e96b756409add053 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:41:11 +0100 Subject: [PATCH 54/91] test(documents.js): add direct coverage for decimalToRational and reduceRational Neither function had any test at all despite being the shared exact-rational arithmetic behind decimal-literal lowering and the coherence lint's own comparison normalisation. --- .../documents.js/src/latex/rational.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/documents.js/src/latex/rational.test.ts diff --git a/packages/documents.js/src/latex/rational.test.ts b/packages/documents.js/src/latex/rational.test.ts new file mode 100644 index 0000000000..30db766806 --- /dev/null +++ b/packages/documents.js/src/latex/rational.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { decimalToRational, reduceRational } from "./rational"; + +describe("decimalToRational", () => { + it("reduces a fractional literal to its lowest-terms rational", () => { + expect(decimalToRational("3.14")).toEqual({ + numerator: "157", + denominator: "50", + }); + }); + + it("treats a bare integer literal as an exact whole-number rational", () => { + expect(decimalToRational("42")).toEqual({ + numerator: "42", + denominator: "1", + }); + }); + + it("reduces a zero-valued literal to the schema's 0/1 convention regardless of trailing zeros", () => { + expect(decimalToRational("0.00")).toEqual({ + numerator: "0", + denominator: "1", + }); + }); + + it("returns undefined for a literal with two decimal points", () => { + expect(decimalToRational("3.1.4")).toBeUndefined(); + }); + + it("returns undefined for a literal containing non-digit characters", () => { + expect(decimalToRational("12a")).toBeUndefined(); + }); + + it("returns undefined for an empty literal", () => { + expect(decimalToRational("")).toBeUndefined(); + }); +}); + +describe("reduceRational", () => { + it("divides both terms by their greatest common divisor", () => { + expect(reduceRational(6n, 3n)).toEqual({ + numerator: "2", + denominator: "1", + }); + }); + + it("leaves an already-reduced pair unchanged", () => { + expect(reduceRational(7n, 5n)).toEqual({ + numerator: "7", + denominator: "5", + }); + }); + + it("reduces a zero numerator against gcd(0, denominator) == denominator, landing on 0/1", () => { + expect(reduceRational(0n, 9n)).toEqual({ + numerator: "0", + denominator: "1", + }); + }); +}); From d530af4deee7af3559d0517420aba8cc2815c399 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:45:51 +0100 Subject: [PATCH 55/91] test(documents.js): add direct coverage for the mathml node-walking primitives attrValue, elementChildren, firstChildByLocalName, and textContent had no test at all; each one's own predicate (attribute name equality, local-name equality, the non-element/non-text fallthrough) was only ever exercised indirectly through the layout engine's end-to-end tests. --- .../documents.js/src/mathml/nodes.test.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 packages/documents.js/src/mathml/nodes.test.ts diff --git a/packages/documents.js/src/mathml/nodes.test.ts b/packages/documents.js/src/mathml/nodes.test.ts new file mode 100644 index 0000000000..98d84c2e5d --- /dev/null +++ b/packages/documents.js/src/mathml/nodes.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import type { MathMlElement, MathMlNode } from "./nodes"; +import { + attrValue, + elementChildren, + elementLocalName, + firstChildByLocalName, + isMathMlElement, + localName, + textContent, +} from "./nodes"; + +function element( + tag: string, + attributes: readonly { name: string; value: string }[] = [], + children: readonly MathMlNode[] = [], +): MathMlElement { + return { type: "element", tag, attributes, children }; +} + +function text(value: string): MathMlNode { + return { type: "text", value }; +} + +describe("localName / elementLocalName", () => { + it("strips a single leading namespace prefix", () => { + expect(localName("math:mfrac")).toBe("mfrac"); + expect(elementLocalName(element("math:mfrac"))).toBe("mfrac"); + }); + + it("leaves an unprefixed tag unchanged", () => { + expect(localName("mfrac")).toBe("mfrac"); + }); +}); + +describe("attrValue", () => { + it("finds the value of the attribute matching the requested name, not just the first one present", () => { + const el = element("mo", [ + { name: "stretchy", value: "false" }, + { name: "fence", value: "true" }, + ]); + expect(attrValue(el, "fence")).toBe("true"); + expect(attrValue(el, "stretchy")).toBe("false"); + }); + + it("returns undefined when no attribute matches", () => { + expect( + attrValue(element("mo", [{ name: "fence", value: "true" }]), "missing"), + ).toBeUndefined(); + }); +}); + +describe("elementChildren", () => { + it("keeps only element children, skipping text siblings", () => { + const child = element("mi"); + const node = element("mrow", [], [text("x"), child, text("y")]); + expect(elementChildren(node)).toEqual([child]); + }); +}); + +describe("firstChildByLocalName", () => { + it("finds the first element child whose local name matches, ignoring namespace prefixes", () => { + const numerator = element("math:mn"); + const denominator = element("math:mn"); + const node = element("mfrac", [], [numerator, denominator]); + expect(firstChildByLocalName(node, "mn")).toBe(numerator); + }); + + it("returns undefined when no element child has that local name", () => { + const node = element("mfrac", [], [element("mn")]); + expect(firstChildByLocalName(node, "mrow")).toBeUndefined(); + }); + + it("skips a non-matching child rather than returning it regardless of name", () => { + const wrong = element("mo"); + const right = element("mi"); + const node = element("mrow", [], [wrong, right]); + expect(firstChildByLocalName(node, "mi")).toBe(right); + }); +}); + +describe("isMathMlElement", () => { + it("distinguishes an element node from a text node", () => { + expect(isMathMlElement(element("mi"))).toBe(true); + expect(isMathMlElement(text("x"))).toBe(false); + }); +}); + +describe("textContent", () => { + it("returns a text node's own value", () => { + expect(textContent(text("x"))).toBe("x"); + }); + + it("concatenates every descendant text node depth-first, in document order", () => { + const node = element( + "mrow", + [], + [ + element("mi", [], [text("a")]), + text("b"), + element("mo", [], [text("c")]), + ], + ); + expect(textContent(node)).toBe("abc"); + }); + + it("returns an empty string for a node that is neither text nor element", () => { + const comment: MathMlNode = { type: "comment" }; + expect(textContent(comment)).toBe(""); + }); + + it("returns an empty string for an element with no children, not undefined", () => { + expect(textContent(element("mspace"))).toBe(""); + }); +}); From 22b688159d409e47e59cf94601b1b1d637ed0599 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:54:52 +0100 Subject: [PATCH 56/91] fix(documents.js): drop readFirebirdBackup's unreachable schema-lookup guard tablesInOrder held names looked back up in the schema map to build each table, but schema.set(relation.name, relation) always runs in the same statement that pushes the name, so the lookup could never fail. Track the relation objects themselves in creation order instead, removing the never-true undefined guard entirely rather than leaving it untestable. Also adds direct coverage for readBurpHeader's att_backup_format/compress guards, relationToColumns' computed-field filter, and the two unsupported nested/top-level record error messages. --- .../documents.js/src/firebird/backup.test.ts | 127 ++++++++++++++++++ packages/documents.js/src/firebird/backup.ts | 15 +-- 2 files changed, 132 insertions(+), 10 deletions(-) diff --git a/packages/documents.js/src/firebird/backup.test.ts b/packages/documents.js/src/firebird/backup.test.ts index 64bd71f1e0..198625e269 100644 --- a/packages/documents.js/src/firebird/backup.test.ts +++ b/packages/documents.js/src/firebird/backup.test.ts @@ -179,6 +179,16 @@ describe("readFirebirdBackup: format guards, never a silent wrong result", () => ); }); + it("names its errors FirebirdBackupFormatError, not the bare Error default", () => { + try { + readFirebirdBackup(minimalBurpStream(11)); + throw new Error("expected readFirebirdBackup to throw"); + } catch (error) { + expect(error).toBeInstanceOf(FirebirdBackupFormatError); + expect((error as Error).name).toBe("FirebirdBackupFormatError"); + } + }); + it("throws FirebirdBackupFormatError for a non-transportable (native binary) backup", () => { // No att_backup_transportable attribute present at all -- mvol.cpp only ever writes it when true, so its absence IS "false" (see reader.ts's own Encoding 1 note). expect(() => @@ -198,6 +208,26 @@ describe("readFirebirdBackup: format guards, never a silent wrong result", () => ); }); + it("throws FirebirdBackupFormatError when rec_burp has no att_backup_format attribute at all", () => { + // rec_burp(0) immediately followed by att_end(0) -- a leading record with an empty attribute list. + const bytes = new Uint8Array([0, 0]); + expect(() => readFirebirdBackup(bytes)).toThrow(FirebirdBackupFormatError); + expect(() => readFirebirdBackup(bytes)).toThrow( + /had no att_backup_format attribute/, + ); + }); + + it("reports compressed:false when att_backup_compress is absent, not merely truthy-adjacent", () => { + // A fully valid, transportable, uncompressed rec_burp header -- no att_backup_compress attribute at all -- followed directly by rec_end, so nothing beyond the header is ever parsed. + const bytes = minimalBurpStream( + SUPPORTED_BACKUP_FORMAT_VERSION, + [5, 4, 1, 0, 0, 0], + ); + const { summary } = readFirebirdBackup(bytes); + expect(summary.transportable).toBe(true); + expect(summary.compressed).toBe(false); + }); + it("throws FirebirdCompositeRecordUnsupportedError, not a silent skip, for a genuinely unrecognised top-level record kind", () => { // Valid rec_burp header (transportable=true) followed immediately by an unrecognised record type (250) instead of rec_end. const transportableAttr = [5, 4, 1, 0, 0, 0]; @@ -216,6 +246,103 @@ describe("readFirebirdBackup: format guards, never a silent wrong result", () => expect(() => readFirebirdBackup(bytes)).toThrow( FirebirdCompositeRecordUnsupportedError, ); + expect(() => readFirebirdBackup(bytes)).toThrow( + /while walking the backup stream's own top-level record sequence/, + ); + }); + + it("throws FirebirdCompositeRecordUnsupportedError for a relation carrying an unrecognised nested record (a rec_view child)", () => { + // rec_burp header, then rec_relation("T") whose own nested-record loop opens with an unrecognised record type (99) instead of a rec_field or rec_relation_end. + const transportableAttr = [5, 4, 1, 0, 0, 0]; + const bytes = new Uint8Array([ + 0, + 2, + 4, + SUPPORTED_BACKUP_FORMAT_VERSION, + 0, + 0, + 0, + ...transportableAttr, + 0, + 3, // REC_RELATION + 1, + 1, + 84, // att_relation_name = "T" + 0, // att_end + 99, // unrecognised nested record type + ]); + expect(() => readFirebirdBackup(bytes)).toThrow( + FirebirdCompositeRecordUnsupportedError, + ); + expect(() => readFirebirdBackup(bytes)).toThrow( + /while reading a relation's own schema \(a rec_view child, most likely\)/, + ); + }); + + it("excludes a computed field from the reported columns, since gbak's own row writer never includes one either", () => { + // rec_burp header, then rec_relation("T") with two rec_field children -- "ID" (ordinary) and "CALC" (att_field_computed_flag=1) -- followed by rec_relation_end and rec_end. + const transportableAttr = [5, 4, 1, 0, 0, 0]; + const BLR_LONG = 8; + const idField = [ + 4, // REC_FIELD + 1, + 2, + 73, + 68, // att_field_name = "ID" + 8, + 4, + BLR_LONG, + 0, + 0, + 0, // att_field_type + 0, // att_end + ]; + const calcField = [ + 4, // REC_FIELD + 1, + 4, + 67, + 65, + 76, + 67, // att_field_name = "CALC" + 8, + 4, + BLR_LONG, + 0, + 0, + 0, // att_field_type + 23, + 4, + 1, + 0, + 0, + 0, // att_field_computed_flag = true + 0, // att_end + ]; + const bytes = new Uint8Array([ + 0, + 2, + 4, + SUPPORTED_BACKUP_FORMAT_VERSION, + 0, + 0, + 0, + ...transportableAttr, + 0, + 3, // REC_RELATION + 1, + 1, + 84, // att_relation_name = "T" + 0, // att_end + ...idField, + ...calcField, + 9, // REC_RELATION_END + 10, // REC_END + ]); + const { tables } = readFirebirdBackup(bytes); + expect(tables).toEqual([ + { tableName: "T", columns: [{ name: "ID", type: "INTEGER" }], rows: [] }, + ]); }); }); diff --git a/packages/documents.js/src/firebird/backup.ts b/packages/documents.js/src/firebird/backup.ts index dba1a25c8e..73bd346378 100644 --- a/packages/documents.js/src/firebird/backup.ts +++ b/packages/documents.js/src/firebird/backup.ts @@ -163,7 +163,8 @@ export function readFirebirdBackup( let pageSizeBytes: number | undefined; const schema = new Map<string, FirebirdRelation>(); - const tablesInOrder: string[] = []; + // The relation objects themselves, in creation order -- not just their names -- so the final table-building step below can read tableName/columns straight off each one rather than looking a name back up in `schema` (which would always succeed, since every name pushed here was set in `schema` in the same statement, but a lookup that can never fail is exactly the redundant guard this module's own equivalent-mutant policy requires eliminating rather than leaving untestable). + const relationsInOrder: FirebirdRelation[] = []; const rowsByRelation = new Map< string, ReturnType<typeof readRelationData>["rows"] @@ -187,7 +188,7 @@ export function readFirebirdBackup( ); }); schema.set(relation.name, relation); - tablesInOrder.push(relation.name); + relationsInOrder.push(relation); continue; } if (recordType === REC_RELATION_DATA) { @@ -208,14 +209,8 @@ export function readFirebirdBackup( // No check that reader.atEnd() here -- confirmed against a real fixture that rec_end is genuinely NOT the last byte of the stream: mvol.cpp writes backup volumes in fixed-size blocks (att_backup_blksize), zero-padding the final block out to that size, so real trailing bytes after rec_end are legitimate filler, not a sign of a mis-walked stream. restore.epp's own top-level loop (`while (get_record(&record, tdgbl) != rec_end)`) matches this exactly -- it stops at rec_end and never inspects what follows. // Only USER tables (schema.system_flag-free, which this reader never reads at all -- see the README's .odb Tier 3 Fidelity note) are reported: RDB$RELATIONS/RDB$RELATION_FIELDS/RDB$FIELDS and every other system table never appear as their own rec_relation records in a gbak backup at all -- gbak's own schema dump only ever emits user-created relations (plus any user-created VIEWs, which this reader throws on as an unsupported composite record -- see schema.ts's own onUnhandledNested). There is consequently no RDB$RELATIONS-bootstrap step in this reader at all: unlike raw ODS-page reading, gbak's own backup format has ALREADY resolved table/column definitions into rec_relation/rec_field records by the time this reader ever sees them -- see the README's own .odb Tier 3 Gotchas entry for why this is a genuine, load-bearing correction to the design plan's original raw-page-format premise. - const tables: HsqldbTable[] = tablesInOrder.map((name) => { - const relation = schema.get(name); - if (relation === undefined) { - throw new FirebirdBackupFormatError( - `internal error: relation "${name}" missing from its own schema map`, - ); - } - const rows = rowsByRelation.get(name) ?? []; + const tables: HsqldbTable[] = relationsInOrder.map((relation) => { + const rows = rowsByRelation.get(relation.name) ?? []; return { tableName: relation.name, columns: relationToColumns(relation), From 56d8439a5c1820ce81f15d9ad3a1dca184dc9fab Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 09:59:44 +0100 Subject: [PATCH 57/91] test(documents.js): add direct coverage for OdgPage.remove and its post-removal guard Nothing exercised remove() itself (splicing the page out of the drawing, marking the handle removed) or the resulting live()/removed throw on any further use -- removePageAt on the editor manipulates the tree directly rather than going through an OdgPage instance's own remove(). --- .../documents.js/src/edit/odg/page.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/documents.js/src/edit/odg/page.test.ts diff --git a/packages/documents.js/src/edit/odg/page.test.ts b/packages/documents.js/src/edit/odg/page.test.ts new file mode 100644 index 0000000000..ef595271fc --- /dev/null +++ b/packages/documents.js/src/edit/odg/page.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { createOdg } from "./editor"; + +describe("OdgPage.remove", () => { + it("splices the page out of the drawing's own document so it no longer appears in pages()", () => { + const editor = createOdg(); + editor.addPage(); + editor.addPage(); + expect(editor.pages()).toHaveLength(2); + + const [first] = editor.pages(); + first?.remove(); + + expect(editor.pages()).toHaveLength(1); + }); + + it("marks the handle removed, so any further use throws rather than silently operating on a detached element", () => { + const editor = createOdg(); + const page = editor.addPage(); + page.remove(); + + expect(() => page.shapes()).toThrow( + "this OdgPage has been removed from the drawing and can no longer be used", + ); + expect(() => + page.addRect({ frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 } }), + ).toThrow(/removed/); + }); +}); From e3cfa33e0c42d6605ef93e05ad7d8194c74edac4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 10:06:42 +0100 Subject: [PATCH 58/91] test(documents.js): cover buildRelativeTarget from a root-level part Every existing case had at least one path segment before the file name on both sides; nothing exercised dirSegments' own no-slash-found branch for the source part. --- packages/documents.js/src/opc/paths.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/documents.js/src/opc/paths.test.ts b/packages/documents.js/src/opc/paths.test.ts index 69ccfefa24..6fc5742e95 100644 --- a/packages/documents.js/src/opc/paths.test.ts +++ b/packages/documents.js/src/opc/paths.test.ts @@ -40,4 +40,10 @@ describe("buildRelativeTarget", () => { buildRelativeTarget("ppt/presentation.xml", "ppt/slides/slide1.xml"), ).toBe("slides/slide1.xml"); }); + + it("targets a nested part from a root-level part with no directory of its own", () => { + expect(buildRelativeTarget("document.xml", "word/document.xml")).toBe( + "word/document.xml", + ); + }); }); From 5ce0d0255d7d04bad80c5de10448fb14169f9f66 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 10:08:27 +0100 Subject: [PATCH 59/91] fix(documents.js): make nextObjectIndex's running max unconditional Replaces the if-guarded max = index assignment with Math.max, matching the odf-package/media.ts and opc/media.ts convention added earlier in this same mutation-coverage effort -- every Object N directory carries a distinct index, so the if-comparison's own tie boundary was unobservable, and the function had no test at all until now. --- .../src/odf-package/formula.test.ts | 37 +++++++++++++++++++ .../documents.js/src/odf-package/formula.ts | 8 ++-- 2 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 packages/documents.js/src/odf-package/formula.test.ts diff --git a/packages/documents.js/src/odf-package/formula.test.ts b/packages/documents.js/src/odf-package/formula.test.ts new file mode 100644 index 0000000000..3614209a1f --- /dev/null +++ b/packages/documents.js/src/odf-package/formula.test.ts @@ -0,0 +1,37 @@ +import type { Package } from "odf.js"; +import { describe, expect, it } from "vitest"; +import { nextObjectIndex } from "./formula"; + +function packageWithParts(paths: readonly string[]): Package { + const parts: Package["parts"] = {}; + for (const path of paths) { + parts[path] = { kind: "xml", nodes: [] }; + } + return { parts }; +} + +describe("nextObjectIndex", () => { + it("is 1 for a package with no existing Object directories at all", () => { + expect(nextObjectIndex(packageWithParts(["content.xml"]))).toBe(1); + }); + + it("is one past the highest index present, regardless of encounter order", () => { + expect( + nextObjectIndex( + packageWithParts([ + "Object 3/content.xml", + "Object 1/content.xml", + "Object 2/content.xml", + ]), + ), + ).toBe(4); + }); + + it("resumes from a gap left by a removed object, rather than reusing the lowest free index", () => { + expect( + nextObjectIndex( + packageWithParts(["Object 1/content.xml", "Object 5/content.xml"]), + ), + ).toBe(6); + }); +}); diff --git a/packages/documents.js/src/odf-package/formula.ts b/packages/documents.js/src/odf-package/formula.ts index 0ee7545a21..ba1a53a74e 100644 --- a/packages/documents.js/src/odf-package/formula.ts +++ b/packages/documents.js/src/odf-package/formula.ts @@ -28,7 +28,7 @@ export interface AddedOdfFormula { } // One past the highest "Object N" directory already present, so a second formula in the same document never collides with the first -- mirroring src/odf-package/media.ts's own nextPictureIndex exactly, including its tolerance of a gap left by an earlier object that is no longer there. -function nextObjectIndex(pkg: Package): number { +export function nextObjectIndex(pkg: Package): number { const pattern = /^Object (\d+)\//; let max = 0; for (const path of Object.keys(pkg.parts)) { @@ -37,10 +37,8 @@ function nextObjectIndex(pkg: Package): number { if (digits === undefined) { continue; } - const index = Number.parseInt(digits, 10); - if (index > max) { - max = index; - } + // Math.max rather than an if-comparison: every "Object N" directory in a real package is distinct, so no two paths this loop sees ever carry the same index -- an if-guarded assignment and a running max are equally correct here, but only the latter has no tie-boundary comparison left for a mutation to flip unobservably. + max = Math.max(max, Number.parseInt(digits, 10)); } return max + 1; } From 737569b496829de2e15896074efa204da6562568 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 10:13:11 +0100 Subject: [PATCH 60/91] test(documents.js): cover shapeDetail's fallback label and a path's evenodd fill-rule Neither buildSvgText diagnostic nor written attribute had a dedicated test: a shape with no name and no sourcePath still needs a non-empty label, and a path vector's own fill-rule="evenodd" attribute was only ever exercised through the read side, never confirmed on write. --- .../documents.js/src/svg/read-write.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/documents.js/src/svg/read-write.test.ts b/packages/documents.js/src/svg/read-write.test.ts index 73c78e6aeb..a0d2ea0144 100644 --- a/packages/documents.js/src/svg/read-write.test.ts +++ b/packages/documents.js/src/svg/read-write.test.ts @@ -652,6 +652,63 @@ describe("buildSvgText", () => { }, ]); }); + + it("falls back to the literal 'shape' when a diagnostic's own shape has neither a name nor a sourcePath", () => { + const diagnostics: SvgDiagnostic[] = []; + const document: ContentDocument = { + kind: "drawing", + metadata: {}, + pages: [ + { + size: { widthPt: 100, heightPt: 60 }, + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [], + }, + ], + vectors: [], + }, + ], + }; + buildSvgText(document, { + onSvgDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + expect(diagnostics[0]?.detail.startsWith("shape:")).toBe(true); + }); + + it('writes a fill-rule="evenodd" attribute on a path vector whose own fillRule is evenodd', () => { + const document: ContentDocument = { + kind: "drawing", + metadata: {}, + pages: [ + { + size: { widthPt: 100, heightPt: 60 }, + shapes: [], + vectors: [ + { + kind: "path", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + fillRule: "evenodd", + subpaths: [ + { + start: { xPt: 0, yPt: 0 }, + segments: [], + closed: true, + }, + ], + }, + ], + }, + ], + }; + const text = buildSvgText(document); + expect(text).toContain('fill-rule="evenodd"'); + }); }); describe("readSvgContent -> buildSvgText round trip", () => { From 59f99466b3f8a81e509e5c8331e40e1006392d90 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 10:17:36 +0100 Subject: [PATCH 61/91] fix(documents.js): stop chaining a non-optional call off an optional property access diagnostics[0]?.detail.startsWith(...) only guards the .detail access, not the subsequent .startsWith call on a value TypeScript still sees as string | undefined under tsconfig.node.json's stricter settings -- assert against .detail directly with toMatch instead. --- packages/documents.js/src/svg/read-write.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/documents.js/src/svg/read-write.test.ts b/packages/documents.js/src/svg/read-write.test.ts index a0d2ea0144..fd0e6dc041 100644 --- a/packages/documents.js/src/svg/read-write.test.ts +++ b/packages/documents.js/src/svg/read-write.test.ts @@ -678,7 +678,7 @@ describe("buildSvgText", () => { buildSvgText(document, { onSvgDiagnostic: (diagnostic) => diagnostics.push(diagnostic), }); - expect(diagnostics[0]?.detail.startsWith("shape:")).toBe(true); + expect(diagnostics[0]?.detail).toMatch(/^shape:/); }); it('writes a fill-rule="evenodd" attribute on a path vector whose own fillRule is evenodd', () => { From 883dd838ceda7ec584574d7f834be0f2fe45fc16 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:05:43 +0100 Subject: [PATCH 62/91] test(documents.js): cover CONTENT_READERS.rtf's abort-signal forwarding Mirrors the existing markdown/PDF cases: readRtfContent checks options.signal before tokenizing, so an already-aborted signal must reach it through the { signal } options object rather than an empty one. --- packages/documents.js/src/codecs/read.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/documents.js/src/codecs/read.test.ts b/packages/documents.js/src/codecs/read.test.ts index 587bdb33eb..469a99f982 100644 --- a/packages/documents.js/src/codecs/read.test.ts +++ b/packages/documents.js/src/codecs/read.test.ts @@ -27,6 +27,23 @@ describe("CONTENT_READERS.markdown", () => { }); }); +describe("CONTENT_READERS.rtf", () => { + it("forwards the abort signal through to readRtfContent, which checks it before tokenizing", () => { + const controller = new AbortController(); + controller.abort(); + let caught: unknown; + try { + CONTENT_READERS.rtf(new TextEncoder().encode("{\\rtf1 hi}"), { + signal: controller.signal, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe("AbortError"); + }); +}); + describe("readDocumentLayout", () => { it("forwards the signal option through to readPdf, which checks it before parsing", () => { const controller = new AbortController(); From 319c18e4176c66e7eefe88bf09707171eb111d15 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:05:57 +0100 Subject: [PATCH 63/91] fix(documents.js): make localName branchless to remove an equivalent mutant Slicing from indexOf(":") + 1 already returns the whole string when there is no colon (indexOf yields -1, so the slice starts at 0), so the colonIndex === -1 ternary guard was redundant: every input was already handled correctly by the slice alone. That redundancy made a ConditionalExpression mutation on the guard permanently equivalent (unkillable by any test), since the branch it toggled produced the same output either way. Dropping the guard removes the mutation opportunity outright. --- packages/documents.js/src/mathml/nodes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/documents.js/src/mathml/nodes.ts b/packages/documents.js/src/mathml/nodes.ts index b760d4983c..ac9769c0ed 100644 --- a/packages/documents.js/src/mathml/nodes.ts +++ b/packages/documents.js/src/mathml/nodes.ts @@ -31,9 +31,9 @@ function isMathMlText(node: MathMlNode): node is MathMlText { } // Real MathML producers (confirmed against LibreOffice's own content.xml output) write element tags with a "math:" namespace prefix when math is not the document's default namespace (<math:mfrac>, <math:mrow>, ...), and bare, unprefixed tags when it is (<mfrac>, <mrow>, ...) -- odf.js's own readOdfFormulaMathMl already handles exactly this ambiguity for the root element (MATH_ROOT_TAGS = ['math', 'math:math']). This module applies the same tolerance uniformly to every element, not just the root: strip a single leading "prefix:" segment before comparing against a canonical MathML tag name, so this layout engine works unmodified regardless of which form a given producer chose. +// Deliberately branchless: slicing from `indexOf(":") + 1` already returns the whole string when there is no colon (indexOf yields -1, so the slice starts at 0), so a colonIndex === -1 guard would be redundant -- every input this function accepts is already correctly handled by the single slice below. export function localName(tag: string): string { - const colonIndex = tag.indexOf(":"); - return colonIndex === -1 ? tag : tag.slice(colonIndex + 1); + return tag.slice(tag.indexOf(":") + 1); } export function elementLocalName(element: MathMlElement): string { From 01d4a86efced0ff2884bcf7ea6d706096b061404 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:06:09 +0100 Subject: [PATCH 64/91] test(documents.js): cover gcd(0, 0) and a leading-sign literal rejection reduceRational(0n, 0n) exercises gcd's own 0/0 special case (defined as 1 to avoid a BigInt division-by-zero crash) directly, rather than only through a path decimalToRational can actually reach. decimalToRational("-5") pins that the ^\d+$ digits check rejects a literal whose only non-digit character sits before its digits, not just one with non-digit characters at the end (already covered by the existing "12a" case). --- packages/documents.js/src/latex/rational.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/documents.js/src/latex/rational.test.ts b/packages/documents.js/src/latex/rational.test.ts index 30db766806..5ade58ec63 100644 --- a/packages/documents.js/src/latex/rational.test.ts +++ b/packages/documents.js/src/latex/rational.test.ts @@ -34,6 +34,10 @@ describe("decimalToRational", () => { it("returns undefined for an empty literal", () => { expect(decimalToRational("")).toBeUndefined(); }); + + it("returns undefined for a literal with a leading sign, even though its trailing characters are digits", () => { + expect(decimalToRational("-5")).toBeUndefined(); + }); }); describe("reduceRational", () => { @@ -57,4 +61,12 @@ describe("reduceRational", () => { denominator: "1", }); }); + + it("treats gcd(0, 0) as 1 rather than dividing by zero for a 0/0-shaped input", () => { + expect(() => reduceRational(0n, 0n)).not.toThrow(); + expect(reduceRational(0n, 0n)).toEqual({ + numerator: "0", + denominator: "0", + }); + }); }); From 6bb13fffa712c2d724d889ca4c931b983bcfddb8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:06:21 +0100 Subject: [PATCH 65/91] test(documents.js): cover mergeMetadata preserving subject and keywords The existing test only exercised title/author being kept when overrides omits them; subject and keywords had the identical conditional-spread shape with no coverage of their own absent-field case. --- .../documents.js/src/metadata/core-patch.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/documents.js/src/metadata/core-patch.test.ts b/packages/documents.js/src/metadata/core-patch.test.ts index 70ba83247c..b513fe4222 100644 --- a/packages/documents.js/src/metadata/core-patch.test.ts +++ b/packages/documents.js/src/metadata/core-patch.test.ts @@ -33,4 +33,17 @@ describe("mergeMetadata", () => { mergeMetadata({ title: "Original", author: "Ada" }, { title: "New" }), ).toEqual({ title: "New", author: "Ada" }); }); + + it("keeps the current subject when overrides does not mention it", () => { + expect( + mergeMetadata({ subject: "Original subject" }, { title: "New" }), + ).toEqual({ subject: "Original subject", title: "New" }); + }); + + it("keeps the current keywords when overrides does not mention them", () => { + expect(mergeMetadata({ keywords: ["a", "b"] }, { title: "New" })).toEqual({ + keywords: ["a", "b"], + title: "New", + }); + }); }); From 9a4deaf25e0f0127e1ba4e8e63d83590b12f9901 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:06:37 +0100 Subject: [PATCH 66/91] fix(documents.js): compare cell keys less-than-first to avoid equivalent mutants compareCellKeys checked equality first, then used a nested less-than ternary for the remainder. Since the equality guard already rules out left === right before that nested comparison runs, a < -to- <= mutation on it produced identical output for every reachable input: the equal case never reaches that branch, and < versus <= only differ on equality. Reordering to check < (then >, then implicit equal) puts the equal-values input back in reach of that comparison, so a relational-operator mutation there is observable again. --- packages/documents.js/src/odb/values.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/documents.js/src/odb/values.ts b/packages/documents.js/src/odb/values.ts index 9dc0e1bf88..a54137ec3a 100644 --- a/packages/documents.js/src/odb/values.ts +++ b/packages/documents.js/src/odb/values.ts @@ -51,18 +51,19 @@ export function compareCellKeys( right: CellComparisonKey, fail: CellValueFailure, ): number { + // Ordered as less-than-first rather than equality-first: with equality checked first, the surrounding guard already rules out left === right by the time a `<` (or `<=`) comparison runs, making the two relational spellings produce identical output for every reachable input -- an unkillable, permanently-equivalent mutant. Checking `<` first means a `<`-to-`<=` mutation is reachable at the equal-values input (it would wrongly report -1 instead of 0), so this ordering carries no equivalent-mutant gap. if (left.valueClass === "numeric" && right.valueClass === "numeric") { - return left.numeric === right.numeric - ? 0 - : left.numeric < right.numeric - ? -1 - : 1; + return left.numeric < right.numeric + ? -1 + : left.numeric > right.numeric + ? 1 + : 0; } if (left.valueClass === "boolean" && right.valueClass === "boolean") { return left.boolean === right.boolean ? 0 : left.boolean ? 1 : -1; } if (left.valueClass === "text" && right.valueClass === "text") { - return left.text === right.text ? 0 : left.text < right.text ? -1 : 1; + return left.text < right.text ? -1 : left.text > right.text ? 1 : 0; } throw fail( `cannot compare a ${left.valueClass} value with a ${right.valueClass} value`, From 6f40ebd37c314264d5e8af82a5ce9f9be233e261 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:06:48 +0100 Subject: [PATCH 67/91] test(documents.js): pin nextPictureIndex's Pictures/ prefix check The existing "outside Pictures/" case used a path whose Pictures/- prefix-length slice happens not to look like an image filename, so it could not distinguish a real prefix check from one that never actually skipped. Adds a path deliberately crafted so slicing at the Pictures/ prefix length spells a valid image filename by coincidence, pinning that the function only follows the real path prefix. --- packages/documents.js/src/odf-package/media.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/documents.js/src/odf-package/media.test.ts b/packages/documents.js/src/odf-package/media.test.ts index 67824fdec0..01630e03e3 100644 --- a/packages/documents.js/src/odf-package/media.test.ts +++ b/packages/documents.js/src/odf-package/media.test.ts @@ -117,4 +117,15 @@ describe("nextPictureIndex", () => { }; expect(nextPictureIndex(pkg, "p.g")).toBe(1); }); + + // "Pictures0image5.png" is 9 characters ("Pictures0") ahead of a slice that -- once the leading "Pictures/" (also 9 characters) is stripped off a real Pictures/ path -- looks exactly like "image5.png". A path-prefix check that only LOOKED at whether the loop should skip a part, without actually gating the pattern match against it, would still slice this non-Pictures path at the same fixed offset and misread it as Pictures/image5.png -- this path is deliberately crafted so that coincidence is exercised, unlike a plain "Other/imageN.ext" path (whose own 9-character-in slice does not happen to spell a valid image filename). + it("ignores a same-named file outside Pictures/ even when slicing its path at the Pictures/ prefix length would coincidentally spell a valid image filename", () => { + const pkg: Package = { + parts: { + "Pictures/image1.png": { kind: "binary", base64: "" }, + "Pictures0image5.png": { kind: "binary", base64: "" }, + }, + }; + expect(nextPictureIndex(pkg, "png")).toBe(2); + }); }); From 0cf84eca2e1d5b66c20dcb5be0a7a9765d4438ac Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:07:02 +0100 Subject: [PATCH 68/91] fix(documents.js): drop readOdpContent's redundant empty-groups guard The early return for an empty vector-groups array only skipped allocating a shapes array that the rebuild loop below would otherwise reconstruct with identical contents (the insertion while-loop never runs when groups is empty, so every shapeIndex iteration just re-pushes the shape already at that index). The guard changed nothing observable, making its ConditionalExpression mutation permanently equivalent. Removing it drops the mutation opportunity. --- packages/documents.js/src/odf/odp/read.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/documents.js/src/odf/odp/read.ts b/packages/documents.js/src/odf/odp/read.ts index a0715aad96..992ddf49ad 100644 --- a/packages/documents.js/src/odf/odp/read.ts +++ b/packages/documents.js/src/odf/odp/read.ts @@ -63,10 +63,8 @@ export function readOdpContent(pkg: Package): ContentDocument { }; } + // No early return for an empty `groups`: the rebuild loop below already reduces to a no-op copy of the slide's existing shapes when there is nothing to insert (the inner insertion while-loop never runs, so every shapeIndex iteration just re-pushes the shape already at that index) -- an early-return guard here would only skip allocating an equivalent array, never change what gets assigned, making the guard a permanently equivalent mutation target rather than a real correctness branch. const groups = collectSlideVectorGroups(pageElement.children, pkg); - if (groups.length === 0) { - return; - } const shapes: ContentShape[] = []; let shapeIndex = 0; let groupIndex = 0; From d9114cd455e4f65dd5de224fe4e4e7513f383507 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:07:13 +0100 Subject: [PATCH 69/91] test(documents.js): pin ensureDefaultContentType's tag check The presence loop matched on element tag AND attribute value, but no test distinguished that from matching on the attribute value alone. Adds a case where an Override element carries an Extension attribute equal to the value being ensured, confirming ensureDefaultContentType still adds a genuine Default entry rather than mistaking the Override for one. --- packages/documents.js/src/opc/content-types.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/documents.js/src/opc/content-types.test.ts b/packages/documents.js/src/opc/content-types.test.ts index 13b47a0f7b..69e90832e7 100644 --- a/packages/documents.js/src/opc/content-types.test.ts +++ b/packages/documents.js/src/opc/content-types.test.ts @@ -77,6 +77,18 @@ describe("ensureDefaultContentType", () => { ensureDefaultContentType(pkg, "jpeg", "image/jpeg"); expect(findChildElements(rootChildren(pkg), "Default")).toHaveLength(2); }); + + it("does not mistake an Override element carrying the same Extension attribute value for an existing Default", () => { + const pkg = emptyPackage(); + ensureContentTypeOverride(pkg, "png", "image/png"); + // Force an Extension attribute onto that Override entry, matching what ensureDefaultContentType would look for on a Default -- proving the presence check keys on the element's own tag, not merely on the attribute value. + const [override] = findChildElements(rootChildren(pkg), "Override"); + if (override !== undefined) { + override.node.attributes.push({ name: "Extension", value: "png" }); + } + ensureDefaultContentType(pkg, "png", "image/png"); + expect(findChildElements(rootChildren(pkg), "Default")).toHaveLength(1); + }); }); describe("ensureContentTypeOverride", () => { From 75d792f3bda2320060a4c8ec3f3196f38060397f Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:24:22 +0100 Subject: [PATCH 70/91] test(documents.js): pin nextMediaIndex's escaping and prefix check escapeRegExp's replacement text turning a special character into a literal match, rather than deleting it outright, needs a part whose extension contains one to distinguish -- the existing "p.g" case only proved over-matching was prevented, not that a genuine "p+g" match still works. Also mirrors src/odf-package/media.test.ts's own prefix-length coincidence case for this sibling OOXML-side implementation. --- packages/documents.js/src/opc/media.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/documents.js/src/opc/media.test.ts b/packages/documents.js/src/opc/media.test.ts index 9d23967b36..affd510250 100644 --- a/packages/documents.js/src/opc/media.test.ts +++ b/packages/documents.js/src/opc/media.test.ts @@ -127,4 +127,25 @@ describe("nextMediaIndex", () => { }; expect(nextMediaIndex(pkg, "word/media", "image", "p.g")).toBe(1); }); + + it("still matches an extension containing a regex-special character against its own literal spelling", () => { + // "p+g" contains a literal plus -- if escapeRegExp deleted the special character instead of escaping it, the built pattern would require the literal text "pg" and this genuinely matching "p+g" part would be missed. + const pkg: Package = { + parts: { + "word/media/image1.p+g": { kind: "binary", base64: "" }, + }, + }; + expect(nextMediaIndex(pkg, "word/media", "image", "p+g")).toBe(2); + }); + + // "word/mediaXimage5.png" is exactly as long as "word/media/" ("word/mediaX" is 11 characters, matching "word/media/"'s own 11), so slicing it at the media-directory-prefix length spells "image5.png" by coincidence -- deliberately exercising the same prefix-check coincidence as src/odf-package/media.test.ts's own nextPictureIndex case, for the sibling OOXML-side implementation. + it("ignores a same-named file outside the media directory even when slicing its path at the prefix length would coincidentally spell a valid image filename", () => { + const pkg: Package = { + parts: { + "word/media/image1.png": { kind: "binary", base64: "" }, + "word/mediaXimage5.png": { kind: "binary", base64: "" }, + }, + }; + expect(nextMediaIndex(pkg, "word/media", "image", "png")).toBe(2); + }); }); From ac498a24dcbc56a677ba7dbe9d45f795dc0d7aee Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:24:53 +0100 Subject: [PATCH 71/91] fix(documents.js): remove insertInSchemaOrder's redundant not-in-order guard siblingRank !== -1 was always true by the time siblingRank > childRank runs, since -1 can never be greater than childRank (already known non-negative from the earlier childRank === -1 return above) -- the guard changed nothing observable, an equivalent mutant. Also adds insertBefore's own not-found-append-at-end case (insertAfter already had one) and a same-schema-rank case for insertInSchemaOrder, both previously unexercised. --- packages/documents.js/src/xml/edit.test.ts | 18 ++++++++++++++++++ packages/documents.js/src/xml/edit.ts | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/xml/edit.test.ts b/packages/documents.js/src/xml/edit.test.ts index ef1c00de7d..1e00369284 100644 --- a/packages/documents.js/src/xml/edit.test.ts +++ b/packages/documents.js/src/xml/edit.test.ts @@ -62,6 +62,16 @@ describe("removeChild / insertBefore / insertAfter", () => { expect(container).toEqual([a, before, b, after]); }); + it("insertBefore appends at the end when the reference sibling is not in the container, rather than immediately before the last element", () => { + const a = el("a"); + const b = el("b"); + const container: XmlNode[] = [a, b]; + const stray = el("stray"); + const newNode = el("new"); + insertBefore(container, stray, newNode); + expect(container).toEqual([a, b, newNode]); + }); + it("insertAfter places the node right after a found reference that is not the container's last element", () => { const a = el("a"); const b = el("b"); @@ -127,6 +137,14 @@ describe("insertInSchemaOrder", () => { parent.children.map((c) => (c.type === "element" ? c.tag : c.type)), ).toEqual(RPR_ORDER); }); + + it("appends after a same-rank sibling rather than inserting before it", () => { + const parent = el("w:rPr", {}, [el("w:b")]); + insertInSchemaOrder(parent, el("w:b"), RPR_ORDER); + expect( + parent.children.map((c) => (c.type === "element" ? c.tag : c.type)), + ).toEqual(["w:b", "w:b"]); + }); }); describe("directChildElement / getOrCreateChildElement", () => { diff --git a/packages/documents.js/src/xml/edit.ts b/packages/documents.js/src/xml/edit.ts index 60141fa75f..7cb3f4a78c 100644 --- a/packages/documents.js/src/xml/edit.ts +++ b/packages/documents.js/src/xml/edit.ts @@ -67,8 +67,9 @@ export function insertInSchemaOrder( if (sibling.type !== "element") { continue; } + // No explicit "not in order" guard: order.indexOf yields -1 for a sibling whose tag is absent from `order`, and childRank is already known non-negative (the -1 case returned above), so -1 > childRank is always false on its own -- an explicit siblingRank !== -1 check ahead of it would never change the outcome, only duplicate what the comparison below already guarantees. const siblingRank = order.indexOf(sibling.tag); - if (siblingRank !== -1 && siblingRank > childRank) { + if (siblingRank > childRank) { insertBefore(parent.children, sibling, child); return; } From 6fcb6575ebe0860bdd9e49431bb2b23ec11df09b Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:26:07 +0100 Subject: [PATCH 72/91] fix(documents.js): drop the space-run scan's redundant length bound Indexing a string past its end yields undefined in JavaScript, and undefined === " " is already false, so the i + runLength < text.length check could never change the loop's outcome on its own -- it only ever agreed with what the character comparison already decided, making three separate mutations on it (the bound itself, its operator, and its arithmetic) all permanently equivalent. Relying on the character comparison alone removes the redundant check. --- packages/documents.js/src/xml/odf-text.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/xml/odf-text.ts b/packages/documents.js/src/xml/odf-text.ts index 44fedee727..8e8688075e 100644 --- a/packages/documents.js/src/xml/odf-text.ts +++ b/packages/documents.js/src/xml/odf-text.ts @@ -31,7 +31,8 @@ export function encodeOdfText(text: string): XmlNode[] { const ch = text.charAt(i); if (ch === " ") { let runLength = 1; - while (i + runLength < text.length && text[i + runLength] === " ") { + // No explicit i + runLength < text.length bound check: indexing a string past its end yields undefined in JavaScript, and undefined === " " is already false, so the length comparison could never change the loop's outcome -- it would only ever agree with what the character comparison below already decides on its own, making it a permanently equivalent mutation target. + while (text[i + runLength] === " ") { runLength += 1; } if (runLength >= MIN_SPACE_RUN_FOR_TEXT_S) { From 2151f15f9f546de44b143872683d1772f80b6acd Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:27:21 +0100 Subject: [PATCH 73/91] test(documents.js): pin ods scaffold's root tags and namespace/styles content None of content.xml/styles.xml/meta.xml's own root element tag was directly asserted -- every existing check reached into a child by tag name, which would still succeed even with an empty root tag. Also pins that CONTENT_NS_PREFIXES is genuinely spread into content.xml's attributes (xmlns:table specifically, distinct from the hand-declared xmlns:of already checked) and that styles.xml carries its own empty office:styles sibling. --- packages/documents.js/src/edit/ods/scaffold.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/documents.js/src/edit/ods/scaffold.test.ts b/packages/documents.js/src/edit/ods/scaffold.test.ts index 5bb7a41695..c2d4231334 100644 --- a/packages/documents.js/src/edit/ods/scaffold.test.ts +++ b/packages/documents.js/src/edit/ods/scaffold.test.ts @@ -89,9 +89,14 @@ describe("createEmptyOdsPackage", () => { it("content.xml declares the of: namespace (required for table:formula's OpenFormula grammar to recalculate on open) alongside version 1.3 and one empty, named default sheet", () => { const pkg = createEmptyOdsPackage(); const root = xmlRoot(pkg, "content.xml"); + expect(root.tag).toBe("office:document-content"); expect(attr(root, "xmlns:of")).toBe( "urn:oasis:names:tc:opendocument:xmlns:of:1.2", ); + // xmlns:table specifically (rather than only xmlns:of, hand-declared separately above): pins that CONTENT_NS_PREFIXES's own prefix list is actually spread into the element's attributes, not silently dropped. + expect(attr(root, "xmlns:table")).toBe( + "urn:oasis:names:tc:opendocument:xmlns:table:1.0", + ); expect(attr(root, "office:version")).toBe("1.3"); const automaticStyles = elementChild(root, "office:automatic-styles"); @@ -119,7 +124,10 @@ describe("createEmptyOdsPackage", () => { it("styles.xml declares version 1.3, a PAGE_SIZE_A4/2cm-margin page layout, and the Standard master page referencing it", () => { const pkg = createEmptyOdsPackage(); const root = xmlRoot(pkg, "styles.xml"); + expect(root.tag).toBe("office:document-styles"); expect(attr(root, "office:version")).toBe("1.3"); + // An empty office:styles sibling, distinct from office:automatic-styles below -- odf.js's own consumers expect this element to exist even when this scaffold defines no named paragraph/cell styles in it. + expect(elementChild(root, "office:styles")).toBeDefined(); const automaticStyles = elementChild(root, "office:automatic-styles"); const pageLayout = elementChild(automaticStyles, "style:page-layout"); @@ -141,6 +149,7 @@ describe("createEmptyOdsPackage", () => { it("meta.xml has an empty office:meta when no metadata is given", () => { const pkg = createEmptyOdsPackage(); const root = xmlRoot(pkg, "meta.xml"); + expect(root.tag).toBe("office:document-meta"); expect(attr(root, "office:version")).toBe("1.3"); const meta = elementChild(root, "office:meta"); expect(meta.children).toHaveLength(0); From 8fed334b61322021327afa6f6ff4d5858deaf62d Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:28:33 +0100 Subject: [PATCH 74/91] test(documents.js): pin markdownBlock's inline vs display formula markers MATH_INLINE_SOURCE, MATH_INLINE_FONT_MARKER, and MATH_BLOCK_STYLE_ID had no test exercising formulaParagraph at all: a formula whose provenance source matches the inline marker renders as a \( \) span, and any other source renders as a $$ display block instead. --- .../documents.js/src/markdown/write.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/documents.js/src/markdown/write.test.ts b/packages/documents.js/src/markdown/write.test.ts index 3727ade3a5..54baa2b237 100644 --- a/packages/documents.js/src/markdown/write.test.ts +++ b/packages/documents.js/src/markdown/write.test.ts @@ -3,10 +3,22 @@ import type { ContentBlock, ContentDocument } from "document-schema.js"; import { MarkdownUnsupportedDocumentKindError } from "markdown-codec"; import { describe, expect, it } from "vitest"; import { MarkdownUnbalancedConstructMarkersError } from "markdown-codec"; +import { latexToFormula } from "../latex/lower"; +import { buildFormulaBlock } from "../model/formula"; import { richMarkdownText } from "../test-support/markdown"; import { readMarkdownContent } from "./read"; import { buildMarkdownText } from "./write"; +const FORMULA_FRAME = { xPt: 0, yPt: 0, widthPt: 0, heightPt: 22 }; + +function formulaBlock(latex: string, source: string): ContentBlock { + return buildFormulaBlock( + latexToFormula(latex, { source }).formula, + FORMULA_FRAME, + "test:formula", + ); +} + const CONSTRUCT_START: ContentBlock = { kind: "constructStart", descriptor: { kind: "anchor", anchorType: "bookmark", name: "b1" }, @@ -60,6 +72,18 @@ describe("buildMarkdownText", () => { expect(buildMarkdownText(document)).not.toContain("<!-- page break -->"); }); + it("renders a formula whose provenance source is markdown:math-inline as an inline \\( \\) span", () => { + const document = markerDocument([ + formulaBlock("x+1", "markdown:math-inline"), + ]); + expect(buildMarkdownText(document)).toBe("\\(x+1\\)"); + }); + + it("renders a formula from any other provenance source as a $$ display block, not the inline span", () => { + const document = markerDocument([formulaBlock("x+1", "docx:equation")]); + expect(buildMarkdownText(document)).toBe("$$\nx+1\n$$"); + }); + it("throws MarkdownUnsupportedDocumentKindError for a non-wordprocessing ContentDocument", () => { const presentation: ContentDocument = { kind: "presentation", From c52131e5e27f728c5f861ef8498145e58d92faed Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:29:46 +0100 Subject: [PATCH 75/91] test(documents.js): pin core-properties' declaration and xmlns attributes Neither the XML declaration node nor the four xmlns attributes on cp:coreProperties were directly asserted anywhere -- every existing check reached into a child by tag name or attribute, which would still succeed with an empty declaration/attributes object. --- .../src/opc/core-properties.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/documents.js/src/opc/core-properties.test.ts b/packages/documents.js/src/opc/core-properties.test.ts index a40d12d0a3..ddd2a19561 100644 --- a/packages/documents.js/src/opc/core-properties.test.ts +++ b/packages/documents.js/src/opc/core-properties.test.ts @@ -19,6 +19,23 @@ function emptyPackage(): Package { } describe("addCoreProperties", () => { + it("starts docProps/core.xml with the standard version/encoding/standalone declaration", () => { + const pkg = emptyPackage(); + addCoreProperties(pkg, {}); + const part = pkg.parts[CORE_PROPERTIES_PATH]; + 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" }, + { name: "standalone", value: "yes" }, + ], + }); + }); + it("writes every supplied field to its real OOXML core-properties element", () => { const pkg = emptyPackage(); const metadata: LayoutMetadata = { @@ -34,6 +51,18 @@ describe("addCoreProperties", () => { const root = rootElement(pkg.parts[CORE_PROPERTIES_PATH]); expect(root).toBeDefined(); expect(root?.tag).toBe("cp:coreProperties"); + expect(root === undefined ? undefined : attr(root, "xmlns:cp")).toBe( + "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + ); + expect(root === undefined ? undefined : attr(root, "xmlns:dc")).toBe( + "http://purl.org/dc/elements/1.1/", + ); + expect(root === undefined ? undefined : attr(root, "xmlns:dcterms")).toBe( + "http://purl.org/dc/terms/", + ); + expect(root === undefined ? undefined : attr(root, "xmlns:xsi")).toBe( + "http://www.w3.org/2001/XMLSchema-instance", + ); const title = root === undefined ? undefined : childrenWithTag(root, "dc:title")[0]; From a623958387e93b29d6b26f602c64537df6125611 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:51:12 +0100 Subject: [PATCH 76/91] fix(documents.js): use one combined bound in buildRelativeTarget's scan Two independently-ANDed length checks (common < fromDirs.length && common < toDirs.length) meant relaxing either one in isolation never changed the loop's outcome: the sibling, still-correct check kept stopping the loop at the same common, and wherever the two paths' directories genuinely differ, fromDirs[common] === toDirs[common] already fails once one side runs out (a real segment can never equal undefined). That made both length checks, their operators, and the && joining them all permanently equivalent mutation targets. A single combined bound has no sibling clause left to mask a boundary mutation, observable via two identically-deep directory chains. Also drops relsPathFor's own redundant lastSlash === -1 guard for the filename split, the same "slicing from -1 + 1 already returns the whole string" equivalence already fixed in src/mathml/nodes.ts's localName. --- packages/documents.js/src/opc/paths.test.ts | 10 ++++++++++ packages/documents.js/src/opc/paths.ts | 11 +++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/documents.js/src/opc/paths.test.ts b/packages/documents.js/src/opc/paths.test.ts index 6fc5742e95..a95ea448d7 100644 --- a/packages/documents.js/src/opc/paths.test.ts +++ b/packages/documents.js/src/opc/paths.test.ts @@ -14,6 +14,11 @@ describe("relsPathFor", () => { it("handles a root-level part with no directory", () => { expect(relsPathFor("document.xml")).toBe("/_rels/document.xml.rels"); }); + + // A single-character directory puts the slash at index 1 -- deliberately exercising a genuinely different lastSlash value from the -1/no-slash case above, so a mutation swapping which index the filename split point compares against would extract the whole path as the filename rather than just the part after the slash. + it("splits correctly when the directory is a single character", () => { + expect(relsPathFor("a/file.xml")).toBe("a/_rels/file.xml.rels"); + }); }); describe("buildRelativeTarget", () => { @@ -46,4 +51,9 @@ describe("buildRelativeTarget", () => { "word/document.xml", ); }); + + // Both parts share the identical, fully-matching directory chain ("a/b"), so the common-prefix scan runs all the way to that shared length on both sides at once -- the one case where the two length bounds stop protecting each other (see buildRelativeTarget's own comment on combinedLimit). + it("targets a sibling part in a two-level-deep identical directory chain", () => { + expect(buildRelativeTarget("a/b/x.xml", "a/b/y.xml")).toBe("y.xml"); + }); }); diff --git a/packages/documents.js/src/opc/paths.ts b/packages/documents.js/src/opc/paths.ts index a70965b149..01eb2d8409 100644 --- a/packages/documents.js/src/opc/paths.ts +++ b/packages/documents.js/src/opc/paths.ts @@ -2,7 +2,8 @@ export function relsPathFor(partPath: string): string { const lastSlash = partPath.lastIndexOf("/"); const dir = lastSlash === -1 ? "" : partPath.slice(0, lastSlash); - const fileName = lastSlash === -1 ? partPath : partPath.slice(lastSlash + 1); + // No lastSlash === -1 ternary guard here (unlike dir above): slicing from lastSlash + 1 already returns the whole path when there is no slash at all (lastIndexOf yields -1, so the slice starts at 0), making a guard for that case redundant -- see src/mathml/nodes.ts's localName for the identical pattern and reasoning. + const fileName = partPath.slice(lastSlash + 1); return `${dir}/_rels/${fileName}.rels`; } @@ -24,11 +25,9 @@ export function buildRelativeTarget( const toFileName = toPartPath.slice(toPartPath.lastIndexOf("/") + 1); let common = 0; - while ( - common < fromDirs.length && - common < toDirs.length && - fromDirs[common] === toDirs[common] - ) { + // A single combined bound, not two independently-ANDed length checks: with two separate `common < fromDirs.length && common < toDirs.length` clauses, relaxing (or dropping) either one in isolation never changes the loop's outcome on its own -- the OTHER, still-correct clause independently stops the loop at the same `common`, and wherever the two arrays' lengths genuinely differ, the fromDirs[common] === toDirs[common] comparison itself already fails once one side runs out (a real segment can never equal undefined). That made every mutation on either individual clause (and on the && joining them) permanently equivalent. A single combinedLimit bound has no sibling clause left to compensate, so a boundary mutation on it is only masked when the two paths share every directory segment all the way to a shared length -- covered by the identical-directories case below. + const combinedLimit = Math.min(fromDirs.length, toDirs.length); + while (common < combinedLimit && fromDirs[common] === toDirs[common]) { common++; } From 2b13b2cd4d8a82a7d0df04586ee97908e4be0e76 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 14:51:30 +0100 Subject: [PATCH 77/91] test(documents.js): cover MarkdownEditor's guards, defaults, and geometry passthrough paragraphs() had no test proving it excludes non-paragraph blocks (a table specifically) from the fixture it already round-trips. startList's own task default had no case exercising the unset (false) path, only the explicit task: true one. Neither constructor guard (non-wordprocessing kind, an empty sections array) nor the pageSize/margins/clock passthrough into createMarkdownEditor had any coverage at all. --- .../src/edit/markdown/editor.test.ts | 59 ++++++++++++++++++- .../src/edit/markdown/list.test.ts | 2 + 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/edit/markdown/editor.test.ts b/packages/documents.js/src/edit/markdown/editor.test.ts index fdf871201f..2d89a255b5 100644 --- a/packages/documents.js/src/edit/markdown/editor.test.ts +++ b/packages/documents.js/src/edit/markdown/editor.test.ts @@ -1,8 +1,14 @@ +import type { ContentDocument } from "document-schema.js"; import { MarkdownDiagnosticCodes } from "markdown-codec"; import { describe, expect, it } from "vitest"; import { readMarkdownContent } from "../../markdown/read"; import { buildMarkdownText } from "../../markdown/write"; -import { createMarkdownEditor, openMarkdown } from "./editor"; +import { createMarkdownEditor, MarkdownEditor, openMarkdown } from "./editor"; + +// MarkdownEditor deliberately exposes no pageSize/margins getter of its own (mirroring every other live editor's constructor-only intake of these fields) -- reaching the private `document` field this way is the only way to prove createMarkdownEditor's own pageSize/margins options genuinely reach readMarkdownContent, since neither field is observable through toMarkdownText() (plain CommonMark/GFM text carries no page-geometry construct at all). +function underlyingDocument(editor: MarkdownEditor): ContentDocument { + return (editor as unknown as { document: ContentDocument }).document; +} describe("createMarkdownEditor", () => { it("produces a document whose toMarkdownText() matches what an empty readMarkdownContent round trip produces", () => { @@ -10,6 +16,54 @@ describe("createMarkdownEditor", () => { const expected = buildMarkdownText(readMarkdownContent("")); expect(editor.toMarkdownText()).toBe(expected); }); + + it("passes pageSize and margins through to the underlying document rather than the default geometry", () => { + const pageSize = { widthPt: 300, heightPt: 400 }; + const margins = { topPt: 10, rightPt: 20, bottomPt: 30, leftPt: 40 }; + const editor = createMarkdownEditor({ pageSize, margins }); + const document = underlyingDocument(editor); + if (document.kind !== "wordprocessing") { + throw new Error("expected a wordprocessing ContentDocument"); + } + expect(document.sections[0]?.pageSize).toEqual(pageSize); + expect(document.sections[0]?.margins).toEqual(margins); + }); + + it("defaults created/modified timestamps from systemClock when no clock is given", () => { + const before = Date.now(); + const editor = createMarkdownEditor(); + const after = Date.now(); + const document = underlyingDocument(editor); + const createdIso = document.metadata.createdIso; + expect(createdIso).toBeDefined(); + const createdMs = new Date(createdIso ?? "").getTime(); + expect(createdMs).toBeGreaterThanOrEqual(before); + expect(createdMs).toBeLessThanOrEqual(after); + }); +}); + +describe("MarkdownEditor constructor", () => { + it("throws for a non-wordprocessing ContentDocument", () => { + const presentation: ContentDocument = { + kind: "presentation", + metadata: {}, + slides: [], + }; + expect(() => new MarkdownEditor(presentation)).toThrow( + 'MarkdownEditor requires a wordprocessing ContentDocument, got "presentation"', + ); + }); + + it("throws for a wordprocessing document with no sections", () => { + const empty: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [], + }; + expect(() => new MarkdownEditor(empty)).toThrow( + "markdown ContentDocument carries no sections", + ); + }); }); describe("openMarkdown / toMarkdownText round trip", () => { @@ -29,6 +83,9 @@ describe("openMarkdown / toMarkdownText round trip", () => { it("round-trips headings, bold/italic/strike, a hyperlink, a bullet list, and a table", () => { const editor = openMarkdown(fixture); + // Exactly four paragraph-kind blocks (heading, prose, two list items), NOT five: the fixture's own table must not be surfaced through paragraphs() alongside them. + expect(editor.paragraphs()).toHaveLength(4); + const [heading, prose] = editor.paragraphs(); expect(heading?.headingLevel).toBe(1); expect(heading?.text).toBe("Title"); diff --git a/packages/documents.js/src/edit/markdown/list.test.ts b/packages/documents.js/src/edit/markdown/list.test.ts index 7289bef1fa..1a12877ff3 100644 --- a/packages/documents.js/src/edit/markdown/list.test.ts +++ b/packages/documents.js/src/edit/markdown/list.test.ts @@ -36,6 +36,8 @@ describe("MarkdownList.appendItem", () => { const first = editor.body.startList({ type: "bullet" }); const second = editor.body.startList({ type: "bullet" }); expect(first.numId).not.toBe(second.numId); + // No `task` field supplied at all: the minted numId's own +task suffix (markdown-codec's own grammar, see list-id.ts) must be absent, not defaulted on. + expect(first.numId).not.toContain("+task"); const itemA = first.appendItem(0, { text: "A" }); const itemB = first.appendItem(0, { text: "B" }); expect(itemA.list?.numId).toBe(first.numId); From 22006ee2b19565d0e76d336384cd27d2ff09bd16 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 15:11:41 +0100 Subject: [PATCH 78/91] test(documents.js): cover from-pdf's signal/images forwarding and outline stamping readDocumentMetadata and readNativeDocumentTree had no coverage at all for abort-signal forwarding on either their pdf or non-pdf dispatch branch, nor for the markdown images resolver readNativeDocumentTree also forwards. The stampPdfPackageTables call had no test able to distinguish it running from being skipped either: the existing pdf fixture (docxToPdf of a plain docx) carries no outline or destinations of its own, so stamping is a no-op either way for that input. Building a small PDF directly through pdf-codec's own writePdf with a bare outline entry, bypassing every documents.js writer, gives the call something to actually stamp. --- .../documents.js/src/convert/from-pdf.test.ts | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/convert/from-pdf.test.ts b/packages/documents.js/src/convert/from-pdf.test.ts index eb766b4d37..18b97f38b8 100644 --- a/packages/documents.js/src/convert/from-pdf.test.ts +++ b/packages/documents.js/src/convert/from-pdf.test.ts @@ -12,7 +12,12 @@ import { type Package as OoxmlPackage, } from "ooxml.js"; import { el, txt } from "ooxml.js/xml/fragment"; -import { readPdf } from "pdf-codec"; +import { + LAYOUT_FORMAT_VERSION, + readPdf, + type LayoutDocument, + writePdf, +} from "pdf-codec"; import { describe, expect, it } from "vitest"; import { docxToPdf, odsToXlsx } from "./convert"; import { readCsvContent } from "../csv/read"; @@ -142,6 +147,34 @@ describe("readDocumentMetadata", () => { expect(metadata.modifiedIso).toBe("2024-02-03T04:05:06Z"); expect(metadata.producer).toBeUndefined(); }); + + it("pdf: forwards the abort signal to readDocumentLayout, which checks it before parsing", () => { + const controller = new AbortController(); + controller.abort(); + const bytes = docxToPdf(minimalDocxBytes()); + let caught: unknown; + try { + readDocumentMetadata("pdf", bytes, { signal: controller.signal }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe("AbortError"); + }); + + it("markdown: forwards the abort signal to the underlying CONTENT_READERS entry, which checks it before parsing", () => { + const controller = new AbortController(); + controller.abort(); + const bytes = encodeMarkdownText("hi"); + let caught: unknown; + try { + readDocumentMetadata("markdown", bytes, { signal: controller.signal }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe("AbortError"); + }); }); // Each case proves readNativeDocumentTree(format, bytes) dispatches to exactly the same underlying reader every ergonomic conversion in this package already uses for that format, decomposed into tree form via assembleTree with no bridging, no cross-variant transform, and (for every format but pdf) no layout pass at all -- unlike ConversionResult.package/onDocument, which report whatever hop actually produced a REQUESTED conversion's output (see this file's own from-pdf.ts module comment, and ExaDev/documents.js#823, for why that can be a different, lossy shape). @@ -246,6 +279,64 @@ describe("readNativeDocumentTree", () => { expect(captured.pages).toBeDefined(); }); + // A PDF built directly through pdf-codec's own writePdf (bypassing every documents.js writer) with a bare, destination-less outline entry -- a minimalDocxBytes()-derived pdf carries no outline at all, so that fixture alone cannot distinguish readNativeDocumentTree actually calling stampPdfPackageTables from silently skipping it. This one can: stampPdfPackageTables only ever populates pkg.destinations when layout.outline (or layout.destinations) is non-empty, so its own presence here proves the call happened. + it("pdf: stamps the outline table onto the reported tree, not just the pages", () => { + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [{ widthPt: 612, heightPt: 792, items: [] }], + images: {}, + outline: [{ title: "Chapter 1", children: [] }], + }; + const pdfBytes = writePdf(doc, { compress: false }); + const tree = readNativeDocumentTree("pdf", pdfBytes); + expect(tree.destinations?.["outline-1"]).toEqual({ + kind: "outline", + title: "Chapter 1", + }); + }); + + it("pdf: forwards the abort signal to readPdf, which checks it before parsing", () => { + const controller = new AbortController(); + controller.abort(); + const bytes = docxToPdf(minimalDocxBytes()); + let caught: unknown; + try { + readNativeDocumentTree("pdf", bytes, { signal: controller.signal }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe("AbortError"); + }); + + it("markdown: forwards the abort signal to the underlying CONTENT_READERS entry, which checks it before parsing", () => { + const controller = new AbortController(); + controller.abort(); + const bytes = encodeMarkdownText("hi"); + let caught: unknown; + try { + readNativeDocumentTree("markdown", bytes, { signal: controller.signal }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe("AbortError"); + }); + + it("markdown: forwards the images resolver to the underlying CONTENT_READERS entry", () => { + const resolver = (): undefined => undefined; + let called: readonly [string, unknown] | undefined; + const bytes = encodeMarkdownText("![alt](img.png)"); + readNativeDocumentTree("markdown", bytes, { + images: (src, context) => { + called = [src, context]; + resolver(); + }, + }); + expect(called?.[0]).toBe("img.png"); + }); + // The regression test for ExaDev/documents.js#823's Ask 1: a real xlsx workbook with cell values, a formula, a merged range, and a comment -- exactly the data the issue reports the OLD --dump-package path losing entirely once a cross-variant bridge (here, xlsx -> markdown, which shares no ContentDocument variant and so composes through a pdf pivot) is in the picture. buildXlsxPackageFromContent/OdsSheet have no write path for a comment (see ooxml.js's own documented cell-comment asymmetry, "read but do not write"), so the comment part is spliced onto the real xlsx package by hand, mirroring ooxml.js's own comments.test.ts synthetic-package convention -- every other fact (cells, the merge, the formula) comes from the real xlsx writer, unedited. const REL_COMMENTS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; From b2f254ff512ff55f05514d233ad389ee5915ea22 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Mon, 14 Sep 2026 15:12:12 +0100 Subject: [PATCH 79/91] test(documents.js): cover local converter's odf route guard and font-message shapes The odf -> pdf special case checked source.format === "odf" && targetFormat === "pdf", but nothing exercised the targetFormat half: an odf source to any OTHER target had no test, so a request that should reject as unsupported (odf has no composition-engine route except to pdf) could have silently succeeded through odfToPdf instead. Also adds direct coverage for the char/substituted diagnostic's exact message text, the bold/italic weight suffixes in a font-substitution message, odfToPdf's own onDocument/signal forwarding, and that the odf->pdf route's options object genuinely reaches the call. --- .../documents.js/src/convert/local.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/documents.js/src/convert/local.test.ts b/packages/documents.js/src/convert/local.test.ts index 320d5961ae..0a2972d0ed 100644 --- a/packages/documents.js/src/convert/local.test.ts +++ b/packages/documents.js/src/convert/local.test.ts @@ -420,6 +420,41 @@ describe("createLocalDocumentConverter: convert", () => { ); expect(result.document.format).toBe("pdf"); expect(pdfHeader(result.document.bytes)).toBe("%PDF-"); + // onDocument must actually reach odfToPdf -- this is the pair's own onDocument-forwarding contract (see this file's own top comment on the special-case odf -> pdf route), and the surest proof options genuinely reach the call rather than an empty object. + expect(result.package?.kind).toBe("formula"); + }); + + it("pdf: odf source forwards the abort signal to odfToPdf, which checks it before rendering", () => { + const converter = createLocalDocumentConverter(); + const controller = new AbortController(); + controller.abort(); + let caught: unknown; + try { + void converter.convert( + { + source: { format: "odf", bytes: odfFormulaBytes(FRACTION_FORMULA) }, + targetFormat: "pdf", + }, + { signal: controller.signal }, + ); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DOMException); + expect((caught as DOMException).name).toBe("AbortError"); + }); + + // odf's own special-case route only ever applies to a pdf target -- every other odf conversion goes through the ordinary composition pathfinder, which reports odf unsupported as a source for anything but pdf (see composition.ts's own module doc). A source.format === "odf" check alone, without also requiring targetFormat === "pdf", would wrongly route this non-pdf request through odfToPdf and resolve successfully with mislabelled PDF bytes instead of rejecting. + it("rejects odf as a source for a non-pdf target, rather than silently routing it through odfToPdf", async () => { + const converter = createLocalDocumentConverter(); + const promise = converter.convert( + { + source: { format: "odf", bytes: odfFormulaBytes(FRACTION_FORMULA) }, + targetFormat: "markdown", + }, + { signal: new AbortController().signal }, + ); + await expect(promise).rejects.toBeInstanceOf(UnsupportedConversionError); }); it("converts xlsx to pdf", async () => { @@ -666,6 +701,13 @@ describe("createLocalDocumentConverter: convert", () => { expect(result.diagnostics.some((d) => d.code === "char/substituted")).toBe( true, ); + expect(result.diagnostics).toContainEqual({ + severity: "info", + code: "char/substituted", + message: + '"中" is not representable in a standard-14 font; substituted "?"', + pageIndex: 0, + }); }); it("collects PDF read diagnostics on the pdf->docx path", async () => { @@ -746,6 +788,30 @@ describe("createLocalDocumentConverter: fonts", () => { }); }); + it("names the requested weight and style in the substitution message for a bold italic run", async () => { + const editor = createDocx(); + editor.body.appendParagraph().appendRun({ + text: "Bold italic Calibri", + bold: true, + italic: true, + fontFamily: "Calibri", + }); + const converter = createLocalDocumentConverter(); + const result = await converter.convert( + { + source: { format: "docx", bytes: editor.toBytes() }, + targetFormat: "pdf", + }, + { signal: new AbortController().signal }, + ); + expect(result.diagnostics).toContainEqual({ + severity: "info", + code: "font/substituted", + message: + '"Calibri bold italic" is not available; substituted the metric-compatible "carlito"', + }); + }); + it("forwards the structured substitution to the caller own callback as well", async () => { const converter = createLocalDocumentConverter(); const substitutions: FontSubstitution[] = []; From 9dcf7621ea72a81aa8c45011e4e495a23c2ec247 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:02:47 +0100 Subject: [PATCH 80/91] test(documents.js): distinguish escaping a regex-special extension char from stripping it nextPictureIndex's escapeRegExp replaces a matched special character with an escaped copy ("\\$&"), but the existing regex-special-character test used an extension/filename pair that stayed a non-match whether the character was escaped or dropped entirely, so a replacement of "" survived. Adding a case where the escaped form matches but the stripped form does not makes the escaping itself observable. --- packages/documents.js/src/odf-package/media.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/documents.js/src/odf-package/media.test.ts b/packages/documents.js/src/odf-package/media.test.ts index 01630e03e3..c4b2ea012d 100644 --- a/packages/documents.js/src/odf-package/media.test.ts +++ b/packages/documents.js/src/odf-package/media.test.ts @@ -118,6 +118,14 @@ describe("nextPictureIndex", () => { expect(nextPictureIndex(pkg, "p.g")).toBe(1); }); + // A "." in the extension must be escaped to a literal dot in the built pattern, not stripped out of it -- a part whose own name is missing the dot altogether ("image1.pg") must not match an extension of "p.g", which is exactly what stripping the special character instead of escaping it would let through. + it("does not let a regex-special character in the extension be silently dropped from the match", () => { + const pkg: Package = { + parts: { "Pictures/image1.pg": { kind: "binary", base64: "" } }, + }; + expect(nextPictureIndex(pkg, "p.g")).toBe(1); + }); + // "Pictures0image5.png" is 9 characters ("Pictures0") ahead of a slice that -- once the leading "Pictures/" (also 9 characters) is stripped off a real Pictures/ path -- looks exactly like "image5.png". A path-prefix check that only LOOKED at whether the loop should skip a part, without actually gating the pattern match against it, would still slice this non-Pictures path at the same fixed offset and misread it as Pictures/image5.png -- this path is deliberately crafted so that coincidence is exercised, unlike a plain "Other/imageN.ext" path (whose own 9-character-in slice does not happen to spell a valid image filename). it("ignores a same-named file outside Pictures/ even when slicing its path at the Pictures/ prefix length would coincidentally spell a valid image filename", () => { const pkg: Package = { From 5d2cef69451e8cd00a72b3a48928fd11149c23fd Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:02:57 +0100 Subject: [PATCH 81/91] test(documents.js): assert ensureContentTypeOverride's scan checks tag, not just PartName The existing-override scan matched on PartName alone in its covering tests, so relaxing the tag check to always-true never changed the outcome. Adding a sibling element that carries a matching PartName but isn't an Override makes the tag comparison itself load-bearing: without it, the scan would mistake the decoy for a real entry and add nothing. --- packages/documents.js/src/opc/content-types.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/documents.js/src/opc/content-types.test.ts b/packages/documents.js/src/opc/content-types.test.ts index 69e90832e7..5d6ca28398 100644 --- a/packages/documents.js/src/opc/content-types.test.ts +++ b/packages/documents.js/src/opc/content-types.test.ts @@ -1,6 +1,7 @@ import type { Package, XmlElement } from "ooxml.js"; import { attr, decodePackage, encodePackage } from "ooxml.js"; import { describe, expect, it } from "vitest"; +import { el } from "../xml/fragment"; import type { ElementCursor } from "../xml/query"; import { findChildElements } from "../xml/query"; import { @@ -109,6 +110,16 @@ describe("ensureContentTypeOverride", () => { ensureContentTypeOverride(pkg, "word/document.xml", "application/xml"); expect(findChildElements(rootChildren(pkg), "Override")).toHaveLength(1); }); + + // A sibling element that merely happens to carry a matching PartName attribute must not be mistaken for an existing Override -- the scan has to check the element's own tag, not just its PartName, or a same-named non-Override child would suppress the real Override this call is meant to add. No real Override exists yet, so a scan that matched on PartName alone would wrongly conclude one is already present and add nothing. + it("does not treat a non-Override element with a matching PartName as an existing entry", () => { + const pkg = emptyPackage(); + ensureDefaultContentType(pkg, "png", "image/png"); + const root = rootChildren(pkg); + root.push(el("NotAnOverride", { PartName: "/word/document.xml" })); + ensureContentTypeOverride(pkg, "word/document.xml", "application/xml"); + expect(findChildElements(rootChildren(pkg), "Override")).toHaveLength(1); + }); }); describe("round-trip through ooxml.js encodePackage/decodePackage", () => { From d4f5111079c1f4435a093fe946e3eb80d351ef7b Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:03:05 +0100 Subject: [PATCH 82/91] fix(documents.js): drop buildRelativeTarget's redundant explicit loop bound The common-prefix scan bounded itself with Math.min(fromDirs.length, toDirs.length), but that bound is provably redundant: the instant the scan passes the end of the shorter array, indexing it yields undefined, which can never strictly equal a real path segment, so the comparison already stops the loop there on its own. That made a Math.min/Math.max swap on the bound an equivalent mutation no test could ever kill. Relying on the undefined comparison directly removes the mutation opportunity rather than leaving it unkillable. --- packages/documents.js/src/opc/paths.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/documents.js/src/opc/paths.ts b/packages/documents.js/src/opc/paths.ts index 01eb2d8409..b12a0d5c3e 100644 --- a/packages/documents.js/src/opc/paths.ts +++ b/packages/documents.js/src/opc/paths.ts @@ -25,9 +25,11 @@ export function buildRelativeTarget( const toFileName = toPartPath.slice(toPartPath.lastIndexOf("/") + 1); let common = 0; - // A single combined bound, not two independently-ANDed length checks: with two separate `common < fromDirs.length && common < toDirs.length` clauses, relaxing (or dropping) either one in isolation never changes the loop's outcome on its own -- the OTHER, still-correct clause independently stops the loop at the same `common`, and wherever the two arrays' lengths genuinely differ, the fromDirs[common] === toDirs[common] comparison itself already fails once one side runs out (a real segment can never equal undefined). That made every mutation on either individual clause (and on the && joining them) permanently equivalent. A single combinedLimit bound has no sibling clause left to compensate, so a boundary mutation on it is only masked when the two paths share every directory segment all the way to a shared length -- covered by the identical-directories case below. - const combinedLimit = Math.min(fromDirs.length, toDirs.length); - while (common < combinedLimit && fromDirs[common] === toDirs[common]) { + // No explicit length bound at all -- once `common` reaches the end of the shorter array, indexing it yields `undefined`, which can never strictly equal a real path segment, so the loop already stops there on its own. An explicit bound (either two independently-ANDed length checks, or a single Math.min/Math.max of the two) is provably redundant for the same reason and, worse, is an equivalent mutant no test can ever kill: every mutation on such a bound is masked by the fromDirs[common] === toDirs[common] comparison already failing the instant one side runs out. Dropping the bound removes the mutation opportunity outright rather than leaving it unkillable. + while ( + fromDirs[common] !== undefined && + fromDirs[common] === toDirs[common] + ) { common++; } From ab43a69f3a2e82a65ec9ac3a70ba00c7e2801e8f Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:03:11 +0100 Subject: [PATCH 83/91] test(documents.js): assert insertInSchemaOrder places a same-rank sibling after, not before The prior same-rank test compared only the resulting tag sequence, which reads identically whether the new node lands before or after an existing sibling sharing its own tag -- so relaxing the rank comparison from strictly-greater to greater-or-equal never changed the assertion's outcome. Distinguishing the two elements by a marker attribute makes which physical node ended up first observable. --- packages/documents.js/src/xml/edit.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/documents.js/src/xml/edit.test.ts b/packages/documents.js/src/xml/edit.test.ts index 1e00369284..020a38e69b 100644 --- a/packages/documents.js/src/xml/edit.test.ts +++ b/packages/documents.js/src/xml/edit.test.ts @@ -1,4 +1,5 @@ import type { XmlElement, XmlNode } from "ooxml.js"; +import { attr } from "ooxml.js"; import { describe, expect, it } from "vitest"; import { directChildElement, @@ -139,11 +140,14 @@ describe("insertInSchemaOrder", () => { }); it("appends after a same-rank sibling rather than inserting before it", () => { - const parent = el("w:rPr", {}, [el("w:b")]); - insertInSchemaOrder(parent, el("w:b"), RPR_ORDER); + const existing = el("w:b", { id: "existing" }); + const inserted = el("w:b", { id: "inserted" }); + const parent = el("w:rPr", {}, [existing]); + insertInSchemaOrder(parent, inserted, RPR_ORDER); + // Same tag on both sides means the tag sequence alone reads identically either way an equal-rank sibling could be placed -- a distinguishing attribute on each element is what actually tells "appended after" apart from "inserted before". expect( - parent.children.map((c) => (c.type === "element" ? c.tag : c.type)), - ).toEqual(["w:b", "w:b"]); + parent.children.map((c) => (c.type === "element" ? attr(c, "id") : c)), + ).toEqual(["existing", "inserted"]); }); }); From 948dc96644b82e2eda32d7a507aaa0e6672eb212 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:03:19 +0100 Subject: [PATCH 84/91] fix(documents.js): drop encodeOdfText's equivalent past-the-end loop bound The main character-walking loop's i < text.length bound admits a mutation (i <= text.length) that is unobservable: charAt past a string's end already returns "", and appending "" never changes the accumulated literal buffer, so no test could ever kill it. Comparing i directly against text.length with !== keeps the identical behaviour (i only ever advances by positive steps that land exactly on an untouched index or on text.length itself) while making the loop's own termination condition a real mutation target: flipping !== to === inverts it outright. --- packages/documents.js/src/xml/odf-text.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/xml/odf-text.ts b/packages/documents.js/src/xml/odf-text.ts index 8e8688075e..872a17f9fd 100644 --- a/packages/documents.js/src/xml/odf-text.ts +++ b/packages/documents.js/src/xml/odf-text.ts @@ -27,7 +27,8 @@ export function encodeOdfText(text: string): XmlNode[] { }; let i = 0; - while (i < text.length) { + // i !== text.length, not i < text.length: every step below advances i by a positive integer that always lands exactly on an untouched index or on text.length itself (never past it -- the space-run lookahead's own loop is bounded the same way, see its comment), so the two conditions are behaviorally identical here. They are not equivalent as mutation targets, though: flipping a `<` to `<=` past the string's end is unobservable (an out-of-range charAt/index access yields ""/undefined either way, appending "" changes nothing), while flipping `!==` to `===` inverts the loop's own run condition entirely. + while (i !== text.length) { const ch = text.charAt(i); if (ch === " ") { let runLength = 1; From a71a48587a927a5a3d3c8223e069ae5739cc9274 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:03:27 +0100 Subject: [PATCH 85/91] test(documents.js): spy on readPdf and reconstructWordprocessing to assert their own arguments readNativeDocumentTree's pdf branch forwards signal (and, for readPdf alone, sink) into two separate calls, but reconstructWordprocessing's own independent signal check already throws AbortError for an aborted signal, so a black-box test asserting the outer function throws could not tell whether readPdf itself ever received the signal or sink at all -- both an intact and a stripped call to readPdf produced the identical outer throw. Spying on each call directly makes its own argument object observable. --- .../documents.js/src/convert/from-pdf.test.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/convert/from-pdf.test.ts b/packages/documents.js/src/convert/from-pdf.test.ts index 18b97f38b8..6949fd2d9e 100644 --- a/packages/documents.js/src/convert/from-pdf.test.ts +++ b/packages/documents.js/src/convert/from-pdf.test.ts @@ -18,7 +18,8 @@ import { type LayoutDocument, writePdf, } from "pdf-codec"; -import { describe, expect, it } from "vitest"; +import * as pdfCodecRead from "pdf-codec/read"; +import { describe, expect, it, vi } from "vitest"; import { docxToPdf, odsToXlsx } from "./convert"; import { readCsvContent } from "../csv/read"; import { decodeCsvText, encodeCsvText } from "../csv/text"; @@ -30,6 +31,7 @@ import { readOdgContent } from "../odf/odg/read"; import { readOdpContent } from "../odf/odp/read"; import { readOdsContent } from "../odf/ods/read"; import { readOdtContent } from "../odf/odt/read"; +import * as reconstructModule from "../layout/reconstruct"; import { readDocxContent } from "../ooxml/docx/read"; import { readPptxContent } from "../ooxml/pptx/read"; import { decodeDocumentPackage, encodeDocumentPackage } from "../package-codec"; @@ -296,6 +298,38 @@ describe("readNativeDocumentTree", () => { }); }); + // reconstructWordprocessing's own signal check independently throws AbortError for an already-aborted signal, so a test only asserting readNativeDocumentTree throws when aborted cannot tell whether readPdf itself genuinely received the signal (and sink) or was called with neither -- both produce the identical outer throw. Spying on the call is what makes the forwarding observable. + it("pdf: passes the given signal and sink through to readPdf itself, not just to reconstructWordprocessing", () => { + const pdfBytes = docxToPdf(minimalDocxBytes()); + const controller = new AbortController(); + const sink = (): void => {}; + const readPdfSpy = vi.spyOn(pdfCodecRead, "readPdf"); + readNativeDocumentTree("pdf", pdfBytes, { + signal: controller.signal, + sink, + }); + expect(readPdfSpy).toHaveBeenCalledWith(pdfBytes, { + signal: controller.signal, + sink, + }); + readPdfSpy.mockRestore(); + }); + + it("pdf: passes the given signal through to reconstructWordprocessing", () => { + const pdfBytes = docxToPdf(minimalDocxBytes()); + const controller = new AbortController(); + const reconstructSpy = vi.spyOn( + reconstructModule, + "reconstructWordprocessing", + ); + readNativeDocumentTree("pdf", pdfBytes, { signal: controller.signal }); + expect(reconstructSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ signal: controller.signal }), + ); + reconstructSpy.mockRestore(); + }); + it("pdf: forwards the abort signal to readPdf, which checks it before parsing", () => { const controller = new AbortController(); controller.abort(); From fe93a0720681925008aa0d10d00d5cb51131c100 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:03:34 +0100 Subject: [PATCH 86/91] test(documents.js): cover the missing-face font substitution message fontSubstitutionDiagnostic's else branch (the "missing-face" reason, reached when a caller-supplied family exists but not the exact bold/italic combination requested) had no test reaching it at all -- every existing substitution test exercised only "vendored-substitute". Requesting bold text while supplying just the family's regular face triggers the family-fallback path instead of the vendored table. --- .../documents.js/src/convert/local.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/documents.js/src/convert/local.test.ts b/packages/documents.js/src/convert/local.test.ts index 0a2972d0ed..3b0c1a1add 100644 --- a/packages/documents.js/src/convert/local.test.ts +++ b/packages/documents.js/src/convert/local.test.ts @@ -812,6 +812,40 @@ describe("createLocalDocumentConverter: fonts", () => { }); }); + // The "missing-face" reason (a caller-supplied family exists but not the exact bold/italic combination requested, so the family's regular face substitutes) reaches a message distinct from "vendored-substitute"'s -- "substituted another face of ..." rather than "substituted the metric-compatible ...". Requesting bold text while supplying only a regular caller face for the same family is what triggers it, rather than falling through to the vendored table. + it("names a caller-supplied family's own regular face, not the metric-compatible vendored table, when only the exact weight is missing", async () => { + const editor = createDocx(); + editor.body.appendParagraph().appendRun({ + text: "Bold Calibri", + bold: true, + fontFamily: "Calibri", + }); + const converter = createLocalDocumentConverter(); + const result = await converter.convert( + { + source: { format: "docx", bytes: editor.toBytes() }, + targetFormat: "pdf", + }, + { + signal: new AbortController().signal, + fonts: [ + { + family: "Calibri", + bold: false, + italic: false, + bytes: caladeaRegularBytes(), + }, + ], + }, + ); + expect(result.diagnostics).toContainEqual({ + severity: "info", + code: "font/substituted", + message: + '"Calibri bold" is not available; substituted another face of "Calibri"', + }); + }); + it("forwards the structured substitution to the caller own callback as well", async () => { const converter = createLocalDocumentConverter(); const substitutions: FontSubstitution[] = []; From 79c7d367b8b64cd5fd0d7b7c359e8a2fd6ea8ce5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:09:58 +0100 Subject: [PATCH 87/91] test(documents.js): assert odfFormulaBytes' own zip layout and annotation branch odfFormulaBytes' mimetype part name, its stored-uncompressed requirement, and the presence/absence of the StarMath annotation element were never independently verified: every consuming test only reads content.xml back through a real ODF/MathML reader, which never inspects the mimetype entry's name or compression method and would tolerate either annotation branch's exact text unnoticed. A direct test on the fixture's own raw zip bytes and content.xml is what makes both properties observable. --- .../documents.js/src/test-support/odf.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 packages/documents.js/src/test-support/odf.test.ts diff --git a/packages/documents.js/src/test-support/odf.test.ts b/packages/documents.js/src/test-support/odf.test.ts new file mode 100644 index 0000000000..a5d862b183 --- /dev/null +++ b/packages/documents.js/src/test-support/odf.test.ts @@ -0,0 +1,41 @@ +import { ODF_MEDIA_TYPES, unzipPackage } from "odf.js"; +import { describe, expect, it } from "vitest"; +import { odfFormulaBytes } from "./odf"; + +describe("odfFormulaBytes", () => { + it("writes the mimetype part first, stored uncompressed, with the real ODF formula media type", () => { + const bytes = odfFormulaBytes("<math:mi>x</math:mi>"); + // ZIP local file header: signature(4) version(2) flags(2) then the compression method at offset 8-9 -- 0 means stored (no DEFLATE), the requirement ODF's own mimetype part has. + expect(Array.from(bytes.subarray(0, 4))).toEqual([0x50, 0x4b, 0x03, 0x04]); + expect(Array.from(bytes.subarray(8, 10))).toEqual([0, 0]); + // Filename length at offset 26-27, the filename itself starting at offset 30. + const nameLength = bytes[26]! | (bytes[27]! << 8); + const name = new TextDecoder().decode(bytes.subarray(30, 30 + nameLength)); + expect(name).toBe("mimetype"); + + const parts = unzipPackage(bytes); + expect(new TextDecoder().decode(parts.mimetype)).toBe(ODF_MEDIA_TYPES.odf); + }); + + it("puts exactly the given MathML inner content into math:semantics, with no annotation and no stray text, when no StarMath fallback is given", () => { + const bytes = odfFormulaBytes("<math:mi>x</math:mi>"); + const contentXml = new TextDecoder().decode( + unzipPackage(bytes)["content.xml"], + ); + expect(contentXml).toContain( + "<math:semantics><math:mi>x</math:mi></math:semantics>", + ); + }); + + it("includes a StarMath annotation element when a fallback is given", () => { + const bytes = odfFormulaBytes("<math:mi>x</math:mi>", { + starMath: "x", + }); + const contentXml = new TextDecoder().decode( + unzipPackage(bytes)["content.xml"], + ); + expect(contentXml).toContain( + '<math:annotation encoding="StarMath 5.0">x</math:annotation>', + ); + }); +}); From 8a0746291fcbba1f03352844360e561a47c02b7b Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:11:31 +0100 Subject: [PATCH 88/91] test(documents.js): cover collectDrawingMlVectors' throw path and its own tag guard Neither branch had any direct coverage: nothing exercised an a:ln-bearing shape the production reader can't recognise (the throw this test-support oracle exists to surface), and every consuming test's fixtures only ever built shapes whose tag already matched spPrTag, so the tag comparison itself was never load-bearing in any covering test. --- .../src/test-support/drawingml-vector.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/documents.js/src/test-support/drawingml-vector.test.ts diff --git a/packages/documents.js/src/test-support/drawingml-vector.test.ts b/packages/documents.js/src/test-support/drawingml-vector.test.ts new file mode 100644 index 0000000000..6afd564f47 --- /dev/null +++ b/packages/documents.js/src/test-support/drawingml-vector.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { el } from "../xml/fragment"; +import { collectDrawingMlVectors } from "./drawingml-vector"; + +describe("collectDrawingMlVectors", () => { + it("throws a descriptive error for a shape carrying a:ln whose geometry the production reader does not recognise", () => { + const spPr = el("wps:spPr", {}, [el("a:ln")]); + const root = el("w:body", {}, [spPr]); + expect(() => collectDrawingMlVectors(root, "wps:spPr")).toThrow( + "unrecognised DrawingML vector shape: wps:spPr", + ); + }); + + it("does not treat an element with a matching a:ln child but a different tag as a vector shape", () => { + // If the tag comparison were dropped, this element -- carrying the same a:ln child a real spPrTag element would -- would be misread as a vector and either throw (unrecognised geometry) or be collected; the correct behaviour is to walk straight past it. + const decoy = el("not-a-spPr-tag", {}, [el("a:ln")]); + const root = el("w:body", {}, [decoy]); + expect(collectDrawingMlVectors(root, "wps:spPr")).toEqual([]); + }); +}); From 197a72767e923e08fea058df446e67ffc5b02596 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 13:19:45 +0100 Subject: [PATCH 89/91] fix(documents.js): replace rotationsOf's equivalent kind check with a property-presence guard rotationsOf's vector.kind === "line" comparison was a genuine equivalent- mutant trap: forcing that comparison to always-true still type-narrows on the original condition text, so accessing rotationDeg in that branch stays valid, and a line vector's own missing key reports undefined either way -- indistinguishable from the correct branch's own explicit undefined, so no test could ever kill it. A "rotationDeg" in vector check narrows identically for every real input but has no such loophole: an always-true mutation of it fails to compile outright (accessing rotationDeg on the now-unnarrowed union), leaving only an always-false mutation, which a real rotationDeg value does kill. withoutRotation's own analogous survivors were a toEqual gap rather than an equivalent mutant: spreading an explicit rotationDeg: undefined onto a line vector (which never carries that key at all) still toEqual's the untouched original, since toEqual treats an undefined-valued key as indistinguishable from an absent one. toStrictEqual does not. --- .../src/test-support/vectors.test.ts | 39 +++++++++++++++++++ .../documents.js/src/test-support/vectors.ts | 4 +- 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 packages/documents.js/src/test-support/vectors.test.ts diff --git a/packages/documents.js/src/test-support/vectors.test.ts b/packages/documents.js/src/test-support/vectors.test.ts new file mode 100644 index 0000000000..5d192db78b --- /dev/null +++ b/packages/documents.js/src/test-support/vectors.test.ts @@ -0,0 +1,39 @@ +import type { ContentVector } from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { rotationsOf, withoutRotation } from "./vectors"; + +const rect: ContentVector = { + kind: "rect", + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + rotationDeg: 30, +}; + +const line: ContentVector = { + kind: "line", + from: { xPt: 0, yPt: 0 }, + to: { xPt: 10, yPt: 10 }, + stroke: { widthPt: 1, color: { r: 0, g: 0, b: 0 } }, +}; + +describe("withoutRotation", () => { + it("strips rotationDeg from a non-line vector", () => { + expect(withoutRotation([rect])).toEqual([ + { ...rect, rotationDeg: undefined }, + ]); + }); + + it("leaves a line vector, which has no rotationDeg field at all, untouched -- not spread with an explicit rotationDeg: undefined key added", () => { + // toStrictEqual, not toEqual: toEqual treats an explicit `rotationDeg: undefined` key as indistinguishable from the key being absent altogether, which is exactly the difference this test needs to catch. + expect(withoutRotation([line])).toStrictEqual([line]); + }); +}); + +describe("rotationsOf", () => { + it("reports a non-line vector's own rotationDeg", () => { + expect(rotationsOf([rect])).toEqual([30]); + }); + + it("reports undefined for a line, positionally", () => { + expect(rotationsOf([rect, line])).toEqual([30, undefined]); + }); +}); diff --git a/packages/documents.js/src/test-support/vectors.ts b/packages/documents.js/src/test-support/vectors.ts index cf3b3f4e9f..ed59e7642a 100644 --- a/packages/documents.js/src/test-support/vectors.ts +++ b/packages/documents.js/src/test-support/vectors.ts @@ -80,11 +80,11 @@ export function withoutRotation( ); } -// The rotations withoutRotation drops, positionally. 'line' has no rotationDeg on ContentVectorSchema at all, so it always reports undefined here. +// The rotations withoutRotation drops, positionally. A property-presence check ("rotationDeg" in vector), not a vector.kind === "line" comparison: 'line' is the only variant lacking rotationDeg, so the two guards narrow identically -- but a kind comparison here is a genuine equivalent-mutant trap TypeScript itself cannot rescue: forcing that comparison's own condition to always-true still type-narrows on the ORIGINAL condition text, so `vector.rotationDeg` stays valid in the branch reached, and a 'line' object with no such key simply reports undefined either way, indistinguishable from the correct branch's own explicit undefined. The `in` check has no such loophole -- Stryker's own typescript-checker rejects an always-true mutation of it outright (accessing rotationDeg on the still-fully-widened union fails to compile), leaving only an always-false mutation, which a real rotationDeg value on a non-line vector does kill. export function rotationsOf( vectors: readonly ContentVector[], ): (number | undefined)[] { return vectors.map((vector) => - vector.kind === "line" ? undefined : vector.rotationDeg, + "rotationDeg" in vector ? vector.rotationDeg : undefined, ); } From ce253b74905f6bdd72e8dc979cf6ab2d299bc412 Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 19:14:27 +0100 Subject: [PATCH 90/91] test(documents.js): cover glyphOfSymbolText, mintedSymbolId, and SymbolResolver The LaTeX symbol table's own command-to-glyph lookup, id-minting scheme, and curated/minted resolver class had no direct test coverage at all -- only the prose scanner built on top of them was tested. Add per-command assertions for every entry in the glyph map, the plain-character passthrough and unmapped-command cases, and SymbolResolver's curated lookup, first-entry-wins duplicate handling, minting with reuse on repeat lookups, and first-mint ordering. --- .../documents.js/src/latex/symbols.test.ts | 125 +++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/documents.js/src/latex/symbols.test.ts b/packages/documents.js/src/latex/symbols.test.ts index ae731eccb0..aae823f904 100644 --- a/packages/documents.js/src/latex/symbols.test.ts +++ b/packages/documents.js/src/latex/symbols.test.ts @@ -2,7 +2,130 @@ import type { ContentDocument } from "document-schema.js"; import { describe, expect, it } from "vitest"; import type { LatexDiagnostic } from "./diagnostics"; -import { extractSymbolDefinitionsFromProse } from "./symbols"; +import { + extractSymbolDefinitionsFromProse, + glyphOfSymbolText, + mintedSymbolId, + SymbolResolver, +} from "./symbols"; + +// The full command -> glyph table glyphOfSymbolText resolves against, restated here (not imported -- COMMAND_GLYPHS is module-private) so every entry gets its own assertion pair and a mutated string literal anywhere in the table is caught by the one command that names it. +const COMMAND_GLYPHS: Readonly<Record<string, string>> = { + "\\alpha": "α", + "\\beta": "β", + "\\gamma": "γ", + "\\delta": "δ", + "\\epsilon": "ε", + "\\varepsilon": "ε", + "\\zeta": "ζ", + "\\eta": "η", + "\\theta": "θ", + "\\vartheta": "ϑ", + "\\iota": "ι", + "\\kappa": "κ", + "\\lambda": "λ", + "\\mu": "μ", + "\\nu": "ν", + "\\xi": "ξ", + "\\pi": "π", + "\\varpi": "ϖ", + "\\rho": "ρ", + "\\varrho": "ϱ", + "\\sigma": "σ", + "\\varsigma": "ς", + "\\tau": "τ", + "\\upsilon": "υ", + "\\phi": "φ", + "\\varphi": "φ", + "\\chi": "χ", + "\\psi": "ψ", + "\\omega": "ω", + "\\Gamma": "Γ", + "\\Delta": "Δ", + "\\Theta": "Θ", + "\\Lambda": "Λ", + "\\Xi": "Ξ", + "\\Pi": "Π", + "\\Sigma": "Σ", + "\\Upsilon": "Υ", + "\\Phi": "Φ", + "\\Psi": "Ψ", + "\\Omega": "Ω", + "\\infty": "∞", + "\\partial": "∂", + "\\nabla": "∇", + "\\ell": "ℓ", + "\\hbar": "ℏ", + "\\Re": "ℜ", + "\\Im": "ℑ", + "\\aleph": "ℵ", +}; + +describe("glyphOfSymbolText", () => { + it.each(Object.entries(COMMAND_GLYPHS))( + "resolves %s to its own written glyph", + (command, glyph) => { + expect(glyphOfSymbolText(command)).toBe(glyph); + }, + ); + + it("passes a plain (non-command) character through unchanged", () => { + expect(glyphOfSymbolText("x")).toBe("x"); + expect(glyphOfSymbolText("R")).toBe("R"); + }); + + it("returns undefined for a backslash command outside the map", () => { + expect(glyphOfSymbolText("\\notacommand")).toBeUndefined(); + }); +}); + +describe("mintedSymbolId", () => { + it("prefixes the glyph with the symbols: scheme", () => { + expect(mintedSymbolId("α")).toBe("symbols:α"); + expect(mintedSymbolId("x")).toBe("symbols:x"); + }); +}); + +describe("SymbolResolver", () => { + it("resolves a curated glyph to its curated id without minting", () => { + const resolver = new SymbolResolver([ + { glyph: "R", scope: "document", id: "quantities:resistance" }, + ]); + expect(resolver.isCurated("R")).toBe(true); + expect(resolver.resolve("R")).toBe("quantities:resistance"); + expect(resolver.mintedEntries()).toEqual([]); + }); + + it("takes the first entry for a glyph curated more than once", () => { + const resolver = new SymbolResolver([ + { glyph: "R", scope: "document", id: "first" }, + { glyph: "R", scope: "document", id: "second" }, + ]); + expect(resolver.resolve("R")).toBe("first"); + }); + + it("mints a fresh entry for an uncurated glyph and reuses it on repeat lookups", () => { + const resolver = new SymbolResolver([]); + expect(resolver.isCurated("x")).toBe(false); + const first = resolver.resolve("x"); + expect(first).toBe("symbols:x"); + expect(resolver.resolve("x")).toBe(first); + expect(resolver.mintedEntries()).toEqual([ + { glyph: "x", scope: "document", id: "symbols:x" }, + ]); + }); + + it("returns minted entries in first-mint order", () => { + const resolver = new SymbolResolver([]); + resolver.resolve("β"); + resolver.resolve("α"); + resolver.resolve("β"); + expect(resolver.mintedEntries().map((entry) => entry.glyph)).toEqual([ + "β", + "α", + ]); + }); +}); // The prose scanner's conservatism is the point (precision over recall): every case below pins a boundary the matcher must respect -- the two where/let forms it reads, and the shapes it declines rather than mis-seeding the table. From 11fbdbe1f5364c5b02f9bef808a74da637370a2f Mon Sep 17 00:00:00 2001 From: Joseph Mearman <joseph@mearman.co.uk> Date: Tue, 15 Sep 2026 19:28:25 +0100 Subject: [PATCH 91/91] fix(documents.js): resolve PptxSlide.remove's slide entry by relationship target remove() previously spliced the p:sld root element itself out of sldIdLst, but sldIdLst holds p:sldId entries referencing each slide by r:id, not the slide part's own root element -- so the splice never matched anything and the presentation kept a dangling reference to the removed slide's part. Resolve the presentation's own relationships and match each p:sldId's r:id target against this slide's part path instead, mirroring the same lookup PptxEditor.removeSlideAt already does by index, then delete the slide part itself from the package. Export PRESENTATION_PART_PATH from scaffold.ts as the single source of truth instead of duplicating the literal in editor.ts. --- packages/documents.js/src/edit/pptx/editor.ts | 2 +- .../documents.js/src/edit/pptx/scaffold.ts | 2 +- .../documents.js/src/edit/pptx/slide.test.ts | 67 ++++++++++++++++++- packages/documents.js/src/edit/pptx/slide.ts | 28 +++++++- 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/packages/documents.js/src/edit/pptx/editor.ts b/packages/documents.js/src/edit/pptx/editor.ts index cdf6fc969d..16e00eecfa 100644 --- a/packages/documents.js/src/edit/pptx/editor.ts +++ b/packages/documents.js/src/edit/pptx/editor.ts @@ -21,6 +21,7 @@ import { createEmptyPptxPackage, DML_NS, PML_NS, + PRESENTATION_PART_PATH, R_NS, SLIDE_LAYOUT_PART_PATH, SLIDE_LAYOUT_REL_TYPE, @@ -28,7 +29,6 @@ import { import type { SlideContext } from "./slide"; import { PptxSlide } from "./slide"; -const PRESENTATION_PART_PATH = "ppt/presentation.xml"; const MEDIA_DIR = "ppt/media"; const SLIDE_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"; diff --git a/packages/documents.js/src/edit/pptx/scaffold.ts b/packages/documents.js/src/edit/pptx/scaffold.ts index b196fbf040..0f611655b6 100644 --- a/packages/documents.js/src/edit/pptx/scaffold.ts +++ b/packages/documents.js/src/edit/pptx/scaffold.ts @@ -26,7 +26,7 @@ const DEFAULT_NOTES_HEIGHT_EMU = "9144000"; const SLIDE_MASTER_PART_PATH = "ppt/slideMasters/slideMaster1.xml"; export const SLIDE_LAYOUT_PART_PATH = "ppt/slideLayouts/slideLayout1.xml"; const THEME_PART_PATH = "ppt/theme/theme1.xml"; -const PRESENTATION_PART_PATH = "ppt/presentation.xml"; +export const PRESENTATION_PART_PATH = "ppt/presentation.xml"; const NOTES_MASTER_PART_PATH = "ppt/notesMasters/notesMaster1.xml"; const SLIDE_MASTER_CONTENT_TYPE = diff --git a/packages/documents.js/src/edit/pptx/slide.test.ts b/packages/documents.js/src/edit/pptx/slide.test.ts index d51d72b65c..80eb722d64 100644 --- a/packages/documents.js/src/edit/pptx/slide.test.ts +++ b/packages/documents.js/src/edit/pptx/slide.test.ts @@ -1,5 +1,5 @@ import type { Part, XmlElement } from "ooxml.js"; -import { rootElement } from "ooxml.js"; +import { resolveRelationships, rootElement } from "ooxml.js"; import { describe, expect, it } from "vitest"; import { createPptx, openPptx } from "./editor"; @@ -146,3 +146,68 @@ describe("PptxSlide.shapes / tables", () => { expect(reopenedSlide?.shapes()).toHaveLength(0); }); }); + +describe("PptxSlide.addVector", () => { + it("appends a vector primitive as its own shape, in paint order after an earlier addTextBox", () => { + const editor = createPptx(); + const slide = editor.addSlide(); + slide.addTextBox({ + frame: { xPt: 0, yPt: 0, widthPt: 50, heightPt: 20 }, + text: "Behind", + }); + const vectorShape = slide.addVector({ + kind: "rect", + frame: { xPt: 10, yPt: 10, widthPt: 30, heightPt: 30 }, + }); + + const shapes = slide.shapes(); + expect(shapes).toHaveLength(2); + expect(shapes[1]).toEqual(vectorShape); + expect(vectorShape.frame).toEqual({ + xPt: 10, + yPt: 10, + widthPt: 30, + heightPt: 30, + }); + }); +}); + +describe("PptxSlide.registerHyperlink", () => { + it("adds an External hyperlink relationship on the slide's own part and returns its r:id", () => { + const editor = createPptx(); + const slide = editor.addSlide(); + const rId = slide.registerHyperlink("https://example.com/"); + + const slidePartPath = Object.keys(editor.toPackage().parts).find((p) => + /^ppt\/slides\/slide\d+\.xml$/.test(p), + ); + if (slidePartPath === undefined) { + throw new Error("expected a ppt/slides/slideN.xml part"); + } + const rels = resolveRelationships(editor.toPackage(), slidePartPath); + const rel = rels.get(rId); + expect(rel).toEqual({ + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + target: "https://example.com/", + targetMode: "External", + }); + }); +}); + +describe("PptxSlide.remove", () => { + it("removes the slide from the presentation and throws on further use", () => { + const editor = createPptx(); + const first = editor.addSlide(); + first.addTextBox({ + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + text: "Keep", + }); + const second = editor.addSlide(); + + second.remove(); + + expect(editor.slides()).toHaveLength(1); + expect(editor.slides()[0]?.shapes()[0]?.text).toBe("Keep"); + expect(() => second.shapes()).toThrow(/removed/); + }); +}); diff --git a/packages/documents.js/src/edit/pptx/slide.ts b/packages/documents.js/src/edit/pptx/slide.ts index 08742b7c43..5f1a33614b 100644 --- a/packages/documents.js/src/edit/pptx/slide.ts +++ b/packages/documents.js/src/edit/pptx/slide.ts @@ -16,6 +16,7 @@ import { ensureNotesMaster, NOTES_MASTER_REL_TYPE, PML_NS, + PRESENTATION_PART_PATH, } from "./scaffold"; import { buildTextBoxShape, PptxShape } from "./shape"; import type { PptxTableInit } from "./table"; @@ -42,6 +43,15 @@ export interface SlideTableInit { readonly rotationDeg?: number; } +function attrValue(element: XmlElement, name: string): string | undefined { + for (const a of element.attributes) { + if (a.name === name) { + return a.value; + } + } + return undefined; +} + function directChild(parent: XmlElement, tag: string): XmlElement | undefined { for (const child of parent.children) { if (child.type === "element" && child.tag === tag) { @@ -288,8 +298,24 @@ export class PptxSlide { }); } + // Removes this slide from the presentation: the p:sldId entry in sldIdLst -- this.container -- references this slide's own part by r:id, not by the p:sld root element remove() previously (and wrongly) tried to splice out of that same array, so finding it means resolving each p:sldId's relationship and matching its target against this slide's own slidePartPath, exactly as PptxEditor.removeSlideAt does by index. remove(): void { - removeChild(this.container, this.live()); + const { pkg, slidePartPath } = this.context; + const presentationRels = resolveRelationships(pkg, PRESENTATION_PART_PATH); + for (const child of this.container) { + if (child.type !== "element" || child.tag !== "p:sldId") { + continue; + } + const rId = attrValue(child, "r:id"); + if (rId === undefined) { + continue; + } + if (presentationRels.get(rId)?.target === slidePartPath) { + removeChild(this.container, child); + break; + } + } + Reflect.deleteProperty(pkg.parts, slidePartPath); this.removed = true; } }