From 8324c1afdabc58b1d64f31806d79b046643ca5f0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 04:39:05 +0100 Subject: [PATCH 1/6] feat(ooxml.js): read and write the xlsx per-cell font through the font table ContentSheetCell gains its uniform font on both directions of the xlsx pair. The reader resolves each cell's xf through and diffs the entry its fontId names against the table's own entry 0, stating only genuine differences -- mirroring xls-codec's default-diffing policy for BIFF8's identical font-table-and-index mechanism, since xlsx gives a cell no way to say "no font", only an index, and entry 0 is what the format's default concretely means for a given file. The write side widens the cell-format interning tuple from (number format, decoration) to (number format, font, decoration): the writer's own single Calibri-11 font stays fixed at index 0 as the reserved scaffolding entry, one further is interned per distinct cell font, and a ContentFont normalising back to the entry-0 fields references index 0 and mints nothing -- an explicit bold:false from a file whose default was bold is a restatement of this writer's own not-bold default, not a new font. A fonted xf carries fontId plus applyFont, the identical flag discipline applyFill and applyBorder already draw. What CT_Font states that ContentFont has no member for (vertAlign's superscript/subscript, outline, shadow, condense, extend, family, charset, scheme) is read past rather than half-modelled, and a colour carried only as theme/indexed/auto resolves to no colour statement, matching the fill and border colour policy. --- .../ooxml.js/src/typed/xlsx/build.test.ts | 169 ++++++++++++++++ packages/ooxml.js/src/typed/xlsx/build.ts | 42 +++- packages/ooxml.js/src/typed/xlsx/content.ts | 7 +- .../ooxml.js/src/typed/xlsx/styles.test.ts | 181 +++++++++++++++++ packages/ooxml.js/src/typed/xlsx/styles.ts | 190 +++++++++++++++++- 5 files changed, 570 insertions(+), 19 deletions(-) diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index c06c8a600..440c5c3db 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -919,6 +919,175 @@ const DECORATED_SHEET: ContentSheet = { }, }; +const FONTED_SHEET: ContentSheet = { + name: "Fonted", + cells: [ + { + row: 0, + column: 0, + value: { kind: "string", value: "Header" }, + displayText: "Header", + font: { bold: true, color: { r: 1, g: 0, b: 0 } }, + }, + { + row: 1, + column: 0, + value: { kind: "number", value: 42 }, + displayText: "42", + font: { fontFamily: "Courier New", sizePt: 14, strike: true }, + }, + { + row: 2, + column: 0, + value: { kind: "number", value: 7 }, + displayText: "7", + // bold: false against this writer's own not-bold default font restates the default, so this cell must share font entry 0 and mint nothing. + font: { bold: false }, + }, + ], + columns: [], + rows: [], + images: [], + printSettings: { + pageSize: PAGE_SIZE_A4, + margins: { topPt: 36, rightPt: 36, bottomPt: 36, leftPt: 36 }, + gridlines: true, + headers: true, + pageOrder: "downThenOver", + }, +}; + +describe("buildXlsxPackageFromContent: writes the per-cell font into xl/styles.xml", () => { + const pkg = buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: { + title: undefined, + author: undefined, + subject: undefined, + keywords: undefined, + creator: undefined, + producer: undefined, + createdIso: undefined, + modifiedIso: undefined, + }, + sheets: [FONTED_SHEET], + }); + const styles = rootElement(pkg.parts["xl/styles.xml"]); + if (styles === undefined) { + throw new Error("expected xl/styles.xml to have a root element"); + } + const required = ( + element: XmlElement | undefined, + message: string, + ): XmlElement => { + if (element === undefined) { + throw new Error(message); + } + return element; + }; + + it("writes the default Calibri-11 font at index 0, then one entry per distinct cell font", () => { + const fontsEl = required( + childrenWithTag(styles, "fonts")[0], + "expected a element", + ); + const fonts = childrenWithTag(fontsEl, "font"); + expect(fonts).toHaveLength(3); + const defaultFont = required(fonts[0], "expected the default at 0"); + expect( + childrenWithTag(defaultFont, "sz")[0]?.attributes.find( + (a) => a.name === "val", + )?.value, + ).toBe("11"); + expect( + childrenWithTag(defaultFont, "name")[0]?.attributes.find( + (a) => a.name === "val", + )?.value, + ).toBe("Calibri"); + const boldRed = required(fonts[1], "expected a bold at 1"); + expect(childrenWithTag(boldRed, "b")).toHaveLength(1); + expect( + childrenWithTag(boldRed, "color")[0]?.attributes.find( + (a) => a.name === "rgb", + )?.value, + ).toBe("FFff0000"); + const courier = required(fonts[2], "expected a courier at 2"); + expect(childrenWithTag(courier, "strike")).toHaveLength(1); + expect( + childrenWithTag(courier, "sz")[0]?.attributes.find( + (a) => a.name === "val", + )?.value, + ).toBe("14"); + expect( + childrenWithTag(courier, "name")[0]?.attributes.find( + (a) => a.name === "val", + )?.value, + ).toBe("Courier New"); + }); + + it("writes fontId and applyFont on a fonted xf, leaving the default xf free of both", () => { + const cellXfsEl = required( + childrenWithTag(styles, "cellXfs")[0], + "expected a element", + ); + const xfs = childrenWithTag(cellXfsEl, "xf"); + // xf[0] = default (General + default font, shared with the bold:false cell); xf[1] = bold red; xf[2] = courier + expect(xfs).toHaveLength(3); + const defaultXf = required(xfs[0], "expected the default at 0"); + expect(defaultXf.attributes.map((a) => a.name)).not.toContain("applyFont"); + expect(defaultXf.attributes.find((a) => a.name === "fontId")?.value).toBe( + "0", + ); + const boldRedXf = required(xfs[1], "expected a fonted at 1"); + expect(boldRedXf.attributes.find((a) => a.name === "fontId")?.value).toBe( + "1", + ); + expect(boldRedXf.attributes.map((a) => a.name)).toContain("applyFont"); + expect(xfs[2]?.attributes.find((a) => a.name === "fontId")?.value).toBe( + "2", + ); + }); +}); + +describe("readXlsxContent(buildXlsxPackageFromContent(x)) round-trips the per-cell font", () => { + const result = readXlsxContent( + buildXlsxPackageFromContent({ + kind: "spreadsheet", + metadata: { + title: undefined, + author: undefined, + subject: undefined, + keywords: undefined, + creator: undefined, + producer: undefined, + createdIso: undefined, + modifiedIso: undefined, + }, + sheets: [FONTED_SHEET], + }), + ); + if (result.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + const cells = result.sheets[0]?.cells ?? []; + + it("preserves each distinct cell font through the round trip", () => { + expect(cells[0]?.font).toEqual({ + bold: true, + color: { r: 1, g: 0, b: 0 }, + }); + expect(cells[1]?.font).toEqual({ + fontFamily: "Courier New", + sizePt: 14, + strike: true, + }); + }); + + it("reads a font that normalised back to the default as no font of its own", () => { + expect(cells[2]?.font).toBeUndefined(); + }); +}); + describe("buildXlsxPackageFromContent: writes cell decoration (fills/borders/alignment) into xl/styles.xml", () => { const pkg = buildXlsxPackageFromContent({ kind: "spreadsheet", diff --git a/packages/ooxml.js/src/typed/xlsx/build.ts b/packages/ooxml.js/src/typed/xlsx/build.ts index 94c60a43c..ed0bffb2d 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.ts @@ -40,6 +40,7 @@ import { SharedStringTable } from "./shared-strings"; import { CellFormatTable, DEFAULT_CELL_FORMAT_INDEX, + DEFAULT_FONT_INDEX, GENERAL_NUM_FMT_ID, RESERVED_BORDER_INDICES, RESERVED_FILL_INDICES, @@ -369,9 +370,9 @@ function buildSharedStringsPart(sharedStrings: SharedStringTable): XmlPart { // --- xl/styles.xml: the minimal font/border scaffolding real Excel/LibreOffice require, plus the interned fills/borders/cell formats --- -// and the two reserved entries (index 0 "none", index 1 Excel's mandatory gray125) plus the empty reserved entry (index 0) are fixed scaffolding, confirmed against multiple independent references as the source of Excel's "we found a problem with some content" repair prompt when a hand-rolled writer omits them. On top of that scaffolding this writer now emits the real solid fills and real per-edge borders the cells themselves carried, interned by CellFormatTable alongside the number formats. +// and the two reserved entries (index 0 "none", index 1 Excel's mandatory gray125) plus the empty reserved entry (index 0) are fixed scaffolding, confirmed against multiple independent references as the source of Excel's "we found a problem with some content" repair prompt when a hand-rolled writer omits them. On top of that scaffolding this writer now emits the real per-cell fonts, real solid fills, and real per-edge borders the cells themselves carried, interned by CellFormatTable alongside the number formats. // -// The variable parts come straight from the CellFormatTable the worksheets filled: one per custom code interned (and NO element at all when nothing was, which is what keeps a workbook of ordinary numbers and strings byte-identical to what this writer produced before number formats existed), one per distinct solid background, one per distinct edge set, and one per cell-format index -- index 0 always being the General + no-decoration default. is the same story for conditionalFormatting rule styling: one per DxfTable.intern call the worksheets made (also NO element at all when a workbook has no styled conditional-format rule), populated by the very same per-sheet build pass, which is why buildXlsxPackageFromContent's own worksheets-before-styles ordering note below applies to dxfTable exactly as it already does to cellFormats. +// The variable parts come straight from the CellFormatTable the worksheets filled: one per custom code interned (and NO element at all when nothing was, which is what keeps a workbook of ordinary numbers and strings byte-identical to what this writer produced before number formats existed), one per distinct cell font (the DEFAULT_FONT Calibri-11 entry always at index 0, one further entry per font that genuinely differs), one per distinct solid background, one per distinct edge set, and one per cell-format index -- index 0 always being the General + default-font + no-decoration default. is the same story for conditionalFormatting rule styling: one per DxfTable.intern call the worksheets made (also NO element at all when a workbook has no styled conditional-format rule), populated by the very same per-sheet build pass, which is why buildXlsxPackageFromContent's own worksheets-before-styles ordering note below applies to dxfTable exactly as it already does to cellFormats. // // CT_Stylesheet's own required child element ORDER (ECMA-376 Part 1 SS18.8.39): numFmts?, fonts?, fills?, borders?, cellStyleXfs?, cellXfs?, cellStyles?, dxfs?, ... -- numFmts FIRST, before the fonts element that used to lead this part, and dxfs right after cellStyles (confirmed against real-producer-validation-and-cellis.xlsx's own styles.xml, which places its there, immediately before its element this writer does not emit). function buildStylesPart( @@ -444,10 +445,30 @@ function buildStylesPart( return el("border", {}, edgeElements); }); + const fontElements = cellFormats.fontDeclarations().map((font) => { + const children: XmlElement[] = []; + if (font.bold === true) { + children.push(el("b")); + } + if (font.italic === true) { + children.push(el("i")); + } + if (font.strike === true) { + children.push(el("strike")); + } + if (font.underline === true) { + children.push(el("u", { val: "single" })); + } + if (font.colorRgb !== undefined) { + children.push(el("color", { rgb: `FF${font.colorRgb}` })); + } + children.push(el("sz", { val: font.sz })); + children.push(el("name", { val: encodeXmlText(font.name) })); + return el("font", {}, children); + }); + children.push( - el("fonts", { count: "1" }, [ - el("font", {}, [el("sz", { val: "11" }), el("name", { val: "Calibri" })]), - ]), + el("fonts", { count: String(fontElements.length) }, fontElements), el("fills", { count: String(fillElements.length) }, fillElements), el("borders", { count: String(borderElements.length) }, borderElements), el("cellStyleXfs", { count: "1" }, [ @@ -459,7 +480,7 @@ function buildStylesPart( const xfElements = xfRecords.map((record) => { const attrs: Record = { numFmtId: String(record.numFmtId), - fontId: "0", + fontId: String(record.fontId), fillId: String(record.fillId), borderId: String(record.borderId), xfId: "0", @@ -468,7 +489,10 @@ function buildStylesPart( // CT_Xf/@applyNumberFormat tells a consumer to honour this xf's OWN numFmtId rather than the one it would otherwise inherit from the cell style it is based on (xfId). Real producers differ here -- Excel writes it on every formatted xf, LibreOffice omits it entirely and relies on numFmtId alone (see this directory's own kitchen-sink fixture, whose six formatted xfs carry no applyNumberFormat at all) -- so this writer emits the explicit form, which cannot be misread by either: LibreOffice 26.2 renders every format below correctly with it present (verified), and Excel's own inheritance rule makes it the unambiguous spelling. attrs.applyNumberFormat = writeXmlBool(true); } - // Each apply* flag mirrors applyNumberFormat: it tells a consumer to honour this xf's OWN fillId/borderId/alignment rather than the one inherited from the cell style it is based on. Set next to the id that drives it so what triggers the flag stays local to the line. + // Each apply* flag mirrors applyNumberFormat: it tells a consumer to honour this xf's OWN fontId/fillId/borderId/alignment rather than the one inherited from the cell style it is based on. Set next to the id that drives it so what triggers the flag stays local to the line. + if (record.fontId !== DEFAULT_FONT_INDEX) { + attrs.applyFont = writeXmlBool(true); + } if (record.fillId !== RESERVED_FILL_INDICES.none) { attrs.applyFill = writeXmlBool(true); } @@ -726,13 +750,15 @@ function buildCellElement( cell.formula !== undefined, sharedStrings, ); - // The cell's own decoration (background/borders/alignment/verticalAlignment) is interned INTO the same cellXfs index as its number format, so two cells sharing both format and decoration share one entry exactly as a real producer's own output does. An undecorated cell passes no decoration through, landing on the same xf an identical-format undecorated cell already did before decoration existed. + // The cell's own font and decoration (font/background/borders/alignment/verticalAlignment) is interned INTO the same cellXfs index as its number format, so two cells sharing format, font, and decoration share one entry exactly as a real producer's own output does. A cell carrying neither lands on the same xf an identical-format undecorated cell already did before decoration existed. const decoration = + cell.font !== undefined || cell.background !== undefined || cell.borders !== undefined || cell.alignment !== undefined || cell.verticalAlignment !== undefined ? { + font: cell.font, background: cell.background, borders: cell.borders, alignment: cell.alignment, diff --git a/packages/ooxml.js/src/typed/xlsx/content.ts b/packages/ooxml.js/src/typed/xlsx/content.ts index d8ba9b985..5527165bf 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.ts @@ -46,7 +46,7 @@ import { readDataValidations } from "./data-validation"; // // This is the flat, content-level half of the xlsx read pair: readXlsx (typed/document-tree.ts) wraps it into a tree-form DocumentTree, which is the primary name. Nothing is lost either way -- a spreadsheet's tree is one sheet group per sheet -- so which one to call is purely which shape the caller works in. // -// SCOPE, stated up front rather than only at each individual site below: (1) xlsx's own cell-type vocabulary (t="n"/absent, "s", "str", "inlineStr", "b", "e") has no percentage/currency/date variant the way ODF's office:value-type does -- those are all just numeric cells with a number-format style applied, so recovering them means resolving the cell's own style index through xl/styles.xml to a numFmt code and classifying that code. This reader does exactly that (typed/xlsx/styles.ts resolves, typed/xlsx/number-format.ts classifies, typed/xlsx/serial.ts converts a date/time serial to ISO), so a numeric cell reads as ContentCellValue's 'percentage'/'currency'/'date'/'time'/'dateTime' kind whenever its format genuinely says so, and 'number' otherwise. What that classifier is NOT is a FORMATTER: nothing here renders a value through a format code, which is why (2) below still holds. Only genuinely numeric cells are ever reclassified -- an s/str/inlineStr/b/e/d cell already carries its own type in the file and is never second-guessed by a style. (2) displayText has no native xlsx equivalent to read verbatim the way ODF's text:p content or a cached string gives readOds for free -- see deriveDisplayText below for exactly how this reader constructs one instead. (3) ContentSheetCellSchema's own `runs` field (genuinely mixed inline formatting within one cell) is never populated -- xlsx rich-text runs (/'s own nested ...) use a distinct font-property vocabulary from docx/pptx's own run styling, and resolving it would duplicate a meaningful slice of that machinery for a rarely-used feature not in this reader's own required field list; only the concatenated plain text (via deriveDisplayText) is read. (4) The cell DECORATION fields (background/borders/alignment/verticalAlignment) ARE read now, resolved from the same cellXfs index the number format comes from: typed/xlsx/styles.ts's readCellStyles resolves each entry's fill bg colour, per-edge borders, and inline straight off the the cell's own s attribute indexes, and readCell below copies whichever of them are present onto the ContentSheetCell -- mirroring how odf.js's readOds populates the same fields from a table:table-cell's style chain. Two genuine scope limits on that resolution live in styles.ts: a fill/border colour carried only as theme/indexed/tint/auto (not rgb) is left unread, and the dash-family border tokens (dashDot/dashDotDot/...) collapse to ContentStrokeStyle 'dashed' since the schema has no dash-dot member. (5) The cell COMMENT field IS read, from both mechanisms xlsx has ever used for comments -- legacy VML-anchored notes (xl/comments{N}.xml) and the Office-365 threaded-comments extension -- resolved through the worksheet part's own relationships into typed/xlsx/comments.ts, whose own header states the full shape decisions. Comments are read-only: buildXlsxPackageFromContent never writes a comment part, so a ContentDocument round-tripped through that pair keeps its cells and drops their annotations. +// SCOPE, stated up front rather than only at each individual site below: (1) xlsx's own cell-type vocabulary (t="n"/absent, "s", "str", "inlineStr", "b", "e") has no percentage/currency/date variant the way ODF's office:value-type does -- those are all just numeric cells with a number-format style applied, so recovering them means resolving the cell's own style index through xl/styles.xml to a numFmt code and classifying that code. This reader does exactly that (typed/xlsx/styles.ts resolves, typed/xlsx/number-format.ts classifies, typed/xlsx/serial.ts converts a date/time serial to ISO), so a numeric cell reads as ContentCellValue's 'percentage'/'currency'/'date'/'time'/'dateTime' kind whenever its format genuinely says so, and 'number' otherwise. What that classifier is NOT is a FORMATTER: nothing here renders a value through a format code, which is why (2) below still holds. Only genuinely numeric cells are ever reclassified -- an s/str/inlineStr/b/e/d cell already carries its own type in the file and is never second-guessed by a style. (2) displayText has no native xlsx equivalent to read verbatim the way ODF's text:p content or a cached string gives readOds for free -- see deriveDisplayText below for exactly how this reader constructs one instead. (3) ContentSheetCellSchema's own `runs` field (genuinely mixed inline formatting within one cell) is never populated -- xlsx rich-text runs (/'s own nested ...) use a distinct font-property vocabulary from docx/pptx's own run styling, and resolving it would duplicate a meaningful slice of that machinery for a rarely-used feature not in this reader's own required field list; only the concatenated plain text (via deriveDisplayText) is read. The uniform per-cell font, by contrast, IS read (ContentSheetCell.font): a cell's xf resolves through into the one font every cell of that format states, diffed against the workbook's own entry-0 default so only genuine differences survive (see contentFontOf in typed/xlsx/styles.ts). (4) The cell DECORATION fields (background/borders/alignment/verticalAlignment) ARE read now, resolved from the same cellXfs index the number format comes from: typed/xlsx/styles.ts's readCellStyles resolves each entry's fill bg colour, per-edge borders, and inline straight off the the cell's own s attribute indexes, and readCell below copies whichever of them are present onto the ContentSheetCell -- mirroring how odf.js's readOds populates the same fields from a table:table-cell's style chain. Two genuine scope limits on that resolution live in styles.ts: a fill/border colour carried only as theme/indexed/tint/auto (not rgb) is left unread, and the dash-family border tokens (dashDot/dashDotDot/...) collapse to ContentStrokeStyle 'dashed' since the schema has no dash-dot member. (5) The cell COMMENT field IS read, from both mechanisms xlsx has ever used for comments -- legacy VML-anchored notes (xl/comments{N}.xml) and the Office-365 threaded-comments extension -- resolved through the worksheet part's own relationships into typed/xlsx/comments.ts, whose own header states the full shape decisions. Comments are read-only: buildXlsxPackageFromContent never writes a comment part, so a ContentDocument round-tripped through that pair keeps its cells and drops their annotations. const WORKBOOK_PATH = "xl/workbook.xml"; @@ -358,9 +358,12 @@ function readCell( if (formula !== undefined) { cellEntry.formula = formula; } - // The cell's own decoration (background/borders/alignment/verticalAlignment) resolves through the SAME cellXfs index the number format above resolved through -- the entry's four optional fields mirror ContentSheetCellSchema's own four, and each is copied through only when present, so a cell whose xf declares none of them stays field-free rather than inheriting fabricated defaults. This is the xlsx-side counterpart to odf.js readOds's own table:style-name -> table-cell cascade resolution of the same four fields, resolved through xlsx's own table instead of an ODF style chain. + // The cell's own font and decoration (font/background/borders/alignment/verticalAlignment) resolve through the SAME cellXfs index the number format above resolved through -- the entry's optional fields mirror ContentSheetCellSchema's own, and each is copied through only when present, so a cell whose xf declares none of them stays field-free rather than inheriting fabricated defaults. font states only the properties genuinely differing from the workbook's default font (typed/xlsx/styles.ts's contentFontOf), the identical default-diffing policy xls-codec's BIFF8 reader applies to its own font table. This is the xlsx-side counterpart to odf.js readOds's own table:style-name -> table-cell cascade resolution of the same fields, resolved through xlsx's own table instead of an ODF style chain. const entry = entryOf(cell, context); if (entry !== undefined) { + if (entry.font !== undefined) { + cellEntry.font = entry.font; + } if (entry.background !== undefined) { cellEntry.background = entry.background; } diff --git a/packages/ooxml.js/src/typed/xlsx/styles.test.ts b/packages/ooxml.js/src/typed/xlsx/styles.test.ts index a1548615d..737cc41e1 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.test.ts @@ -425,6 +425,7 @@ describe("CellFormatTable: interning decoration alongside the number format", () const records = table.cellFormatRecords(); expect(records[first]).toEqual({ numFmtId: GENERAL_NUM_FMT_ID, + fontId: 0, fillId: 0, borderId: 0, alignment: { horizontal: "center", vertical: "middle" }, @@ -438,6 +439,186 @@ describe("CellFormatTable: interning decoration alongside the number format", () ); expect(table.cellFormatRecords()[0]).toEqual({ numFmtId: GENERAL_NUM_FMT_ID, + fontId: 0, + fillId: 0, + borderId: 0, + }); + }); +}); + +// --- the cell font: read-side diffing against the workbook's own default font --- + +describe("readCellStyles: the cell font, diffed against entry 0", () => { + it("states only the properties that genuinely differ from the workbook's own default font", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [ + el("sz", { val: "11" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [ + el("b"), + el("i"), + el("strike"), + el("sz", { val: "14" }), + el("name", { val: "Courier New" }), + el("color", { rgb: "FFFF0000" }), + ]), + ]), + el("cellXfs", {}, [ + el("xf", { numFmtId: "0", fontId: "0" }), + el("xf", { numFmtId: "0", fontId: "1" }), + ]), + ]), + ); + const entries = readCellStyles(pkg); + expect(entries[0]?.font).toBeUndefined(); + expect(entries[1]?.font).toEqual({ + bold: true, + italic: true, + strike: true, + sizePt: 14, + fontFamily: "Courier New", + color: { r: 1, g: 0, b: 0 }, + }); + }); + + it("states bold: false for a cell font whose only difference is turning the default's bold off", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("b"), el("name", { val: "Calibri" })]), + el("font", {}, [el("name", { val: "Calibri" })]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.font).toEqual({ bold: false }); + }); + + it("states underline: true for any named underline style, and nothing for u val=none", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("name", { val: "Calibri" })]), + el("font", {}, [ + el("u", { val: "double" }), + el("name", { val: "Calibri" }), + ]), + el("font", {}, [ + el("u", { val: "none" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [ + el("xf", { numFmtId: "0", fontId: "1" }), + el("xf", { numFmtId: "0", fontId: "2" }), + ]), + ]), + ); + const entries = readCellStyles(pkg); + expect(entries[0]?.font).toEqual({ underline: true }); + expect(entries[1]?.font).toBeUndefined(); + }); + + it("leaves a theme- or indexed-carried colour unstated, matching the fill/border colour policy", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("name", { val: "Calibri" })]), + el("font", {}, [ + el("color", { theme: "1" }), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.font).toBeUndefined(); + }); + + it("reads past a ContentFont has no member for, stating the differences it can", () => { + const pkg = stylesPackage( + el("styleSheet", {}, [ + el("fonts", {}, [ + el("font", {}, [el("name", { val: "Calibri" })]), + el("font", {}, [ + el("vertAlign", { val: "superscript" }), + el("b"), + el("name", { val: "Calibri" }), + ]), + ]), + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "1" })]), + ]), + ); + expect(readCellStyles(pkg)[0]?.font).toEqual({ bold: true }); + }); + + it("states no font for an out-of-range fontId or a workbook with no table", () => { + const outOfRange = stylesPackage( + el("styleSheet", {}, [ + el("cellXfs", {}, [el("xf", { numFmtId: "0", fontId: "99" })]), + ]), + ); + expect(readCellStyles(outOfRange)[0]?.font).toBeUndefined(); + expect(readCellStyles(stylesPackage(el("styleSheet", {}, [])))).toEqual([]); + }); +}); + +// --- the cell font: write-side interning --- + +describe("CellFormatTable: interning the cell font alongside the number format", () => { + it("always carries the default Calibri-11 font at index 0, and a font normalising back to it references that entry", () => { + const table = new CellFormatTable(); + expect(table.fontDeclarations()).toEqual([{ sz: "11", name: "Calibri" }]); + expect( + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: { bold: false } }, + ), + ).toBe(DEFAULT_CELL_FORMAT_INDEX); + // bold: false against THIS writer's not-bold entry 0 is a restatement of the default, so nothing was minted. + expect(table.fontDeclarations()).toEqual([{ sz: "11", name: "Calibri" }]); + }); + + it("mints one entry per distinct font and deduplicates identical ones", () => { + const table = new CellFormatTable(); + const boldRed = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: { bold: true, color: { r: 1, g: 0, b: 0 } } }, + ); + expect( + table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: { bold: true, color: { r: 1, g: 0, b: 0 } } }, + ), + ).toBe(boldRed); + expect(table.fontDeclarations()).toEqual([ + { sz: "11", name: "Calibri" }, + { bold: true, colorRgb: "ff0000", sz: "11", name: "Calibri" }, + ]); + const courierBig = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: { fontFamily: "Courier New", sizePt: 14, strike: true } }, + ); + expect(courierBig).not.toBe(boldRed); + expect(table.fontDeclarations()[2]).toEqual({ + strike: true, + sz: "14", + name: "Courier New", + }); + }); + + it("carries fontId on the cellFormatRecord, distinct from the default font's 0", () => { + const table = new CellFormatTable(); + const index = table.intern( + { kind: "builtin", id: GENERAL_NUM_FMT_ID }, + { font: { italic: true } }, + ); + expect(table.cellFormatRecords()[index]).toEqual({ + numFmtId: GENERAL_NUM_FMT_ID, + fontId: 1, fillId: 0, borderId: 0, }); diff --git a/packages/ooxml.js/src/typed/xlsx/styles.ts b/packages/ooxml.js/src/typed/xlsx/styles.ts index fc8ce74c9..9e6bbcf9d 100644 --- a/packages/ooxml.js/src/typed/xlsx/styles.ts +++ b/packages/ooxml.js/src/typed/xlsx/styles.ts @@ -6,6 +6,7 @@ import type { ContentCellBorders, ContentCellFill, ContentCellPatternType, + ContentFont, ContentStrokeStyle, } from "document-schema.js"; import { @@ -22,7 +23,7 @@ import type { CellNumberFormat } from "./number-format"; import { attr, childrenWithTag, decodeEntities, rootElement } from "../util"; import { BUILTIN_NUMBER_FORMATS } from "excel-number-format"; -// Resolves xl/styles.xml for typed/xlsx/content.ts (read) and typed/xlsx/build.ts (write). The read side produces one entry per -- the array index IS the value of a cell's own s attribute -- carrying everything ContentSheetCellSchema models that lives in a cell format: the number-format CODE STRING (resolved through , classified by typed/xlsx/number-format.ts upstream), and the cell DECORATION (background fill, per-edge borders, horizontal/vertical alignment) added in this same widening that gave ContentSheetCell its background/borders/alignment/verticalAlignment fields. The write side is the same relationship in reverse: CellFormatTable interns the (number format, decoration) tuples a written workbook needs, ready to serialize as ///. +// Resolves xl/styles.xml for typed/xlsx/content.ts (read) and typed/xlsx/build.ts (write). The read side produces one entry per -- the array index IS the value of a cell's own s attribute -- carrying everything ContentSheetCellSchema models that lives in a cell format: the number-format CODE STRING (resolved through , classified by typed/xlsx/number-format.ts upstream), the cell DECORATION (background fill, per-edge borders, horizontal/vertical alignment), and the cell FONT (resolved through the xf's own fontId into , diffed against that table's entry 0 -- see contentFontOf below). The write side is the same relationship in reverse: CellFormatTable interns the (number format, font, decoration) tuples a written workbook needs, ready to serialize as ////. const STYLES_PATH = "xl/styles.xml"; @@ -53,17 +54,109 @@ function readNumberFormatCodesById( return codes; } -// --- the read side: per-cellXfs number format + decoration -------------------------------------------------------- +// --- the read side: per-cellXfs number format + font + decoration ------------------------------------------------ -// Everything this reader resolves for one entry. numberFormatCode is the numFmt code string that xf displays its value through (undefined when the xf points at a numFmtId no code anywhere supplies); the four decoration fields mirror document-schema.js's own ContentSheetCellSchema fields of the same names, and are each undefined when the xf carries no real value for them -- matching the schema's own "absent means default" semantics for every one. +// Everything this reader resolves for one entry. numberFormatCode is the numFmt code string that xf displays its value through (undefined when the xf points at a numFmtId no code anywhere supplies); font is the cell's own font as ContentSheetCell.font carries it (only the properties genuinely differing from the workbook's default font -- see contentFontOf below); the four decoration fields mirror document-schema.js's own ContentSheetCellSchema fields of the same names, and are each undefined when the xf carries no real value for them -- matching the schema's own "absent means default" semantics for every one. export interface CellStyleEntry { numberFormatCode?: string; + font?: ContentFont; background?: ContentCellFill; borders?: ContentCellBorders; alignment?: Alignment; verticalAlignment?: "top" | "middle" | "bottom"; } +// One entry (CT_Font, ECMA-376 Part 1 SS18.8.22) in the fields ContentFont can express, read per property with its absence spelled as that property's "not stated" value: the four boolean members are false when their element is absent or carries an explicit off value, fontFamily/sizePt undefined when / state nothing resolvable, and colour undefined for a this reader cannot resolve (a theme/indexed/auto colour -- the identical resolution colorFromElement already applies to a fill's or border's colour, shared here through readColorRgb). What CT_Font states that ContentFont has no member for (vertAlign's superscript/subscript, outline, shadow, condense, extend, family, charset, scheme) is read past rather than half-modelled, the identical scope limit xls-codec's own Font-record reader applies to the same vocabulary's BIFF8 spelling. +export interface FontTableEntry { + readonly bold: boolean; + readonly italic: boolean; + readonly underline: boolean; + readonly strike: boolean; + readonly fontFamily: string | undefined; + readonly sizePt: number | undefined; + readonly color: Color | undefined; +} + +// CT_Boolean/@w:val... CT_Font's toggle children (b/i/strike) are on by bare presence, with an optional val attribute ("0"/"false" per xsd:boolean, whose absent form means on -- the same convention typed/docx/styles.ts's own readToggle states for WordprocessingML's identical boolean-property shape). An absent element is "not bold", not "unknown": a font-table entry states every font absolutely, unlike a cascade layer. +function readFontToggle(el: XmlElement | undefined): boolean { + if (el === undefined) { + return false; + } + const val = attr(el, "val"); + return val !== "0" && val !== "false"; +} + +// (CT_UnderlineProperty) carries @val from ST_UnderlineValues with "single" as its schema default; "none" is the one value that means off, so a bare or any named style (single/double/the two accounting spellings) all state underline=true -- the boolean collapse ContentFont's own single underline member demands, the same one the border reader applies to the dash-family tokens. +function readFontUnderline(u: XmlElement | undefined): boolean { + if (u === undefined) { + return false; + } + return attr(u, "val") !== "none"; +} + +function readFontTableEntry(font: XmlElement): FontTableEntry { + const name = childrenWithTag(font, "name")[0]; + const sz = childrenWithTag(font, "sz")[0]; + const szVal = sz === undefined ? undefined : attr(sz, "val"); + const szNum = szVal === undefined ? undefined : Number(szVal); + return { + bold: readFontToggle(childrenWithTag(font, "b")[0]), + italic: readFontToggle(childrenWithTag(font, "i")[0]), + underline: readFontUnderline(childrenWithTag(font, "u")[0]), + strike: readFontToggle(childrenWithTag(font, "strike")[0]), + fontFamily: name === undefined ? undefined : attr(name, "val"), + sizePt: szNum !== undefined && Number.isFinite(szNum) ? szNum : undefined, + color: readColorRgb(font, "color"), + }; +} + +// One entry per , in document order, so the array index IS the value an 's own fontId attribute carries. +function readFontTable(styleSheet: XmlElement): readonly FontTableEntry[] { + const fontsEl = childrenWithTag(styleSheet, "fonts")[0]; + if (fontsEl === undefined) { + return []; + } + return childrenWithTag(fontsEl, "font").map(readFontTableEntry); +} + +// The cell-level font one font-table entry resolves to, as ContentSheetCell.font carries it: only the properties that DIFFER from the workbook's own first font, or undefined when the entry is that font outright -- the format's default, which the schema models as the field being absent rather than an explicitly restated copy of it. xlsx mirrors BIFF8 here (xls-codec's own contentFontOf, its per-cell-fonts PR): the format gives a cell no way to say "no font", only an index into the table, so entry 0 is what "the format's default" concretely means for a given file. The diff is per property, since a real cell font usually differs from the default in one or two respects and agrees in the rest: a Courier-bold cell font against an Arial default yields { fontFamily: "Courier", bold: true } and says nothing about size, which the default already settles. A colour equal to the default's own resolved colour states nothing even where the two spellings differed (rgb black against an indexed system black): the cell said "the same colour as the default", not a colour that happens to coincide. +export function contentFontOf( + font: FontTableEntry, + baseline: FontTableEntry, +): ContentFont | undefined { + const result: ContentFont = {}; + if (font.bold !== baseline.bold) { + result.bold = font.bold; + } + if (font.italic !== baseline.italic) { + result.italic = font.italic; + } + if (font.underline !== baseline.underline) { + result.underline = font.underline; + } + if (font.strike !== baseline.strike) { + result.strike = font.strike; + } + // The three value-carrying members state nothing when the entry's own value is absent: an entry leaving // unstated differs from a baseline that states one, but the honest spelling of "defer to the default" is the field's absence, never an explicit undefined-valued restatement of the default's own value. + if ( + font.fontFamily !== undefined && + font.fontFamily !== baseline.fontFamily + ) { + result.fontFamily = font.fontFamily; + } + if (font.sizePt !== undefined && font.sizePt !== baseline.sizePt) { + result.sizePt = font.sizePt; + } + if ( + font.color !== undefined && + (baseline.color === undefined || + colorToRgbHex(font.color) !== colorToRgbHex(baseline.color)) + ) { + result.color = font.color; + } + return Object.keys(result).length > 0 ? result : undefined; +} + // ST_PatternType's own seventeen non-solid, non-none members (ECMA-376 Part 1 SS18.18.55) -- the SpreadsheetML half of ContentCellPatternType's shared vocabulary, and (ExaDev/documents.js#951) the exact string spelling already uses, so no translation table is needed the way doc-codec's Ipat and ooxml.js's own docx w:shd each need one: the attribute value IS the schema's own member name. Named as its own narrow type (rather than typing the guard below `value is ContentCellPatternType`) so a caller already holding a full ContentCellPatternType -- the write side, validating a real cell's own pattern name -- narrows its negative branch to the WordprocessingML-only remainder instead of `never`. type XlsxPatternType = Extract< ContentCellPatternType, @@ -310,7 +403,7 @@ function readAlignment(xf: XmlElement): { }; } -// One entry per , in document order, so the array index IS the value of a cell's own s attribute. numberFormatCode is read directly off the cellXf's numFmtId (not chased through xfId into : real producers write the resolved numFmtId onto the cellXf itself -- see the note on readCellFormatCodes below -- and the same holds for fillId/borderId/alignment, which this reader also reads off the cellXf directly). fillId/borderId resolve through the / tables; alignment is the inline child. A cell whose xf carries applyAlignment="0" still reads its inline alignment here, matching the numFmtId policy and real producer output. +// One entry per , in document order, so the array index IS the value of a cell's own s attribute. numberFormatCode is read directly off the cellXf's numFmtId (not chased through xfId into : real producers write the resolved numFmtId onto the cellXf itself -- see the note on readCellFormatCodes below -- and the same holds for fontId/fillId/borderId/alignment, which this reader also reads off the cellXf directly). fontId resolves through the table and contentFontOf's diff against that table's entry 0; fillId/borderId resolve through the / tables; alignment is the inline child. A cell whose xf carries applyAlignment="0" still reads its inline alignment here, matching the numFmtId policy and real producer output. export function readCellStyles(pkg: Package): readonly CellStyleEntry[] { const styleSheet = rootElement(pkg.parts[STYLES_PATH]); if (styleSheet === undefined) { @@ -321,6 +414,7 @@ export function readCellStyles(pkg: Package): readonly CellStyleEntry[] { return []; } const codes = readNumberFormatCodesById(styleSheet); + const fonts = readFontTable(styleSheet); const fills = readFills(styleSheet); const borders = readBorders(styleSheet); return childrenWithTag(cellXfsEl, "xf").map((xf) => { @@ -336,6 +430,11 @@ export function readCellStyles(pkg: Package): readonly CellStyleEntry[] { entry.numberFormatCode = code; } } + const fontId = parseChildIndex(attr(xf, "fontId")); + const font = fontId === undefined ? undefined : fonts[fontId]; + if (font !== undefined && fonts[0] !== undefined) { + entry.font = contentFontOf(font, fonts[0]); + } const fillId = parseChildIndex(attr(xf, "fillId")); if (fillId !== undefined) { entry.background = fills[fillId]; @@ -392,6 +491,9 @@ const FIRST_CUSTOM_NUM_FMT_ID = 164; // The cell-format index every cell with nothing but General formatting and no decoration carries, and the one entry this table always starts with, so a workbook that needs no formats at all still writes exactly the single- cellXfs it did before this table existed. export const DEFAULT_CELL_FORMAT_INDEX = 0; +// The index every cell carrying no font of its own references, and the one font entry this table always starts with -- the reserved scaffolding slot a real producer's font table also gives its workbook default. +export const DEFAULT_FONT_INDEX = 0; + // A custom format as it must be declared in : the id this table assigned it, and the code itself (raw, NOT XML-encoded -- the caller encodes when it writes the formatCode attribute, matching how every other string this package writes is handled). export interface DeclaredNumberFormat { id: number; @@ -414,8 +516,9 @@ function signatureOfNumberFormat(format: CellNumberFormat): string { : `custom:${format.code}`; } -// The four decoration fields a cell format can carry alongside its number format, mirroring CellStyleEntry's own shape. Each is optional and independently interned; a cell carrying none of them passes an empty object and shares the default xf with every other undecorated cell. +// The four decoration fields a cell format can carry alongside its number format and font, mirroring CellStyleEntry's own shape. Each is optional and independently interned; a cell carrying none of them passes an empty object and shares the default xf with every other undecorated cell. export interface CellFormatDecoration { + font?: ContentFont; background?: ContentCellFill; borders?: ContentCellBorders; alignment?: Alignment; @@ -424,6 +527,48 @@ export interface CellFormatDecoration { const EMPTY_DECORATION: CellFormatDecoration = {}; +// The workbook-default font every table this writer emits carries at index 0, and the value every absent ContentFont member normalises back to on write: Calibri 11pt with no flags and no stated colour -- this writer's own long-established single font, unchanged, now simply the baseline other entries are interned against. The write-side mirror of the read side's diff against a file's own entry 0: a cell whose ContentFont normalises back to these fields references font 0 and mints no entry of its own, exactly as a read-back cell carrying no font field does. +export const DEFAULT_FONT: DeclaredFont = { sz: "11", name: "Calibri" }; + +// One declared as the writer must emit it: the four boolean flags (absent means off), an optional colour as its 6-hex RGB, and the size/name pair every entry states in full because a font-table entry is absolute, never a delta. Child emission order follows CT_Font's own listing (ECMA-376 Part 1 SS18.8.22's b/i/strike/u/sz/color/name members). +export interface DeclaredFont { + readonly bold?: boolean; + readonly italic?: boolean; + readonly underline?: boolean; + readonly strike?: boolean; + readonly colorRgb?: string; + readonly sz: string; + readonly name: string; +} + +// The normalisation every ContentFont member passes through before interning: absent or false booleans are off (an explicit false from a file whose own default was bold states nothing against THIS writer's not-bold entry 0), and absent size/family take the default font's own values -- so a font that restates only defaults collides with entry 0's signature and references it. +function normalisedFontOf(font: ContentFont | undefined): DeclaredFont { + if (font === undefined) { + return DEFAULT_FONT; + } + return { + bold: font.bold === true ? true : undefined, + italic: font.italic === true ? true : undefined, + underline: font.underline === true ? true : undefined, + strike: font.strike === true ? true : undefined, + colorRgb: font.color === undefined ? undefined : colorToRgbHex(font.color), + sz: String(font.sizePt ?? DEFAULT_FONT.sz), + name: font.fontFamily ?? DEFAULT_FONT.name, + }; +} + +function signatureOfFont(font: ContentFont | undefined): string { + const declared = normalisedFontOf(font); + let sig = `b:${declared.bold === true}`; + sig += `|i:${declared.italic === true}`; + sig += `|u:${declared.underline === true}`; + sig += `|s:${declared.strike === true}`; + sig += `|rgb:${declared.colorRgb ?? ""}`; + sig += `|sz:${declared.sz}`; + sig += `|n:${declared.name}`; + return sig; +} + // A deterministic signature for one ContentCellFill, shared by signatureOfDecoration (the cellXfs interning key) and CellFormatTable.internFill (the table's own dedup key) so the two can never disagree about which fills count as identical. function fillSignature(fill: ContentCellFill): string { return fill.kind === "solid" @@ -433,7 +578,8 @@ function fillSignature(fill: ContentCellFill): string { // A deterministic signature for a decoration, so two cells carrying identical decoration share one xf entry. widthPt is encoded with enough precision to round-trip the named-weight widths above (0.5/0.75/1.5/2.25) without floating-point drift producing spurious distinct entries. function signatureOfDecoration(decoration: CellFormatDecoration): string { - let sig = ""; + // The font segment is always present, never conditional: a font normalising back to the default (an absent font, or one restating only default values) must collide with the no-font signature exactly as it collides with entry 0 inside internFont, or a cell restating the default would mint a redundant xf of its own. + let sig = `|font:${signatureOfFont(decoration.font)}`; if (decoration.background !== undefined) { sig += `|bg:${fillSignature(decoration.background)}`; } @@ -494,9 +640,10 @@ export interface DeclaredBorder { }; } -// One resolved record: the numFmtId, fillId, borderId, and inline alignment the writer emits for that index, plus whether applyAlignment should be set. fontId/xfId are fixed (this writer interns no fonts and bases every cellXf on cellStyleXfs entry 0); numFmtId/fillId/borderId come straight from the three interning tables this class also drives. +// One resolved record: the numFmtId, fontId, fillId, borderId, and inline alignment the writer emits for that index, plus which apply* flags should be set. xfId is fixed (this writer bases every cellXf on cellStyleXfs entry 0); numFmtId/fontId/fillId/borderId come straight from the four interning tables this class also drives. export interface CellFormatRecord { numFmtId: number; + fontId: number; fillId: number; borderId: number; alignment?: { @@ -505,7 +652,7 @@ export interface CellFormatRecord { }; } -// The write-side counterpart to readCellStyles above, and a direct mirror of shared-strings.ts's own SharedStringTable: typed/xlsx/build.ts fills it on demand while it walks cells, and it hands back a stable index each time -- the value of that cell's own `s` attribute, an index into . What is deduplicated is the cell FORMAT as a whole: two cells wanting the same number format AND the same decoration share one xf entry, and two cells wanting the same custom CODE share one declaration too, exactly as a real producer's own output does. Fonts are single-entry throughout (one in ), so a font never contributes to the interning key -- only number format and decoration distinguish one xf from another in what this writer produces. +// The write-side counterpart to readCellStyles above, and a direct mirror of shared-strings.ts's own SharedStringTable: typed/xlsx/build.ts fills it on demand while it walks cells, and it hands back a stable index each time -- the value of that cell's own `s` attribute, an index into . What is deduplicated is the cell FORMAT as a whole: two cells wanting the same number format, font, AND decoration share one xf entry, and two cells wanting the same custom CODE, the same font, or the same fill share one // declaration too, exactly as a real producer's own output does. The font table always carries the DEFAULT_FONT at index 0, so a cell whose font normalises back to it references entry 0 -- the write-side mirror of the read side diffing every cell font against a file's own entry 0. export class CellFormatTable { private readonly indexBySignature = new Map([ [ @@ -517,11 +664,16 @@ export class CellFormatTable { private readonly records: CellFormatRecord[] = [ { numFmtId: GENERAL_NUM_FMT_ID, + fontId: DEFAULT_FONT_INDEX, fillId: NONE_FILL_INDEX, borderId: EMPTY_BORDER_INDEX, }, ]; private readonly declared: DeclaredNumberFormat[] = []; + private readonly fontIndexBySignature = new Map([ + [signatureOfFont(undefined), DEFAULT_FONT_INDEX], + ]); + private readonly fonts: DeclaredFont[] = [DEFAULT_FONT]; private readonly fillIndexBySignature = new Map(); private readonly fills: DeclaredFill[] = [ { kind: "none" }, @@ -545,6 +697,7 @@ export class CellFormatTable { format.kind === "builtin" ? format.id : this.declareNumberFormat(format.code); + const fontId = this.internFont(decoration.font); const fillId = decoration.background === undefined ? NONE_FILL_INDEX @@ -553,7 +706,7 @@ export class CellFormatTable { decoration.borders === undefined ? EMPTY_BORDER_INDEX : this.internBorder(decoration.borders); - const record: CellFormatRecord = { numFmtId, fillId, borderId }; + const record: CellFormatRecord = { numFmtId, fontId, fillId, borderId }; if ( decoration.alignment !== undefined || decoration.verticalAlignment !== undefined @@ -574,6 +727,11 @@ export class CellFormatTable { return this.declared; } + // The section: the DEFAULT_FONT entry first (index 0), then one font per distinct cell font actually interned, in first-intern order. + fontDeclarations(): readonly DeclaredFont[] { + return this.fonts; + } + // One numFmtId per cellXfs entry, in index order: the array index IS the value a cell's own `s` attribute carries. Kept for callers that consumed the original numFmtId-only view; cellFormatRecords() below is the richer entry point that also carries fillId/borderId/alignment. cellFormats(): readonly number[] { return this.records.map((record) => record.numFmtId); @@ -600,6 +758,20 @@ export class CellFormatTable { return id; } + // Interns one cell font against the normalisation signatureOfFont builds, so a ContentFont normalising back to the DEFAULT_FONT's own fields returns entry 0 and mints nothing -- the identical dedup discipline internFill/internBorder apply to their own tables. + private internFont(font: ContentFont | undefined): number { + const signature = signatureOfFont(font); + const existing = this.fontIndexBySignature.get(signature); + if (existing !== undefined) { + return existing; + } + const declared = normalisedFontOf(font); + const index = this.fonts.length; + this.fonts.push(declared); + this.fontIndexBySignature.set(signature, index); + return index; + } + private internFill(background: ContentCellFill): number { const signature = fillSignature(background); const existing = this.fillIndexBySignature.get(signature); From 3cf9852b3cb0eb99066c6fba54a6850c50678ac5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 04:57:32 +0100 Subject: [PATCH 2/6] feat(ooxml.js): carry xlsx defined names on the ContentDocument both ways The workbook's defined names now ride the ContentDocument's own names field: readWorkbookNames reads every xl/workbook.xml including the _xlnm built-ins, refersTo verbatim in Excel's own formula language, with localSheetId mapped onto scopeSheetIndex in the same sheets document order the reader resolves sheets in. The write side emits the names array verbatim and in its own order -- the file's own definedName order is the only order a same-format round trip can reproduce, and the array's refersTo (a multi-area print range, a quoted sheet name) is the higher-fidelity spelling of exactly the two _xlnm print names a structured printRange can restate. The print-settings derivation now fills in only the print names the array does not already carry, so a hand-built document stating a structured printRange still gets its reserved definedName while a read-back workbook writes each name exactly once, from the array. The namedRange half of the tree root's definitions table is retired: its content was exactly what names now carries across the flat/tree boundary natively, and a tree reader feeding both channels would double-write every general definedName on the way back out. The definitions option and table keep carrying Table/List objects, which genuinely have no flat spelling. A name's refersTo passes the same security gate a namedRange entry always did -- sheet-qualified internal A1 references only, refused by name -- since it is the identical live-formula surface. --- .../ooxml.js/src/typed/document-tree.test.ts | 69 ++++-- packages/ooxml.js/src/typed/document-tree.ts | 4 +- .../ooxml.js/src/typed/xlsx/build.test.ts | 206 +++++++++++++----- packages/ooxml.js/src/typed/xlsx/build.ts | 43 ++-- .../ooxml.js/src/typed/xlsx/content.test.ts | 18 ++ packages/ooxml.js/src/typed/xlsx/content.ts | 5 +- .../ooxml.js/src/typed/xlsx/defined-names.ts | 35 +++ .../src/typed/xlsx/definitions-write.ts | 104 +++------ .../ooxml.js/src/typed/xlsx/definitions.ts | 40 +--- 9 files changed, 319 insertions(+), 205 deletions(-) diff --git a/packages/ooxml.js/src/typed/document-tree.test.ts b/packages/ooxml.js/src/typed/document-tree.test.ts index 6f09faf53..c7ac107c6 100644 --- a/packages/ooxml.js/src/typed/document-tree.test.ts +++ b/packages/ooxml.js/src/typed/document-tree.test.ts @@ -746,20 +746,12 @@ describe("readXlsx / buildXlsxPackage: the xlsx DocumentTree boundary", () => { }; } - it("reads general defined names and table objects into the tree's definitions table, excluding the two _xlnm names print settings already carry", () => { - const tree = readXlsx(workbookWithTablesAndNames()); - expect(tree.definitions).toEqual({ - "namedRange:TaxRate": { - kind: "namedRange", - name: "TaxRate", - refersTo: "Summary!$B$1", - }, - "namedRange:ReportTitle": { - kind: "namedRange", - name: "ReportTitle", - refersTo: "Data!$A$1", - localSheetId: 0, - }, + it("reads table objects into the tree's definitions table, with defined names riding the tree's own names field instead", () => { + const wide = readXlsx(workbookWithTablesAndNames()); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet DocumentTree"); + } + expect(wide.definitions).toEqual({ "table:SalesTable": { kind: "table", name: "SalesTable", @@ -768,15 +760,39 @@ describe("readXlsx / buildXlsxPackage: the xlsx DocumentTree boundary", () => { columns: ["Item", "Amount"], }, }); + // The names field assembleTree spliced onto the root: every definedName including the _xlnm built-in, refersTo verbatim, localSheetId mapped onto scopeSheetIndex. + expect(wide.names).toEqual([ + { name: "TaxRate", refersTo: "Summary!$B$1" }, + { name: "ReportTitle", refersTo: "Data!$A$1", scopeSheetIndex: 0 }, + { + name: "_xlnm.Print_Area", + refersTo: "Data!$A$1:$C$4", + scopeSheetIndex: 0, + }, + ]); }); - it("leaves the definitions field absent for a workbook carrying no general names and no tables (the kitchen-sink fixture carries only Print_Area/Print_Titles)", () => { - expect( - readXlsx(decodePackage(fixtureBytes("kitchen-sink.xlsx"))).definitions, - ).toBeUndefined(); + it("leaves the definitions field absent for a workbook carrying no tables (the kitchen-sink fixture's defined names all ride names, which the fixture's two _xlnm print names populate)", () => { + const wide = readXlsx(decodePackage(fixtureBytes("kitchen-sink.xlsx"))); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet DocumentTree"); + } + expect(wide.definitions).toBeUndefined(); + expect(wide.names).toEqual([ + { + name: "_xlnm.Print_Area", + refersTo: "Data!$A$1:$I$20", + scopeSheetIndex: 0, + }, + { + name: "_xlnm.Print_Titles", + refersTo: "Data!$A:$A,Data!$1:$1", + scopeSheetIndex: 0, + }, + ]); }); - it("writes the tree's own definitions table back out (ExaDev/documents.js#973): buildXlsxPackage closes the row flattenTree itself cannot carry, while the flat write pair still emits neither a general defined name nor an xl/tables part", () => { + it("writes the tree's own definitions table back out (ExaDev/documents.js#973): buildXlsxPackage closes the row flattenTree itself cannot carry, while the flat write pair still emits no xl/tables part", () => { const pkg = workbookWithTablesAndNames(); const treePackage = buildXlsxPackage(readXlsx(pkg)); const flatPackage = buildXlsxPackageFromContent(readXlsxContent(pkg)); @@ -789,11 +805,24 @@ describe("readXlsx / buildXlsxPackage: the xlsx DocumentTree boundary", () => { expect(flattenTree(readXlsx(pkg))).toEqual(readXlsxContent(pkg)); }); - it("round-trips the tree's own definitions table through a real byte encode/decode: reading the freshly-built package back recovers the same general defined names and table object", () => { + it("round-trips the tree's own definitions table through a real byte encode/decode: reading the freshly-built package back recovers the same table object", () => { const pkg = workbookWithTablesAndNames(); const rebuilt = decodePackage( encodePackage(buildXlsxPackage(readXlsx(pkg))), ); expect(readXlsx(rebuilt).definitions).toEqual(readXlsx(pkg).definitions); }); + + it("round-trips the tree's own names field through a real byte encode/decode: reading the freshly-built package back recovers every defined name, scope and verbatim refersTo", () => { + const pkg = workbookWithTablesAndNames(); + const rebuilt = decodePackage( + encodePackage(buildXlsxPackage(readXlsx(pkg))), + ); + const reread = readXlsx(rebuilt); + const original = readXlsx(pkg); + if (reread.kind !== "spreadsheet" || original.kind !== "spreadsheet") { + throw new Error("expected spreadsheet DocumentTrees"); + } + expect(reread.names).toEqual(original.names); + }); }); diff --git a/packages/ooxml.js/src/typed/document-tree.ts b/packages/ooxml.js/src/typed/document-tree.ts index f4f4e8271..5f44722df 100644 --- a/packages/ooxml.js/src/typed/document-tree.ts +++ b/packages/ooxml.js/src/typed/document-tree.ts @@ -47,14 +47,14 @@ export function readPptx(pkg: Package): DocumentTree { // A decoded xlsx Package -> the tree-form DocumentTree, via readXlsxContent (the geometry- and print-settings-rich reader), which already returns a full ContentDocument envelope and so needs no envelope wrap here. Not to be confused with readXlsxWorkbook (typed/xlsx.ts): that is a different reading view of the same bytes -- cell values only, no write side, no ContentDocument shape to decompose. // -// The one thing this reader carries that its flat half cannot: the workbook's general defined names and table/List objects ride the tree root's definitions table (typed/xlsx/definitions.ts), the landing document-schema.js's own verdict gives a sheet-scoped named range -- no block-flow extent to wrap, so a definitions entry naming its range, and the definitions facility is tree-only. flattenTree drops the table on the way back down (document-schema.js's own rule -- the flat ContentDocument structurally cannot carry it), so buildXlsxPackage passes it to buildXlsxPackageFromContent as a SEPARATE option (typed/xlsx/build.ts's own BuildXlsxContentOptions) rather than through the flattened content itself: the write pair now closes this row (ExaDev/documents.js#973), while buildXlsxPackageFromContent(readXlsxContent(pkg)) alone still emits no xl/tables part and only the two _xlnm print names, exactly as before. +// The one thing this reader carries that its flat half cannot: the workbook's table/List objects ride the tree root's definitions table (typed/xlsx/definitions.ts), the landing document-schema.js's own verdict gives a sheet-scoped construct with no block-flow extent to wrap. Defined names no longer need that landing -- they ride the ContentDocument's own names field, which assembleTree splices onto the root and flattenTree splices back, so they cross the flat/tree boundary natively and a tree round trip writes them exactly once. flattenTree still drops the definitions table on the way back down (document-schema.js's own rule -- the flat ContentDocument structurally cannot carry it), so buildXlsxPackage passes it to buildXlsxPackageFromContent as a SEPARATE option (typed/xlsx/build.ts's own BuildXlsxContentOptions) rather than through the flattened content itself: the write pair now closes this row (ExaDev/documents.js#973), while buildXlsxPackageFromContent(readXlsxContent(pkg)) alone still emits no xl/tables part, exactly as before. export function readXlsx(pkg: Package): DocumentTree { const definitions = readWorkbookDefinitions(pkg); const tree = assembleTree(readXlsxContent(pkg)); return definitions === undefined ? tree : { ...tree, definitions }; } -// The inverse: a spreadsheet DocumentTree -> a complete, freshly-built xlsx Package. Exactly buildXlsxPackageFromContent's own fidelity, PLUS the tree's own definitions table (general defined names and Table/List objects, ExaDev/documents.js#973) -- flattenTree drops that table on the way to a flat ContentDocument, so it is threaded through as buildXlsxPackageFromContent's own options argument instead. +// The inverse: a spreadsheet DocumentTree -> a complete, freshly-built xlsx Package. Exactly buildXlsxPackageFromContent's own fidelity, PLUS the tree's own definitions table (Table/List objects, ExaDev/documents.js#973) -- flattenTree drops that table on the way to a flat ContentDocument, so it is threaded through as buildXlsxPackageFromContent's own options argument instead. The tree root's names field needs no such threading: it IS the flattened document's own names field, carried across by flattenTree itself. export function buildXlsxPackage(document: DocumentTree): Package { const content = flattenTree(document); if (content.kind !== "spreadsheet") { diff --git a/packages/ooxml.js/src/typed/xlsx/build.test.ts b/packages/ooxml.js/src/typed/xlsx/build.test.ts index 440c5c3db..4915a1c8f 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { + ContentDefinedName, ContentDocument, ContentEmbeddedObject, ContentSheet, @@ -10,7 +11,13 @@ import type { XmlElement } from "../../model/node"; import type { Package } from "../../model/package"; import { encodePackage } from "../../codec"; import { parsePackage } from "../../package-io/read"; -import { attr, childrenWithTag, decodeEntities, rootElement } from "../util"; +import { + attr, + childrenWithTag, + decodeEntities, + rootElement, + textContent, +} from "../util"; import { buildXlsxPackageFromContent } from "./build"; import { readXlsxContent } from "./content"; import { readWorkbookDefinitions } from "./definitions"; @@ -1751,17 +1758,7 @@ function tableDefinitions(): DefinitionsTable { }; } -function namedRangeDefinitions(): DefinitionsTable { - return { - "namedRange:TaxRate": { - kind: "namedRange", - name: "TaxRate", - refersTo: "Sheet1!$B$1", - }, - }; -} - -describe("buildXlsxPackageFromContent: definitions table (general defined names and Table objects)", () => { +describe("buildXlsxPackageFromContent: the definitions option (Table objects) and the document's own names", () => { it("writes a real xl/tables/tableN.xml plus a worksheet tableParts entry for a table definitions entry, and reading it back through readWorkbookDefinitions recovers the same entry", () => { const pkg = buildXlsxPackageFromContent(singleSheetDocument([]), { definitions: tableDefinitions(), @@ -1780,10 +1777,13 @@ describe("buildXlsxPackageFromContent: definitions table (general defined names expect(readWorkbookDefinitions(pkg)).toEqual(tableDefinitions()); }); - it("writes a real general for a namedRange definitions entry, and reading it back through readWorkbookDefinitions recovers the same entry", () => { - const pkg = buildXlsxPackageFromContent(singleSheetDocument([]), { - definitions: namedRangeDefinitions(), - }); + it("writes a real general for a names entry, and reading it back through the flat reader recovers the same entry verbatim", () => { + const wide = singleSheetDocument([]); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + wide.names = [{ name: "TaxRate", refersTo: "Sheet1!$B$1" }]; + const pkg = buildXlsxPackageFromContent(wide); const workbook = rootElement(pkg.parts["xl/workbook.xml"]); if (workbook === undefined) { throw new Error("expected xl/workbook.xml to have a root element"); @@ -1796,40 +1796,56 @@ describe("buildXlsxPackageFromContent: definitions table (general defined names (element) => attr(element, "name") === "TaxRate", ); expect(definedName).toBeDefined(); + if (definedName !== undefined) { + expect(textContent(definedName)).toBe("Sheet1!$B$1"); + } - expect(readWorkbookDefinitions(pkg)).toEqual(namedRangeDefinitions()); + const reread = readXlsxContent(pkg); + if (reread.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + expect(reread.names).toEqual([ + { name: "TaxRate", refersTo: "Sheet1!$B$1" }, + ]); }); - it("writes sheet-quoted ranges, unions, column ranges, and row ranges as named ranges, and recovers each through readWorkbookDefinitions", () => { - const definitions: DefinitionsTable = { - "namedRange:Quoted": { - kind: "namedRange", - name: "Quoted", - refersTo: "'Q1 Summary'!$A$1:$C$3", - }, - "namedRange:Union": { - kind: "namedRange", - name: "Union", - refersTo: "Sheet1!$A$1:$A$9,Sheet1!$C$1:$C$9", - }, - "namedRange:Columns": { - kind: "namedRange", - name: "Columns", - refersTo: "Sheet1!$A:$C", - }, - "namedRange:Rows": { - kind: "namedRange", - name: "Rows", - refersTo: "Sheet1!$1:$3", - }, - }; - const pkg = buildXlsxPackageFromContent(singleSheetDocument([]), { - definitions, - }); - expect(readWorkbookDefinitions(pkg)).toEqual(definitions); + it("writes scopeSheetIndex back as localSheetId, recovering the same sheet-scoped name", () => { + const wide = singleSheetDocument([]); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + wide.names = [ + { name: "ReportTitle", refersTo: "Sheet1!$A$1", scopeSheetIndex: 0 }, + ]; + const reread = readXlsxContent(buildXlsxPackageFromContent(wide)); + if (reread.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + expect(reread.names).toEqual([ + { name: "ReportTitle", refersTo: "Sheet1!$A$1", scopeSheetIndex: 0 }, + ]); + }); + + it("writes sheet-quoted ranges, unions, column ranges, and row ranges as names, and recovers each through the flat reader", () => { + const names: ContentDefinedName[] = [ + { name: "Quoted", refersTo: "'Q1 Summary'!$A$1:$C$3" }, + { name: "Union", refersTo: "Sheet1!$A$1:$A$9,Sheet1!$C$1:$C$9" }, + { name: "Columns", refersTo: "Sheet1!$A:$C" }, + { name: "Rows", refersTo: "Sheet1!$1:$3" }, + ]; + const wide = singleSheetDocument([]); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + wide.names = names; + const reread = readXlsxContent(buildXlsxPackageFromContent(wide)); + if (reread.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + expect(reread.names).toEqual(names); }); - it("refuses a named range whose refersTo carries formula or external-reference content, by name", () => { + it("refuses a name whose refersTo carries formula or external-reference content, by name", () => { const refused: readonly string[] = [ 'Sheet1!$A$1&WEBSERVICE("http://example.invalid/"&A1)', "SUM(Sheet1!$A$1:$A$9)", @@ -1840,20 +1856,98 @@ describe("buildXlsxPackageFromContent: definitions table (general defined names "#REF!", ]; for (const refersTo of refused) { - const definitions: DefinitionsTable = { - "namedRange:Danger": { - kind: "namedRange", - name: "Danger", - refersTo, - }, - }; - expect(() => - buildXlsxPackageFromContent(singleSheetDocument([]), { definitions }), - ).toThrow(/sheet-qualified internal A1 reference/); + const wide = singleSheetDocument([]); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + wide.names = [{ name: "Danger", refersTo }]; + expect(() => buildXlsxPackageFromContent(wide)).toThrow( + /sheet-qualified internal A1 reference/, + ); + } + }); + + it("writes a names entry's own print built-in VERBATIM, deriving one from print settings only when the array does not already carry it", () => { + const wide = singleSheetDocument([]); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + const sheet = wide.sheets[0]; + if (sheet === undefined) { + throw new Error("expected a sheet at index 0"); + } + sheet.printSettings = { + ...sheet.printSettings, + printRange: { startRow: 0, startColumn: 0, endRow: 9, endColumn: 1 }, + }; + wide.names = [ + // The names array's own refersTo is the higher-fidelity spelling -- the derived Print_Area for sheet 0 must not duplicate or replace it. + { + name: "_xlnm.Print_Area", + refersTo: "Sheet1!$A$1:$B$10,Sheet1!$D$1:$E$5", + scopeSheetIndex: 0, + }, + { name: "TaxRate", refersTo: "Sheet1!$B$1" }, + ]; + const pkg = buildXlsxPackageFromContent(wide); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + const definedNames = childrenWithTag(workbook, "definedNames")[0]; + if (definedNames === undefined) { + throw new Error("expected a container"); + } + const entries = childrenWithTag(definedNames, "definedName").map( + (element) => ({ + name: attr(element, "name"), + localSheetId: attr(element, "localSheetId"), + refersTo: textContent(element), + }), + ); + expect(entries).toEqual([ + { + name: "_xlnm.Print_Area", + localSheetId: "0", + refersTo: "Sheet1!$A$1:$B$10,Sheet1!$D$1:$E$5", + }, + { name: "TaxRate", localSheetId: undefined, refersTo: "Sheet1!$B$1" }, + ]); + }); + + it("derives the reserved print names from structured print settings when the names array carries none", () => { + const wide = singleSheetDocument([]); + if (wide.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet ContentDocument"); + } + const sheet = wide.sheets[0]; + if (sheet === undefined) { + throw new Error("expected a sheet at index 0"); + } + sheet.printSettings = { + ...sheet.printSettings, + printRange: { startRow: 0, startColumn: 0, endRow: 9, endColumn: 1 }, + }; + const pkg = buildXlsxPackageFromContent(wide); + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + throw new Error("expected xl/workbook.xml to have a root element"); + } + const definedNames = childrenWithTag(workbook, "definedNames")[0]; + if (definedNames === undefined) { + throw new Error("expected a container"); + } + const entries = childrenWithTag(definedNames, "definedName"); + expect(entries).toHaveLength(1); + const printArea = entries[0]; + if (printArea === undefined) { + throw new Error("expected a print-area definedName"); } + expect(attr(printArea, "name")).toBe("_xlnm.Print_Area"); + expect(attr(printArea, "localSheetId")).toBe("0"); }); - it("writes no container and no xl/tables part at all when no definitions are supplied", () => { + it("writes no container and no xl/tables part at all when no definitions are supplied and the document carries no names", () => { const pkg = buildXlsxPackageFromContent(singleSheetDocument([])); const workbook = rootElement(pkg.parts["xl/workbook.xml"]); if (workbook === undefined) { diff --git a/packages/ooxml.js/src/typed/xlsx/build.ts b/packages/ooxml.js/src/typed/xlsx/build.ts index ed0bffb2d..a9f9efb5b 100644 --- a/packages/ooxml.js/src/typed/xlsx/build.ts +++ b/packages/ooxml.js/src/typed/xlsx/build.ts @@ -1,5 +1,6 @@ import type { ContentCellValue, + ContentDefinedName, ContentDocument, ContentSheet, ContentSheetCell, @@ -61,9 +62,8 @@ import { import { buildDataValidationsElement } from "./data-validation"; import { buildThreadedCommentsRoot, sheetHasComments } from "./comments-write"; import { - buildGeneralDefinedNameElements, + buildNameDefinedNameElements, buildTablePart, - collectNamedRangeEntries, collectTableEntries, type TableEntry, } from "./definitions-write"; @@ -74,7 +74,7 @@ import { newDrawingCounters, } from "./drawings-write"; -// ContentDocument (kind: 'spreadsheet') -> Package: the first genuinely NEW xlsx package this ecosystem writes from scratch, rather than decoding/re-encoding an existing one -- every part below is constructed directly via xml/fragment.ts's el/txt, matching typed/xlsx/content.ts's own readXlsxContent as its read-side inverse: writing everything that reader reads, through the same number-format vocabulary that reader classifies (see renderCellValue and typed/xlsx/number-format.ts's own write-side section), and honestly re-approximating the one lossy conversion left on the way in (column-width characters). ContentSheetCell.comment now survives a round trip too, via the threaded-comments part comments-write.ts builds (see buildWorksheetPart's own note below). The reader's own drawing rows -- chart graphic frames (embeddedObjects) and pictures (images), typed/xlsx/drawings.ts -- now have a real write side too (ExaDev/documents.js#973, typed/xlsx/drawings-write.ts): every image and chart embedded object a sheet carries writes back out as a real xdr:oneCellAnchor in a genuine xl/drawings/drawingN.xml, plus xl/media/imageN. or xl/charts/chartN.xml as appropriate. The workbook's own definitions table (general defined names and Table/List objects, typed/xlsx/definitions.ts on the read side) closes the same way, via the optional `definitions` passed in BuildXlsxContentOptions (typed/xlsx/definitions-write.ts) -- flattenTree itself still cannot carry that table (it is a tree-only facility, document-schema.js's own rule), so buildXlsxPackage (typed/document-tree.ts) threads it through as this separate option rather than through the flattened ContentDocument. See typed/xlsx/content.test.ts and typed/xlsx/build.test.ts for the real-LibreOffice round-trip verification this pairing is built and tested against. +// ContentDocument (kind: 'spreadsheet') -> Package: the first genuinely NEW xlsx package this ecosystem writes from scratch, rather than decoding/re-encoding an existing one -- every part below is constructed directly via xml/fragment.ts's el/txt, matching typed/xlsx/content.ts's own readXlsxContent as its read-side inverse: writing everything that reader reads, through the same number-format vocabulary that reader classifies (see renderCellValue and typed/xlsx/number-format.ts's own write-side section), and honestly re-approximating the one lossy conversion left on the way in (column-width characters). ContentSheetCell.comment now survives a round trip too, via the threaded-comments part comments-write.ts builds (see buildWorksheetPart's own note below). The reader's own drawing rows -- chart graphic frames (embeddedObjects) and pictures (images), typed/xlsx/drawings.ts -- now have a real write side too (ExaDev/documents.js#973, typed/xlsx/drawings-write.ts): every image and chart embedded object a sheet carries writes back out as a real xdr:oneCellAnchor in a genuine xl/drawings/drawingN.xml, plus xl/media/imageN. or xl/charts/chartN.xml as appropriate. The workbook's own defined names now ride the ContentDocument's names field both ways (typed/xlsx/defined-names.ts's readWorkbookNames, definitions-write.ts's buildNameDefinedNameElements), and its table/List objects still ride the tree-only definitions table (typed/xlsx/definitions.ts on the read side, the optional `definitions` passed in BuildXlsxContentOptions on the write side) -- flattenTree cannot carry that table (it is a tree-only facility, document-schema.js's own rule), so buildXlsxPackage (typed/document-tree.ts) threads it through as this separate option rather than through the flattened ContentDocument. See typed/xlsx/content.test.ts and typed/xlsx/build.test.ts for the real-LibreOffice round-trip verification this pairing is built and tested against. // // This is the flat, content-level half of the xlsx write pair: buildXlsxPackage (typed/document-tree.ts) is the primary name, flattening a tree-form DocumentTree (styles-table refs materialised away) and handing the result straight to this function. @@ -285,15 +285,27 @@ function buildWorkbookRelsPart(sheetCount: number): XmlPart { return xmlPart(root); } -// --- xl/workbook.xml (sheets list + sheet-scoped Print_Area/Print_Titles defined names) -------------------------- +// --- xl/workbook.xml (sheets list + the document's own names + sheet-scoped Print_Area/Print_Titles defined names) --- + +// The (name, localSheetId) identity of one emitted definedName, the key the two emission passes reconcile against: a workbook never carries two definedNames of the same name and scope, so a derived print name whose (name, scope) the names array already carries verbatim is a second spelling of the one fact, derived only when the array does not carry it. An unscoped name keys on the empty sheet segment. +function definedNameKey( + name: string, + localSheetId: number | undefined, +): string { + return `${name}@${localSheetId ?? ""}`; +} function buildDefinedNameElements( sheets: readonly ContentSheet[], + carriedNames: ReadonlySet, ): XmlElement[] { const elements: XmlElement[] = []; sheets.forEach((sheet, sheetIndex) => { const { printRange, repeatRows, repeatColumns } = sheet.printSettings; - if (printRange !== undefined) { + if ( + printRange !== undefined && + !carriedNames.has(definedNameKey(XLNM_PRINT_AREA, sheetIndex)) + ) { const value = buildPrintAreaValue(sheet.name, printRange); elements.push( el( @@ -303,7 +315,10 @@ function buildDefinedNameElements( ), ); } - if (repeatRows !== undefined || repeatColumns !== undefined) { + if ( + (repeatRows !== undefined || repeatColumns !== undefined) && + !carriedNames.has(definedNameKey(XLNM_PRINT_TITLES, sheetIndex)) + ) { const value = buildPrintTitlesValue( sheet.name, repeatRows, @@ -325,7 +340,7 @@ function buildDefinedNameElements( function buildWorkbookPart( sheets: readonly ContentSheet[], - generalDefinedNameElements: readonly XmlElement[], + names: readonly ContentDefinedName[], ): XmlPart { const sheetElements = sheets.map((sheet, index) => el("sheet", { @@ -335,9 +350,12 @@ function buildWorkbookPart( }), ); const children: XmlElement[] = [el("sheets", {}, sheetElements)]; + // The document's own names array writes back VERBATIM and in its own order -- the file's own definedName order is the only order a same-format round trip can hope to reproduce, and the array's refersTo (a multi-area print range, a quoted sheet name) is the higher-fidelity spelling of exactly the two _xlnm print names a structured printRange/repeatRows can restate. The print-settings derivation then fills in only what the array does not carry: a hand-built document stating a structured printRange with no matching names entry still gets its reserved definedName. + const carriedNames = new Set(); + const nameElements = buildNameDefinedNameElements(names, carriedNames); const definedNameElements = [ - ...buildDefinedNameElements(sheets), - ...generalDefinedNameElements, + ...nameElements, + ...buildDefinedNameElements(sheets, carriedNames), ]; if (definedNameElements.length > 0) { children.push(el("definedNames", {}, definedNameElements)); @@ -1059,7 +1077,7 @@ function buildWorksheetPart( // --- entry point ----------------------------------------------------------------------------------------------- export interface BuildXlsxContentOptions { - // A workbook's general defined names and Table/List objects -- the tree reader's own root-level facility (typed/document-tree.ts's readXlsx/typed/xlsx/definitions.ts), passed straight through by buildXlsxPackage since flattenTree itself drops the table on the way down (document-schema.js's own rule -- the flat ContentDocument structurally cannot carry it). A caller driving this flat entry point directly may also supply one. + // A workbook's Table/List objects -- the tree reader's own root-level facility (typed/document-tree.ts's readXlsx/typed/xlsx/definitions.ts), passed straight through by buildXlsxPackage since flattenTree itself drops the table on the way down (document-schema.js's own rule -- the flat ContentDocument structurally cannot carry it). A caller driving this flat entry point directly may also supply one. Defined names are NOT this option's concern: they ride the ContentDocument's own names field both ways, so supplying them here is no longer possible. readonly definitions?: DefinitionsTable; } @@ -1096,9 +1114,6 @@ export function buildXlsxPackageFromContent( ...entry, id: index + 1, })); - const generalDefinedNameElements = buildGeneralDefinedNameElements( - collectNamedRangeEntries(options?.definitions), - ); const commentedSheetIndices: number[] = []; const drawingSheetIndices: number[] = []; @@ -1188,7 +1203,7 @@ export function buildXlsxPackageFromContent( usedImageFormats, ), "_rels/.rels": buildPackageRelsPart(), - "xl/workbook.xml": buildWorkbookPart(sheets, generalDefinedNameElements), + "xl/workbook.xml": buildWorkbookPart(sheets, document.names ?? []), "xl/_rels/workbook.xml.rels": buildWorkbookRelsPart(sheets.length), "xl/styles.xml": buildStylesPart(cellFormats, dxfTable), "xl/sharedStrings.xml": buildSharedStringsPart(sharedStrings), diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 3559199e5..0661e7dce 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -325,6 +325,23 @@ describe("readXlsxContent: kitchen-sink.xlsx (real LibreOffice output)", () => { expect(summary.printSettings.repeatColumns).toBeUndefined(); }); }); + + describe("the workbook's own defined names (names, refersTo verbatim)", () => { + it("carries every definedName the fixture declares, the two _xlnm print built-ins included, refersTo verbatim", () => { + expect(document.names).toEqual([ + { + name: "_xlnm.Print_Area", + refersTo: "Data!$A$1:$I$20", + scopeSheetIndex: 0, + }, + { + name: "_xlnm.Print_Titles", + refersTo: "Data!$A:$A,Data!$1:$1", + scopeSheetIndex: 0, + }, + ]); + }); + }); }); describe("readXlsxContent: minimal.xlsx (real LibreOffice output, default/unmodified sheet)", () => { @@ -410,6 +427,7 @@ describe("readXlsxContent: scope boundaries and error/fallback paths (synthetic expect(result.kind).toBe("spreadsheet"); if (result.kind === "spreadsheet") { expect(result.sheets).toEqual([]); + expect(result.names).toBeUndefined(); } }); diff --git a/packages/ooxml.js/src/typed/xlsx/content.ts b/packages/ooxml.js/src/typed/xlsx/content.ts index 5527165bf..22e45d3f1 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.ts @@ -20,7 +20,7 @@ import { import { readCoreProperties } from "../shared/metadata"; import { parseCellReference, parseRangeReference } from "document-schema.js"; import type { SheetDefinedNames } from "./defined-names"; -import { readDefinedNamesBySheet } from "./defined-names"; +import { readDefinedNamesBySheet, readWorkbookNames } from "./defined-names"; import type { NumberFormatClass } from "excel-number-format"; import { classifyNumberFormat } from "excel-number-format"; import { readSheetDrawing } from "./drawings"; @@ -588,9 +588,12 @@ export function readXlsxContent(pkg: Package): ContentDocument { dxfs, ), ); + // The workbook's own defined names (typed/xlsx/defined-names.ts's readWorkbookNames): every including the _xlnm built-ins, refersTo verbatim -- absent when the workbook carries none, so a plain workbook's document is field-for-field what it was. + const names = readWorkbookNames(pkg); return { kind: "spreadsheet", metadata: readCoreProperties(pkg), sheets, + ...(names.length > 0 ? { names } : {}), }; } diff --git a/packages/ooxml.js/src/typed/xlsx/defined-names.ts b/packages/ooxml.js/src/typed/xlsx/defined-names.ts index af047fe74..9b936362d 100644 --- a/packages/ooxml.js/src/typed/xlsx/defined-names.ts +++ b/packages/ooxml.js/src/typed/xlsx/defined-names.ts @@ -1,4 +1,5 @@ import type { + ContentDefinedName, ContentSheetPrintRange, ContentSheetRepeatRange, } from "document-schema.js"; @@ -59,6 +60,40 @@ export function readDefinedNamesBySheet( return map; } +// Every defined name in xl/workbook.xml as the schema's own ContentDefinedName carries it: refersTo VERBATIM (the element's own text, in Excel's own formula language -- the identical "no closed grammar without a general formula engine" reasoning ContentSheetCell.formula already records), and a sheet-scoped name's localSheetId mapped onto scopeSheetIndex, which indexes the SAME document order this package reads sheets in. The _xlnm built-ins are included rather than filtered: they are definedName entries like any other, and the two print names the print-settings reader additionally promotes into structured printRange/repeatRows fields still belong to the workbook's own name list. Order is the file's own document order, the only order a same-format writer can hope to reproduce. +export function readWorkbookNames(pkg: Package): ContentDefinedName[] { + const workbook = rootElement(pkg.parts["xl/workbook.xml"]); + if (workbook === undefined) { + return []; + } + const container = childrenWithTag(workbook, "definedNames")[0]; + if (container === undefined) { + return []; + } + const names: ContentDefinedName[] = []; + for (const definedName of childrenWithTag(container, "definedName")) { + const name = attr(definedName, "name"); + if (name === undefined) { + continue; + } + const localSheetIdRaw = attr(definedName, "localSheetId"); + const localSheetId = + localSheetIdRaw === undefined + ? undefined + : Number.parseInt(localSheetIdRaw, 10); + names.push({ + name, + refersTo: textContent(definedName), + ...(localSheetId !== undefined && + Number.isInteger(localSheetId) && + localSheetId >= 0 + ? { scopeSheetIndex: localSheetId } + : {}), + }); + } + return names; +} + // Strips a leading "SheetName!" (or "'Sheet Name'!") prefix from one reference segment. Excel sheet names cannot themselves contain "!" (a reserved formula character), so the LAST "!" in the segment unambiguously separates the sheet-name prefix from the cell/range reference that follows, with no need to parse the optional single-quote sheet-name quoting at all. function stripSheetPrefix(segment: string): string { const bang = segment.lastIndexOf("!"); diff --git a/packages/ooxml.js/src/typed/xlsx/definitions-write.ts b/packages/ooxml.js/src/typed/xlsx/definitions-write.ts index 668bc22ec..5c39a4e81 100644 --- a/packages/ooxml.js/src/typed/xlsx/definitions-write.ts +++ b/packages/ooxml.js/src/typed/xlsx/definitions-write.ts @@ -1,13 +1,15 @@ -import type { DefinitionEntry, DefinitionsTable } from "document-schema.js"; +import type { + ContentDefinedName, + DefinitionEntry, + DefinitionsTable, +} from "document-schema.js"; import type { XmlElement } from "../../model/node"; import { el, txt } from "../../xml/fragment"; import { encodeXmlText } from "../../xml/entities"; -// The write-side inverse of typed/xlsx/definitions.ts: a workbook's own DefinitionsTable (the tree reader's namedRange/table entries, ExaDev/documents.js#973) back into xl/workbook.xml's general and xl/tables/tableN.xml parts. definitions.ts's own header states the shape decisions this mirrors; this module owns only the entry validation and element construction, never part/relationship wiring, which stays in build.ts alongside every other part this writer assembles. +// The write-side inverse of typed/xlsx/definitions.ts and of the names half of typed/xlsx/defined-names.ts: a workbook's own table objects back into real xl/tables/tableN.xml parts, and the ContentDocument's own names array back into xl/workbook.xml's general . The definitions module's own header states the split this mirrors; this module owns only the entry validation and element construction, never part/relationship wiring, which stays in build.ts alongside every other part this writer assembles. // -// SECURITY BOUNDARY: a namedRange's refersTo is only ever written when it matches INTERNAL_RANGE_PATTERN below -- a sheet-qualified internal A1 reference and nothing else. A defined name is live formula context in every real spreadsheet application, so writing an attacker-shaped refersTo verbatim (a WEBSERVICE call, an external-workbook reference, a formula) would restore executable content the moment a recipient opens or recalculates the output, the exfiltration shape SECURITY.md's formula paragraph names. Refused values throw by name rather than degrading. - -const SML_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; +// SECURITY BOUNDARY: a name's refersTo is only ever written when it matches INTERNAL_RANGE_PATTERN below -- a sheet-qualified internal A1 reference and nothing else. A defined name is live formula context in every real spreadsheet application, so writing an attacker-shaped refersTo verbatim (a WEBSERVICE call, an external-workbook reference, a formula) would restore executable content the moment a recipient opens or recalculates the output, the exfiltration shape SECURITY.md's formula paragraph names. Refused values throw by name rather than degrading. function asString(value: unknown, field: string, kind: string): string { if (typeof value !== "string") { @@ -18,22 +20,6 @@ function asString(value: unknown, field: string, kind: string): string { return value; } -function asOptionalNumber( - value: unknown, - field: string, - kind: string, -): number | undefined { - if (value === undefined) { - return undefined; - } - if (typeof value !== "number") { - throw new Error( - `buildXlsxPackageFromContent: a "${kind}" definitions entry's "${field}" field must be a number`, - ); - } - return value; -} - function asStringArray(value: unknown, field: string, kind: string): string[] { if ( !Array.isArray(value) || @@ -46,12 +32,6 @@ function asStringArray(value: unknown, field: string, kind: string): string[] { return value; } -export interface NamedRangeEntry { - readonly name: string; - readonly refersTo: string; - readonly localSheetId: number | undefined; -} - // The one shape of refersTo this writer will place into an ACTIVE workbook defined-name context: a sheet-qualified internal A1 reference (a cell, a cell range, a column range, or a row range), optionally a comma-separated union of them, every area carrying its own sheet qualifier. Everything else is refused -- and "everything else" is exactly the executable-formula surface: parentheses carry function calls (a preserved WEBSERVICE(...&A1) name restores network exfiltration the moment a recipient recalculates), square brackets carry external-workbook references, and a bare unqualified range depends on whatever sheet context the opening application happens to resolve it in. XML escaping protects markup, not formula semantics; this boundary protects formula semantics. const SHEET_QUALIFIER_SOURCE = "(?:'[^']*'|[A-Za-z0-9_.]+)!"; const AREA_SOURCE = @@ -60,19 +40,13 @@ const INTERNAL_RANGE_PATTERN = new RegExp( `^${SHEET_QUALIFIER_SOURCE}${AREA_SOURCE}(?:,${SHEET_QUALIFIER_SOURCE}${AREA_SOURCE})*$`, ); -function asInternalRangeRefersTo( - value: unknown, - field: string, - kind: string, - name: string, -): string { - const refersTo = asString(value, field, kind); - if (!INTERNAL_RANGE_PATTERN.test(refersTo)) { +function asInternalRangeRefersTo(value: string, name: string): string { + if (!INTERNAL_RANGE_PATTERN.test(value)) { throw new Error( - `buildXlsxPackageFromContent: the named range "${name}"'s refersTo must be a sheet-qualified internal A1 reference (a cell, cell range, column range, or row range, optionally a comma-separated union) -- '${refersTo}' carries formula or external-reference content this writer refuses to place into an active defined-name context`, + `buildXlsxPackageFromContent: the defined name "${name}"'s refersTo must be a sheet-qualified internal A1 reference (a cell, cell range, column range, or row range, optionally a comma-separated union) -- '${value}' carries formula or external-reference content this writer refuses to place into an active defined-name context`, ); } - return refersTo; + return value; } export interface TableEntry { @@ -88,34 +62,7 @@ function definitionEntries( return definitions === undefined ? [] : Object.values(definitions); } -// Every 'namedRange' definitions entry (typed/xlsx/definitions.ts's own readNamedRanges), validated field-by-field rather than trusted -- a caller-constructed DefinitionsTable is only schema-checked down to DefinitionEntry's own loose `{kind: string}` shape, so a malformed per-tenant field fails loudly here instead of writing a workbook that silently drops or mis-types the range. -export function collectNamedRangeEntries( - definitions: DefinitionsTable | undefined, -): NamedRangeEntry[] { - const entries: NamedRangeEntry[] = []; - for (const entry of definitionEntries(definitions)) { - if (entry.kind !== "namedRange") { - continue; - } - entries.push({ - name: asString(entry.name, "name", "namedRange"), - refersTo: asInternalRangeRefersTo( - entry.refersTo, - "refersTo", - "namedRange", - typeof entry.name === "string" ? entry.name : String(entry.name), - ), - localSheetId: asOptionalNumber( - entry.localSheetId, - "localSheetId", - "namedRange", - ), - }); - } - return entries; -} - -// Every 'table' definitions entry (typed/xlsx/definitions.ts's own readTableEntries), same validation discipline as collectNamedRangeEntries above. +// Every 'table' definitions entry (typed/xlsx/definitions.ts's own readTableEntries), validated field-by-field rather than trusted -- a caller-constructed DefinitionsTable is only schema-checked down to DefinitionEntry's own loose `{kind: string}` shape, so a malformed per-tenant field fails loudly here instead of writing a workbook that silently drops or mis-types the range. export function collectTableEntries( definitions: DefinitionsTable | undefined, ): TableEntry[] { @@ -134,17 +81,26 @@ export function collectTableEntries( return entries; } -// xl/workbook.xml elements for the workbook's general named ranges -- alongside, never replacing, buildDefinedNameElements' own two reserved _xlnm.Print_Area/_xlnm.Print_Titles names (build.ts merges both lists into one container). -export function buildGeneralDefinedNameElements( - entries: readonly NamedRangeEntry[], +// xl/workbook.xml elements for the document's own names array, written VERBATIM and in the array's own order -- alongside, never replacing, buildDefinedNameElements' own two reserved _xlnm.Print_Area/_xlnm.Print_Titles names (build.ts merges both lists into one container, names first). `carriedNames` is filled with the (name, localSheetId) identity of every entry emitted here, so the print-settings derivation pass can confine itself to the print names the array does not already carry -- a workbook never carries two definedNames of the same name and scope, and the array's own refersTo is the higher-fidelity spelling of exactly the two it restates. +export function buildNameDefinedNameElements( + names: readonly ContentDefinedName[], + carriedNames: Set, ): XmlElement[] { - return entries.map((entry) => { + const elements: XmlElement[] = []; + for (const entry of names) { + const localSheetId = entry.scopeSheetIndex; + carriedNames.add(`${entry.name}@${localSheetId ?? ""}`); const attrs: Record = { name: encodeXmlText(entry.name) }; - if (entry.localSheetId !== undefined) { - attrs.localSheetId = String(entry.localSheetId); + if (localSheetId !== undefined) { + attrs.localSheetId = String(localSheetId); } - return el("definedName", attrs, [txt(encodeXmlText(entry.refersTo))]); - }); + elements.push( + el("definedName", attrs, [ + txt(encodeXmlText(asInternalRangeRefersTo(entry.refersTo, entry.name))), + ]), + ); + } + return elements; } // One xl/tables/tableN.xml part for one TableEntry: CT_Table's own required id/name/displayName/ref quartet, an spanning the same ref (every real producer emits one, even for a table that filters nothing), and in the entry's own column order -- the exact inverse of definitions.ts's readTableEntries, which reads name/ref/columns back through this identical wrapper shape. id is workbook-scoped (build.ts assigns it as the table's own 1-based position across every table entry, matching CT_Table/@id's own "unique within the workbook" rule); displayName mirrors name verbatim, since DefinitionEntry carries only the one producer-facing name. @@ -155,7 +111,7 @@ export function buildTablePart(entry: TableEntry, id: number): XmlElement { return el( "table", { - xmlns: SML_NS, + xmlns: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", id: String(id), name: encodeXmlText(entry.name), displayName: encodeXmlText(entry.name), diff --git a/packages/ooxml.js/src/typed/xlsx/definitions.ts b/packages/ooxml.js/src/typed/xlsx/definitions.ts index d55b54938..c7d348ee1 100644 --- a/packages/ooxml.js/src/typed/xlsx/definitions.ts +++ b/packages/ooxml.js/src/typed/xlsx/definitions.ts @@ -5,48 +5,13 @@ import { childrenWithTag, resolveRelationships, rootElement, - textContent, } from "../util"; import { resolveSheetEntries } from "./content"; -import { XLNM_PRINT_AREA, XLNM_PRINT_TITLES } from "./defined-names"; -// A workbook's sheet-scoped named expressions: general defined names (xl/workbook.xml's beyond the two _xlnm print names the print-settings reader already consumes) and table/List objects (xl/tables/*.xml, reached through each worksheet's own relationships). document-schema.js's own verdict for this construct family is that a named range has no block-flow extent to wrap, so it rides a definitions-table entry naming its range -- and the definitions table is a tree-root facility the flat ContentDocument structurally cannot carry, which is why readXlsx (the tree reader) attaches these and readXlsxContent (the flat reader) cannot. Entries are keyed by family-prefixed producer name (namedRange:TaxRate, table:SalesTable), so a range and a table may share a bare name without colliding; per-tenant fields are the producer's own vocabulary, exactly as the definitions facility specifies. +// A workbook's table/List objects (xl/tables/*.xml, reached through each worksheet's own relationships), read into the tree root's definitions table -- keyed table:SalesTable by producer name, per-tenant fields the producer's own vocabulary, exactly as the definitions facility specifies. This table is tree-only because its other half moved out: the workbook's general defined names now ride the ContentDocument's own names field (typed/xlsx/defined-names.ts's readWorkbookNames, ExaDev/documents.js's per-cell-fonts-and-names schema widening), which crosses the flat/tree boundary natively where a definitions entry cannot -- so a named range needs no definitions entry of its own here, and duplicating it in both channels would double-write every general definedName on a tree round trip. -const WORKBOOK_PATH = "xl/workbook.xml"; const TABLE_REL_SUFFIX = "/table"; -function readNamedRanges(pkg: Package, out: DefinitionsTable): void { - const workbook = rootElement(pkg.parts[WORKBOOK_PATH]); - const container = - workbook === undefined - ? undefined - : childrenWithTag(workbook, "definedNames")[0]; - if (container === undefined) { - return; - } - for (const definedName of childrenWithTag(container, "definedName")) { - const name = attr(definedName, "name"); - if ( - name === undefined || - name === XLNM_PRINT_AREA || - name === XLNM_PRINT_TITLES - ) { - continue; - } - const localSheetIdRaw = attr(definedName, "localSheetId"); - const localSheetId = - localSheetIdRaw === undefined - ? undefined - : Number.parseInt(localSheetIdRaw, 10); - out[`namedRange:${name}`] = { - kind: "namedRange", - name, - refersTo: textContent(definedName), - ...(Number.isInteger(localSheetId) ? { localSheetId } : {}), - }; - } -} - function readTableEntries(pkg: Package, out: DefinitionsTable): void { for (const entry of resolveSheetEntries(pkg)) { for (const rel of resolveRelationships(pkg, entry.path).values()) { @@ -81,12 +46,11 @@ function readTableEntries(pkg: Package, out: DefinitionsTable): void { } } -// The workbook's definitions table, or undefined when it carries no general defined name and no table object -- absent rather than empty, so a plain workbook's tree is field-for-field what it was. +// The workbook's definitions table, or undefined when it carries no table object -- absent rather than empty, so a plain workbook's tree is field-for-field what it was. export function readWorkbookDefinitions( pkg: Package, ): DefinitionsTable | undefined { const out: DefinitionsTable = {}; - readNamedRanges(pkg, out); readTableEntries(pkg, out); return Object.keys(out).length === 0 ? undefined : out; } From 8e1a2ebc68a1d0bc087a66534efa2fdcf5777f16 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 05:01:23 +0100 Subject: [PATCH 3/6] feat(ooxml.js): read and write docx vertAlign, rtl, and bidi w:vertAlign lands on ContentRun.verticalAlign and w:rtl on ContentRun.direction, both resolved through the same style cascade bold and italic already ride (docDefaults through the character-style chain to direct formatting), since both are ordinary rPr members a style can state and inherit. The one wrinkle is vertAlign val= "baseline": that is the explicit none-of-the-above a producer writes to turn an inherited position off, so it wins the cascade merge as a value of its own and then disappears -- ContentRun models baseline as the field's absence, and the writer materialises the resolved position as direct formatting, so the override survives as plain text. w:bidi (the genuine WordprocessingML paragraph-level spelling, right- to-left paragraph layout rather than run direction) lands on ContentParagraph.direction through the paragraph cascade the same way. Both writers spell an explicitly left-to-right value with the off form (w:rtl/w:bidi val="0"), mirroring how bold: false is written: an absent element is "inherit" to the read-side cascade, not "off", so a resolved ltr must be stated rather than omitted. Element placement follows CT_PPrBase (bidi between numPr and spacing) and CT_RPr (vertAlign and rtl after u). --- packages/ooxml.js/src/typed/docx/read.test.ts | 110 ++++++++++++++++++ packages/ooxml.js/src/typed/docx/read.ts | 6 + packages/ooxml.js/src/typed/docx/styles.ts | 27 +++++ packages/ooxml.js/src/typed/docx/write.ts | 13 ++- 4 files changed, 155 insertions(+), 1 deletion(-) diff --git a/packages/ooxml.js/src/typed/docx/read.test.ts b/packages/ooxml.js/src/typed/docx/read.test.ts index d80dd43be..56d91edac 100644 --- a/packages/ooxml.js/src/typed/docx/read.test.ts +++ b/packages/ooxml.js/src/typed/docx/read.test.ts @@ -619,6 +619,116 @@ describe("readDocxContent: run text with tab/break", () => { }); }); +describe("readDocxContent: verticalAlign and direction (w:vertAlign/w:rtl/w:bidi)", () => { + it("reads w:vertAlign superscript/subscript onto ContentRun.verticalAlign, and w:rtl onto ContentRun.direction", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:rPr", {}, [el("w:vertAlign", { "w:val": "superscript" })]), + el("w:t", {}, [txt("above")]), + ]), + el("w:r", {}, [ + el("w:rPr", {}, [el("w:vertAlign", { "w:val": "subscript" })]), + el("w:t", {}, [txt("below")]), + ]), + el("w:r", {}, [ + el("w:rPr", {}, [el("w:rtl")]), + el("w:t", {}, [txt("right to left")]), + ]), + el("w:r", {}, [ + el("w:rPr", {}, [el("w:rtl", { "w:val": "0" })]), + el("w:t", {}, [txt("explicitly ltr")]), + ]), + textRun("plain"), + ]); + const doc = readDocxContent(paragraphPackage(paragraph)); + const runs = firstParagraph(doc).runs; + expect(runs.map((run) => run.verticalAlign)).toEqual([ + "superscript", + "subscript", + undefined, + undefined, + undefined, + ]); + expect(runs.map((run) => run.direction)).toEqual([ + undefined, + undefined, + "rtl", + "ltr", + undefined, + ]); + }); + + it("reads a baseline vertAlign as the explicit override of an inherited position, stating nothing on the run", () => { + // The named character style supersedes its basedOn chain: the chain says superscript, the direct rPr turns it back off, and the resolved run carries no verticalAlign -- baseline, the schema's own spelling of the field's absence. + const styles = el("w:styles", {}, [ + el( + "w:style", + { "w:type": "paragraph", "w:styleId": "Normal", "w:default": "1" }, + [], + ), + el("w:style", { "w:type": "character", "w:styleId": "Sup" }, [ + el("w:basedOn", { "w:val": "Normal" }), + el("w:rPr", {}, [el("w:vertAlign", { "w:val": "superscript" })]), + ]), + ]); + const paragraph = el("w:p", {}, [ + el("w:r", {}, [ + el("w:rPr", {}, [ + el("w:rStyle", { "w:val": "Sup" }), + el("w:vertAlign", { "w:val": "baseline" }), + ]), + el("w:t", {}, [txt("flattened")]), + ]), + ]); + const doc = readDocxContent( + paragraphPackage(paragraph, { + "word/styles.xml": { kind: "xml", nodes: [styles] }, + }), + ); + expect(firstParagraph(doc).runs[0]?.verticalAlign).toBeUndefined(); + }); + + it("reads w:bidi onto ContentParagraph.direction, both on and explicitly off", () => { + const on = readDocxContent( + paragraphPackage( + el("w:p", {}, [ + el("w:pPr", {}, [el("w:bidi")]), + textRun("rtl paragraph"), + ]), + ), + ); + expect(firstParagraph(on).direction).toBe("rtl"); + const off = readDocxContent( + paragraphPackage( + el("w:p", {}, [ + el("w:pPr", {}, [el("w:bidi", { "w:val": "0" })]), + textRun("explicitly ltr paragraph"), + ]), + ), + ); + expect(firstParagraph(off).direction).toBe("ltr"); + }); + + it("round-trips verticalAlign, run direction, and paragraph direction through buildDocxPackageFromContent", () => { + const paragraph = el("w:p", {}, [ + el("w:pPr", {}, [el("w:bidi")]), + el("w:r", {}, [ + el("w:rPr", {}, [ + el("w:vertAlign", { "w:val": "superscript" }), + el("w:rtl"), + ]), + el("w:t", {}, [txt("everything at once")]), + ]), + ]); + const before = readDocxContent(paragraphPackage(paragraph)); + const after = readDocxContent(buildDocxPackageFromContent(before)); + const roundTripped = firstParagraph(after); + expect(roundTripped.direction).toBe("rtl"); + expect(roundTripped.runs[0]?.verticalAlign).toBe("superscript"); + expect(roundTripped.runs[0]?.direction).toBe("rtl"); + }); +}); + describe("readDocxContent: tables", () => { it("reads column widths and a horizontally-merged cell's colSpan and background", () => { const doc = readDocxContent(buildFixturePackage()); diff --git a/packages/ooxml.js/src/typed/docx/read.ts b/packages/ooxml.js/src/typed/docx/read.ts index c23822d6d..c97fd921d 100644 --- a/packages/ooxml.js/src/typed/docx/read.ts +++ b/packages/ooxml.js/src/typed/docx/read.ts @@ -485,6 +485,10 @@ function readRun( fontFamily: props.fontFamily, sizePt: props.sizePt, color: props.color, + // "baseline" is the cascade's own explicit-override spelling, not a position ContentRun states: the schema models baseline as verticalAlign's absence, so the resolved layer value disappears here exactly as the resolved formatting it overrode did. + verticalAlign: + props.verticalAlign === "baseline" ? undefined : props.verticalAlign, + direction: props.rtl === undefined ? undefined : props.rtl ? "rtl" : "ltr", }; } @@ -847,6 +851,8 @@ function readParagraph( lineSpacing: props.lineSpacing, indentLeftPt: props.indentLeftPt, indentFirstLinePt: props.indentFirstLinePt, + direction: + props.bidi === undefined ? undefined : props.bidi ? "rtl" : "ltr", borders: readParagraphBorders(pPr), }, pageBreak: events.pageBreak, diff --git a/packages/ooxml.js/src/typed/docx/styles.ts b/packages/ooxml.js/src/typed/docx/styles.ts index 057dd3528..a9e072700 100644 --- a/packages/ooxml.js/src/typed/docx/styles.ts +++ b/packages/ooxml.js/src/typed/docx/styles.ts @@ -28,8 +28,13 @@ export interface ResolvedParagraphProperties { readonly indentFirstLinePt?: number; // w:pPr/w:outlineLvl verbatim, 0-based (0 is a level-1 heading). This is ECMA-376's own "this paragraph style is a heading at level N" mechanism, inherited through w:basedOn like every other field here -- which is why a custom style based on Heading2 resolves without name-matching the styleId. Kept raw because this interface mirrors the cascade; readParagraph converts it to the schema's 1-based headingLevel. readonly outlineLvl?: number; + // w:pPr/w:bidi (ECMA-376 Part 1 17.3.1.6, "Right to Left Paragraph Layout") resolved through the cascade: true means the paragraph lays out right-to-left, false an explicit left-to-right, absent unspecified. + readonly bidi?: boolean; } +// One layer's w:vertAlign value: superscript/subscript map onto ContentRun.verticalAlign's own members, while "baseline" is the cascade's own explicit-override spelling -- a layer stating baseline turns OFF a lower layer's inherited superscript or subscript, so it must win the merge and then disappear (ContentRun models baseline as the field's absence). The bare string union keeps mergeRunLayer's layer-wins semantics uniform across all members. +type VerticalAlignLayer = "superscript" | "subscript" | "baseline" | undefined; + export interface ResolvedRunProperties { readonly bold?: boolean; readonly italic?: boolean; @@ -38,6 +43,9 @@ export interface ResolvedRunProperties { readonly fontFamily?: string; readonly sizePt?: number; readonly color?: Color; + readonly verticalAlign?: VerticalAlignLayer; + // w:rPr/w:rtl (ECMA-376 Part 1 17.3.2.30) resolved through the cascade: true means the run's text is right-to-left, false an explicit left-to-right, absent unspecified. The direction field this feeds is the schema's own run-level scope, the WordprocessingML spelling RTF's \rtlch/\ltrch pair states at the same level. + readonly rtl?: boolean; } function mergeParagraphLayer( @@ -52,6 +60,7 @@ function mergeParagraphLayer( indentLeftPt: layer.indentLeftPt ?? base.indentLeftPt, indentFirstLinePt: layer.indentFirstLinePt ?? base.indentFirstLinePt, outlineLvl: layer.outlineLvl ?? base.outlineLvl, + bidi: layer.bidi ?? base.bidi, }; } @@ -67,6 +76,8 @@ function mergeRunLayer( fontFamily: layer.fontFamily ?? base.fontFamily, sizePt: layer.sizePt ?? base.sizePt, color: layer.color ?? base.color, + verticalAlign: layer.verticalAlign ?? base.verticalAlign, + rtl: layer.rtl ?? base.rtl, }; } @@ -186,6 +197,17 @@ function readRunFontFamily( return undefined; } +// w:vertAlign/@w:val (CT_VerticalAlignRun, ST_VerticalAlignRun: baseline/superscript/subscript): superscript and subscript survive onto ContentRun.verticalAlign, and "baseline" is the explicit none-of-the-above a producer writes to turn an inherited position off -- an unrecognised or absent value leaves the layer unspecified rather than guessing a position. +function readVerticalAlignLayer( + vertAlign: XmlElement | undefined, +): VerticalAlignLayer { + const val = vertAlign === undefined ? undefined : attr(vertAlign, "w:val"); + if (val === "superscript" || val === "subscript" || val === "baseline") { + return val; + } + return undefined; +} + function readRunPropertiesLayer( rPr: XmlElement | undefined, theme: DrawingTheme, @@ -203,6 +225,10 @@ function readRunPropertiesLayer( fontFamily: readRunFontFamily(childrenWithTag(rPr, "w:rFonts")[0], theme), sizePt: szVal === undefined ? undefined : halfPointsToPt(Number(szVal)), color: readRunColor(childrenWithTag(rPr, "w:color")[0], theme), + verticalAlign: readVerticalAlignLayer( + childrenWithTag(rPr, "w:vertAlign")[0], + ), + rtl: readToggle(childrenWithTag(rPr, "w:rtl")[0]), }; } @@ -264,6 +290,7 @@ function readParagraphPropertiesLayer( ? -twipsToPt(Number(hanging)) : undefined, outlineLvl: outlineLvlVal === undefined ? undefined : Number(outlineLvlVal), + bidi: readToggle(childrenWithTag(pPr, "w:bidi")[0]), }; } diff --git a/packages/ooxml.js/src/typed/docx/write.ts b/packages/ooxml.js/src/typed/docx/write.ts index 74b496c7e..24795c825 100644 --- a/packages/ooxml.js/src/typed/docx/write.ts +++ b/packages/ooxml.js/src/typed/docx/write.ts @@ -322,6 +322,13 @@ function buildRunProperties(run: ContentRun): XmlElement | undefined { if (run.underline !== undefined) { children.push(el("w:u", { "w:val": run.underline ? "single" : "none" })); } + if (run.verticalAlign !== undefined) { + children.push(el("w:vertAlign", { "w:val": run.verticalAlign })); + } + // An explicitly left-to-right run says so with the off spelling, mirroring bold: false -- an absent w:rtl is "inherit" to the read-side cascade, not "left-to-right", so a resolved ltr must be spelled rather than omitted. + if (run.direction !== undefined) { + children.push(toggleElement("w:rtl", run.direction === "rtl")); + } return children.length === 0 ? undefined : el("w:rPr", {}, children); } @@ -375,7 +382,7 @@ const JUSTIFICATION_BY_ALIGNMENT: Readonly> = { justify: "both", }; -// CT_PPr's own child sequence, which Word enforces: pStyle, pageBreakBefore, numPr, spacing, ind, jc, outlineLvl. An indentFirstLinePt is w:firstLine when positive and w:hanging (the signed inverse) when negative, matching the convention readParagraphPropertiesLayer reads it back through. +// CT_PPr's own child sequence, which Word enforces: pStyle, pageBreakBefore, numPr, bidi, spacing, ind, jc, outlineLvl. An indentFirstLinePt is w:firstLine when positive and w:hanging (the signed inverse) when negative, matching the convention readParagraphPropertiesLayer reads it back through. function buildParagraphProperties( paragraph: ContentParagraph, pageBreakBefore: boolean, @@ -400,6 +407,10 @@ function buildParagraphProperties( } children.push(el("w:numPr", {}, numPrChildren)); } + // CT_PPrBase places bidi between the numPr family and spacing; an explicitly left-to-right paragraph says so with the off spelling, the same discipline w:rtl's own writer below applies at run level. + if (paragraph.direction !== undefined) { + children.push(toggleElement("w:bidi", paragraph.direction === "rtl")); + } const spacing: Record = {}; if (paragraph.spacingBeforePt !== undefined) { spacing["w:before"] = String(ptToTwips(paragraph.spacingBeforePt)); From 5a538548fab30867d2c611e9380b13769f3e7dee Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 05:04:40 +0100 Subject: [PATCH 4/6] feat(ooxml.js): state chart and diagram origin on the content nodes The origin annotation channel names what a node's content IS, and the two graphic-frame kinds that establish it now state it: a chart's cached-model table (pptx) and embedded chart object (xlsx) carry origin: 'chart', and a SmartArt graphic frame's shape carries origin: 'diagram' -- distinguishing a native chart part's exact numbers and a diagram's own node text from the identical-looking content a pasted screenshot or freeform text box would produce, which no other field on those nodes says. Stated only where the reader genuinely knows it: an authored a:tbl, a plain text shape, an OLE fallback picture, and a sheet's own cells carry no origin at all, the channel's absent-means-ordinary-prose convention. --- packages/ooxml.js/src/typed/pptx/chart.ts | 1 + packages/ooxml.js/src/typed/pptx/read.test.ts | 15 +++++++++++++++ packages/ooxml.js/src/typed/pptx/read.ts | 5 +++++ packages/ooxml.js/src/typed/xlsx/content.test.ts | 1 + packages/ooxml.js/src/typed/xlsx/drawings.ts | 2 ++ 5 files changed, 24 insertions(+) diff --git a/packages/ooxml.js/src/typed/pptx/chart.ts b/packages/ooxml.js/src/typed/pptx/chart.ts index 63e314dca..373d6cf98 100644 --- a/packages/ooxml.js/src/typed/pptx/chart.ts +++ b/packages/ooxml.js/src/typed/pptx/chart.ts @@ -130,6 +130,7 @@ export function readChartTable( const columnWidthPt = frame.widthPt / (series.length + 1); return { kind: "table", + // origin names what this table's content IS: a chart's cached numbers, not an authored data table -- the fact that separates it from the identical-looking table a pasted screenshot of the same chart would produce, which no other field on the node carries (the schema's motivating case for the annotation channel). origin: "chart", rows, columnWidthsPt: Array.from( diff --git a/packages/ooxml.js/src/typed/pptx/read.test.ts b/packages/ooxml.js/src/typed/pptx/read.test.ts index 675683d30..8756c00f4 100644 --- a/packages/ooxml.js/src/typed/pptx/read.test.ts +++ b/packages/ooxml.js/src/typed/pptx/read.test.ts @@ -1379,6 +1379,13 @@ describe("readPptxContent: chart graphic frames", () => { expect(cellText(table, 0, 2)).toBe("Cost"); }); + it("marks the cached-model table origin: 'chart' -- a chart's numbers, not an authored data table", () => { + const doc = readPptxContent(chartFixturePackage()); + const chartShape = doc.slides[0]?.shapes.find((s) => s.name === "Chart 1"); + const table = asTable(chartShape?.blocks[0]); + expect(table.origin).toBe("chart"); + }); + it("reads one row per category index, in index order, with each series' cached value in its own column", () => { const doc = readPptxContent(chartFixturePackage()); const chartShape = doc.slides[0]?.shapes.find((s) => s.name === "Chart 1"); @@ -1591,6 +1598,14 @@ describe("readPptxContent: SmartArt graphic frames", () => { ]); }); + it("marks the shape origin: 'diagram' -- the diagram's own node text, not freeform slide prose", () => { + const doc = readPptxContent(smartArtFixturePackage()); + const diagramShape = doc.slides[0]?.shapes.find( + (s) => s.name === "Diagram 1", + ); + expect(diagramShape?.origin).toBe("diagram"); + }); + it("keeps the frame's geometry with empty content when the data model relationship resolves to no readable part", () => { const pkg = smartArtFixturePackage(); delete pkg.parts["ppt/diagrams/data1.xml"]; diff --git a/packages/ooxml.js/src/typed/pptx/read.ts b/packages/ooxml.js/src/typed/pptx/read.ts index 724b5122c..8bc2464d6 100644 --- a/packages/ooxml.js/src/typed/pptx/read.ts +++ b/packages/ooxml.js/src/typed/pptx/read.ts @@ -11,6 +11,7 @@ import type { ContentCellPatternType, ContentEmbeddedObjectBlock, ContentImageBlock, + ContentOrigin, ContentParagraph, ContentRun, ContentShape, @@ -836,6 +837,8 @@ function readGraphicFrameShape( : undefined; let blocks: ContentBlock[]; let shapeSource: SourceResidue | undefined; + // origin names what this shape's content IS, when the graphic-frame kind establishes it: a SmartArt diagram's blocks are the diagram's own node text, not freeform slide prose -- the annotation channel's motivating distinction, stated only where the reader genuinely knows it. + let shapeOrigin: ContentOrigin | undefined; if (tbl !== undefined) { blocks = [readTable(tbl, context, slideRels)]; } else if (uri === CHART_GRAPHIC_URI && graphicData !== undefined) { @@ -867,6 +870,7 @@ function readGraphicFrameShape( relPartRoot("r:qs"), relPartRoot("r:cs"), ); + shapeOrigin = "diagram"; } else if (uri === OLE_GRAPHIC_URI && graphicData !== undefined) { // What the slide actually displays is the OLE object's fallback picture (mc:Fallback > p:oleObj > p:pic under the mc:AlternateContent wrapper, or a p:pic directly under p:oleObj where a producer skipped the wrapper), so that picture is read like any other blip image. With no reachable picture, the p:oleObj's progId at least records what kind of object the frame holds. The object's own payload (p:oleObj/@r:id's embedded part) is additionally decoded when it is a ZIP archive -- a modern producer's embedded xlsx/docx/pptx -- and its recovered sub-document appended as an embeddedObject block beside whatever the display path produced (readOleEmbeddedObject below); the classic non-ZIP OLE compound-file payload stays opaque external-application data, and a ZIP that does not decode as one of the three OOXML flavours degrades to no embedded block, so an undecodable payload never fails the slide read. const image = readBlipImage(graphicData, slideRels, pkg, frame); @@ -894,6 +898,7 @@ function readGraphicFrameShape( rotationDeg, ...NO_TEXT_BODY_EXTRAS, source: shapeSource, + origin: shapeOrigin, blocks, }; } diff --git a/packages/ooxml.js/src/typed/xlsx/content.test.ts b/packages/ooxml.js/src/typed/xlsx/content.test.ts index 0661e7dce..00dfe3fd5 100644 --- a/packages/ooxml.js/src/typed/xlsx/content.test.ts +++ b/packages/ooxml.js/src/typed/xlsx/content.test.ts @@ -990,6 +990,7 @@ describe("readXlsxContent: chart graphic frames", () => { expect(document.sheets[0]?.embeddedObjects).toHaveLength(1); const chart = document.sheets[0]?.embeddedObjects?.[0]; expect(chart?.objectKind).toBe("chart"); + expect(chart?.origin).toBe("chart"); expect(chart?.anchorColumn).toBe(0); expect(chart?.anchorRow).toBe(1); // The frame: anchored at column 0 offset 19050 EMU, row 1, spanning to the start of column 2 and row 4 -- absolute position from the sheet's left edge through the declared column widths and default row height, size the difference of the two anchors. diff --git a/packages/ooxml.js/src/typed/xlsx/drawings.ts b/packages/ooxml.js/src/typed/xlsx/drawings.ts index 24255a0f7..ba42a9702 100644 --- a/packages/ooxml.js/src/typed/xlsx/drawings.ts +++ b/packages/ooxml.js/src/typed/xlsx/drawings.ts @@ -418,6 +418,8 @@ export function readSheetDrawing( offsetXPt: placement.offsetXPt, offsetYPt: placement.offsetYPt, source: readChartResidue(chart.root, "xlsx"), + // origin names what the embedded object's content IS: the same chart-cache classification the pptx table reader sets, stated here on the object that carries the cached model as a nested spreadsheet -- the identical fact, the format-agnostic channel for it. + origin: "chart", }); } for (const pic of elementsWithTag([node], "xdr:pic")) { From 3a90edef03af881ce25580d18d68f48cfb5139ff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 05:11:23 +0100 Subject: [PATCH 5/6] feat(ooxml.js): record where each lifted docx image sat in its run stream Every image the docx reader lifts out of a paragraph's run stream now carries anchorRunIndex/anchorOffset: the run whose text the image originally followed, and the character position within that run after which it sat. The run walk records each w:drawing/w:object that is a direct child of an emitted run at exactly the position it occupied -- the same run-and-character indexing the mid-run page-break event already collects -- and the lifting pass resolves the boundary rule from it: an image mid-run anchors inside its own run at the preceding text's length, an image in a run of its own anchors to the previous run at that run's full length, and an image at the paragraph's very start anchors to (0, 0). A drawing nested inside a w:object (the AlternateContent preview spelling) anchors to the position the object itself sat at, the only position the reader honestly knows for it. An element the walk never emitted a run for (inside field code, or a shape the run walk cannot see) carries no anchor -- the schema's absent-when-unknown spelling rather than a guess. A paragraph that splits at a mid-run page break leaves its lifted images unanchored too: the split re-indexes both halves' run arrays, so a pre-split index would name one half or the other ambiguously, the identical no-clean-encoding rule the split applies to a construct spanning the break. The writer re-inlines a lifted image as the last run of its containing paragraph, so an image that already sat at the paragraph's end keeps its anchor through a round trip while one lifted from earlier in the text migrates to the end -- pinned by a test, and the honest statement of a writer that does not yet place runs by anchor. --- packages/ooxml.js/src/typed/docx/read.test.ts | 102 ++++++++++++++++ packages/ooxml.js/src/typed/docx/read.ts | 110 ++++++++++++++---- 2 files changed, 192 insertions(+), 20 deletions(-) diff --git a/packages/ooxml.js/src/typed/docx/read.test.ts b/packages/ooxml.js/src/typed/docx/read.test.ts index 56d91edac..b9a650751 100644 --- a/packages/ooxml.js/src/typed/docx/read.test.ts +++ b/packages/ooxml.js/src/typed/docx/read.test.ts @@ -1317,6 +1317,108 @@ describe("readDocxContent: images", () => { }); }); +describe("readDocxContent: lifted-image anchors (anchorRunIndex/anchorOffset)", () => { + function imageParts(): Package["parts"] { + return { + "word/_rels/document.xml.rels": { + kind: "xml", + nodes: [ + rels([{ id: "rIdImg", type: IMAGE_REL, target: "media/image1.png" }]), + ], + }, + "word/media/image1.png": { kind: "binary", base64: TINY_PNG_BASE64 }, + }; + } + + function imageRun(): XmlElement { + return el("w:r", {}, [ + drawingElement("wp:inline", "rIdImg", "Anchored alt text"), + ]); + } + + it("anchors an image in its own run to the previous run at that run's full length", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [el("w:t", {}, [txt("Hello ")])]), + imageRun(), + el("w:r", {}, [el("w:t", {}, [txt("World")])]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph, imageParts())); + // The image sits between two runs: anchor names the run whose text it followed (index 0, "Hello ") and the position after that run's whole text. + const image = asImage(doc.sections[0]?.blocks[1]); + expect(image.anchorRunIndex).toBe(0); + expect(image.anchorOffset).toBe(6); + }); + + it("anchors an image at the paragraph's very start to (0, 0)", () => { + const paragraph = el("w:p", {}, [ + imageRun(), + el("w:r", {}, [el("w:t", {}, [txt("Trailing text")])]), + ]); + const doc = readDocxContent(paragraphPackage(paragraph, imageParts())); + const image = asImage(doc.sections[0]?.blocks[1]); + expect(image.anchorRunIndex).toBe(0); + expect(image.anchorOffset).toBe(0); + }); + + it("anchors an image sharing a run with text to that run at the length of the text preceding it", () => { + const sharedRun = el("w:r", {}, [ + el("w:t", {}, [txt("foo")]), + drawingElement("wp:inline", "rIdImg", "Mid-run alt text"), + el("w:t", {}, [txt("bar")]), + ]); + const doc = readDocxContent( + paragraphPackage(el("w:p", {}, [sharedRun]), imageParts()), + ); + const image = asImage(doc.sections[0]?.blocks[1]); + expect(image.anchorRunIndex).toBe(0); + expect(image.anchorOffset).toBe(3); + }); + + it("anchors an image inside a hyperlink through the run the walk emitted for it", () => { + const paragraph = el("w:p", {}, [ + el("w:r", {}, [el("w:t", {}, [txt("See ")])]), + el("w:hyperlink", { "r:id": "rIdLink" }, [ + el("w:r", {}, [el("w:t", {}, [txt("the proof")])]), + imageRun(), + ]), + ]); + const parts = imageParts(); + const relsPart = parts["word/_rels/document.xml.rels"]; + if (relsPart?.kind !== "xml") { + throw new Error("expected document rels"); + } + relsPart.nodes = [ + rels([ + { id: "rIdImg", type: IMAGE_REL, target: "media/image1.png" }, + { + id: "rIdLink", + type: HYPERLINK_REL, + target: "https://example.invalid/", + external: true, + }, + ]), + ]; + const doc = readDocxContent(paragraphPackage(paragraph, parts)); + // Runs as walked: [0] "See ", [1] "the proof" (hyperlink-wrapped), [2] the image's own empty run -- the anchor names run 1 at its full length. + const image = asImage(doc.sections[0]?.blocks[1]); + expect(image.anchorRunIndex).toBe(1); + expect(image.anchorOffset).toBe(9); + }); + + it("round-trips an end-of-paragraph image's anchor through buildDocxPackageFromContent", () => { + // The writer re-inlines a lifted image as the last run of its containing paragraph, so an image that already sat at the paragraph's end keeps its anchor through a round trip: (last run, that run's full length) is exactly where the written run lands. + const paragraph = el("w:p", {}, [ + el("w:r", {}, [el("w:t", {}, [txt("Signed: ")])]), + imageRun(), + ]); + const before = readDocxContent(paragraphPackage(paragraph, imageParts())); + const after = readDocxContent(buildDocxPackageFromContent(before)); + const image = asImage(after.sections[0]?.blocks[1]); + expect(image.anchorRunIndex).toBe(0); + expect(image.anchorOffset).toBe(8); + }); +}); + // An inline OLE object's real-world spelling: a w:r carries a w:object whose w:dxaOrig/w:dyaOrig (twips) size it, whose v:shape > v:imagedata names the raster preview picture rendered in its place (a VML spelling this reader has no path for, so the preview contributes no image block), and whose o:OLEObject names the payload part through its own relationship. The payload relationship is parameterised so a test can point rIdOle at whatever part shape it needs (the ZIP-payload case targets the default embeddings/oleObject1.xlsx; the classic-OLE case retargets to a .bin; the linked case goes external) -- the fixture itself ships no embeddings part, so each test adds exactly the payload bytes it wants. extraRuns splices additional runs after the object run inside the same paragraph. function oleObjectFixturePackage( oleRel: { target: string; external?: boolean }, diff --git a/packages/ooxml.js/src/typed/docx/read.ts b/packages/ooxml.js/src/typed/docx/read.ts index c97fd921d..0eaae7e59 100644 --- a/packages/ooxml.js/src/typed/docx/read.ts +++ b/packages/ooxml.js/src/typed/docx/read.ts @@ -423,11 +423,18 @@ function readObjectEmbeddedObject( }; } -// Collects every w:drawing and w:object found anywhere inside a paragraph's own content (nested inside w:r, w:hyperlink, w:ins, w:fldSimple), in document order. Deleted subtrees (w:del, w:moveFrom) are excluded unless the caller is carrying deletions -- mirroring readParagraphRuns' own tracked-changes handling, since a deleted drawing's own w:r sits inside w:del alongside w:delText runs, and a drawing lifted out of a deletion the reader is not carrying would appear as live content. A w:object is pushed at its own position and then recursed into, so a w:drawing nested inside it (a modern producer's mc:AlternateContent preview spelling) is still collected as an image in its own right, exactly as it was before embedded-object recovery existed. +// One lifted element's identity in collection order: the w:drawing/w:object itself, plus -- for a drawing nested inside a w:object (a modern producer's mc:AlternateContent preview spelling) -- the object whose run position is the only position the reader can honestly anchor the preview to. +interface LiftedElement { + readonly element: XmlElement; + readonly owner: XmlElement | undefined; +} + +// Collects every w:drawing and w:object found anywhere inside a paragraph's own content (nested inside w:r, w:hyperlink, w:ins, w:fldSimple), in document order. Deleted subtrees (w:del, w:moveFrom) are excluded unless the caller is carrying deletions -- mirroring readParagraphRuns' own tracked-changes handling, since a deleted drawing's own w:r sits inside w:del alongside w:delText runs, and a drawing lifted out of a deletion the reader is not carrying would appear as live content. A w:object is pushed at its own position and then recursed into (with itself as the nesting owner), so a w:drawing nested inside it is still collected as an image in its own right, exactly as it was before embedded-object recovery existed. function collectLiftedElements( nodes: readonly XmlNode[], carryDeletions: boolean, - out: XmlElement[], + owner: XmlElement | undefined, + out: LiftedElement[], ): void { for (const node of nodes) { if (node.type !== "element") { @@ -440,30 +447,85 @@ function collectLiftedElements( continue; } if (node.tag === "w:drawing" || node.tag === "w:object") { - out.push(node); + out.push({ element: node, owner }); if (node.tag === "w:drawing") { continue; } + collectLiftedElements(node.children, carryDeletions, node, out); + continue; + } + collectLiftedElements(node.children, carryDeletions, owner, out); + } +} + +// The position a run walk recorded for one w:drawing/w:object that sat as a direct child of an emitted run: the index that run occupies in the paragraph's own runs array, and the length of the run text preceding the element inside that same run (readRunText's own accounting -- w:t/w:delText length, w:tab/w:br/w:cr one character each). +interface LiftedPosition { + readonly runIndex: number; + readonly offset: number; +} + +// Records every w:drawing/w:object direct child of one emitted run, at the text position each sat at: the walk has just pushed the run at `runIndex`, so the element's own paragraph-level position is (runIndex, characters of run text before it). Elements not direct children of a run (nested inside a w:object, or inside run children the walk never reaches) get no entry here -- readParagraphLiftedBlocks then leaves their anchor unset, the schema's own "absent when the lifting reader does not know the position" spelling. +function recordLiftedPositions( + run: XmlElement, + runIndex: number, + out: Map, +): void { + let offset = 0; + for (const child of run.children) { + if (child.type !== "element") { + continue; + } + if (child.tag === "w:drawing" || child.tag === "w:object") { + out.set(child, { runIndex, offset }); + continue; + } + if (child.tag === "w:t" || child.tag === "w:delText") { + offset += textContent(child).length; + } else if ( + child.tag === "w:tab" || + child.tag === "w:br" || + child.tag === "w:cr" + ) { + offset += 1; } - collectLiftedElements(node.children, carryDeletions, out); } } -// ContentRun has no field to carry an inline image or embedded object (unlike ContentShape's blocks list in pptx) -- media found inside a paragraph's own runs is therefore surfaced as its own sibling block (ContentImageBlock or ContentEmbeddedObjectBlock), appended immediately after that paragraph's block in the order the markup introduced them, rather than nested inside it. This preserves block-level document order (each lifted block still appears right after the paragraph that contained it, and drawings and objects keep their relative order) at the cost of losing each one's exact character-level position within that paragraph's text -- a real, bounded scope narrowing forced by ContentParagraph's own shape, not a silent drop. +// ContentRun has no field to carry an inline image or embedded object (unlike ContentShape's blocks list in pptx) -- media found inside a paragraph's own runs is therefore surfaced as its own sibling block (ContentImageBlock or ContentEmbeddedObjectBlock), appended immediately after that paragraph's block in the order the markup introduced them, rather than nested inside it. This preserves block-level document order (each lifted block still appears right after the paragraph that contained it, and drawings and objects keep their relative order), and each lifted ContentImageBlock now records where it sat: anchorRunIndex/anchorOffset name the run whose text the image originally followed and the character position within that run after which it sat, so the inline position is recoverable rather than structural. `positions` is the run walk's recorded map; an empty map leaves every anchor unset (the mid-run page-break split's spelling -- see readParagraphBlocks). function readParagraphLiftedBlocks( paragraph: XmlElement, ctx: DocxReadContext, carryDeletions: boolean, + runs: readonly ContentRun[], + positions: ReadonlyMap, ): ContentBlock[] { - const lifted: XmlElement[] = []; - collectLiftedElements(paragraph.children, carryDeletions, lifted); + const lifted: LiftedElement[] = []; + collectLiftedElements(paragraph.children, carryDeletions, undefined, lifted); const blocks: ContentBlock[] = []; - for (const element of lifted) { + for (const entry of lifted) { const block = - element.tag === "w:object" - ? readObjectEmbeddedObject(element, ctx) - : readDrawingImage(element, ctx); + entry.element.tag === "w:object" + ? readObjectEmbeddedObject(entry.element, ctx) + : readDrawingImage(entry.element, ctx); if (block !== undefined) { + if (block.kind === "image") { + const position = positions.get(entry.owner ?? entry.element); + if (position !== undefined) { + if (position.offset > 0) { + block.anchorRunIndex = position.runIndex; + block.anchorOffset = position.offset; + } else if (position.runIndex > 0) { + // The image sat at the head of its own run, so the run whose text it followed is the previous one, at that run's full length -- the schema's "between two runs" spelling. The paragraph's very first position keys (0, 0). + const previous = runs[position.runIndex - 1]; + block.anchorRunIndex = position.runIndex - 1; + block.anchorOffset = + previous === undefined ? 0 : previous.text.length; + } else { + block.anchorRunIndex = 0; + block.anchorOffset = 0; + } + } + } blocks.push(block); } } @@ -536,6 +598,8 @@ interface ParagraphRunEvents { pointAnchors: RunPointAnchorEvent[]; links: RunLinkEvent[]; pageBreak: ParagraphPageBreakEvent | undefined; + // Every w:drawing/w:object direct child of an emitted run, at the (run index, preceding-text length) position it sat at -- the map readParagraphLiftedBlocks resolves lifted images' anchors through, the run-level counterpart of the pageBreak event's own run/char indices. + liftedPositions: Map; } function newParagraphRunEvents(): ParagraphRunEvents { @@ -546,6 +610,7 @@ function newParagraphRunEvents(): ParagraphRunEvents { pointAnchors: [], links: [], pageBreak: undefined, + liftedPositions: new Map(), }; } @@ -671,6 +736,7 @@ function readParagraphRuns( ? run : { ...run, hyperlink: hyperlinkTarget }, ); + recordLiftedPositions(node, runs.length - 1, events.liftedPositions); recordReferenceAnchor(node); } else if (node.tag === "w:fldSimple") { const startRun = runs.length; @@ -815,6 +881,7 @@ function assembleRunConstructs( interface ReadParagraphResult { readonly paragraph: ContentParagraph; readonly pageBreak: ParagraphPageBreakEvent | undefined; + readonly liftedPositions: ReadonlyMap; } function readParagraph( @@ -856,6 +923,7 @@ function readParagraph( borders: readParagraphBorders(pPr), }, pageBreak: events.pageBreak, + liftedPositions: events.liftedPositions, }; } @@ -931,20 +999,25 @@ function splitParagraphAtPageBreak( ]; } -// The one entry point collectParagraph calls: reads a w:p as its own real ContentBlock array, honouring a mid-run page-type w:br by splitting into [before, pageBreak, after] rather than folding it into one paragraph's own literal '\n' text. +// The one entry point collectParagraph calls: reads a w:p as its own real ContentBlock array, honouring a mid-run page-type w:br by splitting into [before, pageBreak, after] rather than folding it into one paragraph's own literal '\n' text, and appending the paragraph's lifted media blocks after whichever halves the split produced. A paragraph that splits carries no lifted anchors: the two halves' own runs arrays are re-indexed and re-shaped by the split, so a pre-split run index would name a position in one half or the other ambiguously -- exactly the "no clean encoding, so dropped rather than mis-encoded" rule the split itself applies to a construct spanning the break -- and an unsplit paragraph (the overwhelmingly common case) anchors every lifted image it has. function readParagraphBlocks( paragraph: XmlElement, ctx: DocxReadContext, carryDeletions: boolean, ): ContentBlock[] { - const { paragraph: block, pageBreak } = readParagraph( + const read = readParagraph(paragraph, ctx, carryDeletions); + const lifted = readParagraphLiftedBlocks( paragraph, ctx, carryDeletions, + read.paragraph.runs, + read.pageBreak === undefined + ? read.liftedPositions + : new Map(), ); - return pageBreak === undefined - ? [block] - : splitParagraphAtPageBreak(block, pageBreak); + return read.pageBreak === undefined + ? [read.paragraph, ...lifted] + : [...splitParagraphAtPageBreak(read.paragraph, read.pageBreak), ...lifted]; } // WordprocessingML's own ST_Border enumeration has several dozen decorative line styles (wave, threeDEmboss, dashDotStroked, ...) that ContentBorder's four-member ContentStrokeStyle can't distinguish individually -- each maps to whichever of solid/dashed/dotted/double it visually resembles most closely, the same "narrow to the closest matching value" convention readAlignment (styles.ts) already applies to w:jc's own both/distribute -> justify. Anything unmapped defaults to 'solid' rather than being dropped, since a border with an unrecognised style is still visually a border. @@ -1432,12 +1505,9 @@ function collectParagraph( if (hasPageBreakBefore(paragraph)) { state.blocks.push({ kind: "pageBreak" }); } - // The pageBreak block above sits outside every extent recorded here: it is the paragraph's own w:pageBreakBefore rendered as a preceding block, not part of any construct that brackets the paragraph. A mid-run page-type w:br produces its own pageBreak block too, spliced between the two ContentParagraph halves readParagraphBlocks returns for it -- see that function's own doc comment. + // The pageBreak block above sits outside every extent recorded here: it is the paragraph's own w:pageBreakBefore rendered as a preceding block, not part of any construct that brackets the paragraph. A mid-run page-type w:br produces its own pageBreak block too, spliced between the two ContentParagraph halves readParagraphBlocks returns for it -- see that function's own doc comment. The lifted media blocks readParagraphBlocks now appends sit INSIDE the extent, exactly where the separate push below used to place them. const paragraphIndex = state.blocks.length; state.blocks.push(...readParagraphBlocks(paragraph, ctx, paragraphDeleted)); - state.blocks.push( - ...readParagraphLiftedBlocks(paragraph, ctx, paragraphDeleted), - ); const endIndex = state.blocks.length; if (tracked !== undefined) { From 89deff980b66330eff8dbce64a69f9ac9fdf17db Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 05:13:27 +0100 Subject: [PATCH 6/6] docs(ooxml.js): state the per-cell font, defined names, direction, origin, and anchor coverage The xlsx bullet now describes the names field's both-ways ride (every definedName including the _xlnm built-ins, refersTo verbatim, the print-derivation fallback, and the internal-A1 security gate), the retirement of the namedRange half of the definitions table, and the per-cell font's default-diffed read and interned-table write. The docx bullet gains the vertAlign/rtl/bidi mappings, the lifted-image anchors and their end-of-paragraph-stable round trip, and the pptx and xlsx bullets name the chart/diagram origin classifications. Also corrects the same bullet's stale claim that a floating wp:anchor position is not recorded: floatPosition has been read since the anchor-position widening, and the parenthetical now says so. --- packages/ooxml.js/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ooxml.js/README.md b/packages/ooxml.js/README.md index 47009f6c6..4eee31a73 100644 --- a/packages/ooxml.js/README.md +++ b/packages/ooxml.js/README.md @@ -292,7 +292,7 @@ The package layers a lossless core outward to lossy convenience views: - **`src/typed/`** — lossy projections. `readDocxContent` resolves the full style cascade (`docDefaults` → `basedOn` → paragraph-mark → character styles → direct formatting) into ordered `sections` plus comments/footnotes/header-footer parts/numbering; `readPptxContent` resolves placeholder → layout → master → theme inheritance into `slides` (presentation order via `p:sldIdLst`); `readXlsxWorkbook` covers cell values/formulas, merged ranges, defined names. `typed/shared/` holds shared OOXML primitives (`drawingml.ts` geometry/theme/colour, `color.ts` `ColorTransform` cascade, `units.ts`, `metadata.ts`, `source-path.ts`). Types come from `document-schema.js`. Only the docx and xlsx `ContentDocument`-shaped pairs encode back to a `Package`; everything else is one-way, and faithful round-tripping goes through `decodePackage`/`encodePackage`. - **`src/typed/document-tree.ts`** — the `DocumentTree`-native surface, and nothing else: `readDocx`/`readPptx`/`readXlsx` are their content-level reader composed with `document-schema.js`'s `assembleTree`, `buildDocxPackage`/`buildXlsxPackage` are `flattenTree` composed with their content-level writer. One module rather than one per format, because the adapters are format-uniform and the reasoning behind them (why a reader mints styles rather than calling bare `decompose`, why a writer's kind guard lives at this boundary, what a docx's non-content parts do instead of riding the tree) is one argument stated once. - **`src/typed/docx/`** — `read.ts` and `write.ts` are a read/write pair over `ContentSection[]`. `constructs.ts` owns the fidelity construct vocabulary both halves share: the descriptor shapes (`contentControl` from `w:sdt`, `field` from `w:fldChar`/`w:fldSimple`, `anchor` from `w:bookmarkStart`/`End`, `provenance` from `w:ins`/`w:del`/`w:moveFrom`/`w:moveTo`) and the rule deciding which occurrences are block-scoped enough to bracket. `shading.ts` owns a table cell's own `w:shd` <-> `ContentCellFill` mapping, shared the identical way. `buildDocxPackageFromContent` builds a complete docx `Package` from scratch; it writes `styles.xml`, `numbering.xml`, comments, footnotes, endnotes, and headers/footers when its input carries them, so the `DocxDocument` fields outside `sections` DO survive the flat pair now (see the "docx pair" gotcha below for the one caveat — a styleId resolves to a real, named style with no formatting of its own, since none survives the read-time cascade to write back). -- **`src/typed/xlsx/`** — a `ContentDocument`-shaped read/write pair alongside the lossy `readXlsxWorkbook` (both exported; different callers). `readXlsxContent` reads column widths, row heights, hidden rows/columns, merged ranges, every cell value kind, print settings, and cell comments (`comments.ts`: legacy `xl/comments{N}.xml` notes plus `[MS-XLSX]` threaded comments, both resolved through the worksheet part's own relationships, never by part name); `buildXlsxPackageFromContent` builds a complete xlsx `Package` from scratch (never editing the decoded package). `number-format.ts`/`styles.ts`/`serial.ts` run both ways: reading classifies style index → format code → kind (`percentage`/`currency`/`date`/`time`/`dateTime`); writing emits interned `numFmt` codes, fed back through the classifier in tests. The classifier is not a formatter (`displayText` is the typed-value spelling). Scope limits: `currency` with no ISO code writes as plain `number`; non-canonical temporal values degrade to text; cell comments round-trip through `[MS-XLSX]` threaded comments (`comments-write.ts`), the strictly richer of the two mechanisms `comments.ts` reads — text, author, timestamp, and flat replies all survive, always through the thread's own inline `displayName` rather than a `persons.xml`/`personId` cross-reference — but a legacy `xl/comments{N}.xml` note never gets written back out, only ever a thread; column widths re-approximate through xlsx's own character-width unit on the first write, then stay fixed: `ptToColumnWidthChars` (`units.ts`) always rounds up to the precision it writes at, so a value that has already been through one write reproduces itself exactly on every further read/write cycle rather than continuing to narrow. `data-validation.ts`/`conditional-format.ts` are the same real-vocabulary read/write pair for `ContentSheet.dataValidations`/`conditionalFormats` (see the gotchas section below for the shape), sharing `sqref.ts`'s A1 range-list parsing/formatting and `rule-residue.ts`'s attribute-level residue helper; `styles.ts` additionally resolves a `cellIs`-family rule's own `dxfId` against `xl/styles.xml`'s `` table (`readDxfElements`), the differential-format counterpart to its existing `` decoration resolution. +- **`src/typed/xlsx/`** — a `ContentDocument`-shaped read/write pair alongside the lossy `readXlsxWorkbook` (both exported; different callers). `readXlsxContent` reads column widths, row heights, hidden rows/columns, merged ranges, every cell value kind, the per-cell font, the workbook's defined names, print settings, and cell comments (`comments.ts`: legacy `xl/comments{N}.xml` notes plus `[MS-XLSX]` threaded comments, both resolved through the worksheet part's own relationships, never by part name); `buildXlsxPackageFromContent` builds a complete xlsx `Package` from scratch (never editing the decoded package). `number-format.ts`/`styles.ts`/`serial.ts` run both ways: reading classifies style index → format code → kind (`percentage`/`currency`/`date`/`time`/`dateTime`); writing emits interned `numFmt` codes, fed back through the classifier in tests. The classifier is not a formatter (`displayText` is the typed-value spelling). Scope limits: `currency` with no ISO code writes as plain `number`; non-canonical temporal values degrade to text; cell comments round-trip through `[MS-XLSX]` threaded comments (`comments-write.ts`), the strictly richer of the two mechanisms `comments.ts` reads — text, author, timestamp, and flat replies all survive, always through the thread's own inline `displayName` rather than a `persons.xml`/`personId` cross-reference — but a legacy `xl/comments{N}.xml` note never gets written back out, only ever a thread; column widths re-approximate through xlsx's own character-width unit on the first write, then stay fixed: `ptToColumnWidthChars` (`units.ts`) always rounds up to the precision it writes at, so a value that has already been through one write reproduces itself exactly on every further read/write cycle rather than continuing to narrow. `data-validation.ts`/`conditional-format.ts` are the same real-vocabulary read/write pair for `ContentSheet.dataValidations`/`conditionalFormats` (see the gotchas section below for the shape), sharing `sqref.ts`'s A1 range-list parsing/formatting and `rule-residue.ts`'s attribute-level residue helper; `styles.ts` additionally resolves a `cellIs`-family rule's own `dxfId` against `xl/styles.xml`'s `` table (`readDxfElements`), the differential-format counterpart to its existing `` decoration resolution. ## Conventions @@ -307,9 +307,9 @@ The package layers a lossless core outward to lossy convenience views: - **A `Content` suffix means the flat form, not a lesser one.** `readDocx` and `readDocxContent` are the same read; the suffix says which shape comes back (`DocumentTree` versus the flat `DocxDocument`/`ContentDocument`), never which is more faithful. The pair that genuinely differs in what it reads is `readXlsxWorkbook`, whose name says so by naming its own return type rather than taking a suffix. - **A construct marker brackets whole blocks, never a sub-sequence of runs.** A construct covering a sub-sequence of one paragraph's runs lands on that paragraph's `constructs` field as a run range (see the fidelity-constructs section above) — bookmarks, fields, internal links, comment extents, note references, and `w:ffData` form fields alike. An inline SDT or a few inserted words inside an otherwise untouched paragraph are still read exactly as before — the text survives, the construct does not — as is a bookmark whose two halves sit in different paragraphs. So does a block extent that crosses another, or that straddles a section break or table-cell boundary: neither is expressible as balanced brackets (nor as a tree group, which is what the marker pair promotes to), and both are dropped — a drop `document-schema.js`'s extent-scope note ratifies — rather than emitted at a position that would decode to a different nesting. - **A table cell's own background (`ContentTableCell`/`ContentSheetCell.background`) is a discriminated `'solid'`/`'pattern'` fill, not a bare colour (ExaDev/documents.js#951).** `typed/docx/shading.ts` maps `w:shd`'s own `ST_Shd` vocabulary (`clear`/`solid` to a `'solid'` fill of `w:fill`/`w:color` respectively, every named percentage/stripe/cross token to a real `'pattern'` fill) and `typed/xlsx/styles.ts` does the identical job for ``'s `ST_PatternType` vocabulary — each throwing when asked to write a pattern name that belongs only to the other format's own half of the shared `ContentCellPatternType` vocabulary, rather than writing the wrong pattern or silently dropping it. pptx's own table reader (read-only) resolves `` into a `'solid'` fill and `` into a `'pattern'` fill (ExaDev/documents.js#1024) — but only for the dozen `ST_PresetPatternVal` presets (`pct5` through `pct90`) that fall inside `ContentCellPatternType`'s own closed, two-vocabulary enumeration; DrawingML's other forty-odd presets (directional hatches, checks, grids, bricks, diamonds, pictorial fills like `sphere`/`wave`/`weave`) have no member that schema can express and read as no background at all, the same "unrecognised token, no fill" fallback an unresolvable `` already gets. -- **The flat docx pair (`readDocxContent`/`buildDocxPackageFromContent`) round-trips the whole `DocxDocument`, including comments, footnotes, endnotes, header/footer parts, and `word/numbering.xml` — the tree pair (`readDocx`/`buildDocxPackage`) still round-trips `sections` alone, since `DocumentTree` has no place for the rest.** Cell border styling/shading, section break kinds (`w:sectPr/w:type` onto `ContentSection.breakType`, both directions), and `w:themeColor` (with `themeShade`/`themeTint` refinement, resolved as a WordprocessingML-specific HSL-lightness transform -- ExaDev/documents.js#962 -- distinct from DrawingML's own thousandths-of-a-percent `a:shade`/`a:tint`) are read; images read into `ContentImageBlock` (floating `wp:anchor` position not recorded); `PAGE`/`NUMPAGES` fields resolve to Word's cached text. Headers and footers are read structurally — every `word/header*.xml` / `word/footer*.xml` part as block flow (`headerFooterParts`, referenced or not) plus per-section default/first/even references (`sectionHeaderFooters`). An inline `w:object`/`o:OLEObject` whose payload part is itself a ZIP archive — a modern producer's embedded xlsx/docx/pptx, detected by magic bytes through `archive-codec` — or a classic OLE compound-file `.bin` whose root storage carries the embedded file as an OLE-packaged `Package` stream, read through `archive-codec`'s bounded CFB reader, is decoded into a nested content document and emitted as a `ContentEmbeddedObjectBlock` beside its containing paragraph's own block, sized from `w:dxaOrig`/`w:dyaOrig`; the object's VML preview picture (`v:imagedata`) and a `w:object` inside a footnote (notes ride as text, with no block flow to lift an object into — a header/footer's own objects DO recover, those parts being walked as block flow) are not read, a compound file holding native legacy streams (BIFF and friends — no `Package` stream, or a packaged file that is not a ZIP) stays opaque, and a payload that does not decode as one of the three OOXML flavours (a plain archive, corrupt zip or compound-file data, a bodyless docx) degrades to no embedded block rather than failing the host read. The writer emits such a block back out as the inverse `w:object`/`o:OLEObject`: the nested document is re-serialised through its own format's builder into a fresh `word/embeddings/oleObject.` ZIP part carrying its relationship and content-type override, and `w:dxaOrig`/`w:dyaOrig` are derived from the block's frame (twips). An embedded presentation document serialises through an injected port — `BuildDocxContentOptions.serialiseEmbeddedPresentation`, a presentation `ContentDocument` → pptx-bytes function this package accepts because it has no PresentationML writer of its own, and the ecosystem's one pptx writer lives one layer up in documents.js (which exports `embeddedPresentationSerialiser`, wired from its own pptx builder, for exactly this call); with no serialiser injected, a `ContentDocument` carrying one is refused with a thrown error rather than a silent drop, and no VML preview picture is regenerated (the reader never read one into the model, and real previews are WMF/EMF this ecosystem has no writer for; Word shows a blank until activated). The writer emits real `styles.xml`, `numbering.xml`, `comments.xml`, `footnotes.xml`/`endnotes.xml`, and header/footer parts (each with its own relationships, so an image inside a header resolves through that part's own `_rels` file, not the document's) whenever `DocxContent` carries them, so a paragraph's `styleId` now resolves to a real, valid `w:style` rather than a dangling `w:pStyle` reference — but that style carries no formatting of its own: `ContentParagraph.styleId` is documented as "round-trip-only... meaningful only to a consumer that already knows that producer's naming convention" (`document-schema.js`), because the style cascade is fully materialised into direct formatting at read time and nothing keeps the original basedOn chain or properties around to write back. Visual fidelity is unaffected either way, since every property a real style would have contributed is already spelled as direct formatting on the paragraphs/runs that used it. -- **pptx is read-only.** Connector shapes (`p:cxnSp`) are skipped; shape rotation composes through groups; a dynamic field (`a:fld`, e.g. slidenum/datetime) reads as an ordinary run plus a `field` run construct carrying `@type` as the instruction and the cached `a:t` as the result; an internal slide-jump link (`a:hlinkClick` resolving to a slide relationship, no `TargetMode`) records a `link` run construct with an internal target naming the destination slide's package part path (e.g. `ppt/slides/slide2.xml` — PresentationML addresses slides by part relationship, never by name; the flat run's `hyperlink` field stays reserved for external URIs, and an action-only `ppaction://hlinkshowjump` names no target part and records nothing); a chart graphic frame reads its chart part's cached series/category model into a table block (header row = series names, one row per category index), quarantining the whole chart part — type, axes, legend, colours, everything the cached model itself doesn't carry — as the table's own `source` residue (ExaDev/documents.js#719's deliberate "chart is not a document kind" decision, not a schema gap: a genuinely richer chart-type-aware model is out of scope, and this residue is the round-trippable middle ground); a SmartArt graphic frame reads its diagram data model's node text as paragraphs in diagram order (depth-first over `parOf` connections, siblings by `srcOrd`), quarantining whichever of the layout/quickStyle/colour parts (`r:lo`/`r:qs`/`r:cs`) actually resolve as the graphic frame shape's own `source` residue, since those decide only how the graph is drawn and carry no text of their own; an OLE graphic frame reads the fallback picture its `mc:Fallback` carries (or, with none reachable, a paragraph naming the `p:oleObj`'s `progId`), and when the payload part is itself a ZIP archive — a modern producer's embedded xlsx/docx/pptx, detected by magic bytes through `archive-codec` — or a classic OLE compound-file `.bin` whose root storage carries the embedded file as an OLE-packaged `Package` stream, read through `archive-codec`'s bounded CFB reader, also decodes it into a nested content document and emits it as an `embeddedObject` block beside the picture, sized to the frame's own geometry; a compound file holding native legacy streams (no `Package` stream, or a packaged file that is not a ZIP) stays opaque external-application data, and a payload that does not decode as one of the three OOXML flavours degrades to no embedded block rather than failing the slide read. -- **xlsx has no native percentage/currency/date/time cell type.** Both directions are closed via the number-format engine: reading classifies style → format code → kind; writing emits interned `numFmt` codes, fed back through the classifier in tests. `displayText` is the typed-value spelling, not the producer's rendered string. A chart graphic frame reads as an embedded `chart` object (`embeddedObjects`, one row per series over the shared category axis, values verbatim `c:v` text, frame resolved through the sheet's own column widths and row heights)quarantining the whole chart part — type, axes, legend, colours — as the object's own `source` residue exactly as the pptx chart row does (same `readChartResidue` helper, same ExaDev/documents.js#719 decision), and a drawing picture (`xdr:pic`) as a `ContentSheetImage` (`images`, media bytes sniffed through the drawing part's relationships, frame resolved the same way) — both rows readable under any of the drawing's three anchor spellings (`twoCellAnchor` marker pairs, `oneCellAnchor` sized by its own `xdr:ext`, and `absoluteAnchor`, whose page-absolute `xdr:pos` is re-based into the cell anchor vocabulary through that same grid geometry). Both directions are closed (ExaDev/documents.js#973): `buildXlsxPackageFromContent` (`typed/xlsx/drawings-write.ts`) writes every image and chart embedded object a sheet carries back out as a real `xdr:oneCellAnchor` in a genuine `xl/drawings/drawingN.xml`, with a real `xl/media/imageN.` for a picture and a real `xl/charts/chartN.xml` (`c:ser`/`c:cat`/`c:val` cache pairs) for a chart, so `readXlsxContent(buildXlsxPackageFromContent(x))` recovers the same series/category data and the same image bytes it started with. A workbook's general defined names and table/List objects ride the tree reader's root `definitions` table (`readXlsx`), the landing the schema's own verdict gives a named range; the write side closes this row too, via `buildXlsxPackageFromContent`'s own `definitions` option (`typed/xlsx/definitions-write.ts`, threaded through automatically by `buildXlsxPackage`) writing a real general `` per named range and a real `xl/tables/tableN.xml` plus worksheet `` entry per table object — the flat content-level pair alone (`buildXlsxPackageFromContent(readXlsxContent(pkg))` with no `definitions` supplied) still carries neither, since `ContentDocument` itself has no definitions field to carry them in. `dataValidation` (`typed/xlsx/data-validation.ts`) and `conditionalFormatting` (`typed/xlsx/conditional-format.ts`) rules are real vocabulary both ways (`ContentSheet.dataValidations`/`conditionalFormats`, ExaDev/documents.js#758), verified against two real LibreOffice-produced fixtures: every `ContentSheetDataValidationTypeSchema` member (the full ECMA-376 vocabulary bar `none`) and every closed-form `ContentSheetConditionalFormatSchema` union member (`cellIs`, the text-predicate and operand-free families, `top10`, `aboveAverage`, `timePeriod`, and the inline-colour `colorScale`/`dataBar`/`iconSet` trio) promote structurally, resolving a `cellIs`-family rule's own `dxfId` against `xl/styles.xml`'s `` table into a `textColor`/`background` style (whatever of a referenced `` a real producer's differential format carries beyond those two — alignment, border, a font/fill's own other properties — rides the style's own `source` residue verbatim, reconstructed on write around the two structured colours). A rule's own `sqref` is parsed as the list of one or more ranges it really is (a real fixture carries a space-separated multi-range sqref, e.g. `"A1 C1"`), and a small set of producer attributes neither schema names (a data validation's `showDropDown`/`imeMode`, a cfRule's `pivot`/`id`) rides each rule's own `source` as attribute-level residue, restored on write underneath whatever the structured fields recompute. The one deliberate hold-out is `expression` (an arbitrary boolean formula with no closed-form structure to promote without a general formula engine) and any type/rule this reader does not recognise (`none` foremost) — both continue through the narrowed anchor-cell residue mechanism unchanged, quarantining the rule verbatim on its first range's top-left cell exactly as every promoted rule's own family used to. +- **The flat docx pair (`readDocxContent`/`buildDocxPackageFromContent`) round-trips the whole `DocxDocument`, including comments, footnotes, endnotes, header/footer parts, and `word/numbering.xml` — the tree pair (`readDocx`/`buildDocxPackage`) still round-trips `sections` alone, since `DocumentTree` has no place for the rest.** Cell border styling/shading, section break kinds (`w:sectPr/w:type` onto `ContentSection.breakType`, both directions), and `w:themeColor` (with `themeShade`/`themeTint` refinement, resolved as a WordprocessingML-specific HSL-lightness transform -- ExaDev/documents.js#962 -- distinct from DrawingML's own thousandths-of-a-percent `a:shade`/`a:tint`) are read; images read into `ContentImageBlock` (a floating `wp:anchor`'s own `wp:positionH`/`wp:positionV` recorded as `floatPosition`); `w:vertAlign` (`superscript`/`subscript`) and `w:rtl` read onto `ContentRun.verticalAlign`/`ContentRun.direction` and `w:bidi` onto `ContentParagraph.direction`, all through the same style cascade bold rides, and written back with an explicitly left-to-right value spelled as the off form (`w:val="0"`) rather than omitted; `PAGE`/`NUMPAGES` fields resolve to Word's cached text. Headers and footers are read structurally — every `word/header*.xml` / `word/footer*.xml` part as block flow (`headerFooterParts`, referenced or not) plus per-section default/first/even references (`sectionHeaderFooters`). An inline `w:object`/`o:OLEObject` whose payload part is itself a ZIP archive — a modern producer's embedded xlsx/docx/pptx, detected by magic bytes through `archive-codec` — or a classic OLE compound-file `.bin` whose root storage carries the embedded file as an OLE-packaged `Package` stream, read through `archive-codec`'s bounded CFB reader, is decoded into a nested content document and emitted as a `ContentEmbeddedObjectBlock` beside its containing paragraph's own block, sized from `w:dxaOrig`/`w:dyaOrig`; every lifted image records where it sat through `anchorRunIndex`/`anchorOffset` (the run whose text it followed, and the character position within that run after which it sat), and the writer re-inlines a lifted image as its containing paragraph's last run, so an image that already sat at the paragraph's end keeps its anchor through a round trip while one lifted from earlier in the text migrates to the end; the object's VML preview picture (`v:imagedata`) and a `w:object` inside a footnote (notes ride as text, with no block flow to lift an object into — a header/footer's own objects DO recover, those parts being walked as block flow) are not read, a compound file holding native legacy streams (BIFF and friends — no `Package` stream, or a packaged file that is not a ZIP) stays opaque, and a payload that does not decode as one of the three OOXML flavours (a plain archive, corrupt zip or compound-file data, a bodyless docx) degrades to no embedded block rather than failing the host read. The writer emits such a block back out as the inverse `w:object`/`o:OLEObject`: the nested document is re-serialised through its own format's builder into a fresh `word/embeddings/oleObject.` ZIP part carrying its relationship and content-type override, and `w:dxaOrig`/`w:dyaOrig` are derived from the block's frame (twips). An embedded presentation document serialises through an injected port — `BuildDocxContentOptions.serialiseEmbeddedPresentation`, a presentation `ContentDocument` → pptx-bytes function this package accepts because it has no PresentationML writer of its own, and the ecosystem's one pptx writer lives one layer up in documents.js (which exports `embeddedPresentationSerialiser`, wired from its own pptx builder, for exactly this call); with no serialiser injected, a `ContentDocument` carrying one is refused with a thrown error rather than a silent drop, and no VML preview picture is regenerated (the reader never read one into the model, and real previews are WMF/EMF this ecosystem has no writer for; Word shows a blank until activated). The writer emits real `styles.xml`, `numbering.xml`, `comments.xml`, `footnotes.xml`/`endnotes.xml`, and header/footer parts (each with its own relationships, so an image inside a header resolves through that part's own `_rels` file, not the document's) whenever `DocxContent` carries them, so a paragraph's `styleId` now resolves to a real, valid `w:style` rather than a dangling `w:pStyle` reference — but that style carries no formatting of its own: `ContentParagraph.styleId` is documented as "round-trip-only... meaningful only to a consumer that already knows that producer's naming convention" (`document-schema.js`), because the style cascade is fully materialised into direct formatting at read time and nothing keeps the original basedOn chain or properties around to write back. Visual fidelity is unaffected either way, since every property a real style would have contributed is already spelled as direct formatting on the paragraphs/runs that used it. +- **pptx is read-only.** Connector shapes (`p:cxnSp`) are skipped; shape rotation composes through groups; a dynamic field (`a:fld`, e.g. slidenum/datetime) reads as an ordinary run plus a `field` run construct carrying `@type` as the instruction and the cached `a:t` as the result; an internal slide-jump link (`a:hlinkClick` resolving to a slide relationship, no `TargetMode`) records a `link` run construct with an internal target naming the destination slide's package part path (e.g. `ppt/slides/slide2.xml` — PresentationML addresses slides by part relationship, never by name; the flat run's `hyperlink` field stays reserved for external URIs, and an action-only `ppaction://hlinkshowjump` names no target part and records nothing); a chart graphic frame reads its chart part's cached series/category model into a table block carrying `origin: 'chart'` (header row = series names, one row per category index), quarantining the whole chart part — type, axes, legend, colours, everything the cached model itself doesn't carry — as the table's own `source` residue (ExaDev/documents.js#719's deliberate "chart is not a document kind" decision, not a schema gap: a genuinely richer chart-type-aware model is out of scope, and this residue is the round-trippable middle ground); a SmartArt graphic frame reads its diagram data model's node text as paragraphs in diagram order (depth-first over `parOf` connections, siblings by `srcOrd`) on a shape carrying `origin: 'diagram'`, quarantining whichever of the layout/quickStyle/colour parts (`r:lo`/`r:qs`/`r:cs`) actually resolve as the graphic frame shape's own `source` residue, since those decide only how the graph is drawn and carry no text of their own; an OLE graphic frame reads the fallback picture its `mc:Fallback` carries (or, with none reachable, a paragraph naming the `p:oleObj`'s `progId`), and when the payload part is itself a ZIP archive — a modern producer's embedded xlsx/docx/pptx, detected by magic bytes through `archive-codec` — or a classic OLE compound-file `.bin` whose root storage carries the embedded file as an OLE-packaged `Package` stream, read through `archive-codec`'s bounded CFB reader, also decodes it into a nested content document and emits it as an `embeddedObject` block beside the picture, sized to the frame's own geometry; a compound file holding native legacy streams (no `Package` stream, or a packaged file that is not a ZIP) stays opaque external-application data, and a payload that does not decode as one of the three OOXML flavours degrades to no embedded block rather than failing the slide read. +- **xlsx has no native percentage/currency/date/time cell type.** Both directions are closed via the number-format engine: reading classifies style → format code → kind; writing emits interned `numFmt` codes, fed back through the classifier in tests. `displayText` is the typed-value spelling, not the producer's rendered string. A chart graphic frame reads as an embedded `chart` object (`embeddedObjects`, one row per series over the shared category axis, values verbatim `c:v` text, frame resolved through the sheet's own column widths and row heights) carrying `origin: 'chart'`, quarantining the whole chart part — type, axes, legend, colours — as the object's own `source` residue exactly as the pptx chart row does (same `readChartResidue` helper, same ExaDev/documents.js#719 decision), and a drawing picture (`xdr:pic`) as a `ContentSheetImage` (`images`, media bytes sniffed through the drawing part's relationships, frame resolved the same way) — both rows readable under any of the drawing's three anchor spellings (`twoCellAnchor` marker pairs, `oneCellAnchor` sized by its own `xdr:ext`, and `absoluteAnchor`, whose page-absolute `xdr:pos` is re-based into the cell anchor vocabulary through that same grid geometry). Both directions are closed (ExaDev/documents.js#973): `buildXlsxPackageFromContent` (`typed/xlsx/drawings-write.ts`) writes every image and chart embedded object a sheet carries back out as a real `xdr:oneCellAnchor` in a genuine `xl/drawings/drawingN.xml`, with a real `xl/media/imageN.` for a picture and a real `xl/charts/chartN.xml` (`c:ser`/`c:cat`/`c:val` cache pairs) for a chart, so `readXlsxContent(buildXlsxPackageFromContent(x))` recovers the same series/category data and the same image bytes it started with. A workbook's defined names ride the `ContentDocument`'s own `names` field both ways — every `workbook.xml` `` including the `_xlnm` built-ins, `refersTo` verbatim, `localSheetId` mapped onto `scopeSheetIndex` (`typed/xlsx/defined-names.ts`/`typed/xlsx/definitions-write.ts`) — written back out in the array's own order, with the structured print-settings derivation filling in only the two reserved `_xlnm` print names the array does not itself carry, and gated by the same sheet-qualified-internal-A1-reference security check a defined name has always had (a `refersTo` carrying formula or external-reference content is refused by name rather than written into live formula context). Table/List objects still ride the tree-only `definitions` table (`readXlsx` reads it; `buildXlsxPackageFromContent`'s `definitions` option, threaded through automatically by `buildXlsxPackage`, writes a real `xl/tables/tableN.xml` plus worksheet `` entry), since a table object has no flat spelling. Each cell's own font survives a round trip too (`ContentSheetCell.font`): read as the `` xf's `` entry diffed per property against the workbook's own entry-0 default — the identical default-diffing policy `xls-codec` applies to BIFF8's font table — and written back through an interned font table whose fixed Calibri-11 entry 0 mirrors that default, one further `` per distinct cell font, `applyFont` on a fonted xf. `dataValidation` (`typed/xlsx/data-validation.ts`) and `conditionalFormatting` (`typed/xlsx/conditional-format.ts`) rules are real vocabulary both ways (`ContentSheet.dataValidations`/`conditionalFormats`, ExaDev/documents.js#758), verified against two real LibreOffice-produced fixtures: every `ContentSheetDataValidationTypeSchema` member (the full ECMA-376 vocabulary bar `none`) and every closed-form `ContentSheetConditionalFormatSchema` union member (`cellIs`, the text-predicate and operand-free families, `top10`, `aboveAverage`, `timePeriod`, and the inline-colour `colorScale`/`dataBar`/`iconSet` trio) promote structurally, resolving a `cellIs`-family rule's own `dxfId` against `xl/styles.xml`'s `` table into a `textColor`/`background` style (whatever of a referenced `` a real producer's differential format carries beyond those two — alignment, border, a font/fill's own other properties — rides the style's own `source` residue verbatim, reconstructed on write around the two structured colours). A rule's own `sqref` is parsed as the list of one or more ranges it really is (a real fixture carries a space-separated multi-range sqref, e.g. `"A1 C1"`), and a small set of producer attributes neither schema names (a data validation's `showDropDown`/`imeMode`, a cfRule's `pivot`/`id`) rides each rule's own `source` as attribute-level residue, restored on write underneath whatever the structured fields recompute. The one deliberate hold-out is `expression` (an arbitrary boolean formula with no closed-form structure to promote without a general formula engine) and any type/rule this reader does not recognise (`none` foremost) — both continue through the narrowed anchor-cell residue mechanism unchanged, quarantining the rule verbatim on its first range's top-left cell exactly as every promoted rule's own family used to. - **`test:smoke` depends on a fresh build.** It runs `tsdown && vitest run --project smoke`, always rebuilding `dist/` first. A bare `vitest` runs both projects; `smoke` fails loudly (`Cannot find module '../dist/index.js'`) if `dist/` is unbuilt. - **Binary-vs-XML part classification is a byte sniff, not an extension check.** `looksLikeXml` looks for a leading `<` after skipping a UTF-8 BOM and whitespace; any future binary format starting with `<` would misclassify. - **`Array.isArray` narrows `unknown` to `any[]`, not `unknown[]`.** Indexing the result reintroduces `any` and trips `no-unsafe-assignment`. `compact.ts` and `xml/parse.ts` each define a local `isUnknownArray` guard (`value is unknown[]`) — use it wherever the narrowed element is read.