@lotics/xlsx 0.1.2 → 0.1.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/xlsx",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
package/src/csv_parser.ts CHANGED
@@ -8,8 +8,12 @@ import type {
8
8
 
9
9
  const MAX_ROWS = 10_000;
10
10
 
11
- export async function parseCsvFile(url: string, signal?: AbortSignal): Promise<ParsedSpreadsheet> {
12
- const response = await fetch(url, { signal });
11
+ export async function parseCsvFile(
12
+ url: string,
13
+ signal?: AbortSignal,
14
+ credentials?: RequestCredentials,
15
+ ): Promise<ParsedSpreadsheet> {
16
+ const response = await fetch(url, { signal, credentials });
13
17
  if (!response.ok) {
14
18
  throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
15
19
  }
@@ -1,8 +1,9 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { buildDataWorkbook } from "./data_workbook";
2
+ import { buildDataWorkbook, buildDataSheet } from "./data_workbook";
3
3
  import { exportWorkbook } from "./xlsx_writer";
4
4
  import { parseExcelBuffer } from "./excel_parser";
5
5
  import { parseToExcelSerial } from "./numfmt_type";
6
+ import { WorkbookModel, StyleRegistry } from "./workbook_model";
6
7
  import type { ParsedSheet, ParsedCell } from "./types";
7
8
 
8
9
  interface FeeRow {
@@ -109,3 +110,31 @@ describe("buildDataWorkbook", () => {
109
110
  expect(widths.get(2)).toBe(40); // explicit override wins
110
111
  });
111
112
  });
113
+
114
+ describe("buildDataSheet", () => {
115
+ it("builds multiple sheets into one shared registry that round-trips with styles intact", () => {
116
+ // Two sheets sharing ONE registry — cell styles are stored by index into a
117
+ // single registry, so this is the only correct way to merge data sheets into
118
+ // one workbook (two buildDataWorkbook calls each mint a private registry).
119
+ const styles = new StyleRegistry();
120
+ const columns = [
121
+ { header: "Container", value: (r: FeeRow) => r.container },
122
+ { header: "Số tiền", type: "currency" as const, value: (r: FeeRow) => r.amount },
123
+ ];
124
+ const a = buildDataSheet({ sheetName: "Gate IN", currencyFormat: '#,##0" ₫"', columns, rows: [ROWS[0]] }, styles);
125
+ const b = buildDataSheet({ sheetName: "Gate OUT", currencyFormat: '#,##0" ₫"', columns, rows: [ROWS[1]] }, styles);
126
+ const wb = new WorkbookModel({ sheets: [a, b], activeSheetIndex: 0, styles });
127
+ const parsed = parseExcelBuffer(new Uint8Array(exportWorkbook(wb)).buffer);
128
+
129
+ expect(parsed.sheets.map((s) => s.name)).toEqual(["Gate IN", "Gate OUT"]);
130
+ // Each sheet's body landed on its own tab.
131
+ expect(cellAt(parsed.sheets[0], 2, 1)?.value).toBe("ABCD1234567");
132
+ expect(cellAt(parsed.sheets[0], 2, 2)?.rawValue).toBe(1_500_000);
133
+ expect(cellAt(parsed.sheets[1], 2, 1)?.value).toBe("WXYZ7654321");
134
+ // Styles resolve correctly across both sheets: bold header on sheet 2, and the
135
+ // currency body cell on sheet 1 keeps its right alignment (proves the shared
136
+ // registry's style indices are valid for every sheet, not just the first).
137
+ expect(cellAt(parsed.sheets[1], 1, 1)?.style?.fontBold).toBe(true);
138
+ expect(cellAt(parsed.sheets[0], 2, 2)?.style?.horizontalAlign).toBe("right");
139
+ });
140
+ });
@@ -123,23 +123,21 @@ function clampWidth(chars: number): number {
123
123
  }
124
124
 
125
125
  /**
126
- * Build a single-sheet workbook from typed columns + rows.
126
+ * Build ONE styled data sheet from typed columns + rows into a caller-supplied
127
+ * style registry. Use this when assembling a multi-sheet workbook so every sheet
128
+ * shares one registry (cell styles are stored by index into a single registry —
129
+ * sheets built against different registries cannot be merged). For the common
130
+ * single-sheet case use {@link buildDataWorkbook}.
127
131
  *
128
132
  * ```ts
129
- * const wb = buildDataWorkbook({
130
- * sheetName: "Phí",
131
- * currencyFormat: '#,##0""',
132
- * columns: [
133
- * { header: "Container", value: (r) => r.container },
134
- * { header: "Ngày", type: "date", value: (r) => r.date },
135
- * { header: "Số tiền", type: "currency", value: (r) => r.amount },
136
- * ],
137
- * rows,
138
- * });
133
+ * const styles = new StyleRegistry();
134
+ * const a = buildDataSheet({ sheetName: "A", columns, rows: rowsA }, styles);
135
+ * const b = buildDataSheet({ sheetName: "B", columns, rows: rowsB }, styles);
136
+ * const wb = new WorkbookModel({ sheets: [a, b], activeSheetIndex: 0, styles });
139
137
  * const bytes = exportWorkbook(wb);
140
138
  * ```
141
139
  */
142
- export function buildDataWorkbook<R>(opts: BuildDataWorkbookOptions<R>): WorkbookModel {
140
+ export function buildDataSheet<R>(opts: BuildDataWorkbookOptions<R>, styles: StyleRegistry): SheetModel {
143
141
  const {
144
142
  columns,
145
143
  rows,
@@ -149,7 +147,6 @@ export function buildDataWorkbook<R>(opts: BuildDataWorkbookOptions<R>): Workboo
149
147
  headerStyle = DEFAULT_HEADER_STYLE,
150
148
  } = opts;
151
149
 
152
- const styles = new StyleRegistry();
153
150
  const sheet = new SheetModel(sanitizeSheetName(sheetName), styles);
154
151
 
155
152
  // Per-type body cell style (interned once each via the StyleRegistry).
@@ -182,5 +179,28 @@ export function buildDataWorkbook<R>(opts: BuildDataWorkbookOptions<R>): Workboo
182
179
  // Freeze the header row.
183
180
  if (rows.length > 0 || columns.length > 0) sheet.setFreeze("A2");
184
181
 
182
+ return sheet;
183
+ }
184
+
185
+ /**
186
+ * Build a single-sheet workbook from typed columns + rows.
187
+ *
188
+ * ```ts
189
+ * const wb = buildDataWorkbook({
190
+ * sheetName: "Phí",
191
+ * currencyFormat: '#,##0" ₫"',
192
+ * columns: [
193
+ * { header: "Container", value: (r) => r.container },
194
+ * { header: "Ngày", type: "date", value: (r) => r.date },
195
+ * { header: "Số tiền", type: "currency", value: (r) => r.amount },
196
+ * ],
197
+ * rows,
198
+ * });
199
+ * const bytes = exportWorkbook(wb);
200
+ * ```
201
+ */
202
+ export function buildDataWorkbook<R>(opts: BuildDataWorkbookOptions<R>): WorkbookModel {
203
+ const styles = new StyleRegistry();
204
+ const sheet = buildDataSheet(opts, styles);
185
205
  return new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
186
206
  }
@@ -120,6 +120,16 @@ export function rowColToRef(row: number, col: number): string {
120
120
  return colNumToLetters(col) + String(row);
121
121
  }
122
122
 
123
+ /** Escape a string for inclusion in XML text or a double-quoted attribute. */
124
+ export function escapeXml(s: string): string {
125
+ return s
126
+ .replace(/&/g, "&amp;")
127
+ .replace(/</g, "&lt;")
128
+ .replace(/>/g, "&gt;")
129
+ .replace(/"/g, "&quot;")
130
+ .replace(/'/g, "&apos;");
131
+ }
132
+
123
133
  // =============================================================================
124
134
  // Packed cell key (row, col) → number
125
135
  // =============================================================================
package/src/index.ts CHANGED
@@ -106,9 +106,24 @@ export { exportWorkbook } from "./xlsx_writer";
106
106
  // Data → workbook (the common "rows in, .xlsx out" helper)
107
107
  // -----------------------------------------------------------------------------
108
108
 
109
- export { buildDataWorkbook } from "./data_workbook";
109
+ export { buildDataWorkbook, buildDataSheet } from "./data_workbook";
110
110
  export type { DataColumn, DataColumnType, BuildDataWorkbookOptions } from "./data_workbook";
111
111
 
112
+ // -----------------------------------------------------------------------------
113
+ // Pivot tables (author a native, refreshable Excel PivotTable into a workbook)
114
+ // -----------------------------------------------------------------------------
115
+
116
+ export { buildPivotTable } from "./pivot_builder";
117
+ export type { PivotBuildSpec } from "./pivot_builder";
118
+ export { PivotTableModel } from "./pivot_model";
119
+ export type {
120
+ ParsedPivotTable,
121
+ ParsedPivotCache,
122
+ PivotCacheField,
123
+ PivotCacheItem,
124
+ PivotAggregateFn,
125
+ } from "./ooxml_pivot_types";
126
+
112
127
  export { unzipXlsx } from "./ooxml_zip";
113
128
 
114
129
  // -----------------------------------------------------------------------------
@@ -0,0 +1,278 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildDataWorkbook, type DataColumn } from "./data_workbook";
3
+ import { SheetModel } from "./workbook_model";
4
+ import { buildPivotTable } from "./pivot_builder";
5
+ import { exportWorkbook } from "./xlsx_writer";
6
+ import { unzipXlsx } from "./ooxml_zip";
7
+ import { parseExcelBuffer } from "./excel_parser";
8
+ import { recomputePivot, type PivotSourceData } from "./pivot_recompute";
9
+
10
+ interface Row {
11
+ line: string;
12
+ size: string;
13
+ container: string;
14
+ amount: number;
15
+ }
16
+
17
+ const COLUMNS: DataColumn<Row>[] = [
18
+ { header: "Line", value: (r) => r.line },
19
+ { header: "Size", value: (r) => r.size },
20
+ { header: "Container", value: (r) => r.container },
21
+ { header: "Amount", type: "number", value: (r) => r.amount },
22
+ ];
23
+
24
+ // SHA: 40HC {C1=100, C2=200}, 20DC {C3=50}; CMA: 40HC {C4=70}.
25
+ const ROWS: Row[] = [
26
+ { line: "SHA", size: "40HC", container: "C1", amount: 100 },
27
+ { line: "SHA", size: "40HC", container: "C2", amount: 200 },
28
+ { line: "SHA", size: "20DC", container: "C3", amount: 50 },
29
+ { line: "CMA", size: "40HC", container: "C4", amount: 70 },
30
+ ];
31
+
32
+ /** A workbook with a "Data" source sheet (A1:D5) + an empty "Pivot" sheet. */
33
+ function workbookWithData() {
34
+ const wb = buildDataWorkbook<Row>({ sheetName: "Data", columns: COLUMNS, rows: ROWS });
35
+ wb.sheets.push(new SheetModel("Pivot", wb.styles));
36
+ return wb;
37
+ }
38
+
39
+ const decode = (b: Uint8Array) => new TextDecoder().decode(b);
40
+
41
+ /** Rebuild the recompute source from the fixture (header + records). */
42
+ function fixtureSource(): PivotSourceData {
43
+ return {
44
+ header: ["Line", "Size", "Container", "Amount"],
45
+ records: ROWS.map((r) => [r.line, r.size, r.container, r.amount]),
46
+ };
47
+ }
48
+
49
+ describe("buildPivotTable — author a native pivot", () => {
50
+ it("places a flagged pivot on the target sheet, sized to the result grid", () => {
51
+ const wb = workbookWithData();
52
+ const model = buildPivotTable(wb, {
53
+ name: "FeesByLineSize",
54
+ source: { sheetName: "Data", ref: "A1:D5" },
55
+ rowField: 0,
56
+ colField: 1,
57
+ data: { field: 3, fn: "sum", numFmtId: "3" },
58
+ location: { sheetName: "Pivot", anchor: "A1" },
59
+ });
60
+ expect(model.authored).toBe(true);
61
+ expect(wb.sheets[1].pivotTables).toHaveLength(1);
62
+ // Cache items sort lexicographically → display order: CMA,SHA / 20DC,40HC.
63
+ expect(model.cache.fields[0].items.map((i) => (i.kind === "string" ? i.value : i.kind))).toEqual(["CMA", "SHA"]);
64
+ expect(model.cache.fields[1].items.map((i) => (i.kind === "string" ? i.value : i.kind))).toEqual(["20DC", "40HC"]);
65
+ // Measure / unused columns carry no enumerated items.
66
+ expect(model.cache.fields[2].items).toEqual([]);
67
+ expect(model.cache.fields[3].items).toEqual([]);
68
+ // ref spans the grid: 2 header rows + 2 data rows + grand row = 5; 1 label col + 2 size cols + grand = 4.
69
+ expect(model.config.ref).toBe("A1:D5");
70
+ });
71
+
72
+ it("rejects fields used in more than one role and out-of-range indices", () => {
73
+ const wb = workbookWithData();
74
+ const base = {
75
+ name: "X",
76
+ source: { sheetName: "Data", ref: "A1:D5" },
77
+ colField: 1,
78
+ data: { field: 3, fn: "sum" as const },
79
+ location: { sheetName: "Pivot", anchor: "A1" },
80
+ };
81
+ expect(() => buildPivotTable(wb, { ...base, rowField: 3 })).toThrow(/more than one role/);
82
+ expect(() => buildPivotTable(wb, { ...base, rowField: 9 })).toThrow(/out of range/);
83
+ });
84
+
85
+ it("fails loud on export when the source changed after build (stale cache)", () => {
86
+ const wb = workbookWithData();
87
+ buildPivotTable(wb, {
88
+ name: "Stale",
89
+ source: { sheetName: "Data", ref: "A1:D5" },
90
+ rowField: 0,
91
+ colField: 1,
92
+ data: { field: 3, fn: "sum" },
93
+ location: { sheetName: "Pivot", anchor: "A1" },
94
+ });
95
+ // Introduce a row-axis value the cache was not built with → encoding the
96
+ // records can't resolve it to a shared item, so the export must throw.
97
+ wb.sheets[0].set("A2", "ZZZ");
98
+ expect(() => exportWorkbook(wb)).toThrow(/missing from its cached shared items/);
99
+ });
100
+ });
101
+
102
+ describe("native pivot round-trips through the parser", () => {
103
+ function exportAndParse() {
104
+ const wb = workbookWithData();
105
+ buildPivotTable(wb, {
106
+ name: "FeesByLineSize",
107
+ source: { sheetName: "Data", ref: "A1:D5" },
108
+ rowField: 0,
109
+ colField: 1,
110
+ data: { field: 3, fn: "sum", name: "Sum of Amount", numFmtId: "3" },
111
+ pageFields: [],
112
+ location: { sheetName: "Pivot", anchor: "A1" },
113
+ });
114
+ const bytes = exportWorkbook(wb);
115
+ return { bytes, parsed: parseExcelBuffer(new Uint8Array(bytes).buffer), zip: unzipXlsx(new Uint8Array(bytes).buffer) };
116
+ }
117
+
118
+ it("reparses the pivot table definition (axes, data field, grand totals)", () => {
119
+ const { parsed } = exportAndParse();
120
+ const pivot = parsed.sheets.find((s) => s.name === "Pivot")?.pivotTables?.[0];
121
+ expect(pivot).toBeDefined();
122
+ expect(pivot!.name).toBe("FeesByLineSize");
123
+ expect(pivot!.cacheId).toBe(1);
124
+ expect(pivot!.rowFieldIndices).toEqual([0]);
125
+ expect(pivot!.colFieldIndices).toEqual([1]);
126
+ expect(pivot!.dataFields).toHaveLength(1);
127
+ // numFmtId is emitted on the dataField and round-trips (built-in 3 = #,##0).
128
+ expect(pivot!.dataFields[0]).toMatchObject({ fieldIndex: 3, subtotal: "sum", name: "Sum of Amount", numFmt: "3" });
129
+ expect(pivot!.display.rowGrandTotals).toBe(true);
130
+ expect(pivot!.display.colGrandTotals).toBe(true);
131
+ expect(pivot!.ref).toBe("A1:D5");
132
+ });
133
+
134
+ it("reparses the cache: worksheet source + sorted shared items + refreshOnLoad", () => {
135
+ const { parsed } = exportAndParse();
136
+ const cache = parsed.pivotCaches?.get(1);
137
+ expect(cache).toBeDefined();
138
+ expect(cache!.refreshOnLoad).toBe(true);
139
+ expect(cache!.source).toEqual({ type: "worksheet", sheetName: "Data", ref: "A1:D5" });
140
+ expect(cache!.fields.map((f) => f.name)).toEqual(["Line", "Size", "Container", "Amount"]);
141
+ expect(cache!.fields[0].items.map((i) => (i.kind === "string" ? i.value : i.kind))).toEqual(["CMA", "SHA"]);
142
+ expect(cache!.fields[1].items.map((i) => (i.kind === "string" ? i.value : i.kind))).toEqual(["20DC", "40HC"]);
143
+ // Measure / unused fields enumerate nothing.
144
+ expect(cache!.fields[2].items).toEqual([]);
145
+ expect(cache!.fields[3].items).toEqual([]);
146
+ });
147
+
148
+ it("the round-tripped definition recomputes to the correct cross-tab", () => {
149
+ const { parsed } = exportAndParse();
150
+ const pivot = parsed.sheets.find((s) => s.name === "Pivot")!.pivotTables![0];
151
+ const cache = parsed.pivotCaches!.get(1)!;
152
+ const grid = recomputePivot(pivot, cache, fixtureSource());
153
+
154
+ // Header rows then body. rowLabelCols = 1; data cols = 2 (20DC, 40HC) + grand.
155
+ const valueAt = (r: number, c: number) => {
156
+ const cell = grid.cells[r][c];
157
+ return cell.kind === "value" || cell.kind === "rowTotal" || cell.kind === "colTotal" || cell.kind === "grandTotal"
158
+ ? cell.value
159
+ : undefined;
160
+ };
161
+ // Row order CMA, SHA after the header rows.
162
+ const hdr = grid.headerRows;
163
+ // CMA row: 20DC empty (sum of no records = 0 in the engine), 40HC 70, rowTotal 70.
164
+ expect(valueAt(hdr, 1)).toBe(0); // CMA × 20DC
165
+ expect(valueAt(hdr, 2)).toBe(70); // CMA × 40HC
166
+ expect(valueAt(hdr, 3)).toBe(70); // CMA row total
167
+ // SHA row: 20DC 50, 40HC 300, rowTotal 350.
168
+ expect(valueAt(hdr + 1, 1)).toBe(50);
169
+ expect(valueAt(hdr + 1, 2)).toBe(300);
170
+ expect(valueAt(hdr + 1, 3)).toBe(350);
171
+ // Grand row: 20DC 50, 40HC 370, grand 420.
172
+ expect(valueAt(hdr + 2, 1)).toBe(50);
173
+ expect(valueAt(hdr + 2, 2)).toBe(370);
174
+ expect(valueAt(hdr + 2, 3)).toBe(420);
175
+ });
176
+
177
+ it("encodes cache records as shared-item indices (axis) + inline values (measure)", () => {
178
+ const { zip } = exportAndParse();
179
+ const records = decode(zip["xl/pivotCache/pivotCacheRecords1.xml"]);
180
+ expect(records).toContain('count="4"');
181
+ // First fixture row SHA(idx1)/40HC(idx1)/C1/100 → indices for the two axis
182
+ // fields, then inline string + inline number.
183
+ expect(records).toContain('<r><x v="1"/><x v="1"/><s v="C1"/><n v="100"/></r>');
184
+ // CMA(idx0)/40HC(idx1)/C4/70.
185
+ expect(records).toContain('<r><x v="0"/><x v="1"/><s v="C4"/><n v="70"/></r>');
186
+ });
187
+
188
+ it("registers content-types, workbook pivotCaches, and the full rels chain", () => {
189
+ const { zip } = exportAndParse();
190
+ const ct = decode(zip["[Content_Types].xml"]);
191
+ expect(ct).toContain("/xl/pivotTables/pivotTable1.xml");
192
+ expect(ct).toContain("/xl/pivotCache/pivotCacheDefinition1.xml");
193
+ expect(ct).toContain("/xl/pivotCache/pivotCacheRecords1.xml");
194
+
195
+ const wb = decode(zip["xl/workbook.xml"]);
196
+ expect(wb).toContain("<pivotCaches>");
197
+ expect(wb).toMatch(/<pivotCache cacheId="1" r:id="rId\d+"\/>/);
198
+
199
+ expect(decode(zip["xl/_rels/workbook.xml.rels"])).toContain("pivotCache/pivotCacheDefinition1.xml");
200
+ // The Pivot sheet (sheet 2) rels → its pivot table.
201
+ expect(decode(zip["xl/worksheets/_rels/sheet2.xml.rels"])).toContain("../pivotTables/pivotTable1.xml");
202
+ // pivotTable → cacheDefinition, cacheDefinition → records.
203
+ expect(decode(zip["xl/pivotTables/_rels/pivotTable1.xml.rels"])).toContain("../pivotCache/pivotCacheDefinition1.xml");
204
+ expect(decode(zip["xl/pivotCache/_rels/pivotCacheDefinition1.xml.rels"])).toContain("pivotCacheRecords1.xml");
205
+ });
206
+
207
+ it("the exported workbook re-parses without error and keeps the data sheet", () => {
208
+ const { parsed } = exportAndParse();
209
+ expect(parsed.sheets.map((s) => s.name)).toEqual(["Data", "Pivot"]);
210
+ // The source data survived alongside the new pivot parts.
211
+ const data = parsed.sheets[0];
212
+ const a1 = data.rows.find((r) => r.index === 1)?.cells.find((c) => c.column === 1);
213
+ expect(a1?.value).toBe("Line");
214
+ });
215
+ });
216
+
217
+ describe("page filter + count aggregation", () => {
218
+ it("round-trips a page (report-filter) field", () => {
219
+ const wb = workbookWithData();
220
+ buildPivotTable(wb, {
221
+ name: "Filtered",
222
+ source: { sheetName: "Data", ref: "A1:D5" },
223
+ rowField: 0,
224
+ colField: 1,
225
+ data: { field: 3, fn: "sum" },
226
+ pageFields: [2], // Container as a report filter
227
+ location: { sheetName: "Pivot", anchor: "A1" },
228
+ });
229
+ const bytes = exportWorkbook(wb);
230
+ const parsed = parseExcelBuffer(new Uint8Array(bytes).buffer);
231
+ const pivot = parsed.sheets[1].pivotTables![0];
232
+ expect(pivot.pageFieldIndices).toEqual([2]);
233
+ // The page field is enumerated like an axis field (it filters on items).
234
+ const cache = parsed.pivotCaches!.get(1)!;
235
+ expect(cache.fields[2].items.map((i) => (i.kind === "string" ? i.value : i.kind))).toEqual(["C1", "C2", "C3", "C4"]);
236
+ });
237
+
238
+ it("Count over a distinct-container roster reproduces distinct counts", () => {
239
+ // The app's distinct-count pivot pattern: a roster with ONE row per distinct
240
+ // container; COUNT of the container column = distinct container count.
241
+ const roster: Row[] = [
242
+ { line: "SHA", size: "40HC", container: "C1", amount: 0 },
243
+ { line: "SHA", size: "40HC", container: "C2", amount: 0 },
244
+ { line: "SHA", size: "20DC", container: "C3", amount: 0 },
245
+ { line: "CMA", size: "40HC", container: "C4", amount: 0 },
246
+ ];
247
+ const wb = buildDataWorkbook<Row>({ sheetName: "Roster", columns: COLUMNS, rows: roster });
248
+ wb.sheets.push(new SheetModel("Pivot", wb.styles));
249
+ buildPivotTable(wb, {
250
+ name: "ContainersByLineSize",
251
+ source: { sheetName: "Roster", ref: "A1:D5" },
252
+ rowField: 0,
253
+ colField: 1,
254
+ data: { field: 2, fn: "count" }, // count of Container
255
+ location: { sheetName: "Pivot", anchor: "A1" },
256
+ });
257
+ const bytes = exportWorkbook(wb);
258
+ const parsed = parseExcelBuffer(new Uint8Array(bytes).buffer);
259
+ const pivot = parsed.sheets[1].pivotTables![0];
260
+ const cache = parsed.pivotCaches!.get(1)!;
261
+ const grid = recomputePivot(pivot, cache, {
262
+ header: ["Line", "Size", "Container", "Amount"],
263
+ records: roster.map((r) => [r.line, r.size, r.container, r.amount]),
264
+ });
265
+ const hdr = grid.headerRows;
266
+ const num = (r: number, c: number) => {
267
+ const cell = grid.cells[r][c];
268
+ return "value" in cell ? cell.value : undefined;
269
+ };
270
+ // SHA row (second, after CMA): 20DC=1, 40HC=2, total 3.
271
+ expect(num(hdr + 1, 1)).toBe(1);
272
+ expect(num(hdr + 1, 2)).toBe(2);
273
+ expect(num(hdr + 1, 3)).toBe(3);
274
+ // Grand row: total distinct containers = 4.
275
+ expect(num(hdr + 2, 3)).toBe(4);
276
+ expect(pivot.dataFields[0].subtotal).toBe("count");
277
+ });
278
+ });
@@ -0,0 +1,187 @@
1
+ // =============================================================================
2
+ // Pivot Table Builder
3
+ // =============================================================================
4
+ //
5
+ // Authors a native Excel PivotTable from scratch over a worksheet range. The
6
+ // returned model is attached to the target sheet (`sheet.pivotTables`) and
7
+ // flagged `authored`, so `exportWorkbook` serializes it into real OOXML pivot
8
+ // parts (see `pivot_writer.ts`).
9
+ //
10
+ // v1 shape (the 2D cross-tab): exactly one row field, one column field, one
11
+ // data field, and zero or more page (report-filter) fields. The shared-item
12
+ // order in the cache is the SAME String()-lexicographic order `pivot_recompute`
13
+ // uses to lay out rows/columns, so the saved item indices line up with the
14
+ // displayed order — no remapping needed in the writer.
15
+ // =============================================================================
16
+
17
+ import type { WorkbookModel } from "./workbook_model";
18
+ import type {
19
+ ParsedPivotCache,
20
+ ParsedPivotTable,
21
+ PivotAggregateFn,
22
+ PivotCacheField,
23
+ PivotCacheItem,
24
+ PivotFieldConfig,
25
+ } from "./ooxml_pivot_types";
26
+ import { PivotTableModel, readSourceData } from "./pivot_model";
27
+ import { recomputePivot, type PivotSourceData } from "./pivot_recompute";
28
+ import { sourceValueToItem, cacheItemKey, cacheItemSortKey } from "./pivot_writer";
29
+ import { refToRowCol, rowColToRef } from "./excel_utils";
30
+
31
+ export interface PivotBuildSpec {
32
+ /** Pivot table name — must be unique within the workbook. */
33
+ name: string;
34
+ /** Source data range: a sheet + a range whose first row is the header. */
35
+ source: { sheetName: string; ref: string };
36
+ /** Source column index (0-based within the range) for the row axis. */
37
+ rowField: number;
38
+ /** Source column index for the column axis. */
39
+ colField: number;
40
+ /**
41
+ * The value field placed on the data axis. `numFmtId` is a BUILT-IN Excel
42
+ * number-format id (e.g. "3" = `#,##0`), not a format code — a custom code
43
+ * (`'#,##0" ₫"'`) would need a registered style, which v1 does not wire up.
44
+ */
45
+ data: { field: number; fn?: PivotAggregateFn; name?: string; numFmtId?: string };
46
+ /** Source column indices used as report (page) filters. Default none. */
47
+ pageFields?: number[];
48
+ /** Where the pivot is placed: a sheet + the top-left anchor cell. */
49
+ location: { sheetName: string; anchor: string };
50
+ /** Show the grand-total column (per-row totals). Default true. */
51
+ rowGrandTotals?: boolean;
52
+ /** Show the grand-total row (per-column totals). Default true. */
53
+ colGrandTotals?: boolean;
54
+ /** Table style name. Default "PivotStyleLight16". */
55
+ styleName?: string;
56
+ }
57
+
58
+ const AGG_VERB: Record<PivotAggregateFn, string> = {
59
+ sum: "Sum",
60
+ count: "Count",
61
+ countNums: "Count",
62
+ average: "Average",
63
+ min: "Min",
64
+ max: "Max",
65
+ product: "Product",
66
+ };
67
+
68
+ export function buildPivotTable(workbook: WorkbookModel, spec: PivotBuildSpec): PivotTableModel {
69
+ const target = workbook.sheets.find((s) => s.name === spec.location.sheetName);
70
+ if (!target) throw new Error(`buildPivotTable: target sheet "${spec.location.sheetName}" not found`);
71
+
72
+ // Read the source range. A minimal cache carries only the source coordinates;
73
+ // `readSourceData` reads header + records straight from the worksheet.
74
+ const cacheSource = { type: "worksheet" as const, sheetName: spec.source.sheetName, ref: spec.source.ref };
75
+ const source = readSourceData(workbook, { id: 0, source: cacheSource, fields: [], refreshOnLoad: true });
76
+ if (!source) throw new Error(`buildPivotTable: source range "${spec.source.ref}" on "${spec.source.sheetName}" not found`);
77
+
78
+ const fieldCount = source.header.length;
79
+ const pageFields = spec.pageFields ?? [];
80
+ validateSpec(spec, fieldCount, pageFields);
81
+
82
+ // Assign a cache id one past the highest existing pivot cache (so authored and
83
+ // any round-tripped pivots never collide).
84
+ const existing = workbook.sheets.flatMap((s) => s.pivotTables);
85
+ const cacheId = existing.reduce((max, p) => Math.max(max, p.cache.id), 0) + 1;
86
+
87
+ const axisFields = new Set([spec.rowField, spec.colField, ...pageFields]);
88
+ const cacheFields: PivotCacheField[] = source.header.map((name, i) => buildCacheField(name, i, source, axisFields.has(i)));
89
+ const cache: ParsedPivotCache = { id: cacheId, source: cacheSource, fields: cacheFields, refreshOnLoad: true };
90
+
91
+ const fn = spec.data.fn ?? "sum";
92
+ const config: ParsedPivotTable = {
93
+ name: spec.name,
94
+ cacheId,
95
+ ref: "A1:A1", // sized after recompute
96
+ firstHeaderRow: 1,
97
+ firstDataRow: 1,
98
+ firstDataCol: 1,
99
+ fields: buildFieldConfigs(fieldCount, spec, pageFields),
100
+ rowFieldIndices: [spec.rowField],
101
+ colFieldIndices: [spec.colField],
102
+ pageFieldIndices: pageFields,
103
+ dataFields: [
104
+ {
105
+ name: spec.data.name ?? `${AGG_VERB[fn]} of ${source.header[spec.data.field] || `field ${spec.data.field}`}`,
106
+ fieldIndex: spec.data.field,
107
+ subtotal: fn,
108
+ numFmt: spec.data.numFmtId,
109
+ },
110
+ ],
111
+ styleName: spec.styleName ?? "PivotStyleLight16",
112
+ display: {
113
+ rowGrandTotals: spec.rowGrandTotals ?? true,
114
+ colGrandTotals: spec.colGrandTotals ?? true,
115
+ showRowStripes: false,
116
+ showColStripes: false,
117
+ },
118
+ };
119
+
120
+ // Recompute to size the location ref and the data-offset hints. The result
121
+ // also seeds `model.result` so a caller can render without a second pass.
122
+ const grid = recomputePivot(config, cache, source);
123
+ const anchor = refToRowCol(spec.location.anchor);
124
+ if (!anchor) throw new Error(`buildPivotTable: invalid anchor "${spec.location.anchor}"`);
125
+ config.ref = `${spec.location.anchor}:${rowColToRef(anchor.row + grid.rows - 1, anchor.col + grid.cols - 1)}`;
126
+ config.firstHeaderRow = 1;
127
+ config.firstDataRow = grid.headerRows;
128
+ config.firstDataCol = grid.rowLabelCols;
129
+
130
+ const model = new PivotTableModel(config, cache, true);
131
+ model.result = grid;
132
+ target.pivotTables.push(model);
133
+ return model;
134
+ }
135
+
136
+ function validateSpec(spec: PivotBuildSpec, fieldCount: number, pageFields: number[]): void {
137
+ const inRange = (i: number) => Number.isInteger(i) && i >= 0 && i < fieldCount;
138
+ if (!inRange(spec.rowField)) throw new Error(`buildPivotTable: rowField ${spec.rowField} out of range (0..${fieldCount - 1})`);
139
+ if (!inRange(spec.colField)) throw new Error(`buildPivotTable: colField ${spec.colField} out of range (0..${fieldCount - 1})`);
140
+ if (!inRange(spec.data.field)) throw new Error(`buildPivotTable: data.field ${spec.data.field} out of range (0..${fieldCount - 1})`);
141
+ for (const p of pageFields) if (!inRange(p)) throw new Error(`buildPivotTable: pageField ${p} out of range (0..${fieldCount - 1})`);
142
+ const roles = [spec.rowField, spec.colField, spec.data.field, ...pageFields];
143
+ if (new Set(roles).size !== roles.length) {
144
+ throw new Error("buildPivotTable: a field cannot be used in more than one role (row/column/data/page)");
145
+ }
146
+ }
147
+
148
+ /** Distinct, display-sorted shared items for an axis field; type hints only for
149
+ * a measure/unused column (its values inline into the cache records). */
150
+ function buildCacheField(name: string, col: number, source: PivotSourceData, enumerate: boolean): PivotCacheField {
151
+ const containsNumber = source.records.some((rec) => typeof rec[col] === "number");
152
+ const containsDate = false;
153
+ if (!enumerate) return { name, items: [], numFmt: undefined, containsNumber, containsDate };
154
+
155
+ const seen = new Set<string>();
156
+ const items: PivotCacheItem[] = [];
157
+ for (const rec of source.records) {
158
+ const item = sourceValueToItem(rec[col]);
159
+ const key = cacheItemKey(item);
160
+ if (seen.has(key)) continue;
161
+ seen.add(key);
162
+ items.push(item);
163
+ }
164
+ items.sort((a, b) => {
165
+ const as = cacheItemSortKey(a);
166
+ const bs = cacheItemSortKey(b);
167
+ return as < bs ? -1 : as > bs ? 1 : 0;
168
+ });
169
+ return { name, items, numFmt: undefined, containsNumber, containsDate };
170
+ }
171
+
172
+ function buildFieldConfigs(fieldCount: number, spec: PivotBuildSpec, pageFields: number[]): PivotFieldConfig[] {
173
+ const pageSet = new Set(pageFields);
174
+ const out: PivotFieldConfig[] = [];
175
+ for (let i = 0; i < fieldCount; i++) {
176
+ const axis =
177
+ i === spec.rowField ? "row" : i === spec.colField ? "column" : pageSet.has(i) ? "page" : i === spec.data.field ? "values" : undefined;
178
+ out.push({
179
+ axis,
180
+ showSubtotal: true,
181
+ compact: true,
182
+ showAll: false,
183
+ selectedPageItem: pageSet.has(i) ? null : undefined,
184
+ });
185
+ }
186
+ return out;
187
+ }
@@ -30,10 +30,18 @@ export class PivotTableModel {
30
30
  cache: ParsedPivotCache;
31
31
  /** Last computed result, or undefined before first recompute. */
32
32
  result: PivotResultGrid | undefined;
33
+ /**
34
+ * True when this pivot was authored from scratch (via `buildPivotTable`) and
35
+ * must be SERIALIZED by the writer into fresh OOXML parts. Parsed pivots leave
36
+ * this false — they round-trip verbatim from the original zip, so the writer
37
+ * skips them. The writer keys emission on this flag.
38
+ */
39
+ authored: boolean;
33
40
 
34
- constructor(config: ParsedPivotTable, cache: ParsedPivotCache) {
41
+ constructor(config: ParsedPivotTable, cache: ParsedPivotCache, authored = false) {
35
42
  this.config = config;
36
43
  this.cache = cache;
44
+ this.authored = authored;
37
45
  }
38
46
 
39
47
  /**
@@ -0,0 +1,340 @@
1
+ // =============================================================================
2
+ // OOXML Pivot Table Writer
3
+ // =============================================================================
4
+ //
5
+ // Serializes AUTHORED pivots (built via `pivot_builder`) into the three OOXML
6
+ // parts Excel expects: `pivotTableN.xml`, `pivotCacheDefinitionN.xml`, and
7
+ // `pivotCacheRecordsN.xml`. The inverse of `ooxml_pivot.ts` (the parser); the
8
+ // two are kept format-compatible so a build → write → parse round-trip is
9
+ // lossless for the supported subset.
10
+ //
11
+ // Supported subset (v1): exactly one row field, one column field, one data
12
+ // field, and zero or more page (report-filter) fields, with grand totals. This
13
+ // is the 2D cross-tab shape; multi-level axes and multiple data fields are not
14
+ // emitted (the builder rejects them) so the row/col item layout stays a flat,
15
+ // verifiable list.
16
+ //
17
+ // Every authored cache carries `refreshOnLoad="1"`: the cache records below are
18
+ // a faithful snapshot of the source range, AND Excel rebuilds them from the
19
+ // live worksheet on open, so the pivot is correct in non-refreshing viewers and
20
+ // stays in sync after edits.
21
+ // =============================================================================
22
+
23
+ import { escapeXml } from "./excel_utils";
24
+ import type {
25
+ ParsedPivotCache,
26
+ ParsedPivotTable,
27
+ PivotCacheField,
28
+ PivotCacheItem,
29
+ } from "./ooxml_pivot_types";
30
+ import type { PivotSourceRecord } from "./pivot_recompute";
31
+
32
+ const MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
33
+ const REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
34
+
35
+ // =============================================================================
36
+ // Shared item helpers (used by both the builder and this writer)
37
+ // =============================================================================
38
+
39
+ /** Coerce a raw source cell value to its cache-item representation. */
40
+ export function sourceValueToItem(
41
+ v: string | number | boolean | null | undefined,
42
+ ): PivotCacheItem {
43
+ if (v === undefined || v === null || v === "") return { kind: "missing" };
44
+ if (typeof v === "number") return { kind: "number", value: v };
45
+ if (typeof v === "boolean") return { kind: "boolean", value: v };
46
+ return { kind: "string", value: v };
47
+ }
48
+
49
+ /** Canonical key for de-duplicating / looking up a cache item. */
50
+ export function cacheItemKey(item: PivotCacheItem): string {
51
+ switch (item.kind) {
52
+ case "missing":
53
+ return "m:";
54
+ case "number":
55
+ return `n:${item.value}`;
56
+ case "boolean":
57
+ return `b:${item.value}`;
58
+ case "string":
59
+ return `s:${item.value}`;
60
+ case "date":
61
+ return `d:${item.value}`;
62
+ case "error":
63
+ return `e:${item.value}`;
64
+ }
65
+ }
66
+
67
+ /** The label used for ordering — matches `pivot_recompute`'s String()-lexicographic
68
+ * tuple sort so the cache item order equals the pivot's displayed row/col order. */
69
+ export function cacheItemSortKey(item: PivotCacheItem): string {
70
+ return item.kind === "missing" ? "" : String(item.value);
71
+ }
72
+
73
+ /** Which fields sit on an axis (row/col/page) — i.e. carry enumerated shared
74
+ * items and reference them by index in the cache records. The data/unused
75
+ * columns inline their values instead. */
76
+ export function enumerateFlags(table: ParsedPivotTable, fieldCount: number): boolean[] {
77
+ const axis = new Set([...table.rowFieldIndices, ...table.colFieldIndices, ...table.pageFieldIndices]);
78
+ return Array.from({ length: fieldCount }, (_, i) => axis.has(i));
79
+ }
80
+
81
+ // =============================================================================
82
+ // pivotCacheRecords
83
+ // =============================================================================
84
+
85
+ /**
86
+ * One `<r>` per source row. A field carried on an axis (row/col/page) is
87
+ * "enumerated": its cells are written as `<x v="i"/>` indices into the cache
88
+ * field's shared items. Other fields (the measure, unused columns) are written
89
+ * inline (`<n>`, `<s>`, `<b>`, `<m/>`).
90
+ */
91
+ export function buildPivotCacheRecordsXml(
92
+ records: PivotSourceRecord[],
93
+ cache: ParsedPivotCache,
94
+ enumerate: boolean[],
95
+ ): string {
96
+ // index map per enumerated field: item key → position in shared items.
97
+ const indexMaps = cache.fields.map((f, i) => {
98
+ if (!enumerate[i]) return undefined;
99
+ const m = new Map<string, number>();
100
+ f.items.forEach((item, idx) => m.set(cacheItemKey(item), idx));
101
+ return m;
102
+ });
103
+
104
+ const rows = records.map((rec) => {
105
+ const cells = cache.fields.map((_f, i) => {
106
+ const item = sourceValueToItem(rec[i]);
107
+ const map = indexMaps[i];
108
+ if (map) {
109
+ const idx = map.get(cacheItemKey(item));
110
+ // A value absent from the shared items means the cache is stale (the
111
+ // source range changed after buildPivotTable) or inconsistent. Fail loud
112
+ // rather than emit a dangling index or silently bucket the value as blank.
113
+ if (idx === undefined) {
114
+ throw new Error(
115
+ `buildPivotCacheRecordsXml: value ${JSON.stringify(rec[i])} for field "${cache.fields[i].name}" is missing from its cached shared items`,
116
+ );
117
+ }
118
+ return `<x v="${idx}"/>`;
119
+ }
120
+ return inlineRecordCell(item);
121
+ });
122
+ return `<r>${cells.join("")}</r>`;
123
+ });
124
+
125
+ return (
126
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
127
+ `<pivotCacheRecords xmlns="${MAIN_NS}" xmlns:r="${REL_NS}" count="${records.length}">` +
128
+ rows.join("") +
129
+ `</pivotCacheRecords>`
130
+ );
131
+ }
132
+
133
+ function inlineRecordCell(item: PivotCacheItem): string {
134
+ switch (item.kind) {
135
+ case "missing":
136
+ return "<m/>";
137
+ case "number":
138
+ return `<n v="${item.value}"/>`;
139
+ case "boolean":
140
+ return `<b v="${item.value ? 1 : 0}"/>`;
141
+ case "date":
142
+ return `<d v="${escapeXml(item.value)}"/>`;
143
+ case "error":
144
+ return `<e v="${escapeXml(item.value)}"/>`;
145
+ case "string":
146
+ return `<s v="${escapeXml(item.value)}"/>`;
147
+ }
148
+ }
149
+
150
+ // =============================================================================
151
+ // pivotCacheDefinition
152
+ // =============================================================================
153
+
154
+ export function buildPivotCacheDefinitionXml(
155
+ cache: ParsedPivotCache,
156
+ enumerate: boolean[],
157
+ recordCount: number,
158
+ recordsRelId: string,
159
+ ): string {
160
+ if (cache.source.type !== "worksheet") {
161
+ throw new Error("buildPivotCacheDefinitionXml: only worksheet sources are supported");
162
+ }
163
+ const src = cache.source;
164
+ const fields = cache.fields
165
+ .map((f, i) => cacheFieldXml(f, enumerate[i]))
166
+ .join("");
167
+
168
+ return (
169
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
170
+ `<pivotCacheDefinition xmlns="${MAIN_NS}" xmlns:r="${REL_NS}" r:id="${recordsRelId}"` +
171
+ ` refreshOnLoad="1" refreshedBy="Lotics" createdVersion="6" refreshedVersion="6"` +
172
+ ` minRefreshableVersion="3" recordCount="${recordCount}">` +
173
+ `<cacheSource type="worksheet">` +
174
+ `<worksheetSource ref="${escapeXml(src.ref)}" sheet="${escapeXml(src.sheetName)}"/>` +
175
+ `</cacheSource>` +
176
+ `<cacheFields count="${cache.fields.length}">${fields}</cacheFields>` +
177
+ `</pivotCacheDefinition>`
178
+ );
179
+ }
180
+
181
+ function cacheFieldXml(field: PivotCacheField, enumerate: boolean): string {
182
+ const name = `name="${escapeXml(field.name)}" numFmtId="0"`;
183
+ if (!enumerate) {
184
+ // Measure / unused column: no enumerated items, just type hints. Excel
185
+ // refreshes the rest from the worksheet source.
186
+ const flags =
187
+ `containsBlank="${field.items.length === 0 ? 0 : 1}"` +
188
+ (field.containsNumber ? ` containsString="0" containsNumber="1"` : "");
189
+ return `<cacheField ${name}><sharedItems ${flags}/></cacheField>`;
190
+ }
191
+ const items = field.items.map(sharedItemXml).join("");
192
+ const hasString = field.items.some((i) => i.kind === "string");
193
+ const hasNumber = field.items.some((i) => i.kind === "number");
194
+ const hasBlank = field.items.some((i) => i.kind === "missing");
195
+ const attrs =
196
+ `count="${field.items.length}"` +
197
+ (hasBlank ? ` containsBlank="1"` : "") +
198
+ (hasNumber && !hasString ? ` containsString="0" containsNumber="1"` : "");
199
+ return `<cacheField ${name}><sharedItems ${attrs}>${items}</sharedItems></cacheField>`;
200
+ }
201
+
202
+ function sharedItemXml(item: PivotCacheItem): string {
203
+ switch (item.kind) {
204
+ case "missing":
205
+ return `<m/>`;
206
+ case "number":
207
+ return `<n v="${item.value}"/>`;
208
+ case "boolean":
209
+ return `<b v="${item.value ? 1 : 0}"/>`;
210
+ case "date":
211
+ return `<d v="${escapeXml(item.value)}"/>`;
212
+ case "error":
213
+ return `<e v="${escapeXml(item.value)}"/>`;
214
+ case "string":
215
+ return `<s v="${escapeXml(item.value)}"/>`;
216
+ }
217
+ }
218
+
219
+ // =============================================================================
220
+ // pivotTable
221
+ // =============================================================================
222
+
223
+ const SUBTOTAL_FN_TO_ENUM: Record<string, string> = {
224
+ sum: "sum",
225
+ count: "count",
226
+ countNums: "countNums",
227
+ average: "average",
228
+ min: "min",
229
+ max: "max",
230
+ product: "product",
231
+ };
232
+
233
+ /**
234
+ * Serialize the pivot table definition. The axis fields (row/col/page) carry a
235
+ * `<pivotField>` with an `<items>` list; the data and unused fields are emitted
236
+ * without items. Axis roles are read from `table`, so no enumerate hint is needed.
237
+ */
238
+ export function buildPivotTableXml(
239
+ table: ParsedPivotTable,
240
+ cache: ParsedPivotCache,
241
+ ): string {
242
+ const rowField = table.rowFieldIndices[0];
243
+ const colField = table.colFieldIndices[0];
244
+ if (rowField === undefined || colField === undefined) {
245
+ throw new Error("buildPivotTableXml: a row field and a column field are required");
246
+ }
247
+ const pageSet = new Set(table.pageFieldIndices);
248
+
249
+ const pivotFields = cache.fields
250
+ .map((f, i) => {
251
+ const onAxis =
252
+ i === rowField ? "axisRow" : i === colField ? "axisCol" : pageSet.has(i) ? "axisPage" : undefined;
253
+ if (onAxis) return axisPivotFieldXml(onAxis, f.items.length);
254
+ if (table.dataFields.some((d) => d.fieldIndex === i)) {
255
+ return `<pivotField dataField="1" showAll="0"/>`;
256
+ }
257
+ return `<pivotField showAll="0"/>`;
258
+ })
259
+ .join("");
260
+
261
+ const rowCount = cache.fields[rowField].items.length;
262
+ const colCount = cache.fields[colField].items.length;
263
+ // A bottom grand-total ROW is a colGrandTotals; a right grand-total COLUMN is
264
+ // a rowGrandTotals (OOXML's naming is "grand totals FOR rows/columns").
265
+ const rowItems = axisItemsXml("rowItems", rowCount, table.display.colGrandTotals);
266
+ const colItems = axisItemsXml("colItems", colCount, table.display.rowGrandTotals);
267
+
268
+ const pageFieldsXml = table.pageFieldIndices.length
269
+ ? `<pageFields count="${table.pageFieldIndices.length}">` +
270
+ table.pageFieldIndices
271
+ .map((fld) => {
272
+ const sel = table.fields[fld]?.selectedPageItem;
273
+ const item = sel === null || sel === undefined ? "" : ` item="${sel}"`;
274
+ return `<pageField fld="${fld}"${item} hier="-1"/>`;
275
+ })
276
+ .join("") +
277
+ `</pageFields>`
278
+ : "";
279
+
280
+ const dataFieldsXml =
281
+ `<dataFields count="${table.dataFields.length}">` +
282
+ table.dataFields
283
+ .map((d) => {
284
+ const fn = SUBTOTAL_FN_TO_ENUM[d.subtotal] ?? "sum";
285
+ const sub = fn === "sum" ? "" : ` subtotal="${fn}"`;
286
+ const numFmt = d.numFmt ? ` numFmtId="${escapeXml(d.numFmt)}"` : "";
287
+ return `<dataField name="${escapeXml(d.name)}" fld="${d.fieldIndex}"${sub} baseField="0" baseItem="0"${numFmt}/>`;
288
+ })
289
+ .join("") +
290
+ `</dataFields>`;
291
+
292
+ const style =
293
+ `<pivotTableStyleInfo name="${escapeXml(table.styleName ?? "PivotStyleLight16")}"` +
294
+ ` showRowHeaders="1" showColHeaders="1"` +
295
+ ` showRowStripes="${table.display.showRowStripes ? 1 : 0}"` +
296
+ ` showColStripes="${table.display.showColStripes ? 1 : 0}" showLastColumn="1"/>`;
297
+
298
+ return (
299
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
300
+ `<pivotTableDefinition xmlns="${MAIN_NS}" xmlns:r="${REL_NS}"` +
301
+ ` name="${escapeXml(table.name)}" cacheId="${table.cacheId}"` +
302
+ ` applyNumberFormats="0" applyBorderFormats="0" applyFontFormats="0"` +
303
+ ` applyPatternFormats="0" applyAlignmentFormats="0" applyWidthHeightFormats="1"` +
304
+ ` dataCaption="Values" updatedVersion="6" minRefreshableVersion="3"` +
305
+ ` useAutoFormatting="1" itemPrintTitles="1" createdVersion="6" indent="0"` +
306
+ ` outline="1" outlineData="1" multipleFieldFilters="0"` +
307
+ ` rowGrandTotals="${table.display.rowGrandTotals ? 1 : 0}"` +
308
+ ` colGrandTotals="${table.display.colGrandTotals ? 1 : 0}">` +
309
+ // ref spans the body grid only; page-filter rows (if any) are laid out by
310
+ // Excel above it on refresh, so the location stays body-relative + consistent.
311
+ `<location ref="${escapeXml(table.ref)}" firstHeaderRow="${table.firstHeaderRow}"` +
312
+ ` firstDataRow="${table.firstDataRow}" firstDataCol="${table.firstDataCol}"/>` +
313
+ `<pivotFields count="${cache.fields.length}">${pivotFields}</pivotFields>` +
314
+ `<rowFields count="1"><field x="${rowField}"/></rowFields>` +
315
+ rowItems +
316
+ `<colFields count="1"><field x="${colField}"/></colFields>` +
317
+ colItems +
318
+ pageFieldsXml +
319
+ dataFieldsXml +
320
+ style +
321
+ `</pivotTableDefinition>`
322
+ );
323
+ }
324
+
325
+ /** A row/col axis field: items listed in shared-item order + a subtotal slot. */
326
+ function axisPivotFieldXml(axis: string, itemCount: number): string {
327
+ const items: string[] = [];
328
+ for (let i = 0; i < itemCount; i++) items.push(`<item x="${i}"/>`);
329
+ items.push(`<item t="default"/>`);
330
+ return `<pivotField axis="${axis}" showAll="0"><items count="${items.length}">${items.join("")}</items></pivotField>`;
331
+ }
332
+
333
+ /** `<rowItems>`/`<colItems>`: one `<i>` per shared item (display order = shared
334
+ * order, since the cache is pre-sorted) plus a trailing grand-total `<i>`. */
335
+ function axisItemsXml(element: "rowItems" | "colItems", itemCount: number, grandTotal: boolean): string {
336
+ const items: string[] = [];
337
+ for (let i = 0; i < itemCount; i++) items.push(`<i><x v="${i}"/></i>`);
338
+ if (grandTotal) items.push(`<i t="grand"><x/></i>`);
339
+ return `<${element} count="${items.length}">${items.join("")}</${element}>`;
340
+ }
@@ -12,8 +12,15 @@ import type { WorkbookModel, SheetModel, CellModel } from "./workbook_model";
12
12
  import type { CellStyle, RichTextPart, RichTextFont } from "./types";
13
13
  import type { ParsedChart, ParsedTable, ParsedDrawing, ChartType, ChartSeries } from "./ooxml_chart_types";
14
14
  import type { ParsedConditionalFormat, ParsedCfStyle } from "./excel_cf_types";
15
- import { rowColToRef, refToRowCol, colNumToLetters } from "./excel_utils";
15
+ import { rowColToRef, refToRowCol, colNumToLetters, escapeXml } from "./excel_utils";
16
16
  import { hexToArgb, hexToOoxmlRgb } from "./ooxml_color";
17
+ import { readSourceData } from "./pivot_model";
18
+ import {
19
+ buildPivotCacheDefinitionXml,
20
+ buildPivotCacheRecordsXml,
21
+ buildPivotTableXml,
22
+ enumerateFlags,
23
+ } from "./pivot_writer";
17
24
 
18
25
  // =============================================================================
19
26
  // Pivot round-trip helpers
@@ -183,6 +190,78 @@ function pivotContentTypeFor(path: string): string | undefined {
183
190
  return undefined;
184
191
  }
185
192
 
193
+ const REL_BASE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
194
+
195
+ function relsXml(rels: { id: string; type: string; target: string }[]): string {
196
+ return (
197
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
198
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">\n` +
199
+ rels.map((r) => `<Relationship Id="${r.id}" Type="${r.type}" Target="${escapeXml(r.target)}"/>`).join("\n") +
200
+ `\n</Relationships>`
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Serialize every AUTHORED pivot (built via `buildPivotTable`) into fresh OOXML
206
+ * parts written straight into `entries`, and return the cross-reference snapshot
207
+ * the regenerated workbook/sheet rels + content-types consume (same shape the
208
+ * round-trip path produces). Parsed/round-tripped pivots are skipped — they pass
209
+ * through verbatim. `startFileIndex` is the first pivotTableN/cacheN suffix,
210
+ * past any round-tripped pivot files, so paths never collide.
211
+ */
212
+ function emitAuthoredPivots(
213
+ workbook: WorkbookModel,
214
+ entries: Record<string, Uint8Array>,
215
+ startFileIndex: number,
216
+ ): PivotRoundTripInfo {
217
+ const info: PivotRoundTripInfo = {
218
+ cachesByWorkbook: new Map(),
219
+ pivotTablesBySheetIndex: new Map(),
220
+ pivotXmlPaths: [],
221
+ };
222
+ let n = startFileIndex;
223
+ for (let i = 0; i < workbook.sheets.length; i++) {
224
+ for (const model of workbook.sheets[i].pivotTables) {
225
+ if (!model.authored) continue;
226
+ const idx = n++;
227
+ const enumerate = enumerateFlags(model.config, model.cache.fields.length);
228
+ const records = readSourceData(workbook, model.cache)?.records ?? [];
229
+
230
+ const cacheDefPath = `xl/pivotCache/pivotCacheDefinition${idx}.xml`;
231
+ const recordsPath = `xl/pivotCache/pivotCacheRecords${idx}.xml`;
232
+ const tablePath = `xl/pivotTables/pivotTable${idx}.xml`;
233
+
234
+ entries[cacheDefPath] = strToU8(buildPivotCacheDefinitionXml(model.cache, enumerate, records.length, "rId1"));
235
+ entries[`xl/pivotCache/_rels/pivotCacheDefinition${idx}.xml.rels`] = strToU8(
236
+ relsXml([{ id: "rId1", type: `${REL_BASE}/pivotCacheRecords`, target: `pivotCacheRecords${idx}.xml` }]),
237
+ );
238
+ entries[recordsPath] = strToU8(buildPivotCacheRecordsXml(records, model.cache, enumerate));
239
+ entries[tablePath] = strToU8(buildPivotTableXml(model.config, model.cache));
240
+ entries[`xl/pivotTables/_rels/pivotTable${idx}.xml.rels`] = strToU8(
241
+ relsXml([{ id: "rId1", type: `${REL_BASE}/pivotCacheDefinition`, target: `../pivotCache/pivotCacheDefinition${idx}.xml` }]),
242
+ );
243
+
244
+ info.cachesByWorkbook.set(model.cache.id, `pivotCache/pivotCacheDefinition${idx}.xml`);
245
+ const list = info.pivotTablesBySheetIndex.get(i) ?? [];
246
+ list.push(`../pivotTables/pivotTable${idx}.xml`);
247
+ info.pivotTablesBySheetIndex.set(i, list);
248
+ info.pivotXmlPaths.push(tablePath, cacheDefPath, recordsPath);
249
+ }
250
+ }
251
+ return info;
252
+ }
253
+
254
+ function mergePivotInfo(a: PivotRoundTripInfo, b: PivotRoundTripInfo): PivotRoundTripInfo {
255
+ const cachesByWorkbook = new Map(a.cachesByWorkbook);
256
+ for (const [k, v] of b.cachesByWorkbook) cachesByWorkbook.set(k, v);
257
+ const pivotTablesBySheetIndex = new Map<number, string[]>();
258
+ for (const [k, v] of a.pivotTablesBySheetIndex) pivotTablesBySheetIndex.set(k, [...v]);
259
+ for (const [k, v] of b.pivotTablesBySheetIndex) {
260
+ pivotTablesBySheetIndex.set(k, [...(pivotTablesBySheetIndex.get(k) ?? []), ...v]);
261
+ }
262
+ return { cachesByWorkbook, pivotTablesBySheetIndex, pivotXmlPaths: [...a.pivotXmlPaths, ...b.pivotXmlPaths] };
263
+ }
264
+
186
265
  // =============================================================================
187
266
  // Main export function
188
267
  // =============================================================================
@@ -244,11 +323,14 @@ export function exportWorkbook(
244
323
  : buildStylesXml(workbook);
245
324
  if (!stylesPassthrough) entries["xl/styles.xml"] = strToU8(stylesResult.xml);
246
325
 
247
- // Pivot round-trip: read original workbook.xml + sheet rels to recover the
248
- // (cacheId cacheDefinition path) and (sheet pivotTable paths) mappings.
249
- // The pivot xml files themselves pass through originalZip; we only need to
250
- // re-emit the cross-references in the regenerated parts.
251
- const pivotInfo = extractPivotRoundTripInfo(originalZip);
326
+ // Pivot cross-references. Two sources feed the regenerated workbook/sheet rels
327
+ // + content-types: pivots ROUND-TRIPPED from `originalZip` (their xml passes
328
+ // through verbatim) and pivots AUTHORED in the model (serialized fresh here,
329
+ // straight into `entries`).
330
+ const roundTripInfo = extractPivotRoundTripInfo(originalZip);
331
+ const authoredStartIndex =
332
+ roundTripInfo.pivotXmlPaths.filter((p) => p.startsWith("xl/pivotTables/")).length + 1;
333
+ const pivotInfo = mergePivotInfo(roundTripInfo, emitAuthoredPivots(workbook, entries, authoredStartIndex));
252
334
 
253
335
  // Build workbook.xml
254
336
  entries["xl/workbook.xml"] = strToU8(buildWorkbookXml(workbook, pivotInfo));
@@ -1539,11 +1621,3 @@ function dataUrlToBytes(dataUrl: string): Uint8Array | null {
1539
1621
  // Helpers
1540
1622
  // =============================================================================
1541
1623
 
1542
- function escapeXml(s: string): string {
1543
- return s
1544
- .replace(/&/g, "&amp;")
1545
- .replace(/</g, "&lt;")
1546
- .replace(/>/g, "&gt;")
1547
- .replace(/"/g, "&quot;")
1548
- .replace(/'/g, "&apos;");
1549
- }