diff --git a/docs/PARITY.md b/docs/PARITY.md index 6b21f59..37dd93f 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 acc8d87..64a15a9 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 381cc64..a8cc83a 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 7aa08de..d743a98 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", () => {