@lotics/xlsx 0.1.3 → 0.1.5
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/biff8_fixtures/synthetic.xls +0 -0
- package/src/biff8_fixtures/synthetic_dates.xls +0 -0
- package/src/biff8_reader.test.ts +100 -0
- package/src/biff8_reader.ts +459 -0
- package/src/excel_parser.ts +6 -0
- package/src/excel_utils.ts +10 -0
- package/src/index.ts +15 -0
- 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
|
@@ -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
|
/**
|