@lotics/xlsx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { aggregate, recomputePivot } from "./pivot_recompute";
3
+ import type { PivotSourceData } from "./pivot_recompute";
4
+ import type {
5
+ ParsedPivotCache,
6
+ ParsedPivotTable,
7
+ } from "./ooxml_pivot_types";
8
+
9
+ // =============================================================================
10
+ // Aggregate
11
+ // =============================================================================
12
+
13
+ describe("aggregate", () => {
14
+ it("sums numbers, ignoring strings", () => {
15
+ expect(aggregate([1, 2, "x", 3], "sum")).toBe(6);
16
+ });
17
+
18
+ it("counts non-blank values, including strings", () => {
19
+ expect(aggregate([1, "x", null, 2, undefined, ""], "count")).toBe(3);
20
+ });
21
+
22
+ it("countNums only counts numeric", () => {
23
+ expect(aggregate([1, "x", null, 2], "countNums")).toBe(2);
24
+ });
25
+
26
+ it("average over numeric values, null when no numbers", () => {
27
+ expect(aggregate([2, 4, 6], "average")).toBe(4);
28
+ expect(aggregate(["x", null], "average")).toBeNull();
29
+ });
30
+
31
+ it("min/max/product over numeric values", () => {
32
+ expect(aggregate([5, 1, 3], "min")).toBe(1);
33
+ expect(aggregate([5, 1, 3], "max")).toBe(5);
34
+ expect(aggregate([2, 3, 4], "product")).toBe(24);
35
+ });
36
+ });
37
+
38
+ // =============================================================================
39
+ // Fixtures
40
+ // =============================================================================
41
+
42
+ const SOURCE: PivotSourceData = {
43
+ header: ["Region", "Quarter", "Revenue"],
44
+ records: [
45
+ ["North", "Q1", 100],
46
+ ["North", "Q2", 200],
47
+ ["South", "Q1", 50],
48
+ ["South", "Q2", 75],
49
+ ],
50
+ };
51
+
52
+ function makeTable(overrides: Partial<ParsedPivotTable> = {}): ParsedPivotTable {
53
+ return {
54
+ name: "Test",
55
+ cacheId: 1,
56
+ ref: "A3:E12",
57
+ firstHeaderRow: 0,
58
+ firstDataCol: 1,
59
+ firstDataRow: 2,
60
+ fields: [
61
+ { showSubtotal: true, compact: true, showAll: false },
62
+ { showSubtotal: true, compact: true, showAll: false },
63
+ { showSubtotal: true, compact: true, showAll: false },
64
+ ],
65
+ rowFieldIndices: [0],
66
+ colFieldIndices: [1],
67
+ pageFieldIndices: [],
68
+ dataFields: [
69
+ { name: "Sum of Revenue", fieldIndex: 2, subtotal: "sum" },
70
+ ],
71
+ display: {
72
+ rowGrandTotals: true,
73
+ colGrandTotals: true,
74
+ showRowStripes: false,
75
+ showColStripes: false,
76
+ },
77
+ ...overrides,
78
+ };
79
+ }
80
+
81
+ const CACHE: ParsedPivotCache = {
82
+ id: 1,
83
+ source: { type: "worksheet", sheetName: "Data", ref: "A1:C5" },
84
+ fields: [
85
+ {
86
+ name: "Region",
87
+ items: [
88
+ { kind: "string", value: "North" },
89
+ { kind: "string", value: "South" },
90
+ ],
91
+ containsNumber: false,
92
+ containsDate: false,
93
+ },
94
+ {
95
+ name: "Quarter",
96
+ items: [
97
+ { kind: "string", value: "Q1" },
98
+ { kind: "string", value: "Q2" },
99
+ ],
100
+ containsNumber: false,
101
+ containsDate: false,
102
+ },
103
+ {
104
+ name: "Revenue",
105
+ items: [],
106
+ containsNumber: true,
107
+ containsDate: false,
108
+ },
109
+ ],
110
+ refreshOnLoad: false,
111
+ };
112
+
113
+ // =============================================================================
114
+ // Recompute
115
+ // =============================================================================
116
+
117
+ describe("recomputePivot", () => {
118
+ it("produces a 4x5 grid for two row tuples × two col tuples + grand totals + label", () => {
119
+ const grid = recomputePivot(makeTable(), CACHE, SOURCE);
120
+ // 1 row label col + 2 col tuples + 1 grand total col = 4 cols
121
+ expect(grid.cols).toBe(4);
122
+ // 1 col-axis header row + 1 value-label row + 2 row tuples + 1 grand total = 5 rows
123
+ expect(grid.rows).toBe(5);
124
+ expect(grid.headerRows).toBe(2);
125
+ expect(grid.rowLabelCols).toBe(1);
126
+ });
127
+
128
+ it("places col-axis labels in row 0", () => {
129
+ const grid = recomputePivot(makeTable(), CACHE, SOURCE);
130
+ expect(grid.cells[0][1]).toMatchObject({ kind: "colHeader", text: "Q1" });
131
+ expect(grid.cells[0][2]).toMatchObject({ kind: "colHeader", text: "Q2" });
132
+ });
133
+
134
+ it("computes body sums correctly", () => {
135
+ const grid = recomputePivot(makeTable(), CACHE, SOURCE);
136
+ // Row 2 (after 2 header rows) is "North". Col 1 = Q1 → 100. Col 2 = Q2 → 200.
137
+ expect(grid.cells[2][0]).toMatchObject({ kind: "rowHeader", text: "North" });
138
+ expect(grid.cells[2][1]).toMatchObject({ kind: "value", value: 100 });
139
+ expect(grid.cells[2][2]).toMatchObject({ kind: "value", value: 200 });
140
+ });
141
+
142
+ it("computes per-row grand totals", () => {
143
+ const grid = recomputePivot(makeTable(), CACHE, SOURCE);
144
+ expect(grid.cells[2][3]).toMatchObject({ kind: "rowTotal", value: 300 }); // North Q1+Q2
145
+ expect(grid.cells[3][3]).toMatchObject({ kind: "rowTotal", value: 125 }); // South
146
+ });
147
+
148
+ it("computes column grand totals", () => {
149
+ const grid = recomputePivot(makeTable(), CACHE, SOURCE);
150
+ const lastRow = grid.rows - 1;
151
+ expect(grid.cells[lastRow][0]).toMatchObject({
152
+ kind: "totalLabel",
153
+ text: "Grand Total",
154
+ });
155
+ expect(grid.cells[lastRow][1]).toMatchObject({ kind: "colTotal", value: 150 }); // Q1
156
+ expect(grid.cells[lastRow][2]).toMatchObject({ kind: "colTotal", value: 275 }); // Q2
157
+ expect(grid.cells[lastRow][3]).toMatchObject({ kind: "grandTotal", value: 425 });
158
+ });
159
+
160
+ it("filters by page-axis selection", () => {
161
+ const filtered = makeTable({
162
+ pageFieldIndices: [0],
163
+ fields: [
164
+ { showSubtotal: true, compact: true, showAll: false, selectedPageItem: 0 }, // North only
165
+ { showSubtotal: true, compact: true, showAll: false },
166
+ { showSubtotal: true, compact: true, showAll: false },
167
+ ],
168
+ rowFieldIndices: [],
169
+ colFieldIndices: [1],
170
+ });
171
+ const grid = recomputePivot(filtered, CACHE, SOURCE);
172
+ // Grand total of just the North rows = 300
173
+ expect(grid.cells[grid.rows - 1].at(-1)).toMatchObject({
174
+ kind: "grandTotal",
175
+ value: 300,
176
+ });
177
+ });
178
+
179
+ it("handles multiple data fields side-by-side under each col tuple", () => {
180
+ const t = makeTable({
181
+ dataFields: [
182
+ { name: "Sum", fieldIndex: 2, subtotal: "sum" },
183
+ { name: "Count", fieldIndex: 2, subtotal: "count" },
184
+ ],
185
+ });
186
+ const grid = recomputePivot(t, CACHE, SOURCE);
187
+ // 1 row-label + 2 col tuples × 2 data fields + 2 grand-total cols = 7
188
+ expect(grid.cols).toBe(7);
189
+ // Value-label row places "Sum" then "Count" repeating
190
+ const labelRow = grid.headerRows - 1;
191
+ expect(grid.cells[labelRow][1]).toMatchObject({ kind: "valueLabel", text: "Sum" });
192
+ expect(grid.cells[labelRow][2]).toMatchObject({ kind: "valueLabel", text: "Count" });
193
+ expect(grid.cells[labelRow][3]).toMatchObject({ kind: "valueLabel", text: "Sum" });
194
+ expect(grid.cells[labelRow][4]).toMatchObject({ kind: "valueLabel", text: "Count" });
195
+ });
196
+
197
+ it("respects display flags: turning off grand totals shrinks the grid", () => {
198
+ const t = makeTable({
199
+ display: {
200
+ rowGrandTotals: false,
201
+ colGrandTotals: false,
202
+ showRowStripes: false,
203
+ showColStripes: false,
204
+ },
205
+ });
206
+ const grid = recomputePivot(t, CACHE, SOURCE);
207
+ expect(grid.cols).toBe(3); // row label + 2 col tuples, no col grand
208
+ expect(grid.rows).toBe(4); // 2 headers + 2 row tuples, no grand row
209
+ });
210
+ });
@@ -0,0 +1,413 @@
1
+ // =============================================================================
2
+ // Pivot Recompute
3
+ // =============================================================================
4
+ //
5
+ // Pure aggregation: given a parsed source range and a pivot configuration,
6
+ // produce a 2D result grid the renderer can paint inside the pivot's `ref`.
7
+ //
8
+ // Design choices:
9
+ // - Multi-level row & column axes: each axis tuple is a list of cell values
10
+ // in axis order. Grouping is by JSON-stringified tuple.
11
+ // - Stable sort by tuple — ties broken alphabetically so tests are
12
+ // deterministic and the layout is human-readable.
13
+ // - Page-axis filter: a field with `selectedPageItem` set drops every record
14
+ // whose value at that field index doesn't match the selected cache item.
15
+ // - Grand totals: appended unconditionally when the model says so. A grand
16
+ // total cell aggregates over *all* records in the corresponding stripe.
17
+ // - Empty output: when a group/value combination has no records, the cell
18
+ // is `null`; the renderer can paint blank (matches Excel's default).
19
+ // =============================================================================
20
+
21
+ import type {
22
+ ParsedPivotCache,
23
+ ParsedPivotTable,
24
+ PivotAggregateFn,
25
+ PivotCacheItem,
26
+ } from "./ooxml_pivot_types";
27
+
28
+ // =============================================================================
29
+ // Types
30
+ // =============================================================================
31
+
32
+ /**
33
+ * A single source record indexed by cache-field position. `undefined` for a
34
+ * missing/empty cell. Numeric strings are pre-coerced to numbers when the
35
+ * cache field declares `containsNumber` so aggregations Just Work.
36
+ */
37
+ export type PivotSourceRecord = (string | number | boolean | null | undefined)[];
38
+
39
+ /**
40
+ * The shape the recompute engine needs from the source range.
41
+ * `header[i]` is the column header text for cache field `i`.
42
+ */
43
+ export interface PivotSourceData {
44
+ header: string[];
45
+ records: PivotSourceRecord[];
46
+ }
47
+
48
+ /**
49
+ * One cell in the layout grid. `kind` carries semantic context for styling
50
+ * decisions (banding, header bold, totals row).
51
+ */
52
+ export type PivotResultCell =
53
+ | { kind: "blank" }
54
+ | { kind: "rowHeader"; depth: number; text: string }
55
+ | { kind: "colHeader"; depth: number; text: string }
56
+ | { kind: "valueLabel"; text: string }
57
+ | { kind: "value"; value: number | null; numFmt?: string }
58
+ | { kind: "rowTotal"; value: number | null }
59
+ | { kind: "colTotal"; value: number | null }
60
+ | { kind: "grandTotal"; value: number | null }
61
+ | { kind: "totalLabel"; text: string };
62
+
63
+ /** 2D grid with row 0 at top, column 0 at left. */
64
+ export interface PivotResultGrid {
65
+ /** [rowIndex][colIndex] */
66
+ cells: PivotResultCell[][];
67
+ /** Number of header rows at the top (for renderer banding logic). */
68
+ headerRows: number;
69
+ /** Number of row-label columns on the left. */
70
+ rowLabelCols: number;
71
+ /** Computed row count, columns. */
72
+ rows: number;
73
+ cols: number;
74
+ }
75
+
76
+ // =============================================================================
77
+ // Recompute
78
+ // =============================================================================
79
+
80
+ const TOTAL_LABEL = "Grand Total";
81
+
82
+ export function recomputePivot(
83
+ table: ParsedPivotTable,
84
+ cache: ParsedPivotCache,
85
+ source: PivotSourceData,
86
+ ): PivotResultGrid {
87
+ const records = filterByPageAxis(table, cache, source.records);
88
+
89
+ const rowTuples = distinctTuples(records, table.rowFieldIndices);
90
+ const colTuples = distinctTuples(records, table.colFieldIndices);
91
+
92
+ // Group records by (rowTuple, colTuple).
93
+ const groupKey = (rec: PivotSourceRecord) =>
94
+ JSON.stringify([
95
+ tupleOf(rec, table.rowFieldIndices),
96
+ tupleOf(rec, table.colFieldIndices),
97
+ ]);
98
+ const groups = new Map<string, PivotSourceRecord[]>();
99
+ for (const rec of records) {
100
+ const key = groupKey(rec);
101
+ let bucket = groups.get(key);
102
+ if (!bucket) {
103
+ bucket = [];
104
+ groups.set(key, bucket);
105
+ }
106
+ bucket.push(rec);
107
+ }
108
+
109
+ return buildGrid(table, source, rowTuples, colTuples, groups, records);
110
+ }
111
+
112
+ // =============================================================================
113
+ // Page filter
114
+ // =============================================================================
115
+
116
+ function filterByPageAxis(
117
+ table: ParsedPivotTable,
118
+ cache: ParsedPivotCache,
119
+ records: PivotSourceRecord[],
120
+ ): PivotSourceRecord[] {
121
+ const filters: { fieldIndex: number; allowed: PivotCacheItem }[] = [];
122
+ for (const fi of table.pageFieldIndices) {
123
+ const cfg = table.fields[fi];
124
+ if (cfg?.selectedPageItem == null) continue;
125
+ const cacheField = cache.fields[fi];
126
+ if (!cacheField) continue;
127
+ const allowed = cacheField.items[cfg.selectedPageItem];
128
+ if (allowed) filters.push({ fieldIndex: fi, allowed });
129
+ }
130
+ if (filters.length === 0) return records;
131
+
132
+ return records.filter((rec) =>
133
+ filters.every(({ fieldIndex, allowed }) =>
134
+ cellMatchesItem(rec[fieldIndex], allowed),
135
+ ),
136
+ );
137
+ }
138
+
139
+ function cellMatchesItem(
140
+ cell: PivotSourceRecord[number],
141
+ item: PivotCacheItem,
142
+ ): boolean {
143
+ switch (item.kind) {
144
+ case "string":
145
+ return typeof cell === "string" && cell === item.value;
146
+ case "number":
147
+ return typeof cell === "number" && cell === item.value;
148
+ case "boolean":
149
+ return typeof cell === "boolean" && cell === item.value;
150
+ case "date":
151
+ return typeof cell === "string" && cell === item.value;
152
+ case "missing":
153
+ return cell === undefined || cell === null || cell === "";
154
+ case "error":
155
+ return typeof cell === "string" && cell === item.value;
156
+ }
157
+ }
158
+
159
+ // =============================================================================
160
+ // Tuple helpers
161
+ // =============================================================================
162
+
163
+ function tupleOf(
164
+ rec: PivotSourceRecord,
165
+ indices: number[],
166
+ ): PivotSourceRecord {
167
+ return indices.map((i) => rec[i] ?? null);
168
+ }
169
+
170
+ function distinctTuples(
171
+ records: PivotSourceRecord[],
172
+ indices: number[],
173
+ ): PivotSourceRecord[] {
174
+ const seen = new Set<string>();
175
+ const out: PivotSourceRecord[] = [];
176
+ for (const rec of records) {
177
+ const t = tupleOf(rec, indices);
178
+ const key = JSON.stringify(t);
179
+ if (seen.has(key)) continue;
180
+ seen.add(key);
181
+ out.push(t);
182
+ }
183
+ // Stable lexicographic sort: numeric values stringify in the form "123",
184
+ // strings as "\"abc\"". Side effect: numbers sort by string order in mixed
185
+ // tuples — acceptable for v1 since real-world axes are usually homogeneous.
186
+ out.sort((a, b) => {
187
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
188
+ const av = a[i];
189
+ const bv = b[i];
190
+ if (av === bv) continue;
191
+ const as = av === null || av === undefined ? "" : String(av);
192
+ const bs = bv === null || bv === undefined ? "" : String(bv);
193
+ if (as < bs) return -1;
194
+ if (as > bs) return 1;
195
+ }
196
+ return 0;
197
+ });
198
+ return out;
199
+ }
200
+
201
+ // =============================================================================
202
+ // Aggregation
203
+ // =============================================================================
204
+
205
+ export function aggregate(
206
+ values: (string | number | boolean | null | undefined)[],
207
+ fn: PivotAggregateFn,
208
+ ): number | null {
209
+ const numeric = values.filter((v): v is number => typeof v === "number");
210
+ switch (fn) {
211
+ case "count":
212
+ return values.filter((v) => v !== null && v !== undefined && v !== "").length;
213
+ case "countNums":
214
+ return numeric.length;
215
+ case "sum":
216
+ return numeric.reduce((a, b) => a + b, 0);
217
+ case "average":
218
+ if (numeric.length === 0) return null;
219
+ return numeric.reduce((a, b) => a + b, 0) / numeric.length;
220
+ case "min":
221
+ return numeric.length === 0 ? null : Math.min(...numeric);
222
+ case "max":
223
+ return numeric.length === 0 ? null : Math.max(...numeric);
224
+ case "product":
225
+ return numeric.length === 0 ? null : numeric.reduce((a, b) => a * b, 1);
226
+ }
227
+ }
228
+
229
+ // =============================================================================
230
+ // Grid builder
231
+ // =============================================================================
232
+
233
+ function buildGrid(
234
+ table: ParsedPivotTable,
235
+ source: PivotSourceData,
236
+ rowTuples: PivotSourceRecord[],
237
+ colTuples: PivotSourceRecord[],
238
+ groups: Map<string, PivotSourceRecord[]>,
239
+ allRecords: PivotSourceRecord[],
240
+ ): PivotResultGrid {
241
+ const numRowFields = table.rowFieldIndices.length;
242
+ const numColFields = table.colFieldIndices.length;
243
+ const numDataFields = Math.max(table.dataFields.length, 1);
244
+ const showRowGrand = table.display.colGrandTotals; // bottom row labelled "row grand"
245
+ const showColGrand = table.display.rowGrandTotals; // rightmost col labelled "col grand"
246
+
247
+ // Layout dimensions
248
+ const rowLabelCols = Math.max(numRowFields, 1);
249
+ // One header row per column-axis level. When there are data fields, one
250
+ // additional header row labels each value column. When there are no
251
+ // column-axis fields, the value-label row is the only header row.
252
+ const colHeaderRows = numColFields + (numDataFields > 0 ? 1 : 0);
253
+ const headerRows = Math.max(colHeaderRows, 1);
254
+
255
+ const dataCols = colTuples.length * numDataFields;
256
+ const totalCols = rowLabelCols + dataCols + (showColGrand ? numDataFields : 0);
257
+ const totalRows = headerRows + rowTuples.length + (showRowGrand ? 1 : 0);
258
+
259
+ const cells: PivotResultCell[][] = [];
260
+ for (let r = 0; r < totalRows; r++) {
261
+ cells.push(new Array(totalCols).fill({ kind: "blank" }));
262
+ }
263
+
264
+ // ---- Column headers ------------------------------------------------------
265
+ for (let level = 0; level < numColFields; level++) {
266
+ let col = rowLabelCols;
267
+ for (const tuple of colTuples) {
268
+ const text = formatCellLabel(tuple[level]);
269
+ for (let i = 0; i < numDataFields; i++) {
270
+ cells[level][col + i] = { kind: "colHeader", depth: level, text };
271
+ }
272
+ col += numDataFields;
273
+ }
274
+ }
275
+
276
+ // Data-field labels row (always present when there is at least one)
277
+ if (numDataFields > 0) {
278
+ const labelRow = colHeaderRows - 1;
279
+ let col = rowLabelCols;
280
+ for (let _t = 0; _t < colTuples.length; _t++) {
281
+ for (let d = 0; d < table.dataFields.length; d++) {
282
+ cells[labelRow][col + d] = {
283
+ kind: "valueLabel",
284
+ text: table.dataFields[d].name,
285
+ };
286
+ }
287
+ col += numDataFields;
288
+ }
289
+ if (showColGrand) {
290
+ for (let d = 0; d < table.dataFields.length; d++) {
291
+ cells[labelRow][rowLabelCols + dataCols + d] = {
292
+ kind: "valueLabel",
293
+ text: table.dataFields[d].name,
294
+ };
295
+ }
296
+ }
297
+ }
298
+
299
+ // ---- Row headers + body --------------------------------------------------
300
+ for (let r = 0; r < rowTuples.length; r++) {
301
+ const rowTuple = rowTuples[r];
302
+ const gridRow = headerRows + r;
303
+
304
+ for (let level = 0; level < numRowFields; level++) {
305
+ cells[gridRow][level] = {
306
+ kind: "rowHeader",
307
+ depth: level,
308
+ text: formatCellLabel(rowTuple[level]),
309
+ };
310
+ }
311
+
312
+ // Body cells
313
+ for (let c = 0; c < colTuples.length; c++) {
314
+ const colTuple = colTuples[c];
315
+ const groupRecords =
316
+ groups.get(JSON.stringify([rowTuple, colTuple])) ?? [];
317
+ for (let d = 0; d < table.dataFields.length; d++) {
318
+ const df = table.dataFields[d];
319
+ const values = groupRecords.map((rec) => rec[df.fieldIndex]);
320
+ cells[gridRow][rowLabelCols + c * numDataFields + d] = {
321
+ kind: "value",
322
+ value: aggregate(values, df.subtotal),
323
+ numFmt: df.numFmt,
324
+ };
325
+ }
326
+ }
327
+
328
+ // Per-row grand total (across all column tuples)
329
+ if (showColGrand) {
330
+ const rowOnly = allRecords.filter((rec) =>
331
+ sameTuple(tupleOf(rec, table.rowFieldIndices), rowTuple),
332
+ );
333
+ for (let d = 0; d < table.dataFields.length; d++) {
334
+ const df = table.dataFields[d];
335
+ cells[gridRow][rowLabelCols + dataCols + d] = {
336
+ kind: "rowTotal",
337
+ value: aggregate(
338
+ rowOnly.map((rec) => rec[df.fieldIndex]),
339
+ df.subtotal,
340
+ ),
341
+ };
342
+ }
343
+ }
344
+ }
345
+
346
+ // ---- Grand total row -----------------------------------------------------
347
+ if (showRowGrand) {
348
+ const gridRow = headerRows + rowTuples.length;
349
+ cells[gridRow][0] = { kind: "totalLabel", text: TOTAL_LABEL };
350
+
351
+ for (let c = 0; c < colTuples.length; c++) {
352
+ const colTuple = colTuples[c];
353
+ const colOnly = allRecords.filter((rec) =>
354
+ sameTuple(tupleOf(rec, table.colFieldIndices), colTuple),
355
+ );
356
+ for (let d = 0; d < table.dataFields.length; d++) {
357
+ const df = table.dataFields[d];
358
+ cells[gridRow][rowLabelCols + c * numDataFields + d] = {
359
+ kind: "colTotal",
360
+ value: aggregate(
361
+ colOnly.map((rec) => rec[df.fieldIndex]),
362
+ df.subtotal,
363
+ ),
364
+ };
365
+ }
366
+ }
367
+
368
+ if (showColGrand) {
369
+ for (let d = 0; d < table.dataFields.length; d++) {
370
+ const df = table.dataFields[d];
371
+ cells[gridRow][rowLabelCols + dataCols + d] = {
372
+ kind: "grandTotal",
373
+ value: aggregate(
374
+ allRecords.map((rec) => rec[df.fieldIndex]),
375
+ df.subtotal,
376
+ ),
377
+ };
378
+ }
379
+ }
380
+ }
381
+
382
+ // Trim labels for empty header areas — touch is cosmetic; renderer treats
383
+ // {kind: "blank"} as nothing, so leaving them is also fine.
384
+ void source;
385
+
386
+ return {
387
+ cells,
388
+ headerRows,
389
+ rowLabelCols,
390
+ rows: totalRows,
391
+ cols: totalCols,
392
+ };
393
+ }
394
+
395
+ function sameTuple(a: PivotSourceRecord, b: PivotSourceRecord): boolean {
396
+ if (a.length !== b.length) return false;
397
+ for (let i = 0; i < a.length; i++) {
398
+ if (a[i] !== b[i]) {
399
+ // Treat null/undefined/empty-string as equal: Excel collapses them on
400
+ // pivot row/col axes.
401
+ const an = a[i] === undefined || a[i] === null || a[i] === "";
402
+ const bn = b[i] === undefined || b[i] === null || b[i] === "";
403
+ if (!(an && bn)) return false;
404
+ }
405
+ }
406
+ return true;
407
+ }
408
+
409
+ function formatCellLabel(v: PivotSourceRecord[number]): string {
410
+ if (v === undefined || v === null || v === "") return "(blank)";
411
+ if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
412
+ return String(v);
413
+ }