@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 +1 -1
- package/src/csv_parser.ts +6 -2
- package/src/data_workbook.test.ts +30 -1
- package/src/data_workbook.ts +33 -13
- package/src/excel_utils.ts +10 -0
- package/src/index.ts +16 -1
- package/src/pivot_builder.test.ts +278 -0
- package/src/pivot_builder.ts +187 -0
- package/src/pivot_model.ts +9 -1
- package/src/pivot_writer.ts +340 -0
- package/src/xlsx_writer.ts +88 -14
package/package.json
CHANGED
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(
|
|
12
|
-
|
|
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
|
+
});
|
package/src/data_workbook.ts
CHANGED
|
@@ -123,23 +123,21 @@ function clampWidth(chars: number): number {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
|
-
* Build
|
|
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
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
|
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
|
}
|
package/src/excel_utils.ts
CHANGED
|
@@ -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, "&")
|
|
127
|
+
.replace(/</g, "<")
|
|
128
|
+
.replace(/>/g, ">")
|
|
129
|
+
.replace(/"/g, """)
|
|
130
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/src/pivot_model.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/xlsx_writer.ts
CHANGED
|
@@ -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
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
const
|
|
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, "&")
|
|
1545
|
-
.replace(/</g, "<")
|
|
1546
|
-
.replace(/>/g, ">")
|
|
1547
|
-
.replace(/"/g, """)
|
|
1548
|
-
.replace(/'/g, "'");
|
|
1549
|
-
}
|