Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
69 changes: 69 additions & 0 deletions src/xlsx/table-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, number> = {
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 ──────────────────────────────────────────────────────────

/**
Expand Down
25 changes: 25 additions & 0 deletions src/xlsx/worksheet-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────

Expand Down Expand Up @@ -861,6 +863,29 @@ function resolveRows(sheet: WriteSheet): Array<Array<ResolvedCell | null>> {
}
}

// 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
}

Expand Down
130 changes: 129 additions & 1 deletion test/tables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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", () => {
Expand Down