@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,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
|
-
}
|