From 3d7f5e27fecfeba70fc71ee9ff86b4491be4290c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roni=20Str=C3=B6m?= Date: Tue, 20 Oct 2026 21:39:00 +0300 Subject: [PATCH] fix(xlsx): write the cells of a table's total row (#604) The total row of a table lived only in the table part: totalsRowLabel, totalsRowFunction and totalsRowFormula, with nothing in the sheet. Excel writes the row twice, in the table part and as ordinary cells, and reads the cells. Excel for Mac showed an empty total row for a built-in function, and asked to repair any table with a custom total, then dropped the total row. The worksheet writer now fills the total row the way Excel does: the label as a string, a built-in function as SUBTOTAL(10x,Name[Column]) on the displayName with [ ] # ' escaped, a custom total as its formula. A cell the caller already filled is left alone, so a workbook read back from Excel keeps its own cells and cached results. Those cells read back as ordinary cells; docs/PARITY.md says so. Four of the six new tests fail against main. Closes #604 --- docs/PARITY.md | 13 ++++ src/xlsx/table-writer.ts | 69 +++++++++++++++++++ src/xlsx/worksheet-writer.ts | 25 +++++++ test/tables.test.ts | 130 ++++++++++++++++++++++++++++++++++- 4 files changed, 236 insertions(+), 1 deletion(-) diff --git a/docs/PARITY.md b/docs/PARITY.md index 6b21f59a..37dd93f8 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -453,6 +453,19 @@ pre-computed value cells; Excel computes them on open. the workbook-level caches, and has no write counterpart because `WritePivotTable` builds its own cache from the source range. +### A table's total row comes back as cells + +A table with `showTotalRow: true` is written with its total row filled +in: the `totalLabel` as a string, a built-in `totalFunction` as +`SUBTOTAL(109,Table1[Qty])` and the like, a custom one as its +`totalFormula`. Read back, those are ordinary cells in `rows` and +`cells`, on a row the caller may not have written. A total-row cell the +caller did fill is kept as it is. + +The table part describes the total row, but Excel reads it from the +cells. Without them a built-in total showed an empty row, and a custom +total made Excel repair the file and drop the row. Fixed in #604. + ### Write-only, by design | field | why | diff --git a/src/xlsx/table-writer.ts b/src/xlsx/table-writer.ts index acc8d876..64a15a9a 100644 --- a/src/xlsx/table-writer.ts +++ b/src/xlsx/table-writer.ts @@ -2,6 +2,7 @@ // Generates xl/tables/tableN.xml for Excel Table (ListObject) support. import type { TableDefinition } from "../_types" +import { parseRange } from "../cell-utils" import { xmlDocument, xmlElement, xmlSelfClose, xmlEscape } from "../xml/writer" const NS_SPREADSHEET = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" @@ -107,6 +108,74 @@ export function writeTable( return { tableXml, tableId } } +// ── Total Row Cells ────────────────────────────────────────────────── + +/** + * `SUBTOTAL` function numbers for each `totalsRowFunction`, as Excel + * writes them. The 10x forms skip rows hidden by the table's filter. + */ +const SUBTOTAL_FUNCTION_NUM: Record = { + average: 101, + countNums: 102, + count: 103, + max: 104, + min: 105, + stdDev: 107, + sum: 109, + var: 110, +} + +/** One cell of a table's total row, 0-based. */ +export interface TotalRowCell { + row: number + col: number + value?: string + formula?: string +} + +/** + * The cells a table's total row needs on the sheet. + * + * `totalsRowFunction`, `totalsRowFormula` and `totalsRowLabel` in the + * table part do not put anything in the grid. Excel writes the total row + * twice, once in the table part and once as ordinary cells, and reads the + * cells. Without them a built-in function shows an empty total row, and + * a custom formula with no matching cell makes Excel repair the file. + * + * The formulas copy what Excel writes. They use the table's + * `displayName`, the name formulas see, and escape `[ ] # '` in the + * column name with a `'`, so `a[b]` is written `Table1[a'[b']]`. + * See #604. + */ +export function totalRowCells(table: TableDefinition, range: string): TotalRowCell[] { + if (table.showTotalRow !== true) return [] + const { startCol, endRow } = parseRange(range) + const displayName = table.displayName ?? table.name + const cells: TotalRowCell[] = [] + + for (let i = 0; i < table.columns.length; i++) { + const col = table.columns[i]! + const fn = col.totalFunction + const at = { row: endRow, col: startCol + i } + + if (fn === "custom") { + if (col.totalFormula) cells.push({ ...at, formula: col.totalFormula }) + } else if (fn !== undefined && Object.hasOwn(SUBTOTAL_FUNCTION_NUM, fn)) { + const ref = `${displayName}[${escapeStructuredRefColumn(col.name)}]` + cells.push({ ...at, formula: `SUBTOTAL(${SUBTOTAL_FUNCTION_NUM[fn]},${ref})` }) + } else if (col.totalLabel) { + cells.push({ ...at, value: col.totalLabel }) + } + } + + return cells +} + +/** Escape a column name for use inside a structured reference's brackets. */ +function escapeStructuredRefColumn(name: string): string { + return name.replace(/[[\]#']/g, "'$&") +} + // ── Helpers ────────────────────────────────────────────────────────── /** diff --git a/src/xlsx/worksheet-writer.ts b/src/xlsx/worksheet-writer.ts index 381cc644..a8cc83af 100644 --- a/src/xlsx/worksheet-writer.ts +++ b/src/xlsx/worksheet-writer.ts @@ -33,6 +33,8 @@ import { DYNAMIC_ARRAY_CM } from "./metadata" import { hashSheetPassword } from "./password" import { validateColumnIndex } from "../_validate" import { toCellValue } from "../_inline-cells" +import { computeTableRange } from "./derived-ranges" +import { totalRowCells } from "./table-writer" // ── Hyperlink Relationship ──────────────────────────────────────── @@ -861,6 +863,29 @@ function resolveRows(sheet: WriteSheet): Array> { } } + // Table total rows. The table part only describes them; Excel reads the + // label and the SUBTOTAL from cells. A cell the caller already filled is + // theirs: a workbook read back from Excel carries these cells with their + // cached results, and rewriting them would only lose those. See #604. + if (sheet.tables) { + for (const table of sheet.tables) { + const range = table.range ?? computeTableRange(table, sheet) + for (const total of totalRowCells(table, range)) { + while (resolved.length <= total.row) resolved.push([]) + const row = resolved[total.row]! + while (row.length <= total.col) row.push(null) + const existing = row[total.col] + if ( + existing && + (existing.value != null || existing.formula !== undefined || existing.richText) + ) { + continue + } + row[total.col] = { ...existing, value: total.value ?? null, formula: total.formula } + } + } + } + return resolved } diff --git a/test/tables.test.ts b/test/tables.test.ts index 7aa08de7..d743a985 100644 --- a/test/tables.test.ts +++ b/test/tables.test.ts @@ -5,7 +5,7 @@ import { writeXlsx } from "../src/xlsx/writer" import { readXlsx } from "../src/xlsx/reader" import { writeTable } from "../src/xlsx/table-writer" import { writeContentTypes } from "../src/xlsx/content-types-writer" -import type { WriteSheet, TableDefinition } from "../src/_types" +import type { CellValue, WriteSheet, TableDefinition } from "../src/_types" // ── Helpers ────────────────────────────────────────────────────────── @@ -544,6 +544,134 @@ describe("XLSX table writing", () => { }) }) +// ── Total row cells ────────────────────────────────────────────────── +// Excel writes a total row twice: in the table part and as ordinary cells, +// and it reads the cells. The expected formulas below are what Excel for +// Mac 16 wrote for the same tables. See #604. + +describe("table total row cells", () => { + const rows: CellValue[][] = [ + ["Name", "Qty"], + ["a", 1], + ["b", 2], + ["c", 3], + ] + + async function totalRow(table: TableDefinition, sheetRows: CellValue[][] = rows) { + const xlsx = await writeXlsx({ sheets: [{ name: "Sheet1", rows: sheetRows, tables: [table] }] }) + return (await readXlsx(xlsx)).sheets[0]! + } + + it("writes the label and a SUBTOTAL for a built-in function", async () => { + const sheet = await totalRow({ + name: "Table1", + range: "A1:B5", + showTotalRow: true, + columns: [ + { name: "Name", totalLabel: "Total" }, + { name: "Qty", totalFunction: "sum" }, + ], + }) + + expect(sheet.rows[4]?.[0]).toBe("Total") + expect(sheet.cells?.get("4,1")?.formula).toBe("SUBTOTAL(109,Table1[Qty])") + }) + + it("writes a custom total formula into its cell", async () => { + // A custom totalsRowFormula with no matching cell makes Excel repair + // the file and drop the total row. + const sheet = await totalRow({ + name: "Table1", + range: "A1:B5", + showTotalRow: true, + columns: [ + { name: "Name", totalLabel: "Total" }, + { name: "Qty", totalFunction: "custom", totalFormula: "SUM(Table1[Qty])*2" }, + ], + }) + + expect(sheet.rows[4]?.[0]).toBe("Total") + expect(sheet.cells?.get("4,1")?.formula).toBe("SUM(Table1[Qty])*2") + }) + + it("uses each function's SUBTOTAL number, the displayName and escaped column names", async () => { + const names = ["Unit Price", "a[b]", "x#y", "it's", "Min", "Max", "Avg", "Count", "Var"] + const fns = ["sum", "sum", "countNums", "stdDev", "min", "max", "average", "count", "var"] + // Placed at C3 so the total row is found from the range, not assumed at A1. + const pad = [null, null] + const sheet = await totalRow( + { + name: "OddTbl", + displayName: "Odd_Tbl", + range: "C3:K5", + showTotalRow: true, + columns: names.map((name, i) => ({ name, totalFunction: fns[i] })), + }, + [[], [], [...pad, ...names], [...pad, 1, 2, 3, 4, 5, 6, 7, 8, 9]], + ) + + const formulas = names.map((_, i) => sheet.cells?.get(`4,${i + 2}`)?.formula) + expect(formulas).toEqual([ + "SUBTOTAL(109,Odd_Tbl[Unit Price])", + "SUBTOTAL(109,Odd_Tbl[a'[b']])", + "SUBTOTAL(102,Odd_Tbl[x'#y])", + "SUBTOTAL(107,Odd_Tbl[it''s])", + "SUBTOTAL(105,Odd_Tbl[Min])", + "SUBTOTAL(104,Odd_Tbl[Max])", + "SUBTOTAL(101,Odd_Tbl[Avg])", + "SUBTOTAL(103,Odd_Tbl[Count])", + "SUBTOTAL(110,Odd_Tbl[Var])", + ]) + }) + + it("puts the total row below the data when the range is computed", async () => { + const sheet = await totalRow({ + name: "Table1", + showTotalRow: true, + columns: [ + { name: "Name", totalLabel: "Total" }, + { name: "Qty", totalFunction: "count" }, + ], + }) + + expect(sheet.tables?.[0]?.range).toBe("A1:B5") + expect(sheet.rows[4]?.[0]).toBe("Total") + expect(sheet.cells?.get("4,1")?.formula).toBe("SUBTOTAL(103,Table1[Qty])") + }) + + it("leaves a total-row cell the caller already filled", async () => { + const sheet = await totalRow( + { + name: "Table1", + range: "A1:B5", + showTotalRow: true, + columns: [ + { name: "Name", totalLabel: "Total" }, + { name: "Qty", totalFunction: "sum" }, + ], + }, + [...rows, ["Grand total", 6]], + ) + + expect(sheet.rows[4]).toEqual(["Grand total", 6]) + expect(sheet.cells?.get("4,1")?.formula).toBeUndefined() + }) + + it("writes nothing for a column with no total, or without showTotalRow", async () => { + const columns = [{ name: "Name" }, { name: "Qty", totalFunction: "sum" }] + const withTotals = await totalRow({ + name: "Table1", + range: "A1:B5", + showTotalRow: true, + columns, + }) + expect(withTotals.rows[4]?.[0] ?? null).toBeNull() + + const withoutTotals = await totalRow({ name: "Table1", range: "A1:B4", columns }) + expect(withoutTotals.rows).toHaveLength(4) + }) +}) + // ── Content Types ──────────────────────────────────────────────────── describe("content types with tables", () => {