From 9234828f93e60606b4ac370bb1d5e9f4919db78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roni=20Str=C3=B6m?= <519060+ronistrom@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:53:40 +0000 Subject: [PATCH 1/3] feat(xlsx): write and read a row format on RowDef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RowDef had height, hidden, outline level and collapsed but no format, so a row Excel formatted as a whole (``, ECMA-376 Part 1 §18.3.1.73) was dropped on read and could not be written at all. ColumnDef.style has carried the column equivalent since #439. RowDef.style is now written by both writeXlsx and writeXlsxStream through the shared rowAttributes(), as `s` plus `customFormat="1"`, and only when the style resolves to a non-default xf, so a row definition without a style writes exactly what it wrote before. A row that has a format and no cells still gets its self-closing ``. Following the column precedent, and what Excel itself writes, the row style is also stamped onto the cells in that row that have no style of their own. Where a column style covers the same cell, the row's wins, number format included; the streaming writer no longer folds a column numFmt into a row format, so the two writers agree. The cell > row > column order is not verified in Excel. The reader takes `s` into RowDef.style when `customFormat` is 1/true and styles are being read, with a per-row copy as for columns; a bare `s` is ignored as the spec says. cloneSheet deep-copies the row style. customFormat leaves the spec-coverage REVIEWED list and SPEC-COVERAGE.md is regenerated; PARITY.md and the README say rows carry a format. Closes #601 --- README.md | 37 ++-- docs/PARITY.md | 18 +- docs/SPEC-COVERAGE.md | 3 +- scripts/spec-coverage.mjs | 1 - src/_types.ts | 11 +- src/builder.ts | 2 +- src/sheet-ops.ts | 4 +- src/xlsx/stream-writer.ts | 27 ++- src/xlsx/worksheet-writer.ts | 42 +++- src/xlsx/worksheet.ts | 24 +++ test/clone-sheet-coverage.test.ts | 3 +- test/real-files.test.ts | 20 ++ test/row-style.test.ts | 291 ++++++++++++++++++++++++++++ test/xlsx-write-read-parity.test.ts | 7 +- 14 files changed, 439 insertions(+), 51 deletions(-) create mode 100644 test/row-style.test.ts diff --git a/README.md b/README.md index 30c8c034..a54745dd 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,17 @@ Anything a cell carries works there — `style`, `formula`, `richText`, `hyperlink`, `checkbox`. `cells` still takes a `"row,col"` map, and wins where both describe the same position. +A whole row or column can carry a format of its own. `rowDefs` takes a +`style` beside `height` and the rest — written as Excel writes a +formatted row, `` — and `columns[i].style` +does the same for a column. Either one is also stamped onto the cells in +it that have no style of their own; where a row and a column both cover +such a cell, the row's wins. + +```ts +rowDefs: new Map([[9, { height: 20, style: { font: { bold: true }, numFmt: "#,##0.00" } }]]) +``` + Features: cell styles, auto column widths, merged cells, freeze/split panes, auto-filter (with per-column value filters — ``; custom/dynamic/colour criteria are not emitted), data validation, hyperlinks, images (PNG/JPEG/GIF/SVG/WebP), comments, tables, conditional formatting (all 15 rule types, with their dxf styles), named ranges, print settings, page breaks, sheet protection, workbook protection, rich text, shared/array/dynamic formulas, sparklines, textboxes, background images, number formats, hidden sheets, Excel 2024 native checkboxes, HTML/Markdown/JSON/TSV export, template engine. ### Auto Column Width @@ -742,19 +753,19 @@ The ODS reader and writer model the same narrow set, so **ODS → ODS is lossless**. The loss shows up when converting _into_ ODS from a format that models more, which in practice means XLSX. -| | ODS | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| Cell values — string, number, boolean, date | yes | -| Formulas, including the `of:=` translation | yes | -| Merged cells | yes | -| Cell styles | **six facets only**: bold, italic, font size, font colour, background colour, number format | -| Document properties | six fields: title, subject, creator, description, keywords, created | -| Borders, alignment, font name, underline, strikethrough | no | -| Column widths, row heights, hidden rows and columns | no | -| Freeze and split panes, sheet views, tab colour | no | -| Data validation, conditional formatting, auto-filter | no | -| Named ranges, tables, images, page setup, sheet protection | no | -| Hidden sheets | no | +| | ODS | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Cell values — string, number, boolean, date | yes | +| Formulas, including the `of:=` translation | yes | +| Merged cells | yes | +| Cell styles | **six facets only**: bold, italic, font size, font colour, background colour, number format | +| Document properties | six fields: title, subject, creator, description, keywords, created | +| Borders, alignment, font name, underline, strikethrough | no | +| Column widths, row heights, row formats, hidden rows and columns | no | +| Freeze and split panes, sheet views, tab colour | no | +| Data validation, conditional formatting, auto-filter | no | +| Named ranges, tables, images, page setup, sheet protection | no | +| Hidden sheets | no | So `readXlsx` → `writeOds` keeps values, formulas, merges and those six style facets, and drops the rest **silently** — there is no warning, diff --git a/docs/PARITY.md b/docs/PARITY.md index 6b21f59a..de8efde6 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -85,10 +85,10 @@ printing, copies, DPI and printer defaults; the exception is `r:id`, which points at a binary printer-settings part and has no portable meaning — headers and footers, sheet views and tab colours, hidden and very-hidden sheets, tables, row and column -definitions, manual page breaks, outline properties, sparklines, text -boxes, background images, images, document properties, named ranges, the -1904 date system, workbook protection, theme colours, Excel 2024 -checkboxes, and encryption. +definitions (a row or column format included), manual page breaks, +outline properties, sparklines, text boxes, background images, images, +document properties, named ranges, the 1904 date system, workbook +protection, theme colours, Excel 2024 checkboxes, and encryption. ### Formula results are write-only in practice @@ -522,11 +522,11 @@ document properties (six fields), and six style facets — bold, italic, font size, font colour, background colour, number format. Not modelled in **either** direction: borders, alignment, font name, -underline, strikethrough, column widths, row heights, hidden rows and -columns, freeze and split panes, data validation, conditional formatting, -auto-filter, tables, images, page setup, sheet protection, tab colour, -hidden sheets, and `time` cells (which read back as the raw ISO duration -string). +underline, strikethrough, column widths, row heights, row formats, hidden +rows and columns, freeze and split panes, data validation, conditional +formatting, auto-filter, tables, images, page setup, sheet protection, +tab colour, hidden sheets, and `time` cells (which read back as the raw +ISO duration string). Named ranges left that list: `` carries them both ways. Only workbook-level ones — ODF scopes a name to a sheet by diff --git a/docs/SPEC-COVERAGE.md b/docs/SPEC-COVERAGE.md index 6d1d258d..c54e8eb5 100644 --- a/docs/SPEC-COVERAGE.md +++ b/docs/SPEC-COVERAGE.md @@ -10,7 +10,7 @@ Corpus under `test/fixtures`: 35 OOXML workbooks (455 element names, 245 attribu ## SpreadsheetML (ECMA-376 Part 1) -360 complex types. **31** names appear in the corpus and are _nowhere_ in `src/`; 6 appear in the corpus and occur in `src/` only inside a longer string, which usually means a writer emits them in a template; 644 the source switches on directly; 494 it knows but the corpus does not use; 917 are in neither. +360 complex types. **30** names appear in the corpus and are _nowhere_ in `src/`; 6 appear in the corpus and occur in `src/` only inside a longer string, which usually means a writer emits them in a template; 645 the source switches on directly; 494 it knows but the corpus does not use; 917 are in neither. ### Not yet looked at @@ -38,7 +38,6 @@ Corpus under `test/fixtures`: 35 OOXML workbooks (455 element names, 245 attribu | `lowestEdited` | attribute | writer provenance — not modelled | | `rupBuild` | attribute | writer provenance — not modelled | | `quotePrefix` | attribute | measured, inert — marks a cell forced to text by a leading apostrophe. The value is already a string in the file, so `rows` is unaffected. Every occurrence in the corpus is `"0"`: openpyxl writes it explicitly false, Excel omits it. Nothing observed to carry | -| `customFormat` | attribute | restates that the row has a style, which the style says | | `spans` | attribute | a hint at which columns a row uses. hucre derives that from the cells themselves, which is the authority — Excel treats a wrong `spans` as advisory too | | `activeCell` | attribute | view state — not modelled | | `baseColWidth` | attribute | measured, inert — the base every column width is relative to. Deriving `defaultColWidth` from it needs the normal font's maximum digit width, which hucre cannot measure. Every occurrence in the corpus is `8`, the schema default, and Excel omits it entirely — so nothing real is lost today | diff --git a/scripts/spec-coverage.mjs b/scripts/spec-coverage.mjs index 16529037..21be0567 100644 --- a/scripts/spec-coverage.mjs +++ b/scripts/spec-coverage.mjs @@ -275,7 +275,6 @@ const REVIEWED = new Map( // Out of scope by design. filterPrivacy: "privacy flag, no data — not modelled", pivotButton: "pivot UI affordance — pivots are round-trip only", - customFormat: "restates that the row has a style, which the style says", outlineLevelCol: "summary of the per-column outline levels hucre reads", outlineLevelRow: "summary of the per-row outline levels hucre reads", diff --git a/src/_types.ts b/src/_types.ts index 8d9591a4..a95b7ed5 100644 --- a/src/_types.ts +++ b/src/_types.ts @@ -248,7 +248,9 @@ export interface ColumnDef { /** * Default style for every cell in the column. Applies whether the rows * come from {@link WriteSheet.data} or {@link WriteSheet.rows} — on the - * `data[]` path the generated header row gets it too. + * `data[]` path the generated header row gets it too. A row's + * {@link RowDef.style} takes its place, `numFmt` included, on the cells + * of that row (#601). */ style?: CellStyle /** Number format. Folded into {@link style}; an explicit `style.numFmt` wins. */ @@ -854,6 +856,13 @@ export interface RowDef { outlineLevel?: number /** Whether this outline group is collapsed */ collapsed?: boolean + /** + * Default style for the whole row — ``. On + * write it also applies to every cell in the row that has no style of + * its own, and wins over {@link ColumnDef.style} there. Read back only + * with `readStyles`. See #601. + */ + style?: CellStyle } // ── Sheet ────────────────────────────────────────────────────────── diff --git a/src/builder.ts b/src/builder.ts index 84e54981..4d8d86ea 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -239,7 +239,7 @@ export class SheetBuilder { return this } - /** Set row-level properties — height, hidden, outline level, collapsed. */ + /** Set row-level properties — height, hidden, outline level, collapsed, format. */ rowDef( row: number, def: NonNullable extends Map ? T : never, diff --git a/src/sheet-ops.ts b/src/sheet-ops.ts index 7858fbfa..f4f990cd 100644 --- a/src/sheet-ops.ts +++ b/src/sheet-ops.ts @@ -955,7 +955,9 @@ export function cloneSheet(sheet: Sheet, newName: string): Sheet { if (sheet.rowDefs && sheet.rowDefs.size > 0) { const rowDefs = new Map() for (const [key, def] of sheet.rowDefs) { - rowDefs.set(key, { ...def }) + // The style is an object of its own, so a spread alone would share + // it between the two sheets. See #601. + rowDefs.set(key, def.style ? { ...def, style: cloneCellStyle(def.style) } : { ...def }) } cloned.rowDefs = rowDefs } diff --git a/src/xlsx/stream-writer.ts b/src/xlsx/stream-writer.ts index 88cde8e8..5fec3085 100644 --- a/src/xlsx/stream-writer.ts +++ b/src/xlsx/stream-writer.ts @@ -78,11 +78,11 @@ export interface StreamWriterOptions { */ repeatHeaders?: boolean /** - * Row-level properties — `height`, `hidden`, `outlineLevel`, `collapsed` — - * keyed by 0-based row index, counted the way the caller sees it: the key - * keeps rising past a rollover, where the per-sheet index restarts at 0. - * Consulted as each row is emitted, so it costs nothing per row that has - * no entry. + * Row-level properties — `height`, `hidden`, `outlineLevel`, `collapsed`, + * `style` — keyed by 0-based row index, counted the way the caller sees + * it: the key keeps rising past a rollover, where the per-sheet index + * restarts at 0. Consulted as each row is emitted, so it costs nothing + * per row that has no entry. */ rowDefs?: Map /** @@ -296,16 +296,22 @@ class RowSerializer { rowDef?: RowDef, ): string | null { const cellElements: string[] = [] + // A row format reaches the cells that carry no style of their own and + // wins over the column's there, as it does in writeXlsx. See #601. + const rowStyle = rowDef?.style for (let c = 0; c < values.length; c++) { const colDef = this.columns?.[c] const raw = values[c] const styled = isStreamStyledCell(raw) const value = styled ? (raw.value ?? null) : (raw as CellValue) - let style: CellStyle | undefined = (styled && raw.style) || colDef?.style + const own = (styled && raw.style) || undefined + let style: CellStyle | undefined = own || rowStyle || colDef?.style - // If numFmt on column but not in style, merge - if (colDef?.numFmt && (!style || !style.numFmt)) { + // If numFmt on column but not in style, merge — except into a row + // format, which replaces the column's outright, number format + // included, the way writeXlsx resolves it. See #601. + if (colDef?.numFmt && (!style || !style.numFmt) && (own || !rowStyle)) { style = { ...style, numFmt: colDef.numFmt } } @@ -319,11 +325,12 @@ class RowSerializer { if (cellXml) cellElements.push(cellXml) } - const attrs = rowAttributes(rowIndex, rowDef) + const attrs = rowAttributes(rowIndex, rowDef, this.styles) if (cellElements.length === 0) { // A row with no cells still has to be emitted when its definition asks - // for something — a height, or a hidden/grouped row the caller wants. + // for something — a height, a format, or a hidden/grouped row the + // caller wants. return hasRowAttributes(rowDef) ? xmlSelfClose("row", attrs) : null } return xmlElement("row", attrs, cellElements) diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 381cc644..3988d01f 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -110,7 +110,8 @@ export function colToLetter(col: number): string { */ export function rowAttributes( rowIndex: number, - rowDef?: RowDef, + rowDef: RowDef | undefined, + styles: StylesCollector, ): Record { const attrs: Record = { r: rowIndex + 1 } if (rowDef?.height !== undefined) { @@ -120,6 +121,16 @@ export function rowAttributes( if (rowDef?.hidden) attrs["hidden"] = 1 if (rowDef?.outlineLevel) attrs["outlineLevel"] = rowDef.outlineLevel if (rowDef?.collapsed) attrs["collapsed"] = 1 + // §18.3.1.73: `s` is the row's format only when `customFormat` is 1 — + // without it Excel ignores `s`. Index 0 is the default xf and needs + // neither, as on ``; a row with no style is unchanged. See #601. + if (rowDef?.style) { + const styleId = styles.addStyle(rowDef.style) + if (styleId !== 0) { + attrs["s"] = styleId + attrs["customFormat"] = 1 + } + } return attrs } @@ -130,7 +141,8 @@ export function hasRowAttributes(rowDef?: RowDef): boolean { (rowDef.height !== undefined || Boolean(rowDef.hidden) || Boolean(rowDef.outlineLevel) || - Boolean(rowDef.collapsed)) + Boolean(rowDef.collapsed) || + rowDef.style !== undefined) ) } @@ -512,9 +524,7 @@ export function writeWorksheetXml( for (let r = 0; r < rowCount; r++) { const row = resolvedRows[r] const rowDef = sheet.rowDefs?.get(r) - const hasRowDef = - rowDef && - (rowDef.height !== undefined || rowDef.hidden || rowDef.outlineLevel || rowDef.collapsed) + const hasRowDef = hasRowAttributes(rowDef) if ((!row || row.length === 0) && !hasRowDef) continue @@ -551,7 +561,7 @@ export function writeWorksheetXml( // floor. The cell fast path below it pays because there are 1.2M // cells to a sheet's 100k rows — a twelfth of the allocations is a // twelfth of the prize. See #472. - const rowAttrs = rowAttributes(r, rowDef) + const rowAttrs = rowAttributes(r, rowDef, styles) if (hasAnyCells) { rowElements.push(xmlElement("row", rowAttrs, cellElements)) } else { @@ -774,6 +784,15 @@ function columnCellStyle(col: ColumnDef | undefined): CellStyle | undefined { function resolveRows(sheet: WriteSheet): Array> { const resolved: Array> = [] + // A row format goes on the cells as well as on ``, the way a + // column format does — Excel writes the row's `s` onto every cell + // already in it, and a reader that looks only at the cells should see + // the format too. It wins over the column's where both cover a cell + // that has no style of its own, `numFmt` included: a cell's own format + // first, then its row's, then its column's is the order Excel is + // understood to resolve them in. Not verified in Excel. See #601. + const rowStyle = (r: number): CellStyle | undefined => sheet.rowDefs?.get(r)?.style + if (sheet.data && sheet.columns) { // Object-based data with column keys const keys = sheet.columns.map((col) => col.key) @@ -782,11 +801,12 @@ function resolveRows(sheet: WriteSheet): Array> { const hasHeaders = sheet.columns.some((col) => col.header) if (hasHeaders) { const headerRow: Array = [] + const rowFormat = rowStyle(0) for (let c = 0; c < sheet.columns.length; c++) { const col = sheet.columns[c] headerRow.push({ value: col.header ?? col.key ?? null, - style: col.style, + style: rowFormat ?? col.style, }) } resolved.push(headerRow) @@ -794,12 +814,13 @@ function resolveRows(sheet: WriteSheet): Array> { for (const obj of sheet.data) { const row: Array = [] + const rowFormat = rowStyle(resolved.length) for (let c = 0; c < keys.length; c++) { const key = keys[c] const raw = key !== undefined ? (obj[key] ?? null) : null const cell: ResolvedCell = { value: isHyperlinkValue(raw) ? raw.text : raw, - style: columnCellStyle(sheet.columns[c]), + style: rowFormat ?? columnCellStyle(sheet.columns[c]), } if (isHyperlinkValue(raw)) cell.hyperlink = toHyperlink(raw) row.push(cell) @@ -815,13 +836,14 @@ function resolveRows(sheet: WriteSheet): Array> { // exempt — hucre cannot tell which of the caller's rows is one. for (const row of sheet.rows) { const resolvedRow: Array = [] + const rowFormat = rowStyle(resolved.length) for (let c = 0; c < row.length; c++) { // `writeXlsx` lifts an inline cell object into `cells` before this // runs, so the entry is a value by then. Reading it through // `toCellValue` keeps `resolveRows` correct for a caller that // reached it another way, rather than emitting `[object Object]`. const value = toCellValue(row[c]!) - const style = sheet.columns ? columnCellStyle(sheet.columns[c]) : undefined + const style = rowFormat ?? (sheet.columns ? columnCellStyle(sheet.columns[c]) : undefined) resolvedRow.push(style ? { value, style } : { value }) } resolved.push(resolvedRow) @@ -847,7 +869,7 @@ function resolveRows(sheet: WriteSheet): Array> { const existing = row[c] row[c] = { value: cellOverride.value ?? existing?.value ?? null, - style: cellOverride.style ?? existing?.style, + style: cellOverride.style ?? existing?.style ?? rowStyle(r), checkbox: cellOverride.checkbox ?? existing?.checkbox, formula: cellOverride.formula ?? existing?.formula, formulaResult: cellOverride.formulaResult ?? existing?.formulaResult, diff --git a/src/xlsx/worksheet.ts b/src/xlsx/worksheet.ts index c208e239..1b87f14a 100644 --- a/src/xlsx/worksheet.ts +++ b/src/xlsx/worksheet.ts @@ -551,6 +551,30 @@ function worksheetParser( rowDefs.set(rowNum, existing) } } + // The row's format. §18.3.1.73 makes `s` mean something only + // when `customFormat` is set, and Excel ignores it otherwise, + // so a bare `s` is not read as one. It used to be dropped + // either way, which lost a formatted row on the way through. + // See #601. + if ( + ctx.readStyles && + ctx.styles && + attrs["s"] !== undefined && + (attrs["customFormat"] === "1" || attrs["customFormat"] === "true") + ) { + const rowNum = Number(attrs["r"]) - 1 + const styleIndex = Number(attrs["s"]) + if (!Number.isNaN(rowNum) && styleIndex >= 0) { + const rowStyle = resolveStyle(ctx.styles, styleIndex) + if (Object.keys(rowStyle).length > 0) { + const existing = rowDefs.get(rowNum) ?? {} + // Each row gets its own copy, as each column does: + // `resolveStyle` hands out shared records. + existing.style = cloneCellStyle(rowStyle) + rowDefs.set(rowNum, existing) + } + } + } } break case "c": diff --git a/test/clone-sheet-coverage.test.ts b/test/clone-sheet-coverage.test.ts index a03b04a7..3a1d7c81 100644 --- a/test/clone-sheet-coverage.test.ts +++ b/test/clone-sheet-coverage.test.ts @@ -50,7 +50,7 @@ const FULL_SHEET: Required = { ], cells: new Map([["0,0", FULL_CELL]]), columns: [{ width: 12, style: { font: { name: "Arial" } } }], - rowDefs: new Map([[0, { height: 30, hidden: true }]]), + rowDefs: new Map([[0, { height: 30, hidden: true, style: { numFmt: "0.00" } }]]), defaultRowHeight: 24, defaultColWidth: 18, merges: [{ startRow: 0, startCol: 0, endRow: 0, endCol: 1 }], @@ -128,6 +128,7 @@ describe("cloneSheet carries every field of Sheet", () => { expect(copy.slicers![0]).not.toBe(FULL_SHEET.slicers[0]) expect(copy.timelines![0]).not.toBe(FULL_SHEET.timelines[0]) expect(copy.threadedComments![0]).not.toBe(FULL_SHEET.threadedComments[0]) + expect(copy.rowDefs!.get(0)!.style).not.toBe(FULL_SHEET.rowDefs.get(0)!.style) copy.rowBreaks!.push(99) copy.sparklines![0]!.dataRange = "Z1:Z9" diff --git a/test/real-files.test.ts b/test/real-files.test.ts index 7c41d00e..8edfb518 100644 --- a/test/real-files.test.ts +++ b/test/real-files.test.ts @@ -507,6 +507,26 @@ describe("workbooks written by Excel, not by this test suite", () => { }) }) + // Excel formats a whole row by writing `s` and `customFormat="1"` on + // ``. Rows 3 and 4 here have a format and no cells at all, so the + // `` is the only place it lives — and the reader used to drop it. + // See #601. + describe("excel-styleonly.xlsx", () => { + it("#601 — reads the row formats Excel wrote", async () => { + const wb = await readXlsx(bytes("excel-styleonly.xlsx"), { readStyles: true }) + const rowDefs = wb.sheets[0]!.rowDefs! + + expect(rowDefs.get(0)?.style?.font?.bold).toBe(true) + for (const r of [1, 2, 3]) { + expect(rowDefs.get(r)?.style?.numFmt).toBe("0.00") + expect(rowDefs.get(r)?.style?.fill).toMatchObject({ + pattern: "solid", + fgColor: { rgb: "DDEBF7" }, + }) + } + }) + }) + // A test that reads no bytes would pass just as green. This one fails // loudly if test/fixtures/ ever stops being on disk. it("is actually reading the files", () => { diff --git a/test/row-style.test.ts b/test/row-style.test.ts new file mode 100644 index 00000000..f78a2b90 --- /dev/null +++ b/test/row-style.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "vitest" +import { writeXlsx } from "../src/xlsx/writer" +import { readXlsx } from "../src/xlsx/reader" +import { writeXlsxStream } from "../src/xlsx/stream-writer" +import { ZipReader } from "../src/zip/reader" +import { ZipWriter } from "../src/zip/writer" +import type { CellStyle, RowDef, WriteSheet } from "../src/_types" + +// ═══════════════════════════════════════════════════════════════════════ +// #601 — a row format. +// +// Excel writes a formatted row as `` +// (ECMA-376 Part 1 §18.3.1.73), and stamps the same `s` onto the cells +// already in the row. `RowDef` had height, hidden and outline but no +// style, so the writer had nothing to say and the reader dropped the +// attribute: a row format in an Excel file was lost on the way through. +// Columns have had the equivalent since #439 §W. +// ═══════════════════════════════════════════════════════════════════════ + +async function part(bytes: Uint8Array, path: string): Promise { + return new TextDecoder().decode(await new ZipReader(bytes).extract(path)) +} + +async function sheetXml(sheet: WriteSheet): Promise { + return part(await writeXlsx({ sheets: [sheet] }), "xl/worksheets/sheet1.xml") +} + +/** Rebuild an archive with one part rewritten, to stand in for another tool's output. */ +async function rewrite( + bytes: Uint8Array, + path: string, + edit: (xml: string) => string, +): Promise { + const all = await new ZipReader(bytes).extractAll() + const zw = new ZipWriter() + for (const [name, data] of all) { + zw.add( + name, + name === path ? new TextEncoder().encode(edit(new TextDecoder().decode(data))) : data, + ) + } + return zw.build() +} + +async function collect(stream: ReadableStream): Promise { + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +const TOTAL: CellStyle = { + numFmt: "0.00", + fill: { type: "pattern", pattern: "solid", fgColor: { rgb: "FFDDEBF7" } }, +} +const ITALIC: CellStyle = { font: { italic: true } } + +/** The `s` a `` carries, or undefined. */ +const rowS = (xml: string, r: number): string | undefined => + xml.match(new RegExp(`]*? s="(\\d+)"`))?.[1] + +describe("a row format reaches the file", () => { + it("writes s and customFormat on , beside the height", async () => { + const xml = await sheetXml({ + name: "S", + rows: [ + ["Name", "Qty"], + ["Total", 3], + ], + rowDefs: new Map([[1, { height: 20, style: TOTAL }]]), + }) + + expect(xml).toMatch(//) + expect(xml).toMatch(//) + }) + + it("writes a row that has a format and no cells", async () => { + const xml = await sheetXml({ + name: "S", + rows: [["a"], [], ["c"]], + rowDefs: new Map([[1, { style: TOTAL }]]), + }) + + expect(xml).toMatch(//) + }) + + it("stamps the cells in the row that carry no style of their own", async () => { + const xml = await sheetXml({ + name: "S", + rows: [["Total", 3]], + cells: new Map([["0,1", { value: 3, style: ITALIC }]]), + rowDefs: new Map([[0, { style: TOTAL }]]), + }) + + const s = rowS(xml, 1) + expect(s).toBeDefined() + expect(xml).toContain(``) + // B1 has a style of its own, so it keeps it. + expect(xml).toMatch(//) + expect(xml.match(//)![1]).not.toBe(s) + }) + + it("lets the row format win over a column format on a cell neither styles", async () => { + // Excel applies whichever format came last; a row format is applied + // after the sheet's column formats here. Not verified in Excel. + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"], ["b"]], + columns: [{ style: ITALIC }], + rowDefs: new Map([[1, { style: TOTAL }]]), + }, + ], + }) + + const cells = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.cells! + expect(cells.get("0,0")!.style!.font!.italic).toBe(true) + expect(cells.get("1,0")!.style!.numFmt).toBe("0.00") + expect(cells.get("1,0")!.style!.font?.italic).toBeUndefined() + }) + + it("stamps the cells written from data[] as well as rows", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + columns: [{ key: "n", header: "N" }], + data: [{ n: 1 }, { n: 2 }], + rowDefs: new Map([[2, { style: TOTAL }]]), + }, + ], + }) + + const cells = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.cells! + expect(cells.get("2,0")!.style!.numFmt).toBe("0.00") + expect(cells.get("1,0")?.style?.numFmt).toBeUndefined() + }) + + it("stamps a cell a cells override adds to the row", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"]], + cells: new Map([["0,3", { value: 4 }]]), + rowDefs: new Map([[0, { style: TOTAL }]]), + }, + ], + }) + + const cells = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.cells! + expect(cells.get("0,3")!.style!.numFmt).toBe("0.00") + }) + + it("is written by the streaming writer too", async () => { + const bytes = await collect( + writeXlsxStream([["Total", 3], [], ["x"]], { + name: "S", + rowDefs: new Map([ + [0, { style: TOTAL }], + [1, { style: TOTAL }], + ]), + }), + ) + + const xml = await part(bytes, "xl/worksheets/sheet1.xml") + const s = rowS(xml, 1) + expect(s).toBeDefined() + expect(xml).toMatch(//) + expect(xml).toContain(``) + expect(xml).toContain(``) + expect(xml).toMatch(//) + }) + + it("lets the row format win over a column number format, streamed or not", async () => { + // The streaming writer folds a column's numFmt into whatever style a + // cell ends up with. A row format replaces the column's outright in + // writeXlsx, so it has to do the same here or the two writers disagree. + const columns = [{ numFmt: "0%" }] + const rowDefs = new Map([[1, { style: ITALIC }]]) + const streamed = await collect(writeXlsxStream([[0.5], [0.5]], { name: "S", columns, rowDefs })) + const buffered = await writeXlsx({ + sheets: [{ name: "S", rows: [[0.5], [0.5]], columns, rowDefs }], + }) + + for (const bytes of [streamed, buffered]) { + const cells = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.cells! + expect(cells.get("0,0")!.style!.numFmt).toBe("0%") + expect(cells.get("1,0")!.style!.font!.italic).toBe(true) + expect(cells.get("1,0")!.style!.numFmt).toBeUndefined() + } + }) + + it("leaves a row with no format byte-for-byte as it was", async () => { + // The new attributes go out only for a style; a row definition without + // one writes exactly the `` and cells it wrote before. + const xml = await sheetXml({ + name: "S", + rows: [ + ["a", 1], + ["b", 2], + ], + rowDefs: new Map([[1, { height: 20 }]]), + }) + + expect(xml).toContain( + '01' + + '12', + ) + expect(xml).not.toContain("customFormat") + }) +}) + +describe("a row format survives a read", () => { + it("reads back as RowDef.style", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["Name"], ["Total"]], + rowDefs: new Map([[1, { height: 20, style: TOTAL }]]), + }, + ], + }) + + const def = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.rowDefs!.get(1)! + + expect(def.height).toBe(20) + expect(def.style!.numFmt).toBe("0.00") + expect(def.style!.fill).toMatchObject({ pattern: "solid", fgColor: { rgb: "DDEBF7" } }) + }) + + it("reads a row that has only a format", async () => { + const bytes = await writeXlsx({ + sheets: [{ name: "S", rows: [["a"], [], ["c"]], rowDefs: new Map([[1, { style: TOTAL }]]) }], + }) + + const def = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.rowDefs!.get(1) + + expect(def?.style?.numFmt).toBe("0.00") + }) + + it("ignores s when customFormat does not say the row is formatted", async () => { + // §18.3.1.73: `s` applies to the row only when `customFormat` is 1. + const bytes = await writeXlsx({ + sheets: [{ name: "S", rows: [["a"]], rowDefs: new Map([[0, { style: TOTAL }]]) }], + }) + const patched = await rewrite(bytes, "xl/worksheets/sheet1.xml", (xml) => + xml.replace('customFormat="1"', 'customFormat="0"'), + ) + + const sheet = (await readXlsx(patched, { readStyles: true })).sheets[0]! + + expect(sheet.rowDefs?.get(0)?.style).toBeUndefined() + }) + + it("leaves the format out when styles are not being read", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"]], + rowDefs: new Map([[0, { height: 20, style: TOTAL }]]), + }, + ], + }) + + const def = (await readXlsx(bytes)).sheets[0]!.rowDefs!.get(0)! + + expect(def).toEqual({ height: 20 }) + }) + + it("gives each row its own style object", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"], ["b"]], + rowDefs: new Map([ + [0, { style: TOTAL }], + [1, { style: TOTAL }], + ]), + }, + ], + }) + + const rowDefs = (await readXlsx(bytes, { readStyles: true })).sheets[0]!.rowDefs! + + expect(rowDefs.get(0)!.style).not.toBe(rowDefs.get(1)!.style) + rowDefs.get(0)!.style!.numFmt = "changed" + expect(rowDefs.get(1)!.style!.numFmt).toBe("0.00") + }) +}) diff --git a/test/xlsx-write-read-parity.test.ts b/test/xlsx-write-read-parity.test.ts index d9a2a218..945354a5 100644 --- a/test/xlsx-write-read-parity.test.ts +++ b/test/xlsx-write-read-parity.test.ts @@ -273,12 +273,15 @@ const SHEET_FIELDS: { [K in keyof Required]: Entry } colBreaks: { value: [1], read: (sheet) => sheet.colBreaks }, rowDefs: { - value: new Map([[1, { height: 30, hidden: false, outlineLevel: 1 }]]), + value: new Map([ + [1, { height: 30, hidden: false, outlineLevel: 1, style: { numFmt: "0.00" } }], + ]), read: (sheet) => ({ height: sheet.rowDefs?.get(1)?.height, outlineLevel: sheet.rowDefs?.get(1)?.outlineLevel, + numFmt: sheet.rowDefs?.get(1)?.style?.numFmt, }), - expected: { height: 30, outlineLevel: 1 }, + expected: { height: 30, outlineLevel: 1, numFmt: "0.00" }, }, defaultRowHeight: { From f1da2335b21f73915b500cf2598aa1dd27b0d85f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roni=20Str=C3=B6m?= Date: Tue, 20 Oct 2026 21:58:30 +0300 Subject: [PATCH 2/3] fix(xlsx): write row formats past the data, and read rows without r MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps the review of #601 found, both older than the row format but now reachable through it: - writeXlsx stopped its row loop at the last row of data, so a row definition past it — a formatted row below a table, or a sheet with only rowDefs — got no . The definitions past the data are now written after it, in row order, without visiting the rows between. - `r` is optional on . The reader numbered the row's cells from its position but read its height, hidden, outline, collapsed and format from `r` alone, which is NaN without it, so all of them were dropped. They now use the same row number as the cells. The coverage test that pinned the old behaviour now expects the flags kept. writeXlsxStream is unchanged: it writes the rows it is given, and a definition for a row it never receives is a separate change. Refs #601 --- src/xlsx/worksheet-writer.ts | 14 +++++++ src/xlsx/worksheet.ts | 14 ++++--- test/coverage-xlsx-worksheet.test.ts | 15 ++++--- test/row-style.test.ts | 61 ++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 3988d01f..8757bd14 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -570,6 +570,20 @@ export function writeWorksheetXml( } } + // A row definition past the last row of data — a formatted row below a + // table, or a sheet with only `rowDefs` — has no cells to carry it, and + // the loop above stops at the data. It still needs its ``, or its + // format, height or hidden flag never reaches the file. Only the + // definitions are visited, so a far-away row costs nothing in between. + if (sheet.rowDefs) { + const trailing = [...sheet.rowDefs.keys()] + .filter((r) => r >= rowCount && hasRowAttributes(sheet.rowDefs!.get(r))) + .sort((a, b) => a - b) + for (const r of trailing) { + rowElements.push(xmlSelfClose("row", rowAttributes(r, sheet.rowDefs.get(r), styles))) + } + } + parts.push(xmlElement("sheetData", undefined, rowElements.length > 0 ? rowElements : "")) // ── SheetProtection (OOXML: after sheetData, before autoFilter) ── diff --git a/src/xlsx/worksheet.ts b/src/xlsx/worksheet.ts index 1b87f14a..12adb8f7 100644 --- a/src/xlsx/worksheet.ts +++ b/src/xlsx/worksheet.ts @@ -513,12 +513,16 @@ function worksheetParser( // lacking an `r` attribute get sequential columns within this row. currentRowNum = Number(attrs["r"]) || currentRowNum + 1 implicitCol = 0 + // `r` is optional on `` (§18.3.1.73): the attributes below + // take the row number worked out above, as the cells do. They + // used to read `attrs["r"]` again, which is NaN without it, so + // a height, hidden flag, outline level or format was dropped. // Parse row-level attributes: ht, customHeight, hidden if ( attrs["ht"] && (attrs["customHeight"] === "1" || attrs["customHeight"] === "true") ) { - const rowNum = Number(attrs["r"]) - 1 // 0-based + const rowNum = currentRowNum - 1 // 0-based const height = Number(attrs["ht"]) if (!Number.isNaN(rowNum) && !Number.isNaN(height)) { const existing = rowDefs.get(rowNum) ?? {} @@ -527,7 +531,7 @@ function worksheetParser( } } if (attrs["hidden"] === "1" || attrs["hidden"] === "true") { - const rowNum = Number(attrs["r"]) - 1 + const rowNum = currentRowNum - 1 if (!Number.isNaN(rowNum)) { const existing = rowDefs.get(rowNum) ?? {} existing.hidden = true @@ -535,7 +539,7 @@ function worksheetParser( } } if (attrs["outlineLevel"]) { - const rowNum = Number(attrs["r"]) - 1 + const rowNum = currentRowNum - 1 const level = Number(attrs["outlineLevel"]) if (!Number.isNaN(rowNum) && !Number.isNaN(level) && level > 0) { const existing = rowDefs.get(rowNum) ?? {} @@ -544,7 +548,7 @@ function worksheetParser( } } if (attrs["collapsed"] === "1" || attrs["collapsed"] === "true") { - const rowNum = Number(attrs["r"]) - 1 + const rowNum = currentRowNum - 1 if (!Number.isNaN(rowNum)) { const existing = rowDefs.get(rowNum) ?? {} existing.collapsed = true @@ -562,7 +566,7 @@ function worksheetParser( attrs["s"] !== undefined && (attrs["customFormat"] === "1" || attrs["customFormat"] === "true") ) { - const rowNum = Number(attrs["r"]) - 1 + const rowNum = currentRowNum - 1 const styleIndex = Number(attrs["s"]) if (!Number.isNaN(rowNum) && styleIndex >= 0) { const rowStyle = resolveStyle(ctx.styles, styleIndex) diff --git a/test/coverage-xlsx-worksheet.test.ts b/test/coverage-xlsx-worksheet.test.ts index c6e6e972..ef146145 100644 --- a/test/coverage-xlsx-worksheet.test.ts +++ b/test/coverage-xlsx-worksheet.test.ts @@ -148,16 +148,21 @@ describe("row definitions", () => { expect(s.rowDefs).toBeUndefined() }) - it("drops row flags on a row that carries no row number", () => { - // Row properties are keyed by row index; with no `r` there is - // nothing to key them to, so the flags are dropped even though the - // row's cells are still read. + it("keys row flags on a row with no row number to its position", () => { + // `r` is optional on `` (§18.3.1.73). The row's cells were + // already numbered from its position; its flags used to be read from + // `r` alone and dropped without it. See #601. const s = data( ``, ) expect(s.rows).toEqual([[1]]) - expect(s.rowDefs).toBeUndefined() + expect(s.rowDefs?.get(0)).toEqual({ + height: 40, + hidden: true, + outlineLevel: 2, + collapsed: true, + }) }) it("stops parsing rows once maxRows is reached", () => { diff --git a/test/row-style.test.ts b/test/row-style.test.ts index f78a2b90..13e62b9c 100644 --- a/test/row-style.test.ts +++ b/test/row-style.test.ts @@ -289,3 +289,64 @@ describe("a row format survives a read", () => { expect(rowDefs.get(1)!.style!.numFmt).toBe("0.00") }) }) + +describe("a row format with no data to sit in", () => { + // The writer's row loop stopped at the last row of data, so a row + // definition past it — a formatted row below a table, or a sheet with + // only `rowDefs` — had no `` and its format was lost. + it("writes a formatted row below the data", async () => { + const xml = await sheetXml({ + name: "S", + rows: [["a"], ["b"]], + rowDefs: new Map([[9, { style: TOTAL }]]), + }) + + expect(xml).toMatch(//) + }) + + it("writes a sheet that has only row definitions", async () => { + const xml = await sheetXml({ + name: "S", + rowDefs: new Map([ + [4, { height: 30 }], + [2, { style: TOTAL }], + ]), + }) + + // In row order, whatever order the map was built in. + expect(xml).toMatch( + //, + ) + }) + + it("still writes nothing for a definition that says nothing", async () => { + const xml = await sheetXml({ name: "S", rows: [["a"]], rowDefs: new Map([[5, {}]]) }) + + expect(xml).not.toContain(' { + // `r` is optional on ``; the reader numbered the cells from the + // row's position but read the row's own attributes from `r`, which is + // NaN without it, so the format and height were dropped. + it("keeps its format and height", async () => { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a"], ["b"]], + rowDefs: new Map([[1, { height: 20, style: TOTAL }]]), + }, + ], + }) + const patched = await rewrite(bytes, "xl/worksheets/sheet1.xml", (xml) => + xml.replace(/ Date: Mon, 26 Oct 2026 12:45:52 +0200 Subject: [PATCH 3/3] fix(xlsx): keep a cell the file left unformatted inside a formatted row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row's `s` is the format of the cells that are not in the row (§18.3.1.73); a cell that is present keeps its own, `s="0"` or no `s` included. readXlsx gave such a cell no style, the same as a cell the caller never styled, so writing it back filled it from the row: read → write and open → save both moved the row's format onto it. Found in review. With readStyles, a cell inside a formatted row that has no format of its own now reads back with `style: {}`. The writer already lets a cell's own style win over the row's, so it keeps the cell at the default format. That needed `{}` to land on xf 0. It did not: the default xf's dedup key lacked the `|cb:0` checkbox flag every other key carries, so a style that sets nothing got a second, identical xf. The key now matches. Checked in Excel for Mac with a file Excel wrote (a formatted row with one cell's formatting cleared): after read → write and open → save the cleared cell stays unformatted and the rest of the row keeps its format. Refs #601 --- docs/PARITY.md | 11 ++++++++ src/_types.ts | 4 ++- src/xlsx/styles-writer.ts | 7 +++-- src/xlsx/worksheet-writer.ts | 6 ++-- src/xlsx/worksheet.ts | 17 ++++++++++++ test/row-style.test.ts | 54 ++++++++++++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 5 deletions(-) diff --git a/docs/PARITY.md b/docs/PARITY.md index de8efde6..a159b9a8 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -250,6 +250,17 @@ Only the **trailing** box shrinks. Interior positions are untouched: a value at D1 still sits at index 3 behind three nulls, and an empty row between two populated ones is still there. +### A row format and the cells already in it + +A row's format (``) is the format of the cells that +are not in the row (ECMA-376 Part 1 §18.3.1.73). A cell that is present +keeps its own `s`, and that includes `s="0"` or no `s` at all. With +`readStyles`, such a cell inside a formatted row reads back with +`style: {}`. That says the file chose the default format, and the writer +keeps it at `s="0"` instead of giving it the row's format, as it does +for a cell the caller left without a style. Without the `{}`, read → +write moved the row's format onto it. Found in review of #612. + ### `Sheet.rows` is a dense rectangle Every row is an array, every row is the same length, and no element is diff --git a/src/_types.ts b/src/_types.ts index a95b7ed5..006987f5 100644 --- a/src/_types.ts +++ b/src/_types.ts @@ -860,7 +860,9 @@ export interface RowDef { * Default style for the whole row — ``. On * write it also applies to every cell in the row that has no style of * its own, and wins over {@link ColumnDef.style} there. Read back only - * with `readStyles`. See #601. + * with `readStyles`. A cell the file left at the default format inside + * such a row reads back with `style: {}`, so writing it again keeps it + * unformatted rather than filling it from the row. See #601. */ style?: CellStyle } diff --git a/src/xlsx/styles-writer.ts b/src/xlsx/styles-writer.ts index c8f617e6..16513db8 100644 --- a/src/xlsx/styles-writer.ts +++ b/src/xlsx/styles-writer.ts @@ -311,8 +311,11 @@ export function createStylesCollector( const numFmtMap = new Map() let nextNumFmtId = 164 - // Cell XFs — default xf at index 0 - const defaultXfKey = "nf:0|f:0|fl:0|b:0|a:|p:" + // Cell XFs — default xf at index 0. The key has to be spelled the way + // `registerXf` builds one, checkbox flag included, or a style that sets + // nothing — `{}` — never finds it and gets a second, identical xf. A + // cell read back as explicitly unformatted (#601) relies on landing here. + const defaultXfKey = "nf:0|f:0|fl:0|b:0|a:|p:|cb:0" const xfs: XfEntry[] = [ { key: defaultXfKey, diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 8757bd14..e6907cb1 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -803,8 +803,10 @@ function resolveRows(sheet: WriteSheet): Array> { // already in it, and a reader that looks only at the cells should see // the format too. It wins over the column's where both cover a cell // that has no style of its own, `numFmt` included: a cell's own format - // first, then its row's, then its column's is the order Excel is - // understood to resolve them in. Not verified in Excel. See #601. + // first, then its row's, then its column's. Excel for Mac showed the + // same order for an empty cell where a formatted row crosses a formatted + // column. A cell read back with `style: {}` was left at the default + // format by the file, and keeps it: `{}` is its own style. See #601. const rowStyle = (r: number): CellStyle | undefined => sheet.rowDefs?.get(r)?.style if (sheet.data && sheet.columns) { diff --git a/src/xlsx/worksheet.ts b/src/xlsx/worksheet.ts index 12adb8f7..e9c6a5e0 100644 --- a/src/xlsx/worksheet.ts +++ b/src/xlsx/worksheet.ts @@ -1052,6 +1052,13 @@ function worksheetParser( // // Under `readStyles: true` those cells do carry information // and still count. See #492. + // A cell that is present in a row with a format keeps its own + // `s`, 0 or absent included (§18.3.1.73): the row's format is + // for the cells that are not there. The model says so with an + // empty `style`, which the writer emits as the default format + // instead of filling the cell from the row. See #601. + const inFormattedRow = + ctx.readStyles === true && rowDefs.get(effRow)?.style !== undefined const carriesData = cellValueText !== "" || inlineText !== "" || @@ -1070,6 +1077,7 @@ function worksheetParser( // `writeXlsxStream` defaults to inline strings. cellType === "inlineStr" || (ctx.readStyles && cellStyleIndex >= 0) || + inFormattedRow || (ctx.styles && cellStyleIndex >= 0 ? (ctx.styles.cellXfs[cellStyleIndex]?.hasCheckboxFeature ?? false) : false) @@ -1093,6 +1101,7 @@ function worksheetParser( cellFormulaCm, effRow, effCol, + inFormattedRow, ) // Track max dimensions if (effRow >= 0 && effCol >= 0) { @@ -1820,6 +1829,7 @@ function processCell( formulaCm?: boolean, fallbackRow?: number, fallbackCol?: number, + inFormattedRow?: boolean, ): void { // When the `r` attribute is missing, fall back to implicit row/col position // (parity with the streaming reader). @@ -2049,6 +2059,7 @@ function processCell( // in sparse mode, where `cells` is the only place a value can live. const hasDetails = ctx.sparse || + inFormattedRow || formula !== undefined || richText !== undefined || (ctx.readStyles && ctx.styles && styleIndex >= 0) || @@ -2113,6 +2124,12 @@ function processCell( }) } } + // Left at the default format inside a formatted row: `{}` says the + // file chose that, where no `style` at all would let the writer fill + // the cell from the row. See the caller and #601. + if (inFormattedRow && cell.style === undefined) { + cell.style = {} + } const cellKey = `${row},${col}` assertCellMapCapacity(cells, cellKey, ctx.sheetName) cells.set(cellKey, cell) diff --git a/test/row-style.test.ts b/test/row-style.test.ts index 13e62b9c..a01ac065 100644 --- a/test/row-style.test.ts +++ b/test/row-style.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import { writeXlsx } from "../src/xlsx/writer" import { readXlsx } from "../src/xlsx/reader" import { writeXlsxStream } from "../src/xlsx/stream-writer" +import { openXlsx, saveXlsx } from "../src/xlsx/roundtrip" import { ZipReader } from "../src/zip/reader" import { ZipWriter } from "../src/zip/writer" import type { CellStyle, RowDef, WriteSheet } from "../src/_types" @@ -350,3 +351,56 @@ describe("a row with no r attribute", () => { expect(def?.style?.numFmt).toBe("0.00") }) }) + +describe("a cell the file left at the default format, in a formatted row", () => { + // §18.3.1.73: a row's `s` is the format of the cells that are *not* in + // the row. A `` that is present keeps its own `s`, 0 included, so a + // cell the file explicitly left unformatted must not pick up the row's + // format on the way through. Found in review of #612. + async function source(cellB1: string): Promise { + const bytes = await writeXlsx({ + sheets: [ + { + name: "S", + rows: [["a", "b"]], + rowDefs: new Map([[0, { style: TOTAL }]]), + }, + ], + }) + return rewrite(bytes, "xl/worksheets/sheet1.xml", (xml) => + xml.replace(/]*>/, cellB1), + ) + } + + for (const [label, cellB1] of [ + ['s="0"', ''], + ["no s at all", ''], + ] as const) { + it(`keeps B1 at the default format through readXlsx → writeXlsx (${label})`, async () => { + const wb = await readXlsx(await source(cellB1), { readStyles: true }) + const s = wb.sheets[0]! + const xml = await sheetXml({ name: s.name, rows: s.rows, cells: s.cells, rowDefs: s.rowDefs }) + + expect(xml).toMatch(//) + expect(xml).toMatch(//) + }) + + it(`keeps B1 at the default format through openXlsx → saveXlsx (${label})`, async () => { + const book = await openXlsx(await source(cellB1), { readStyles: true }) + const xml = await part(await saveXlsx(book), "xl/worksheets/sheet1.xml") + + expect(xml).toMatch(//) + expect(xml).toMatch(//) + }) + } + + it("still gives a cell the caller left unstyled the row's format", async () => { + const xml = await sheetXml({ + name: "S", + rows: [["a", "b"]], + rowDefs: new Map([[0, { style: TOTAL }]]), + }) + + expect(xml).toMatch(//) + }) +})