@niicojs/excel 0.3.0 → 0.3.1
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/LICENSE +20 -20
- package/README.md +585 -585
- package/dist/index.cjs +498 -489
- package/dist/index.d.cts +5 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +498 -489
- package/package.json +1 -1
- package/src/index.ts +45 -45
- package/src/pivot-cache.ts +300 -300
- package/src/pivot-table.ts +684 -684
- package/src/range.ts +154 -154
- package/src/shared-strings.ts +178 -178
- package/src/styles.ts +819 -819
- package/src/table.ts +386 -386
- package/src/types.ts +313 -307
- package/src/utils/address.ts +121 -121
- package/src/utils/xml.ts +140 -140
- package/src/workbook.ts +1390 -1390
- package/src/worksheet.ts +879 -869
package/src/workbook.ts
CHANGED
|
@@ -1,1390 +1,1390 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
-
import type {
|
|
3
|
-
SheetDefinition,
|
|
4
|
-
Relationship,
|
|
5
|
-
PivotTableConfig,
|
|
6
|
-
CellValue,
|
|
7
|
-
SheetFromDataConfig,
|
|
8
|
-
ColumnConfig,
|
|
9
|
-
RichCellValue,
|
|
10
|
-
DateHandling,
|
|
11
|
-
} from './types';
|
|
12
|
-
import { Worksheet } from './worksheet';
|
|
13
|
-
import { SharedStrings } from './shared-strings';
|
|
14
|
-
import { Styles } from './styles';
|
|
15
|
-
import { PivotTable } from './pivot-table';
|
|
16
|
-
import { PivotCache } from './pivot-cache';
|
|
17
|
-
import { readZip, writeZip, readZipText, writeZipText, ZipFiles } from './utils/zip';
|
|
18
|
-
import { parseAddress, parseRange, toAddress } from './utils/address';
|
|
19
|
-
import { parseXml, findElement, getChildren, getAttr, XmlNode, stringifyXml, createElement } from './utils/xml';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Represents an Excel workbook (.xlsx file)
|
|
23
|
-
*/
|
|
24
|
-
export class Workbook {
|
|
25
|
-
private _files: ZipFiles = new Map();
|
|
26
|
-
private _sheets: Map<string, Worksheet> = new Map();
|
|
27
|
-
private _sheetDefs: SheetDefinition[] = [];
|
|
28
|
-
private _relationships: Relationship[] = [];
|
|
29
|
-
private _sharedStrings: SharedStrings;
|
|
30
|
-
private _styles: Styles;
|
|
31
|
-
private _dirty = false;
|
|
32
|
-
|
|
33
|
-
// Pivot table support
|
|
34
|
-
private _pivotTables: PivotTable[] = [];
|
|
35
|
-
private _pivotCaches: PivotCache[] = [];
|
|
36
|
-
private _nextCacheId = 0;
|
|
37
|
-
private _nextCacheFileIndex = 1;
|
|
38
|
-
|
|
39
|
-
// Table support
|
|
40
|
-
private _nextTableId = 1;
|
|
41
|
-
|
|
42
|
-
// Date serialization handling
|
|
43
|
-
private _dateHandling: DateHandling = 'jsDate';
|
|
44
|
-
|
|
45
|
-
private constructor() {
|
|
46
|
-
this._sharedStrings = new SharedStrings();
|
|
47
|
-
this._styles = Styles.createDefault();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Load a workbook from a file path
|
|
52
|
-
*/
|
|
53
|
-
static async fromFile(path: string): Promise<Workbook> {
|
|
54
|
-
const data = await readFile(path);
|
|
55
|
-
return Workbook.fromBuffer(new Uint8Array(data));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Load a workbook from a buffer
|
|
60
|
-
*/
|
|
61
|
-
static async fromBuffer(data: Uint8Array): Promise<Workbook> {
|
|
62
|
-
const workbook = new Workbook();
|
|
63
|
-
workbook._files = await readZip(data);
|
|
64
|
-
|
|
65
|
-
// Parse workbook.xml for sheet definitions
|
|
66
|
-
const workbookXml = readZipText(workbook._files, 'xl/workbook.xml');
|
|
67
|
-
if (workbookXml) {
|
|
68
|
-
workbook._parseWorkbook(workbookXml);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Parse relationships
|
|
72
|
-
const relsXml = readZipText(workbook._files, 'xl/_rels/workbook.xml.rels');
|
|
73
|
-
if (relsXml) {
|
|
74
|
-
workbook._parseRelationships(relsXml);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Parse shared strings
|
|
78
|
-
const sharedStringsXml = readZipText(workbook._files, 'xl/sharedStrings.xml');
|
|
79
|
-
if (sharedStringsXml) {
|
|
80
|
-
workbook._sharedStrings = SharedStrings.parse(sharedStringsXml);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Parse styles
|
|
84
|
-
const stylesXml = readZipText(workbook._files, 'xl/styles.xml');
|
|
85
|
-
if (stylesXml) {
|
|
86
|
-
workbook._styles = Styles.parse(stylesXml);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return workbook;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Create a new empty workbook
|
|
94
|
-
*/
|
|
95
|
-
static create(): Workbook {
|
|
96
|
-
const workbook = new Workbook();
|
|
97
|
-
workbook._dirty = true;
|
|
98
|
-
|
|
99
|
-
return workbook;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Get sheet names
|
|
104
|
-
*/
|
|
105
|
-
get sheetNames(): string[] {
|
|
106
|
-
return this._sheetDefs.map((s) => s.name);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Get number of sheets
|
|
111
|
-
*/
|
|
112
|
-
get sheetCount(): number {
|
|
113
|
-
return this._sheetDefs.length;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Get shared strings table
|
|
118
|
-
*/
|
|
119
|
-
get sharedStrings(): SharedStrings {
|
|
120
|
-
return this._sharedStrings;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Get styles
|
|
125
|
-
*/
|
|
126
|
-
get styles(): Styles {
|
|
127
|
-
return this._styles;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Get the workbook date handling strategy.
|
|
132
|
-
*/
|
|
133
|
-
get dateHandling(): DateHandling {
|
|
134
|
-
return this._dateHandling;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Set the workbook date handling strategy.
|
|
139
|
-
*/
|
|
140
|
-
set dateHandling(value: DateHandling) {
|
|
141
|
-
this._dateHandling = value;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Get the next unique table ID for this workbook.
|
|
146
|
-
* Table IDs must be unique across all worksheets.
|
|
147
|
-
* @internal
|
|
148
|
-
*/
|
|
149
|
-
getNextTableId(): number {
|
|
150
|
-
return this._nextTableId++;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Get a worksheet by name or index
|
|
155
|
-
*/
|
|
156
|
-
sheet(nameOrIndex: string | number): Worksheet {
|
|
157
|
-
let def: SheetDefinition | undefined;
|
|
158
|
-
|
|
159
|
-
if (typeof nameOrIndex === 'number') {
|
|
160
|
-
def = this._sheetDefs[nameOrIndex];
|
|
161
|
-
} else {
|
|
162
|
-
def = this._sheetDefs.find((s) => s.name === nameOrIndex);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (!def) {
|
|
166
|
-
throw new Error(`Sheet not found: ${nameOrIndex}`);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Return cached worksheet if available
|
|
170
|
-
if (this._sheets.has(def.name)) {
|
|
171
|
-
return this._sheets.get(def.name)!;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Load worksheet
|
|
175
|
-
const worksheet = new Worksheet(this, def.name);
|
|
176
|
-
|
|
177
|
-
// Find the relationship to get the file path
|
|
178
|
-
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
179
|
-
if (rel) {
|
|
180
|
-
const sheetPath = `xl/${rel.target}`;
|
|
181
|
-
const sheetXml = readZipText(this._files, sheetPath);
|
|
182
|
-
if (sheetXml) {
|
|
183
|
-
worksheet.parse(sheetXml);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
this._sheets.set(def.name, worksheet);
|
|
188
|
-
return worksheet;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Add a new worksheet
|
|
193
|
-
*/
|
|
194
|
-
addSheet(name: string, index?: number): Worksheet {
|
|
195
|
-
// Check for duplicate name
|
|
196
|
-
if (this._sheetDefs.some((s) => s.name === name)) {
|
|
197
|
-
throw new Error(`Sheet already exists: ${name}`);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
this._dirty = true;
|
|
201
|
-
|
|
202
|
-
// Generate new sheet ID and relationship ID
|
|
203
|
-
const sheetId = Math.max(0, ...this._sheetDefs.map((s) => s.sheetId)) + 1;
|
|
204
|
-
const rId = `rId${Math.max(0, ...this._relationships.map((r) => parseInt(r.id.replace('rId', ''), 10) || 0)) + 1}`;
|
|
205
|
-
|
|
206
|
-
const def: SheetDefinition = { name, sheetId, rId };
|
|
207
|
-
|
|
208
|
-
// Add relationship
|
|
209
|
-
this._relationships.push({
|
|
210
|
-
id: rId,
|
|
211
|
-
type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet',
|
|
212
|
-
target: `worksheets/sheet${sheetId}.xml`,
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
// Insert at index or append
|
|
216
|
-
if (index !== undefined && index >= 0 && index < this._sheetDefs.length) {
|
|
217
|
-
this._sheetDefs.splice(index, 0, def);
|
|
218
|
-
} else {
|
|
219
|
-
this._sheetDefs.push(def);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Create worksheet
|
|
223
|
-
const worksheet = new Worksheet(this, name);
|
|
224
|
-
this._sheets.set(name, worksheet);
|
|
225
|
-
|
|
226
|
-
return worksheet;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Delete a worksheet by name or index
|
|
231
|
-
*/
|
|
232
|
-
deleteSheet(nameOrIndex: string | number): void {
|
|
233
|
-
let index: number;
|
|
234
|
-
|
|
235
|
-
if (typeof nameOrIndex === 'number') {
|
|
236
|
-
index = nameOrIndex;
|
|
237
|
-
} else {
|
|
238
|
-
index = this._sheetDefs.findIndex((s) => s.name === nameOrIndex);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (index < 0 || index >= this._sheetDefs.length) {
|
|
242
|
-
throw new Error(`Sheet not found: ${nameOrIndex}`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (this._sheetDefs.length === 1) {
|
|
246
|
-
throw new Error('Cannot delete the last sheet');
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
this._dirty = true;
|
|
250
|
-
|
|
251
|
-
const def = this._sheetDefs[index];
|
|
252
|
-
this._sheetDefs.splice(index, 1);
|
|
253
|
-
this._sheets.delete(def.name);
|
|
254
|
-
|
|
255
|
-
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
256
|
-
if (rel) {
|
|
257
|
-
const sheetPath = `xl/${rel.target}`;
|
|
258
|
-
this._files.delete(sheetPath);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Remove relationship
|
|
262
|
-
const relIndex = this._relationships.findIndex((r) => r.id === def.rId);
|
|
263
|
-
if (relIndex >= 0) {
|
|
264
|
-
this._relationships.splice(relIndex, 1);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Rename a worksheet
|
|
270
|
-
*/
|
|
271
|
-
renameSheet(oldName: string, newName: string): void {
|
|
272
|
-
const def = this._sheetDefs.find((s) => s.name === oldName);
|
|
273
|
-
if (!def) {
|
|
274
|
-
throw new Error(`Sheet not found: ${oldName}`);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (this._sheetDefs.some((s) => s.name === newName)) {
|
|
278
|
-
throw new Error(`Sheet already exists: ${newName}`);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
this._dirty = true;
|
|
282
|
-
|
|
283
|
-
// Update cached worksheet
|
|
284
|
-
const worksheet = this._sheets.get(oldName);
|
|
285
|
-
if (worksheet) {
|
|
286
|
-
worksheet.name = newName;
|
|
287
|
-
this._sheets.delete(oldName);
|
|
288
|
-
this._sheets.set(newName, worksheet);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
def.name = newName;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Copy a worksheet
|
|
296
|
-
*/
|
|
297
|
-
copySheet(sourceName: string, newName: string): Worksheet {
|
|
298
|
-
const source = this.sheet(sourceName);
|
|
299
|
-
const copy = this.addSheet(newName);
|
|
300
|
-
|
|
301
|
-
// Copy all cells
|
|
302
|
-
for (const [address, cell] of source.cells) {
|
|
303
|
-
const newCell = copy.cell(address);
|
|
304
|
-
newCell.value = cell.value;
|
|
305
|
-
if (cell.formula) {
|
|
306
|
-
newCell.formula = cell.formula;
|
|
307
|
-
}
|
|
308
|
-
if (cell.styleIndex !== undefined) {
|
|
309
|
-
newCell.styleIndex = cell.styleIndex;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Copy column widths
|
|
314
|
-
for (const [col, width] of source.getColumnWidths()) {
|
|
315
|
-
copy.setColumnWidth(col, width);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Copy row heights
|
|
319
|
-
for (const [row, height] of source.getRowHeights()) {
|
|
320
|
-
copy.setRowHeight(row, height);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Copy frozen panes
|
|
324
|
-
const frozen = source.getFrozenPane();
|
|
325
|
-
if (frozen) {
|
|
326
|
-
copy.freezePane(frozen.row, frozen.col);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Copy merged cells
|
|
330
|
-
for (const mergedRange of source.mergedCells) {
|
|
331
|
-
copy.mergeCells(mergedRange);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Copy tables
|
|
335
|
-
for (const table of source.tables) {
|
|
336
|
-
const tableName = this._createUniqueTableName(table.name, newName);
|
|
337
|
-
const newTable = copy.createTable({
|
|
338
|
-
name: tableName,
|
|
339
|
-
range: table.baseRange,
|
|
340
|
-
totalRow: table.hasTotalRow,
|
|
341
|
-
headerRow: table.hasHeaderRow,
|
|
342
|
-
style: table.style,
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
if (!table.hasAutoFilter) {
|
|
346
|
-
newTable.setAutoFilter(false);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (table.hasTotalRow) {
|
|
350
|
-
for (const columnName of table.columns) {
|
|
351
|
-
const fn = table.getTotalFunction(columnName);
|
|
352
|
-
if (fn) {
|
|
353
|
-
newTable.setTotalFunction(columnName, fn);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return copy;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
private _createUniqueTableName(base: string, sheetName: string): string {
|
|
363
|
-
const normalizedSheet = sheetName.replace(/[^A-Za-z0-9_.]/g, '_');
|
|
364
|
-
const sanitizedBase = this._sanitizeTableName(`${base}_${normalizedSheet || 'Sheet'}`);
|
|
365
|
-
let candidate = sanitizedBase;
|
|
366
|
-
let counter = 1;
|
|
367
|
-
|
|
368
|
-
while (this._hasTableName(candidate)) {
|
|
369
|
-
candidate = `${sanitizedBase}_${counter++}`;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return candidate;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
private _sanitizeTableName(name: string): string {
|
|
376
|
-
let result = name.replace(/[^A-Za-z0-9_.]/g, '_');
|
|
377
|
-
if (!/^[A-Za-z_]/.test(result)) {
|
|
378
|
-
result = `_${result}`;
|
|
379
|
-
}
|
|
380
|
-
if (result.length === 0) {
|
|
381
|
-
result = 'Table';
|
|
382
|
-
}
|
|
383
|
-
return result;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
private _hasTableName(name: string): boolean {
|
|
387
|
-
for (const sheetName of this.sheetNames) {
|
|
388
|
-
const ws = this.sheet(sheetName);
|
|
389
|
-
for (const table of ws.tables) {
|
|
390
|
-
if (table.name === name) return true;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
return false;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Create a new worksheet from an array of objects.
|
|
398
|
-
*
|
|
399
|
-
* The first row contains headers (object keys or custom column headers),
|
|
400
|
-
* and subsequent rows contain the object values.
|
|
401
|
-
*
|
|
402
|
-
* @param config - Configuration for the sheet creation
|
|
403
|
-
* @returns The created Worksheet
|
|
404
|
-
*
|
|
405
|
-
* @example
|
|
406
|
-
* ```typescript
|
|
407
|
-
* const data = [
|
|
408
|
-
* { name: 'Alice', age: 30, city: 'Paris' },
|
|
409
|
-
* { name: 'Bob', age: 25, city: 'London' },
|
|
410
|
-
* { name: 'Charlie', age: 35, city: 'Berlin' },
|
|
411
|
-
* ];
|
|
412
|
-
*
|
|
413
|
-
* // Simple usage - all object keys become columns
|
|
414
|
-
* const sheet = wb.addSheetFromData({
|
|
415
|
-
* name: 'People',
|
|
416
|
-
* data: data,
|
|
417
|
-
* });
|
|
418
|
-
*
|
|
419
|
-
* // With custom column configuration
|
|
420
|
-
* const sheet2 = wb.addSheetFromData({
|
|
421
|
-
* name: 'People Custom',
|
|
422
|
-
* data: data,
|
|
423
|
-
* columns: [
|
|
424
|
-
* { key: 'name', header: 'Full Name' },
|
|
425
|
-
* { key: 'age', header: 'Age (years)' },
|
|
426
|
-
* ],
|
|
427
|
-
* });
|
|
428
|
-
*
|
|
429
|
-
* // With rich cell values (value, formula, style)
|
|
430
|
-
* const dataWithFormulas = [
|
|
431
|
-
* { product: 'Widget', price: 10, qty: 5, total: { formula: 'B2*C2', style: { bold: true } } },
|
|
432
|
-
* { product: 'Gadget', price: 20, qty: 3, total: { formula: 'B3*C3', style: { bold: true } } },
|
|
433
|
-
* ];
|
|
434
|
-
* const sheet3 = wb.addSheetFromData({
|
|
435
|
-
* name: 'With Formulas',
|
|
436
|
-
* data: dataWithFormulas,
|
|
437
|
-
* });
|
|
438
|
-
* ```
|
|
439
|
-
*/
|
|
440
|
-
addSheetFromData<T extends object>(config: SheetFromDataConfig<T>): Worksheet {
|
|
441
|
-
const { name, data, columns, headerStyle = true, startCell = 'A1' } = config;
|
|
442
|
-
|
|
443
|
-
if (!data?.length) return this.addSheet(name);
|
|
444
|
-
|
|
445
|
-
// Create the new sheet
|
|
446
|
-
const sheet = this.addSheet(name);
|
|
447
|
-
|
|
448
|
-
// Parse start cell
|
|
449
|
-
const startAddr = parseAddress(startCell);
|
|
450
|
-
let startRow = startAddr.row;
|
|
451
|
-
const startCol = startAddr.col;
|
|
452
|
-
|
|
453
|
-
// Determine columns to use
|
|
454
|
-
const columnConfigs: ColumnConfig<T>[] = columns ?? this._inferColumns(data[0]);
|
|
455
|
-
|
|
456
|
-
// Write header row
|
|
457
|
-
for (let colIdx = 0; colIdx < columnConfigs.length; colIdx++) {
|
|
458
|
-
const colConfig = columnConfigs[colIdx];
|
|
459
|
-
const headerText = colConfig.header ?? String(colConfig.key);
|
|
460
|
-
const cell = sheet.cell(startRow, startCol + colIdx);
|
|
461
|
-
cell.value = headerText;
|
|
462
|
-
|
|
463
|
-
// Apply header style if enabled
|
|
464
|
-
if (headerStyle) {
|
|
465
|
-
cell.style = { bold: true };
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Move to data rows
|
|
470
|
-
startRow++;
|
|
471
|
-
|
|
472
|
-
// Write data rows
|
|
473
|
-
for (let rowIdx = 0; rowIdx < data.length; rowIdx++) {
|
|
474
|
-
const rowData = data[rowIdx];
|
|
475
|
-
|
|
476
|
-
for (let colIdx = 0; colIdx < columnConfigs.length; colIdx++) {
|
|
477
|
-
const colConfig = columnConfigs[colIdx];
|
|
478
|
-
const value = rowData[colConfig.key];
|
|
479
|
-
const cell = sheet.cell(startRow + rowIdx, startCol + colIdx);
|
|
480
|
-
|
|
481
|
-
// Check if value is a rich cell definition
|
|
482
|
-
if (this._isRichCellValue(value)) {
|
|
483
|
-
const richValue = value as RichCellValue;
|
|
484
|
-
if (richValue.value !== undefined) cell.value = richValue.value;
|
|
485
|
-
if (richValue.formula !== undefined) cell.formula = richValue.formula;
|
|
486
|
-
if (richValue.style !== undefined) cell.style = richValue.style;
|
|
487
|
-
} else {
|
|
488
|
-
// Convert value to CellValue
|
|
489
|
-
cell.value = this._toCellValue(value);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Apply column style if defined (merged with cell style)
|
|
493
|
-
if (colConfig.style) {
|
|
494
|
-
cell.style = { ...cell.style, ...colConfig.style };
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
return sheet;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* Check if a value is a rich cell value object with value, formula, or style fields
|
|
504
|
-
*/
|
|
505
|
-
private _isRichCellValue(value: unknown): value is RichCellValue {
|
|
506
|
-
if (value === null || value === undefined) {
|
|
507
|
-
return false;
|
|
508
|
-
}
|
|
509
|
-
if (typeof value !== 'object' || value instanceof Date) {
|
|
510
|
-
return false;
|
|
511
|
-
}
|
|
512
|
-
// Check if it has at least one of the rich cell properties
|
|
513
|
-
const obj = value as Record<string, unknown>;
|
|
514
|
-
return 'value' in obj || 'formula' in obj || 'style' in obj;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Infer column configuration from the first data object
|
|
519
|
-
*/
|
|
520
|
-
private _inferColumns<T extends object>(sample: T): ColumnConfig<T>[] {
|
|
521
|
-
return (Object.keys(sample) as (keyof T)[]).map((key) => ({
|
|
522
|
-
key,
|
|
523
|
-
}));
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* Convert an unknown value to a CellValue
|
|
528
|
-
*/
|
|
529
|
-
private _toCellValue(value: unknown): CellValue {
|
|
530
|
-
if (value === null || value === undefined) {
|
|
531
|
-
return null;
|
|
532
|
-
}
|
|
533
|
-
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
|
|
534
|
-
return value;
|
|
535
|
-
}
|
|
536
|
-
if (value instanceof Date) {
|
|
537
|
-
return value;
|
|
538
|
-
}
|
|
539
|
-
if (typeof value === 'object' && 'error' in value) {
|
|
540
|
-
return value as CellValue;
|
|
541
|
-
}
|
|
542
|
-
// Convert other types to string
|
|
543
|
-
return String(value);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* Create a pivot table from source data.
|
|
548
|
-
*
|
|
549
|
-
* @param config - Pivot table configuration
|
|
550
|
-
* @returns PivotTable instance for fluent configuration
|
|
551
|
-
*
|
|
552
|
-
* @example
|
|
553
|
-
* ```typescript
|
|
554
|
-
* const pivot = wb.createPivotTable({
|
|
555
|
-
* name: 'SalesPivot',
|
|
556
|
-
* source: 'DataSheet!A1:D100',
|
|
557
|
-
* target: 'PivotSheet!A3',
|
|
558
|
-
* });
|
|
559
|
-
*
|
|
560
|
-
* pivot
|
|
561
|
-
* .addRowField('Region')
|
|
562
|
-
* .addColumnField('Product')
|
|
563
|
-
* .addValueField('Sales', 'sum', 'Total Sales');
|
|
564
|
-
* ```
|
|
565
|
-
*/
|
|
566
|
-
createPivotTable(config: PivotTableConfig): PivotTable {
|
|
567
|
-
this._dirty = true;
|
|
568
|
-
|
|
569
|
-
// Parse source reference (Sheet!Range)
|
|
570
|
-
const { sheetName: sourceSheet, range: sourceRange } = this._parseSheetRef(config.source);
|
|
571
|
-
|
|
572
|
-
// Parse target reference
|
|
573
|
-
const { sheetName: targetSheet, range: targetCell } = this._parseSheetRef(config.target);
|
|
574
|
-
|
|
575
|
-
// Ensure target sheet exists
|
|
576
|
-
if (!this._sheetDefs.some((s) => s.name === targetSheet)) {
|
|
577
|
-
this.addSheet(targetSheet);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// Parse target cell address
|
|
581
|
-
const targetAddr = parseAddress(targetCell);
|
|
582
|
-
|
|
583
|
-
// Get source worksheet and extract data
|
|
584
|
-
const sourceWs = this.sheet(sourceSheet);
|
|
585
|
-
const { headers, data } = this._extractSourceData(sourceWs, sourceRange);
|
|
586
|
-
|
|
587
|
-
// Create pivot cache
|
|
588
|
-
const cacheId = this._nextCacheId++;
|
|
589
|
-
const cacheFileIndex = this._nextCacheFileIndex++;
|
|
590
|
-
const cache = new PivotCache(cacheId, sourceSheet, sourceRange, cacheFileIndex);
|
|
591
|
-
cache.buildFromData(headers, data);
|
|
592
|
-
// refreshOnLoad defaults to true; only disable if explicitly set to false
|
|
593
|
-
if (config.refreshOnLoad === false) {
|
|
594
|
-
cache.refreshOnLoad = false;
|
|
595
|
-
}
|
|
596
|
-
this._pivotCaches.push(cache);
|
|
597
|
-
|
|
598
|
-
// Create pivot table
|
|
599
|
-
const pivotTableIndex = this._pivotTables.length + 1;
|
|
600
|
-
const pivotTable = new PivotTable(
|
|
601
|
-
config.name,
|
|
602
|
-
cache,
|
|
603
|
-
targetSheet,
|
|
604
|
-
targetCell,
|
|
605
|
-
targetAddr.row + 1, // Convert to 1-based
|
|
606
|
-
targetAddr.col,
|
|
607
|
-
pivotTableIndex,
|
|
608
|
-
cacheFileIndex,
|
|
609
|
-
);
|
|
610
|
-
|
|
611
|
-
// Set styles reference for number format resolution
|
|
612
|
-
pivotTable.setStyles(this._styles);
|
|
613
|
-
|
|
614
|
-
this._pivotTables.push(pivotTable);
|
|
615
|
-
|
|
616
|
-
return pivotTable;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Parse a sheet reference like "Sheet1!A1:D100" into sheet name and range
|
|
621
|
-
*/
|
|
622
|
-
private _parseSheetRef(ref: string): { sheetName: string; range: string } {
|
|
623
|
-
const match = ref.match(/^(.+?)!(.+)$/);
|
|
624
|
-
if (!match) {
|
|
625
|
-
throw new Error(`Invalid reference format: ${ref}. Expected "SheetName!Range"`);
|
|
626
|
-
}
|
|
627
|
-
return { sheetName: match[1], range: match[2] };
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Extract headers and data from a source range
|
|
632
|
-
*/
|
|
633
|
-
private _extractSourceData(sheet: Worksheet, rangeStr: string): { headers: string[]; data: CellValue[][] } {
|
|
634
|
-
const range = parseRange(rangeStr);
|
|
635
|
-
const headers: string[] = [];
|
|
636
|
-
const data: CellValue[][] = [];
|
|
637
|
-
|
|
638
|
-
// First row is headers
|
|
639
|
-
for (let col = range.start.col; col <= range.end.col; col++) {
|
|
640
|
-
const cell = sheet.cell(toAddress(range.start.row, col));
|
|
641
|
-
headers.push(String(cell.value ?? `Column${col + 1}`));
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// Remaining rows are data
|
|
645
|
-
for (let row = range.start.row + 1; row <= range.end.row; row++) {
|
|
646
|
-
const rowData: CellValue[] = [];
|
|
647
|
-
for (let col = range.start.col; col <= range.end.col; col++) {
|
|
648
|
-
const cell = sheet.cell(toAddress(row, col));
|
|
649
|
-
rowData.push(cell.value);
|
|
650
|
-
}
|
|
651
|
-
data.push(rowData);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
return { headers, data };
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
/**
|
|
658
|
-
* Save the workbook to a file
|
|
659
|
-
*/
|
|
660
|
-
async toFile(path: string): Promise<void> {
|
|
661
|
-
const buffer = await this.toBuffer();
|
|
662
|
-
await writeFile(path, buffer);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* Save the workbook to a buffer
|
|
667
|
-
*/
|
|
668
|
-
async toBuffer(): Promise<Uint8Array> {
|
|
669
|
-
// Update files map with modified content
|
|
670
|
-
this._updateFiles();
|
|
671
|
-
|
|
672
|
-
// Write ZIP
|
|
673
|
-
return writeZip(this._files);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
private _parseWorkbook(xml: string): void {
|
|
677
|
-
const parsed = parseXml(xml);
|
|
678
|
-
const workbook = findElement(parsed, 'workbook');
|
|
679
|
-
if (!workbook) return;
|
|
680
|
-
|
|
681
|
-
const children = getChildren(workbook, 'workbook');
|
|
682
|
-
const sheets = findElement(children, 'sheets');
|
|
683
|
-
if (!sheets) return;
|
|
684
|
-
|
|
685
|
-
for (const child of getChildren(sheets, 'sheets')) {
|
|
686
|
-
if ('sheet' in child) {
|
|
687
|
-
const name = getAttr(child, 'name');
|
|
688
|
-
const sheetId = getAttr(child, 'sheetId');
|
|
689
|
-
const rId = getAttr(child, 'r:id');
|
|
690
|
-
|
|
691
|
-
if (name && sheetId && rId) {
|
|
692
|
-
this._sheetDefs.push({
|
|
693
|
-
name,
|
|
694
|
-
sheetId: parseInt(sheetId, 10),
|
|
695
|
-
rId,
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
private _parseRelationships(xml: string): void {
|
|
703
|
-
const parsed = parseXml(xml);
|
|
704
|
-
const rels = findElement(parsed, 'Relationships');
|
|
705
|
-
if (!rels) return;
|
|
706
|
-
|
|
707
|
-
for (const child of getChildren(rels, 'Relationships')) {
|
|
708
|
-
if ('Relationship' in child) {
|
|
709
|
-
const id = getAttr(child, 'Id');
|
|
710
|
-
const type = getAttr(child, 'Type');
|
|
711
|
-
const target = getAttr(child, 'Target');
|
|
712
|
-
|
|
713
|
-
if (id && type && target) {
|
|
714
|
-
this._relationships.push({ id, type, target });
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
private _updateFiles(): void {
|
|
721
|
-
const relationshipInfo = this._buildRelationshipInfo();
|
|
722
|
-
|
|
723
|
-
// Update workbook.xml
|
|
724
|
-
this._updateWorkbookXml(relationshipInfo.pivotCacheRelIds);
|
|
725
|
-
|
|
726
|
-
// Update relationships
|
|
727
|
-
this._updateRelationshipsXml(relationshipInfo.relNodes);
|
|
728
|
-
|
|
729
|
-
// Update content types
|
|
730
|
-
this._updateContentTypes();
|
|
731
|
-
|
|
732
|
-
// Update shared strings if modified
|
|
733
|
-
if (this._sharedStrings.dirty || this._sharedStrings.count > 0) {
|
|
734
|
-
writeZipText(this._files, 'xl/sharedStrings.xml', this._sharedStrings.toXml());
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Update styles if modified or if file doesn't exist yet
|
|
738
|
-
if (this._styles.dirty || this._dirty || !this._files.has('xl/styles.xml')) {
|
|
739
|
-
writeZipText(this._files, 'xl/styles.xml', this._styles.toXml());
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
// Update worksheets (needed for pivot table targets)
|
|
743
|
-
for (const [name, worksheet] of this._sheets) {
|
|
744
|
-
if (worksheet.dirty || this._dirty || worksheet.tables.length > 0) {
|
|
745
|
-
const def = this._sheetDefs.find((s) => s.name === name);
|
|
746
|
-
if (def) {
|
|
747
|
-
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
748
|
-
if (rel) {
|
|
749
|
-
const sheetPath = `xl/${rel.target}`;
|
|
750
|
-
writeZipText(this._files, sheetPath, worksheet.toXml());
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// Update pivot tables
|
|
757
|
-
if (this._pivotTables.length > 0) {
|
|
758
|
-
this._updatePivotTableFiles();
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// Update tables (sets table rel IDs for tableParts)
|
|
762
|
-
this._updateTableFiles();
|
|
763
|
-
|
|
764
|
-
// Update worksheets to align tableParts with relationship IDs
|
|
765
|
-
for (const [name, worksheet] of this._sheets) {
|
|
766
|
-
if (worksheet.dirty || this._dirty || worksheet.tables.length > 0) {
|
|
767
|
-
const def = this._sheetDefs.find((s) => s.name === name);
|
|
768
|
-
if (def) {
|
|
769
|
-
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
770
|
-
if (rel) {
|
|
771
|
-
const sheetPath = `xl/${rel.target}`;
|
|
772
|
-
writeZipText(this._files, sheetPath, worksheet.toXml());
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
private _updateWorkbookXml(pivotCacheRelIds: Map<number, string>): void {
|
|
780
|
-
const sheetNodes: XmlNode[] = this._sheetDefs.map((def) =>
|
|
781
|
-
createElement('sheet', { name: def.name, sheetId: String(def.sheetId), 'r:id': def.rId }, []),
|
|
782
|
-
);
|
|
783
|
-
|
|
784
|
-
const sheetsNode = createElement('sheets', {}, sheetNodes);
|
|
785
|
-
|
|
786
|
-
const children: XmlNode[] = [sheetsNode];
|
|
787
|
-
|
|
788
|
-
// Add pivot caches if any
|
|
789
|
-
if (this._pivotCaches.length > 0) {
|
|
790
|
-
const pivotCacheNodes: XmlNode[] = this._pivotCaches.map((cache) => {
|
|
791
|
-
const cacheRelId = pivotCacheRelIds.get(cache.cacheId);
|
|
792
|
-
if (!cacheRelId) {
|
|
793
|
-
throw new Error(`Missing pivot cache relationship ID for cache ${cache.cacheId}`);
|
|
794
|
-
}
|
|
795
|
-
return createElement('pivotCache', { cacheId: String(cache.cacheId), 'r:id': cacheRelId }, []);
|
|
796
|
-
});
|
|
797
|
-
children.push(createElement('pivotCaches', {}, pivotCacheNodes));
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
const workbookNode = createElement(
|
|
801
|
-
'workbook',
|
|
802
|
-
{
|
|
803
|
-
xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
|
804
|
-
'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
|
805
|
-
},
|
|
806
|
-
children,
|
|
807
|
-
);
|
|
808
|
-
|
|
809
|
-
const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([workbookNode])}`;
|
|
810
|
-
writeZipText(this._files, 'xl/workbook.xml', xml);
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
private _updateRelationshipsXml(relNodes: XmlNode[]): void {
|
|
814
|
-
const relsNode = createElement(
|
|
815
|
-
'Relationships',
|
|
816
|
-
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
817
|
-
relNodes,
|
|
818
|
-
);
|
|
819
|
-
|
|
820
|
-
const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([relsNode])}`;
|
|
821
|
-
writeZipText(this._files, 'xl/_rels/workbook.xml.rels', xml);
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
private _buildRelationshipInfo(): { relNodes: XmlNode[]; pivotCacheRelIds: Map<number, string> } {
|
|
825
|
-
const relNodes: XmlNode[] = this._relationships.map((rel) =>
|
|
826
|
-
createElement('Relationship', { Id: rel.id, Type: rel.type, Target: rel.target }, []),
|
|
827
|
-
);
|
|
828
|
-
|
|
829
|
-
const reservedRelIds = new Set<string>(relNodes.map((node) => getAttr(node, 'Id') || '').filter(Boolean));
|
|
830
|
-
let nextRelId = Math.max(0, ...this._relationships.map((r) => parseInt(r.id.replace('rId', ''), 10) || 0)) + 1;
|
|
831
|
-
|
|
832
|
-
const allocateRelId = (): string => {
|
|
833
|
-
while (reservedRelIds.has(`rId${nextRelId}`)) {
|
|
834
|
-
nextRelId++;
|
|
835
|
-
}
|
|
836
|
-
const id = `rId${nextRelId}`;
|
|
837
|
-
nextRelId++;
|
|
838
|
-
reservedRelIds.add(id);
|
|
839
|
-
return id;
|
|
840
|
-
};
|
|
841
|
-
|
|
842
|
-
// Add shared strings relationship if needed
|
|
843
|
-
if (this._sharedStrings.count > 0) {
|
|
844
|
-
const hasSharedStrings = this._relationships.some(
|
|
845
|
-
(r) => r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings',
|
|
846
|
-
);
|
|
847
|
-
if (!hasSharedStrings) {
|
|
848
|
-
relNodes.push(
|
|
849
|
-
createElement(
|
|
850
|
-
'Relationship',
|
|
851
|
-
{
|
|
852
|
-
Id: allocateRelId(),
|
|
853
|
-
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings',
|
|
854
|
-
Target: 'sharedStrings.xml',
|
|
855
|
-
},
|
|
856
|
-
[],
|
|
857
|
-
),
|
|
858
|
-
);
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// Add styles relationship if needed
|
|
863
|
-
const hasStyles = this._relationships.some(
|
|
864
|
-
(r) => r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles',
|
|
865
|
-
);
|
|
866
|
-
if (!hasStyles) {
|
|
867
|
-
relNodes.push(
|
|
868
|
-
createElement(
|
|
869
|
-
'Relationship',
|
|
870
|
-
{
|
|
871
|
-
Id: allocateRelId(),
|
|
872
|
-
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles',
|
|
873
|
-
Target: 'styles.xml',
|
|
874
|
-
},
|
|
875
|
-
[],
|
|
876
|
-
),
|
|
877
|
-
);
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
// Add pivot cache relationships
|
|
881
|
-
const pivotCacheRelIds = new Map<number, string>();
|
|
882
|
-
for (const cache of this._pivotCaches) {
|
|
883
|
-
const id = allocateRelId();
|
|
884
|
-
pivotCacheRelIds.set(cache.cacheId, id);
|
|
885
|
-
relNodes.push(
|
|
886
|
-
createElement(
|
|
887
|
-
'Relationship',
|
|
888
|
-
{
|
|
889
|
-
Id: id,
|
|
890
|
-
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
|
|
891
|
-
Target: `pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`,
|
|
892
|
-
},
|
|
893
|
-
[],
|
|
894
|
-
),
|
|
895
|
-
);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
return { relNodes, pivotCacheRelIds };
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
private _updateContentTypes(): void {
|
|
902
|
-
const types: XmlNode[] = [
|
|
903
|
-
createElement(
|
|
904
|
-
'Default',
|
|
905
|
-
{ Extension: 'rels', ContentType: 'application/vnd.openxmlformats-package.relationships+xml' },
|
|
906
|
-
[],
|
|
907
|
-
),
|
|
908
|
-
createElement('Default', { Extension: 'xml', ContentType: 'application/xml' }, []),
|
|
909
|
-
createElement(
|
|
910
|
-
'Override',
|
|
911
|
-
{
|
|
912
|
-
PartName: '/xl/workbook.xml',
|
|
913
|
-
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml',
|
|
914
|
-
},
|
|
915
|
-
[],
|
|
916
|
-
),
|
|
917
|
-
createElement(
|
|
918
|
-
'Override',
|
|
919
|
-
{
|
|
920
|
-
PartName: '/xl/styles.xml',
|
|
921
|
-
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml',
|
|
922
|
-
},
|
|
923
|
-
[],
|
|
924
|
-
),
|
|
925
|
-
];
|
|
926
|
-
|
|
927
|
-
// Add shared strings if present
|
|
928
|
-
if (this._sharedStrings.count > 0) {
|
|
929
|
-
types.push(
|
|
930
|
-
createElement(
|
|
931
|
-
'Override',
|
|
932
|
-
{
|
|
933
|
-
PartName: '/xl/sharedStrings.xml',
|
|
934
|
-
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml',
|
|
935
|
-
},
|
|
936
|
-
[],
|
|
937
|
-
),
|
|
938
|
-
);
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
// Add worksheets
|
|
942
|
-
for (const def of this._sheetDefs) {
|
|
943
|
-
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
944
|
-
if (rel) {
|
|
945
|
-
types.push(
|
|
946
|
-
createElement(
|
|
947
|
-
'Override',
|
|
948
|
-
{
|
|
949
|
-
PartName: `/xl/${rel.target}`,
|
|
950
|
-
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml',
|
|
951
|
-
},
|
|
952
|
-
[],
|
|
953
|
-
),
|
|
954
|
-
);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
// Add pivot cache definitions and records
|
|
959
|
-
for (const cache of this._pivotCaches) {
|
|
960
|
-
types.push(
|
|
961
|
-
createElement(
|
|
962
|
-
'Override',
|
|
963
|
-
{
|
|
964
|
-
PartName: `/xl/pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`,
|
|
965
|
-
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml',
|
|
966
|
-
},
|
|
967
|
-
[],
|
|
968
|
-
),
|
|
969
|
-
);
|
|
970
|
-
types.push(
|
|
971
|
-
createElement(
|
|
972
|
-
'Override',
|
|
973
|
-
{
|
|
974
|
-
PartName: `/xl/pivotCache/pivotCacheRecords${cache.fileIndex}.xml`,
|
|
975
|
-
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml',
|
|
976
|
-
},
|
|
977
|
-
[],
|
|
978
|
-
),
|
|
979
|
-
);
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// Add pivot tables
|
|
983
|
-
for (const pivotTable of this._pivotTables) {
|
|
984
|
-
types.push(
|
|
985
|
-
createElement(
|
|
986
|
-
'Override',
|
|
987
|
-
{
|
|
988
|
-
PartName: `/xl/pivotTables/pivotTable${pivotTable.index}.xml`,
|
|
989
|
-
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml',
|
|
990
|
-
},
|
|
991
|
-
[],
|
|
992
|
-
),
|
|
993
|
-
);
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// Add tables
|
|
997
|
-
let tableIndex = 1;
|
|
998
|
-
for (const def of this._sheetDefs) {
|
|
999
|
-
const worksheet = this._sheets.get(def.name);
|
|
1000
|
-
if (worksheet) {
|
|
1001
|
-
for (let i = 0; i < worksheet.tables.length; i++) {
|
|
1002
|
-
types.push(
|
|
1003
|
-
createElement(
|
|
1004
|
-
'Override',
|
|
1005
|
-
{
|
|
1006
|
-
PartName: `/xl/tables/table${tableIndex}.xml`,
|
|
1007
|
-
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml',
|
|
1008
|
-
},
|
|
1009
|
-
[],
|
|
1010
|
-
),
|
|
1011
|
-
);
|
|
1012
|
-
tableIndex++;
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
const existingTypesXml = readZipText(this._files, '[Content_Types].xml');
|
|
1018
|
-
const existingKeys = new Set(
|
|
1019
|
-
types
|
|
1020
|
-
.map((t) => {
|
|
1021
|
-
if ('Default' in t) {
|
|
1022
|
-
const a = t[':@'] as Record<string, string> | undefined;
|
|
1023
|
-
return `Default:${a?.['@_Extension'] || ''}`;
|
|
1024
|
-
}
|
|
1025
|
-
if ('Override' in t) {
|
|
1026
|
-
const a = t[':@'] as Record<string, string> | undefined;
|
|
1027
|
-
return `Override:${a?.['@_PartName'] || ''}`;
|
|
1028
|
-
}
|
|
1029
|
-
return '';
|
|
1030
|
-
})
|
|
1031
|
-
.filter(Boolean),
|
|
1032
|
-
);
|
|
1033
|
-
if (existingTypesXml) {
|
|
1034
|
-
const parsed = parseXml(existingTypesXml);
|
|
1035
|
-
const typesElement = findElement(parsed, 'Types');
|
|
1036
|
-
if (typesElement) {
|
|
1037
|
-
const existingNodes = getChildren(typesElement, 'Types');
|
|
1038
|
-
for (const node of existingNodes) {
|
|
1039
|
-
if ('Default' in node || 'Override' in node) {
|
|
1040
|
-
const type = 'Default' in node ? 'Default' : 'Override';
|
|
1041
|
-
const attrs = node[':@'] as Record<string, string> | undefined;
|
|
1042
|
-
const key =
|
|
1043
|
-
type === 'Default'
|
|
1044
|
-
? `Default:${attrs?.['@_Extension'] || ''}`
|
|
1045
|
-
: `Override:${attrs?.['@_PartName'] || ''}`;
|
|
1046
|
-
if (!existingKeys.has(key)) {
|
|
1047
|
-
types.push(node);
|
|
1048
|
-
existingKeys.add(key);
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
const typesNode = createElement(
|
|
1056
|
-
'Types',
|
|
1057
|
-
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/content-types' },
|
|
1058
|
-
types,
|
|
1059
|
-
);
|
|
1060
|
-
|
|
1061
|
-
const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([typesNode])}`;
|
|
1062
|
-
writeZipText(this._files, '[Content_Types].xml', xml);
|
|
1063
|
-
|
|
1064
|
-
// Also ensure _rels/.rels exists
|
|
1065
|
-
const rootRelsXml = readZipText(this._files, '_rels/.rels');
|
|
1066
|
-
if (!rootRelsXml) {
|
|
1067
|
-
const rootRels = createElement(
|
|
1068
|
-
'Relationships',
|
|
1069
|
-
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1070
|
-
[
|
|
1071
|
-
createElement(
|
|
1072
|
-
'Relationship',
|
|
1073
|
-
{
|
|
1074
|
-
Id: 'rId1',
|
|
1075
|
-
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument',
|
|
1076
|
-
Target: 'xl/workbook.xml',
|
|
1077
|
-
},
|
|
1078
|
-
[],
|
|
1079
|
-
),
|
|
1080
|
-
],
|
|
1081
|
-
);
|
|
1082
|
-
writeZipText(
|
|
1083
|
-
this._files,
|
|
1084
|
-
'_rels/.rels',
|
|
1085
|
-
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([rootRels])}`,
|
|
1086
|
-
);
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* Generate all pivot table related files
|
|
1092
|
-
*/
|
|
1093
|
-
private _updatePivotTableFiles(): void {
|
|
1094
|
-
// Track which sheets have pivot tables for their .rels files
|
|
1095
|
-
const sheetPivotTables: Map<string, PivotTable[]> = new Map();
|
|
1096
|
-
|
|
1097
|
-
for (const pivotTable of this._pivotTables) {
|
|
1098
|
-
const sheetName = pivotTable.targetSheet;
|
|
1099
|
-
if (!sheetPivotTables.has(sheetName)) {
|
|
1100
|
-
sheetPivotTables.set(sheetName, []);
|
|
1101
|
-
}
|
|
1102
|
-
sheetPivotTables.get(sheetName)!.push(pivotTable);
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
// Generate pivot cache files
|
|
1106
|
-
for (let i = 0; i < this._pivotCaches.length; i++) {
|
|
1107
|
-
const cache = this._pivotCaches[i];
|
|
1108
|
-
|
|
1109
|
-
// Pivot cache definition
|
|
1110
|
-
const definitionPath = `xl/pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`;
|
|
1111
|
-
writeZipText(this._files, definitionPath, cache.toDefinitionXml('rId1'));
|
|
1112
|
-
|
|
1113
|
-
// Pivot cache records
|
|
1114
|
-
const recordsPath = `xl/pivotCache/pivotCacheRecords${cache.fileIndex}.xml`;
|
|
1115
|
-
writeZipText(this._files, recordsPath, cache.toRecordsXml());
|
|
1116
|
-
|
|
1117
|
-
// Pivot cache definition relationships (link to records)
|
|
1118
|
-
const cacheRelsPath = `xl/pivotCache/_rels/pivotCacheDefinition${cache.fileIndex}.xml.rels`;
|
|
1119
|
-
const cacheRels = createElement(
|
|
1120
|
-
'Relationships',
|
|
1121
|
-
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1122
|
-
[
|
|
1123
|
-
createElement(
|
|
1124
|
-
'Relationship',
|
|
1125
|
-
{
|
|
1126
|
-
Id: 'rId1',
|
|
1127
|
-
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords',
|
|
1128
|
-
Target: `pivotCacheRecords${cache.fileIndex}.xml`,
|
|
1129
|
-
},
|
|
1130
|
-
[],
|
|
1131
|
-
),
|
|
1132
|
-
],
|
|
1133
|
-
);
|
|
1134
|
-
writeZipText(
|
|
1135
|
-
this._files,
|
|
1136
|
-
cacheRelsPath,
|
|
1137
|
-
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([cacheRels])}`,
|
|
1138
|
-
);
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// Generate pivot table files
|
|
1142
|
-
for (let i = 0; i < this._pivotTables.length; i++) {
|
|
1143
|
-
const pivotTable = this._pivotTables[i];
|
|
1144
|
-
const ptIdx = pivotTable.index;
|
|
1145
|
-
|
|
1146
|
-
// Pivot table definition
|
|
1147
|
-
const ptPath = `xl/pivotTables/pivotTable${ptIdx}.xml`;
|
|
1148
|
-
writeZipText(this._files, ptPath, pivotTable.toXml());
|
|
1149
|
-
|
|
1150
|
-
// Pivot table relationships (link to cache definition)
|
|
1151
|
-
const cacheIdx = pivotTable.cacheFileIndex;
|
|
1152
|
-
const ptRelsPath = `xl/pivotTables/_rels/pivotTable${ptIdx}.xml.rels`;
|
|
1153
|
-
const ptRels = createElement(
|
|
1154
|
-
'Relationships',
|
|
1155
|
-
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1156
|
-
[
|
|
1157
|
-
createElement(
|
|
1158
|
-
'Relationship',
|
|
1159
|
-
{
|
|
1160
|
-
Id: 'rId1',
|
|
1161
|
-
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
|
|
1162
|
-
Target: `../pivotCache/pivotCacheDefinition${cacheIdx}.xml`,
|
|
1163
|
-
},
|
|
1164
|
-
[],
|
|
1165
|
-
),
|
|
1166
|
-
],
|
|
1167
|
-
);
|
|
1168
|
-
writeZipText(
|
|
1169
|
-
this._files,
|
|
1170
|
-
ptRelsPath,
|
|
1171
|
-
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([ptRels])}`,
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
// Generate worksheet relationships for pivot tables
|
|
1176
|
-
for (const [sheetName, pivotTables] of sheetPivotTables) {
|
|
1177
|
-
const def = this._sheetDefs.find((s) => s.name === sheetName);
|
|
1178
|
-
if (!def) continue;
|
|
1179
|
-
|
|
1180
|
-
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
1181
|
-
if (!rel) continue;
|
|
1182
|
-
|
|
1183
|
-
// Extract sheet file name from target path
|
|
1184
|
-
const sheetFileName = rel.target.split('/').pop();
|
|
1185
|
-
const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
|
|
1186
|
-
|
|
1187
|
-
const existingRelsXml = readZipText(this._files, sheetRelsPath);
|
|
1188
|
-
let relNodes: XmlNode[] = [];
|
|
1189
|
-
let nextRelId = 1;
|
|
1190
|
-
const reservedRelIds = new Set<string>();
|
|
1191
|
-
|
|
1192
|
-
if (existingRelsXml) {
|
|
1193
|
-
const parsed = parseXml(existingRelsXml);
|
|
1194
|
-
const relsElement = findElement(parsed, 'Relationships');
|
|
1195
|
-
if (relsElement) {
|
|
1196
|
-
const existingRelNodes = getChildren(relsElement, 'Relationships');
|
|
1197
|
-
for (const relNode of existingRelNodes) {
|
|
1198
|
-
if ('Relationship' in relNode) {
|
|
1199
|
-
relNodes.push(relNode);
|
|
1200
|
-
const id = getAttr(relNode, 'Id');
|
|
1201
|
-
if (id) {
|
|
1202
|
-
reservedRelIds.add(id);
|
|
1203
|
-
const idNum = parseInt(id.replace('rId', ''), 10);
|
|
1204
|
-
if (idNum >= nextRelId) {
|
|
1205
|
-
nextRelId = idNum + 1;
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
const allocateRelId = (): string => {
|
|
1214
|
-
while (reservedRelIds.has(`rId${nextRelId}`)) {
|
|
1215
|
-
nextRelId++;
|
|
1216
|
-
}
|
|
1217
|
-
const id = `rId${nextRelId}`;
|
|
1218
|
-
nextRelId++;
|
|
1219
|
-
reservedRelIds.add(id);
|
|
1220
|
-
return id;
|
|
1221
|
-
};
|
|
1222
|
-
|
|
1223
|
-
for (const pt of pivotTables) {
|
|
1224
|
-
const target = `../pivotTables/pivotTable${pt.index}.xml`;
|
|
1225
|
-
const existing = relNodes.some(
|
|
1226
|
-
(node) =>
|
|
1227
|
-
getAttr(node, 'Type') ===
|
|
1228
|
-
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable' &&
|
|
1229
|
-
getAttr(node, 'Target') === target,
|
|
1230
|
-
);
|
|
1231
|
-
if (existing) continue;
|
|
1232
|
-
relNodes.push(
|
|
1233
|
-
createElement(
|
|
1234
|
-
'Relationship',
|
|
1235
|
-
{
|
|
1236
|
-
Id: allocateRelId(),
|
|
1237
|
-
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable',
|
|
1238
|
-
Target: target,
|
|
1239
|
-
},
|
|
1240
|
-
[],
|
|
1241
|
-
),
|
|
1242
|
-
);
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
const sheetRels = createElement(
|
|
1246
|
-
'Relationships',
|
|
1247
|
-
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1248
|
-
relNodes,
|
|
1249
|
-
);
|
|
1250
|
-
writeZipText(
|
|
1251
|
-
this._files,
|
|
1252
|
-
sheetRelsPath,
|
|
1253
|
-
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([sheetRels])}`,
|
|
1254
|
-
);
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
/**
|
|
1259
|
-
* Generate all table related files
|
|
1260
|
-
*/
|
|
1261
|
-
private _updateTableFiles(): void {
|
|
1262
|
-
// Collect all tables with their global indices
|
|
1263
|
-
let globalTableIndex = 1;
|
|
1264
|
-
const sheetTables: Map<string, { table: import('./table').Table; globalIndex: number }[]> = new Map();
|
|
1265
|
-
|
|
1266
|
-
for (const def of this._sheetDefs) {
|
|
1267
|
-
const worksheet = this._sheets.get(def.name);
|
|
1268
|
-
if (!worksheet) continue;
|
|
1269
|
-
|
|
1270
|
-
const tables = worksheet.tables;
|
|
1271
|
-
if (tables.length === 0) continue;
|
|
1272
|
-
|
|
1273
|
-
const tableInfos: { table: import('./table').Table; globalIndex: number }[] = [];
|
|
1274
|
-
for (const table of tables) {
|
|
1275
|
-
tableInfos.push({ table, globalIndex: globalTableIndex });
|
|
1276
|
-
globalTableIndex++;
|
|
1277
|
-
}
|
|
1278
|
-
sheetTables.set(def.name, tableInfos);
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
// Generate table files
|
|
1282
|
-
for (const [, tableInfos] of sheetTables) {
|
|
1283
|
-
for (const { table, globalIndex } of tableInfos) {
|
|
1284
|
-
const tablePath = `xl/tables/table${globalIndex}.xml`;
|
|
1285
|
-
writeZipText(this._files, tablePath, table.toXml());
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// Generate worksheet relationships for tables
|
|
1290
|
-
for (const [sheetName, tableInfos] of sheetTables) {
|
|
1291
|
-
const def = this._sheetDefs.find((s) => s.name === sheetName);
|
|
1292
|
-
if (!def) continue;
|
|
1293
|
-
|
|
1294
|
-
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
1295
|
-
if (!rel) continue;
|
|
1296
|
-
|
|
1297
|
-
// Extract sheet file name from target path
|
|
1298
|
-
const sheetFileName = rel.target.split('/').pop();
|
|
1299
|
-
const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
|
|
1300
|
-
|
|
1301
|
-
// Check if there are already pivot table relationships for this sheet
|
|
1302
|
-
const existingRelsXml = readZipText(this._files, sheetRelsPath);
|
|
1303
|
-
let nextRelId = 1;
|
|
1304
|
-
const relNodes: XmlNode[] = [];
|
|
1305
|
-
const reservedRelIds = new Set<string>();
|
|
1306
|
-
|
|
1307
|
-
if (existingRelsXml) {
|
|
1308
|
-
// Parse existing rels and find max rId
|
|
1309
|
-
const parsed = parseXml(existingRelsXml);
|
|
1310
|
-
const relsElement = findElement(parsed, 'Relationships');
|
|
1311
|
-
if (relsElement) {
|
|
1312
|
-
const existingRelNodes = getChildren(relsElement, 'Relationships');
|
|
1313
|
-
for (const relNode of existingRelNodes) {
|
|
1314
|
-
if ('Relationship' in relNode) {
|
|
1315
|
-
relNodes.push(relNode);
|
|
1316
|
-
const id = getAttr(relNode, 'Id');
|
|
1317
|
-
if (id) {
|
|
1318
|
-
reservedRelIds.add(id);
|
|
1319
|
-
const idNum = parseInt(id.replace('rId', ''), 10);
|
|
1320
|
-
if (idNum >= nextRelId) {
|
|
1321
|
-
nextRelId = idNum + 1;
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
const allocateRelId = (): string => {
|
|
1330
|
-
while (reservedRelIds.has(`rId${nextRelId}`)) {
|
|
1331
|
-
nextRelId++;
|
|
1332
|
-
}
|
|
1333
|
-
const id = `rId${nextRelId}`;
|
|
1334
|
-
nextRelId++;
|
|
1335
|
-
reservedRelIds.add(id);
|
|
1336
|
-
return id;
|
|
1337
|
-
};
|
|
1338
|
-
|
|
1339
|
-
// Add table relationships
|
|
1340
|
-
const tableRelIds: string[] = [];
|
|
1341
|
-
for (const { globalIndex } of tableInfos) {
|
|
1342
|
-
const target = `../tables/table${globalIndex}.xml`;
|
|
1343
|
-
const existing = relNodes.some(
|
|
1344
|
-
(node) =>
|
|
1345
|
-
getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' &&
|
|
1346
|
-
getAttr(node, 'Target') === target,
|
|
1347
|
-
);
|
|
1348
|
-
if (existing) {
|
|
1349
|
-
const existingRel = relNodes.find(
|
|
1350
|
-
(node) =>
|
|
1351
|
-
getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' &&
|
|
1352
|
-
getAttr(node, 'Target') === target,
|
|
1353
|
-
);
|
|
1354
|
-
const existingId = existingRel ? getAttr(existingRel, 'Id') : undefined;
|
|
1355
|
-
tableRelIds.push(existingId ?? allocateRelId());
|
|
1356
|
-
continue;
|
|
1357
|
-
}
|
|
1358
|
-
const id = allocateRelId();
|
|
1359
|
-
tableRelIds.push(id);
|
|
1360
|
-
relNodes.push(
|
|
1361
|
-
createElement(
|
|
1362
|
-
'Relationship',
|
|
1363
|
-
{
|
|
1364
|
-
Id: id,
|
|
1365
|
-
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table',
|
|
1366
|
-
Target: target,
|
|
1367
|
-
},
|
|
1368
|
-
[],
|
|
1369
|
-
),
|
|
1370
|
-
);
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
const worksheet = this._sheets.get(sheetName);
|
|
1374
|
-
if (worksheet) {
|
|
1375
|
-
worksheet.setTableRelIds(tableRelIds);
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
const sheetRels = createElement(
|
|
1379
|
-
'Relationships',
|
|
1380
|
-
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1381
|
-
relNodes,
|
|
1382
|
-
);
|
|
1383
|
-
writeZipText(
|
|
1384
|
-
this._files,
|
|
1385
|
-
sheetRelsPath,
|
|
1386
|
-
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([sheetRels])}`,
|
|
1387
|
-
);
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import type {
|
|
3
|
+
SheetDefinition,
|
|
4
|
+
Relationship,
|
|
5
|
+
PivotTableConfig,
|
|
6
|
+
CellValue,
|
|
7
|
+
SheetFromDataConfig,
|
|
8
|
+
ColumnConfig,
|
|
9
|
+
RichCellValue,
|
|
10
|
+
DateHandling,
|
|
11
|
+
} from './types';
|
|
12
|
+
import { Worksheet } from './worksheet';
|
|
13
|
+
import { SharedStrings } from './shared-strings';
|
|
14
|
+
import { Styles } from './styles';
|
|
15
|
+
import { PivotTable } from './pivot-table';
|
|
16
|
+
import { PivotCache } from './pivot-cache';
|
|
17
|
+
import { readZip, writeZip, readZipText, writeZipText, ZipFiles } from './utils/zip';
|
|
18
|
+
import { parseAddress, parseRange, toAddress } from './utils/address';
|
|
19
|
+
import { parseXml, findElement, getChildren, getAttr, XmlNode, stringifyXml, createElement } from './utils/xml';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Represents an Excel workbook (.xlsx file)
|
|
23
|
+
*/
|
|
24
|
+
export class Workbook {
|
|
25
|
+
private _files: ZipFiles = new Map();
|
|
26
|
+
private _sheets: Map<string, Worksheet> = new Map();
|
|
27
|
+
private _sheetDefs: SheetDefinition[] = [];
|
|
28
|
+
private _relationships: Relationship[] = [];
|
|
29
|
+
private _sharedStrings: SharedStrings;
|
|
30
|
+
private _styles: Styles;
|
|
31
|
+
private _dirty = false;
|
|
32
|
+
|
|
33
|
+
// Pivot table support
|
|
34
|
+
private _pivotTables: PivotTable[] = [];
|
|
35
|
+
private _pivotCaches: PivotCache[] = [];
|
|
36
|
+
private _nextCacheId = 0;
|
|
37
|
+
private _nextCacheFileIndex = 1;
|
|
38
|
+
|
|
39
|
+
// Table support
|
|
40
|
+
private _nextTableId = 1;
|
|
41
|
+
|
|
42
|
+
// Date serialization handling
|
|
43
|
+
private _dateHandling: DateHandling = 'jsDate';
|
|
44
|
+
|
|
45
|
+
private constructor() {
|
|
46
|
+
this._sharedStrings = new SharedStrings();
|
|
47
|
+
this._styles = Styles.createDefault();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Load a workbook from a file path
|
|
52
|
+
*/
|
|
53
|
+
static async fromFile(path: string): Promise<Workbook> {
|
|
54
|
+
const data = await readFile(path);
|
|
55
|
+
return Workbook.fromBuffer(new Uint8Array(data));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load a workbook from a buffer
|
|
60
|
+
*/
|
|
61
|
+
static async fromBuffer(data: Uint8Array): Promise<Workbook> {
|
|
62
|
+
const workbook = new Workbook();
|
|
63
|
+
workbook._files = await readZip(data);
|
|
64
|
+
|
|
65
|
+
// Parse workbook.xml for sheet definitions
|
|
66
|
+
const workbookXml = readZipText(workbook._files, 'xl/workbook.xml');
|
|
67
|
+
if (workbookXml) {
|
|
68
|
+
workbook._parseWorkbook(workbookXml);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Parse relationships
|
|
72
|
+
const relsXml = readZipText(workbook._files, 'xl/_rels/workbook.xml.rels');
|
|
73
|
+
if (relsXml) {
|
|
74
|
+
workbook._parseRelationships(relsXml);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Parse shared strings
|
|
78
|
+
const sharedStringsXml = readZipText(workbook._files, 'xl/sharedStrings.xml');
|
|
79
|
+
if (sharedStringsXml) {
|
|
80
|
+
workbook._sharedStrings = SharedStrings.parse(sharedStringsXml);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Parse styles
|
|
84
|
+
const stylesXml = readZipText(workbook._files, 'xl/styles.xml');
|
|
85
|
+
if (stylesXml) {
|
|
86
|
+
workbook._styles = Styles.parse(stylesXml);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return workbook;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create a new empty workbook
|
|
94
|
+
*/
|
|
95
|
+
static create(): Workbook {
|
|
96
|
+
const workbook = new Workbook();
|
|
97
|
+
workbook._dirty = true;
|
|
98
|
+
|
|
99
|
+
return workbook;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get sheet names
|
|
104
|
+
*/
|
|
105
|
+
get sheetNames(): string[] {
|
|
106
|
+
return this._sheetDefs.map((s) => s.name);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get number of sheets
|
|
111
|
+
*/
|
|
112
|
+
get sheetCount(): number {
|
|
113
|
+
return this._sheetDefs.length;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get shared strings table
|
|
118
|
+
*/
|
|
119
|
+
get sharedStrings(): SharedStrings {
|
|
120
|
+
return this._sharedStrings;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get styles
|
|
125
|
+
*/
|
|
126
|
+
get styles(): Styles {
|
|
127
|
+
return this._styles;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get the workbook date handling strategy.
|
|
132
|
+
*/
|
|
133
|
+
get dateHandling(): DateHandling {
|
|
134
|
+
return this._dateHandling;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Set the workbook date handling strategy.
|
|
139
|
+
*/
|
|
140
|
+
set dateHandling(value: DateHandling) {
|
|
141
|
+
this._dateHandling = value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get the next unique table ID for this workbook.
|
|
146
|
+
* Table IDs must be unique across all worksheets.
|
|
147
|
+
* @internal
|
|
148
|
+
*/
|
|
149
|
+
getNextTableId(): number {
|
|
150
|
+
return this._nextTableId++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get a worksheet by name or index
|
|
155
|
+
*/
|
|
156
|
+
sheet(nameOrIndex: string | number): Worksheet {
|
|
157
|
+
let def: SheetDefinition | undefined;
|
|
158
|
+
|
|
159
|
+
if (typeof nameOrIndex === 'number') {
|
|
160
|
+
def = this._sheetDefs[nameOrIndex];
|
|
161
|
+
} else {
|
|
162
|
+
def = this._sheetDefs.find((s) => s.name === nameOrIndex);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!def) {
|
|
166
|
+
throw new Error(`Sheet not found: ${nameOrIndex}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Return cached worksheet if available
|
|
170
|
+
if (this._sheets.has(def.name)) {
|
|
171
|
+
return this._sheets.get(def.name)!;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Load worksheet
|
|
175
|
+
const worksheet = new Worksheet(this, def.name);
|
|
176
|
+
|
|
177
|
+
// Find the relationship to get the file path
|
|
178
|
+
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
179
|
+
if (rel) {
|
|
180
|
+
const sheetPath = `xl/${rel.target}`;
|
|
181
|
+
const sheetXml = readZipText(this._files, sheetPath);
|
|
182
|
+
if (sheetXml) {
|
|
183
|
+
worksheet.parse(sheetXml);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this._sheets.set(def.name, worksheet);
|
|
188
|
+
return worksheet;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Add a new worksheet
|
|
193
|
+
*/
|
|
194
|
+
addSheet(name: string, index?: number): Worksheet {
|
|
195
|
+
// Check for duplicate name
|
|
196
|
+
if (this._sheetDefs.some((s) => s.name === name)) {
|
|
197
|
+
throw new Error(`Sheet already exists: ${name}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this._dirty = true;
|
|
201
|
+
|
|
202
|
+
// Generate new sheet ID and relationship ID
|
|
203
|
+
const sheetId = Math.max(0, ...this._sheetDefs.map((s) => s.sheetId)) + 1;
|
|
204
|
+
const rId = `rId${Math.max(0, ...this._relationships.map((r) => parseInt(r.id.replace('rId', ''), 10) || 0)) + 1}`;
|
|
205
|
+
|
|
206
|
+
const def: SheetDefinition = { name, sheetId, rId };
|
|
207
|
+
|
|
208
|
+
// Add relationship
|
|
209
|
+
this._relationships.push({
|
|
210
|
+
id: rId,
|
|
211
|
+
type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet',
|
|
212
|
+
target: `worksheets/sheet${sheetId}.xml`,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Insert at index or append
|
|
216
|
+
if (index !== undefined && index >= 0 && index < this._sheetDefs.length) {
|
|
217
|
+
this._sheetDefs.splice(index, 0, def);
|
|
218
|
+
} else {
|
|
219
|
+
this._sheetDefs.push(def);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Create worksheet
|
|
223
|
+
const worksheet = new Worksheet(this, name);
|
|
224
|
+
this._sheets.set(name, worksheet);
|
|
225
|
+
|
|
226
|
+
return worksheet;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Delete a worksheet by name or index
|
|
231
|
+
*/
|
|
232
|
+
deleteSheet(nameOrIndex: string | number): void {
|
|
233
|
+
let index: number;
|
|
234
|
+
|
|
235
|
+
if (typeof nameOrIndex === 'number') {
|
|
236
|
+
index = nameOrIndex;
|
|
237
|
+
} else {
|
|
238
|
+
index = this._sheetDefs.findIndex((s) => s.name === nameOrIndex);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (index < 0 || index >= this._sheetDefs.length) {
|
|
242
|
+
throw new Error(`Sheet not found: ${nameOrIndex}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (this._sheetDefs.length === 1) {
|
|
246
|
+
throw new Error('Cannot delete the last sheet');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this._dirty = true;
|
|
250
|
+
|
|
251
|
+
const def = this._sheetDefs[index];
|
|
252
|
+
this._sheetDefs.splice(index, 1);
|
|
253
|
+
this._sheets.delete(def.name);
|
|
254
|
+
|
|
255
|
+
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
256
|
+
if (rel) {
|
|
257
|
+
const sheetPath = `xl/${rel.target}`;
|
|
258
|
+
this._files.delete(sheetPath);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Remove relationship
|
|
262
|
+
const relIndex = this._relationships.findIndex((r) => r.id === def.rId);
|
|
263
|
+
if (relIndex >= 0) {
|
|
264
|
+
this._relationships.splice(relIndex, 1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Rename a worksheet
|
|
270
|
+
*/
|
|
271
|
+
renameSheet(oldName: string, newName: string): void {
|
|
272
|
+
const def = this._sheetDefs.find((s) => s.name === oldName);
|
|
273
|
+
if (!def) {
|
|
274
|
+
throw new Error(`Sheet not found: ${oldName}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (this._sheetDefs.some((s) => s.name === newName)) {
|
|
278
|
+
throw new Error(`Sheet already exists: ${newName}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this._dirty = true;
|
|
282
|
+
|
|
283
|
+
// Update cached worksheet
|
|
284
|
+
const worksheet = this._sheets.get(oldName);
|
|
285
|
+
if (worksheet) {
|
|
286
|
+
worksheet.name = newName;
|
|
287
|
+
this._sheets.delete(oldName);
|
|
288
|
+
this._sheets.set(newName, worksheet);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def.name = newName;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Copy a worksheet
|
|
296
|
+
*/
|
|
297
|
+
copySheet(sourceName: string, newName: string): Worksheet {
|
|
298
|
+
const source = this.sheet(sourceName);
|
|
299
|
+
const copy = this.addSheet(newName);
|
|
300
|
+
|
|
301
|
+
// Copy all cells
|
|
302
|
+
for (const [address, cell] of source.cells) {
|
|
303
|
+
const newCell = copy.cell(address);
|
|
304
|
+
newCell.value = cell.value;
|
|
305
|
+
if (cell.formula) {
|
|
306
|
+
newCell.formula = cell.formula;
|
|
307
|
+
}
|
|
308
|
+
if (cell.styleIndex !== undefined) {
|
|
309
|
+
newCell.styleIndex = cell.styleIndex;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Copy column widths
|
|
314
|
+
for (const [col, width] of source.getColumnWidths()) {
|
|
315
|
+
copy.setColumnWidth(col, width);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Copy row heights
|
|
319
|
+
for (const [row, height] of source.getRowHeights()) {
|
|
320
|
+
copy.setRowHeight(row, height);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Copy frozen panes
|
|
324
|
+
const frozen = source.getFrozenPane();
|
|
325
|
+
if (frozen) {
|
|
326
|
+
copy.freezePane(frozen.row, frozen.col);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Copy merged cells
|
|
330
|
+
for (const mergedRange of source.mergedCells) {
|
|
331
|
+
copy.mergeCells(mergedRange);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Copy tables
|
|
335
|
+
for (const table of source.tables) {
|
|
336
|
+
const tableName = this._createUniqueTableName(table.name, newName);
|
|
337
|
+
const newTable = copy.createTable({
|
|
338
|
+
name: tableName,
|
|
339
|
+
range: table.baseRange,
|
|
340
|
+
totalRow: table.hasTotalRow,
|
|
341
|
+
headerRow: table.hasHeaderRow,
|
|
342
|
+
style: table.style,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (!table.hasAutoFilter) {
|
|
346
|
+
newTable.setAutoFilter(false);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (table.hasTotalRow) {
|
|
350
|
+
for (const columnName of table.columns) {
|
|
351
|
+
const fn = table.getTotalFunction(columnName);
|
|
352
|
+
if (fn) {
|
|
353
|
+
newTable.setTotalFunction(columnName, fn);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return copy;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private _createUniqueTableName(base: string, sheetName: string): string {
|
|
363
|
+
const normalizedSheet = sheetName.replace(/[^A-Za-z0-9_.]/g, '_');
|
|
364
|
+
const sanitizedBase = this._sanitizeTableName(`${base}_${normalizedSheet || 'Sheet'}`);
|
|
365
|
+
let candidate = sanitizedBase;
|
|
366
|
+
let counter = 1;
|
|
367
|
+
|
|
368
|
+
while (this._hasTableName(candidate)) {
|
|
369
|
+
candidate = `${sanitizedBase}_${counter++}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return candidate;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private _sanitizeTableName(name: string): string {
|
|
376
|
+
let result = name.replace(/[^A-Za-z0-9_.]/g, '_');
|
|
377
|
+
if (!/^[A-Za-z_]/.test(result)) {
|
|
378
|
+
result = `_${result}`;
|
|
379
|
+
}
|
|
380
|
+
if (result.length === 0) {
|
|
381
|
+
result = 'Table';
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private _hasTableName(name: string): boolean {
|
|
387
|
+
for (const sheetName of this.sheetNames) {
|
|
388
|
+
const ws = this.sheet(sheetName);
|
|
389
|
+
for (const table of ws.tables) {
|
|
390
|
+
if (table.name === name) return true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Create a new worksheet from an array of objects.
|
|
398
|
+
*
|
|
399
|
+
* The first row contains headers (object keys or custom column headers),
|
|
400
|
+
* and subsequent rows contain the object values.
|
|
401
|
+
*
|
|
402
|
+
* @param config - Configuration for the sheet creation
|
|
403
|
+
* @returns The created Worksheet
|
|
404
|
+
*
|
|
405
|
+
* @example
|
|
406
|
+
* ```typescript
|
|
407
|
+
* const data = [
|
|
408
|
+
* { name: 'Alice', age: 30, city: 'Paris' },
|
|
409
|
+
* { name: 'Bob', age: 25, city: 'London' },
|
|
410
|
+
* { name: 'Charlie', age: 35, city: 'Berlin' },
|
|
411
|
+
* ];
|
|
412
|
+
*
|
|
413
|
+
* // Simple usage - all object keys become columns
|
|
414
|
+
* const sheet = wb.addSheetFromData({
|
|
415
|
+
* name: 'People',
|
|
416
|
+
* data: data,
|
|
417
|
+
* });
|
|
418
|
+
*
|
|
419
|
+
* // With custom column configuration
|
|
420
|
+
* const sheet2 = wb.addSheetFromData({
|
|
421
|
+
* name: 'People Custom',
|
|
422
|
+
* data: data,
|
|
423
|
+
* columns: [
|
|
424
|
+
* { key: 'name', header: 'Full Name' },
|
|
425
|
+
* { key: 'age', header: 'Age (years)' },
|
|
426
|
+
* ],
|
|
427
|
+
* });
|
|
428
|
+
*
|
|
429
|
+
* // With rich cell values (value, formula, style)
|
|
430
|
+
* const dataWithFormulas = [
|
|
431
|
+
* { product: 'Widget', price: 10, qty: 5, total: { formula: 'B2*C2', style: { bold: true } } },
|
|
432
|
+
* { product: 'Gadget', price: 20, qty: 3, total: { formula: 'B3*C3', style: { bold: true } } },
|
|
433
|
+
* ];
|
|
434
|
+
* const sheet3 = wb.addSheetFromData({
|
|
435
|
+
* name: 'With Formulas',
|
|
436
|
+
* data: dataWithFormulas,
|
|
437
|
+
* });
|
|
438
|
+
* ```
|
|
439
|
+
*/
|
|
440
|
+
addSheetFromData<T extends object>(config: SheetFromDataConfig<T>): Worksheet {
|
|
441
|
+
const { name, data, columns, headerStyle = true, startCell = 'A1' } = config;
|
|
442
|
+
|
|
443
|
+
if (!data?.length) return this.addSheet(name);
|
|
444
|
+
|
|
445
|
+
// Create the new sheet
|
|
446
|
+
const sheet = this.addSheet(name);
|
|
447
|
+
|
|
448
|
+
// Parse start cell
|
|
449
|
+
const startAddr = parseAddress(startCell);
|
|
450
|
+
let startRow = startAddr.row;
|
|
451
|
+
const startCol = startAddr.col;
|
|
452
|
+
|
|
453
|
+
// Determine columns to use
|
|
454
|
+
const columnConfigs: ColumnConfig<T>[] = columns ?? this._inferColumns(data[0]);
|
|
455
|
+
|
|
456
|
+
// Write header row
|
|
457
|
+
for (let colIdx = 0; colIdx < columnConfigs.length; colIdx++) {
|
|
458
|
+
const colConfig = columnConfigs[colIdx];
|
|
459
|
+
const headerText = colConfig.header ?? String(colConfig.key);
|
|
460
|
+
const cell = sheet.cell(startRow, startCol + colIdx);
|
|
461
|
+
cell.value = headerText;
|
|
462
|
+
|
|
463
|
+
// Apply header style if enabled
|
|
464
|
+
if (headerStyle) {
|
|
465
|
+
cell.style = { bold: true };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Move to data rows
|
|
470
|
+
startRow++;
|
|
471
|
+
|
|
472
|
+
// Write data rows
|
|
473
|
+
for (let rowIdx = 0; rowIdx < data.length; rowIdx++) {
|
|
474
|
+
const rowData = data[rowIdx];
|
|
475
|
+
|
|
476
|
+
for (let colIdx = 0; colIdx < columnConfigs.length; colIdx++) {
|
|
477
|
+
const colConfig = columnConfigs[colIdx];
|
|
478
|
+
const value = rowData[colConfig.key];
|
|
479
|
+
const cell = sheet.cell(startRow + rowIdx, startCol + colIdx);
|
|
480
|
+
|
|
481
|
+
// Check if value is a rich cell definition
|
|
482
|
+
if (this._isRichCellValue(value)) {
|
|
483
|
+
const richValue = value as RichCellValue;
|
|
484
|
+
if (richValue.value !== undefined) cell.value = richValue.value;
|
|
485
|
+
if (richValue.formula !== undefined) cell.formula = richValue.formula;
|
|
486
|
+
if (richValue.style !== undefined) cell.style = richValue.style;
|
|
487
|
+
} else {
|
|
488
|
+
// Convert value to CellValue
|
|
489
|
+
cell.value = this._toCellValue(value);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Apply column style if defined (merged with cell style)
|
|
493
|
+
if (colConfig.style) {
|
|
494
|
+
cell.style = { ...cell.style, ...colConfig.style };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return sheet;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Check if a value is a rich cell value object with value, formula, or style fields
|
|
504
|
+
*/
|
|
505
|
+
private _isRichCellValue(value: unknown): value is RichCellValue {
|
|
506
|
+
if (value === null || value === undefined) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
if (typeof value !== 'object' || value instanceof Date) {
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
// Check if it has at least one of the rich cell properties
|
|
513
|
+
const obj = value as Record<string, unknown>;
|
|
514
|
+
return 'value' in obj || 'formula' in obj || 'style' in obj;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Infer column configuration from the first data object
|
|
519
|
+
*/
|
|
520
|
+
private _inferColumns<T extends object>(sample: T): ColumnConfig<T>[] {
|
|
521
|
+
return (Object.keys(sample) as (keyof T)[]).map((key) => ({
|
|
522
|
+
key,
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Convert an unknown value to a CellValue
|
|
528
|
+
*/
|
|
529
|
+
private _toCellValue(value: unknown): CellValue {
|
|
530
|
+
if (value === null || value === undefined) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') {
|
|
534
|
+
return value;
|
|
535
|
+
}
|
|
536
|
+
if (value instanceof Date) {
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
if (typeof value === 'object' && 'error' in value) {
|
|
540
|
+
return value as CellValue;
|
|
541
|
+
}
|
|
542
|
+
// Convert other types to string
|
|
543
|
+
return String(value);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Create a pivot table from source data.
|
|
548
|
+
*
|
|
549
|
+
* @param config - Pivot table configuration
|
|
550
|
+
* @returns PivotTable instance for fluent configuration
|
|
551
|
+
*
|
|
552
|
+
* @example
|
|
553
|
+
* ```typescript
|
|
554
|
+
* const pivot = wb.createPivotTable({
|
|
555
|
+
* name: 'SalesPivot',
|
|
556
|
+
* source: 'DataSheet!A1:D100',
|
|
557
|
+
* target: 'PivotSheet!A3',
|
|
558
|
+
* });
|
|
559
|
+
*
|
|
560
|
+
* pivot
|
|
561
|
+
* .addRowField('Region')
|
|
562
|
+
* .addColumnField('Product')
|
|
563
|
+
* .addValueField('Sales', 'sum', 'Total Sales');
|
|
564
|
+
* ```
|
|
565
|
+
*/
|
|
566
|
+
createPivotTable(config: PivotTableConfig): PivotTable {
|
|
567
|
+
this._dirty = true;
|
|
568
|
+
|
|
569
|
+
// Parse source reference (Sheet!Range)
|
|
570
|
+
const { sheetName: sourceSheet, range: sourceRange } = this._parseSheetRef(config.source);
|
|
571
|
+
|
|
572
|
+
// Parse target reference
|
|
573
|
+
const { sheetName: targetSheet, range: targetCell } = this._parseSheetRef(config.target);
|
|
574
|
+
|
|
575
|
+
// Ensure target sheet exists
|
|
576
|
+
if (!this._sheetDefs.some((s) => s.name === targetSheet)) {
|
|
577
|
+
this.addSheet(targetSheet);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Parse target cell address
|
|
581
|
+
const targetAddr = parseAddress(targetCell);
|
|
582
|
+
|
|
583
|
+
// Get source worksheet and extract data
|
|
584
|
+
const sourceWs = this.sheet(sourceSheet);
|
|
585
|
+
const { headers, data } = this._extractSourceData(sourceWs, sourceRange);
|
|
586
|
+
|
|
587
|
+
// Create pivot cache
|
|
588
|
+
const cacheId = this._nextCacheId++;
|
|
589
|
+
const cacheFileIndex = this._nextCacheFileIndex++;
|
|
590
|
+
const cache = new PivotCache(cacheId, sourceSheet, sourceRange, cacheFileIndex);
|
|
591
|
+
cache.buildFromData(headers, data);
|
|
592
|
+
// refreshOnLoad defaults to true; only disable if explicitly set to false
|
|
593
|
+
if (config.refreshOnLoad === false) {
|
|
594
|
+
cache.refreshOnLoad = false;
|
|
595
|
+
}
|
|
596
|
+
this._pivotCaches.push(cache);
|
|
597
|
+
|
|
598
|
+
// Create pivot table
|
|
599
|
+
const pivotTableIndex = this._pivotTables.length + 1;
|
|
600
|
+
const pivotTable = new PivotTable(
|
|
601
|
+
config.name,
|
|
602
|
+
cache,
|
|
603
|
+
targetSheet,
|
|
604
|
+
targetCell,
|
|
605
|
+
targetAddr.row + 1, // Convert to 1-based
|
|
606
|
+
targetAddr.col,
|
|
607
|
+
pivotTableIndex,
|
|
608
|
+
cacheFileIndex,
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
// Set styles reference for number format resolution
|
|
612
|
+
pivotTable.setStyles(this._styles);
|
|
613
|
+
|
|
614
|
+
this._pivotTables.push(pivotTable);
|
|
615
|
+
|
|
616
|
+
return pivotTable;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Parse a sheet reference like "Sheet1!A1:D100" into sheet name and range
|
|
621
|
+
*/
|
|
622
|
+
private _parseSheetRef(ref: string): { sheetName: string; range: string } {
|
|
623
|
+
const match = ref.match(/^(.+?)!(.+)$/);
|
|
624
|
+
if (!match) {
|
|
625
|
+
throw new Error(`Invalid reference format: ${ref}. Expected "SheetName!Range"`);
|
|
626
|
+
}
|
|
627
|
+
return { sheetName: match[1], range: match[2] };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Extract headers and data from a source range
|
|
632
|
+
*/
|
|
633
|
+
private _extractSourceData(sheet: Worksheet, rangeStr: string): { headers: string[]; data: CellValue[][] } {
|
|
634
|
+
const range = parseRange(rangeStr);
|
|
635
|
+
const headers: string[] = [];
|
|
636
|
+
const data: CellValue[][] = [];
|
|
637
|
+
|
|
638
|
+
// First row is headers
|
|
639
|
+
for (let col = range.start.col; col <= range.end.col; col++) {
|
|
640
|
+
const cell = sheet.cell(toAddress(range.start.row, col));
|
|
641
|
+
headers.push(String(cell.value ?? `Column${col + 1}`));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Remaining rows are data
|
|
645
|
+
for (let row = range.start.row + 1; row <= range.end.row; row++) {
|
|
646
|
+
const rowData: CellValue[] = [];
|
|
647
|
+
for (let col = range.start.col; col <= range.end.col; col++) {
|
|
648
|
+
const cell = sheet.cell(toAddress(row, col));
|
|
649
|
+
rowData.push(cell.value);
|
|
650
|
+
}
|
|
651
|
+
data.push(rowData);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return { headers, data };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Save the workbook to a file
|
|
659
|
+
*/
|
|
660
|
+
async toFile(path: string): Promise<void> {
|
|
661
|
+
const buffer = await this.toBuffer();
|
|
662
|
+
await writeFile(path, buffer);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Save the workbook to a buffer
|
|
667
|
+
*/
|
|
668
|
+
async toBuffer(): Promise<Uint8Array> {
|
|
669
|
+
// Update files map with modified content
|
|
670
|
+
this._updateFiles();
|
|
671
|
+
|
|
672
|
+
// Write ZIP
|
|
673
|
+
return writeZip(this._files);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private _parseWorkbook(xml: string): void {
|
|
677
|
+
const parsed = parseXml(xml);
|
|
678
|
+
const workbook = findElement(parsed, 'workbook');
|
|
679
|
+
if (!workbook) return;
|
|
680
|
+
|
|
681
|
+
const children = getChildren(workbook, 'workbook');
|
|
682
|
+
const sheets = findElement(children, 'sheets');
|
|
683
|
+
if (!sheets) return;
|
|
684
|
+
|
|
685
|
+
for (const child of getChildren(sheets, 'sheets')) {
|
|
686
|
+
if ('sheet' in child) {
|
|
687
|
+
const name = getAttr(child, 'name');
|
|
688
|
+
const sheetId = getAttr(child, 'sheetId');
|
|
689
|
+
const rId = getAttr(child, 'r:id');
|
|
690
|
+
|
|
691
|
+
if (name && sheetId && rId) {
|
|
692
|
+
this._sheetDefs.push({
|
|
693
|
+
name,
|
|
694
|
+
sheetId: parseInt(sheetId, 10),
|
|
695
|
+
rId,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private _parseRelationships(xml: string): void {
|
|
703
|
+
const parsed = parseXml(xml);
|
|
704
|
+
const rels = findElement(parsed, 'Relationships');
|
|
705
|
+
if (!rels) return;
|
|
706
|
+
|
|
707
|
+
for (const child of getChildren(rels, 'Relationships')) {
|
|
708
|
+
if ('Relationship' in child) {
|
|
709
|
+
const id = getAttr(child, 'Id');
|
|
710
|
+
const type = getAttr(child, 'Type');
|
|
711
|
+
const target = getAttr(child, 'Target');
|
|
712
|
+
|
|
713
|
+
if (id && type && target) {
|
|
714
|
+
this._relationships.push({ id, type, target });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private _updateFiles(): void {
|
|
721
|
+
const relationshipInfo = this._buildRelationshipInfo();
|
|
722
|
+
|
|
723
|
+
// Update workbook.xml
|
|
724
|
+
this._updateWorkbookXml(relationshipInfo.pivotCacheRelIds);
|
|
725
|
+
|
|
726
|
+
// Update relationships
|
|
727
|
+
this._updateRelationshipsXml(relationshipInfo.relNodes);
|
|
728
|
+
|
|
729
|
+
// Update content types
|
|
730
|
+
this._updateContentTypes();
|
|
731
|
+
|
|
732
|
+
// Update shared strings if modified
|
|
733
|
+
if (this._sharedStrings.dirty || this._sharedStrings.count > 0) {
|
|
734
|
+
writeZipText(this._files, 'xl/sharedStrings.xml', this._sharedStrings.toXml());
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Update styles if modified or if file doesn't exist yet
|
|
738
|
+
if (this._styles.dirty || this._dirty || !this._files.has('xl/styles.xml')) {
|
|
739
|
+
writeZipText(this._files, 'xl/styles.xml', this._styles.toXml());
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Update worksheets (needed for pivot table targets)
|
|
743
|
+
for (const [name, worksheet] of this._sheets) {
|
|
744
|
+
if (worksheet.dirty || this._dirty || worksheet.tables.length > 0) {
|
|
745
|
+
const def = this._sheetDefs.find((s) => s.name === name);
|
|
746
|
+
if (def) {
|
|
747
|
+
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
748
|
+
if (rel) {
|
|
749
|
+
const sheetPath = `xl/${rel.target}`;
|
|
750
|
+
writeZipText(this._files, sheetPath, worksheet.toXml());
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Update pivot tables
|
|
757
|
+
if (this._pivotTables.length > 0) {
|
|
758
|
+
this._updatePivotTableFiles();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Update tables (sets table rel IDs for tableParts)
|
|
762
|
+
this._updateTableFiles();
|
|
763
|
+
|
|
764
|
+
// Update worksheets to align tableParts with relationship IDs
|
|
765
|
+
for (const [name, worksheet] of this._sheets) {
|
|
766
|
+
if (worksheet.dirty || this._dirty || worksheet.tables.length > 0) {
|
|
767
|
+
const def = this._sheetDefs.find((s) => s.name === name);
|
|
768
|
+
if (def) {
|
|
769
|
+
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
770
|
+
if (rel) {
|
|
771
|
+
const sheetPath = `xl/${rel.target}`;
|
|
772
|
+
writeZipText(this._files, sheetPath, worksheet.toXml());
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private _updateWorkbookXml(pivotCacheRelIds: Map<number, string>): void {
|
|
780
|
+
const sheetNodes: XmlNode[] = this._sheetDefs.map((def) =>
|
|
781
|
+
createElement('sheet', { name: def.name, sheetId: String(def.sheetId), 'r:id': def.rId }, []),
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
const sheetsNode = createElement('sheets', {}, sheetNodes);
|
|
785
|
+
|
|
786
|
+
const children: XmlNode[] = [sheetsNode];
|
|
787
|
+
|
|
788
|
+
// Add pivot caches if any
|
|
789
|
+
if (this._pivotCaches.length > 0) {
|
|
790
|
+
const pivotCacheNodes: XmlNode[] = this._pivotCaches.map((cache) => {
|
|
791
|
+
const cacheRelId = pivotCacheRelIds.get(cache.cacheId);
|
|
792
|
+
if (!cacheRelId) {
|
|
793
|
+
throw new Error(`Missing pivot cache relationship ID for cache ${cache.cacheId}`);
|
|
794
|
+
}
|
|
795
|
+
return createElement('pivotCache', { cacheId: String(cache.cacheId), 'r:id': cacheRelId }, []);
|
|
796
|
+
});
|
|
797
|
+
children.push(createElement('pivotCaches', {}, pivotCacheNodes));
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const workbookNode = createElement(
|
|
801
|
+
'workbook',
|
|
802
|
+
{
|
|
803
|
+
xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
|
804
|
+
'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
|
805
|
+
},
|
|
806
|
+
children,
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([workbookNode])}`;
|
|
810
|
+
writeZipText(this._files, 'xl/workbook.xml', xml);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private _updateRelationshipsXml(relNodes: XmlNode[]): void {
|
|
814
|
+
const relsNode = createElement(
|
|
815
|
+
'Relationships',
|
|
816
|
+
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
817
|
+
relNodes,
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([relsNode])}`;
|
|
821
|
+
writeZipText(this._files, 'xl/_rels/workbook.xml.rels', xml);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private _buildRelationshipInfo(): { relNodes: XmlNode[]; pivotCacheRelIds: Map<number, string> } {
|
|
825
|
+
const relNodes: XmlNode[] = this._relationships.map((rel) =>
|
|
826
|
+
createElement('Relationship', { Id: rel.id, Type: rel.type, Target: rel.target }, []),
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const reservedRelIds = new Set<string>(relNodes.map((node) => getAttr(node, 'Id') || '').filter(Boolean));
|
|
830
|
+
let nextRelId = Math.max(0, ...this._relationships.map((r) => parseInt(r.id.replace('rId', ''), 10) || 0)) + 1;
|
|
831
|
+
|
|
832
|
+
const allocateRelId = (): string => {
|
|
833
|
+
while (reservedRelIds.has(`rId${nextRelId}`)) {
|
|
834
|
+
nextRelId++;
|
|
835
|
+
}
|
|
836
|
+
const id = `rId${nextRelId}`;
|
|
837
|
+
nextRelId++;
|
|
838
|
+
reservedRelIds.add(id);
|
|
839
|
+
return id;
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// Add shared strings relationship if needed
|
|
843
|
+
if (this._sharedStrings.count > 0) {
|
|
844
|
+
const hasSharedStrings = this._relationships.some(
|
|
845
|
+
(r) => r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings',
|
|
846
|
+
);
|
|
847
|
+
if (!hasSharedStrings) {
|
|
848
|
+
relNodes.push(
|
|
849
|
+
createElement(
|
|
850
|
+
'Relationship',
|
|
851
|
+
{
|
|
852
|
+
Id: allocateRelId(),
|
|
853
|
+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings',
|
|
854
|
+
Target: 'sharedStrings.xml',
|
|
855
|
+
},
|
|
856
|
+
[],
|
|
857
|
+
),
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Add styles relationship if needed
|
|
863
|
+
const hasStyles = this._relationships.some(
|
|
864
|
+
(r) => r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles',
|
|
865
|
+
);
|
|
866
|
+
if (!hasStyles) {
|
|
867
|
+
relNodes.push(
|
|
868
|
+
createElement(
|
|
869
|
+
'Relationship',
|
|
870
|
+
{
|
|
871
|
+
Id: allocateRelId(),
|
|
872
|
+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles',
|
|
873
|
+
Target: 'styles.xml',
|
|
874
|
+
},
|
|
875
|
+
[],
|
|
876
|
+
),
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Add pivot cache relationships
|
|
881
|
+
const pivotCacheRelIds = new Map<number, string>();
|
|
882
|
+
for (const cache of this._pivotCaches) {
|
|
883
|
+
const id = allocateRelId();
|
|
884
|
+
pivotCacheRelIds.set(cache.cacheId, id);
|
|
885
|
+
relNodes.push(
|
|
886
|
+
createElement(
|
|
887
|
+
'Relationship',
|
|
888
|
+
{
|
|
889
|
+
Id: id,
|
|
890
|
+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
|
|
891
|
+
Target: `pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`,
|
|
892
|
+
},
|
|
893
|
+
[],
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
return { relNodes, pivotCacheRelIds };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private _updateContentTypes(): void {
|
|
902
|
+
const types: XmlNode[] = [
|
|
903
|
+
createElement(
|
|
904
|
+
'Default',
|
|
905
|
+
{ Extension: 'rels', ContentType: 'application/vnd.openxmlformats-package.relationships+xml' },
|
|
906
|
+
[],
|
|
907
|
+
),
|
|
908
|
+
createElement('Default', { Extension: 'xml', ContentType: 'application/xml' }, []),
|
|
909
|
+
createElement(
|
|
910
|
+
'Override',
|
|
911
|
+
{
|
|
912
|
+
PartName: '/xl/workbook.xml',
|
|
913
|
+
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml',
|
|
914
|
+
},
|
|
915
|
+
[],
|
|
916
|
+
),
|
|
917
|
+
createElement(
|
|
918
|
+
'Override',
|
|
919
|
+
{
|
|
920
|
+
PartName: '/xl/styles.xml',
|
|
921
|
+
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml',
|
|
922
|
+
},
|
|
923
|
+
[],
|
|
924
|
+
),
|
|
925
|
+
];
|
|
926
|
+
|
|
927
|
+
// Add shared strings if present
|
|
928
|
+
if (this._sharedStrings.count > 0) {
|
|
929
|
+
types.push(
|
|
930
|
+
createElement(
|
|
931
|
+
'Override',
|
|
932
|
+
{
|
|
933
|
+
PartName: '/xl/sharedStrings.xml',
|
|
934
|
+
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml',
|
|
935
|
+
},
|
|
936
|
+
[],
|
|
937
|
+
),
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Add worksheets
|
|
942
|
+
for (const def of this._sheetDefs) {
|
|
943
|
+
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
944
|
+
if (rel) {
|
|
945
|
+
types.push(
|
|
946
|
+
createElement(
|
|
947
|
+
'Override',
|
|
948
|
+
{
|
|
949
|
+
PartName: `/xl/${rel.target}`,
|
|
950
|
+
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml',
|
|
951
|
+
},
|
|
952
|
+
[],
|
|
953
|
+
),
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Add pivot cache definitions and records
|
|
959
|
+
for (const cache of this._pivotCaches) {
|
|
960
|
+
types.push(
|
|
961
|
+
createElement(
|
|
962
|
+
'Override',
|
|
963
|
+
{
|
|
964
|
+
PartName: `/xl/pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`,
|
|
965
|
+
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml',
|
|
966
|
+
},
|
|
967
|
+
[],
|
|
968
|
+
),
|
|
969
|
+
);
|
|
970
|
+
types.push(
|
|
971
|
+
createElement(
|
|
972
|
+
'Override',
|
|
973
|
+
{
|
|
974
|
+
PartName: `/xl/pivotCache/pivotCacheRecords${cache.fileIndex}.xml`,
|
|
975
|
+
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml',
|
|
976
|
+
},
|
|
977
|
+
[],
|
|
978
|
+
),
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Add pivot tables
|
|
983
|
+
for (const pivotTable of this._pivotTables) {
|
|
984
|
+
types.push(
|
|
985
|
+
createElement(
|
|
986
|
+
'Override',
|
|
987
|
+
{
|
|
988
|
+
PartName: `/xl/pivotTables/pivotTable${pivotTable.index}.xml`,
|
|
989
|
+
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml',
|
|
990
|
+
},
|
|
991
|
+
[],
|
|
992
|
+
),
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Add tables
|
|
997
|
+
let tableIndex = 1;
|
|
998
|
+
for (const def of this._sheetDefs) {
|
|
999
|
+
const worksheet = this._sheets.get(def.name);
|
|
1000
|
+
if (worksheet) {
|
|
1001
|
+
for (let i = 0; i < worksheet.tables.length; i++) {
|
|
1002
|
+
types.push(
|
|
1003
|
+
createElement(
|
|
1004
|
+
'Override',
|
|
1005
|
+
{
|
|
1006
|
+
PartName: `/xl/tables/table${tableIndex}.xml`,
|
|
1007
|
+
ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml',
|
|
1008
|
+
},
|
|
1009
|
+
[],
|
|
1010
|
+
),
|
|
1011
|
+
);
|
|
1012
|
+
tableIndex++;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const existingTypesXml = readZipText(this._files, '[Content_Types].xml');
|
|
1018
|
+
const existingKeys = new Set(
|
|
1019
|
+
types
|
|
1020
|
+
.map((t) => {
|
|
1021
|
+
if ('Default' in t) {
|
|
1022
|
+
const a = t[':@'] as Record<string, string> | undefined;
|
|
1023
|
+
return `Default:${a?.['@_Extension'] || ''}`;
|
|
1024
|
+
}
|
|
1025
|
+
if ('Override' in t) {
|
|
1026
|
+
const a = t[':@'] as Record<string, string> | undefined;
|
|
1027
|
+
return `Override:${a?.['@_PartName'] || ''}`;
|
|
1028
|
+
}
|
|
1029
|
+
return '';
|
|
1030
|
+
})
|
|
1031
|
+
.filter(Boolean),
|
|
1032
|
+
);
|
|
1033
|
+
if (existingTypesXml) {
|
|
1034
|
+
const parsed = parseXml(existingTypesXml);
|
|
1035
|
+
const typesElement = findElement(parsed, 'Types');
|
|
1036
|
+
if (typesElement) {
|
|
1037
|
+
const existingNodes = getChildren(typesElement, 'Types');
|
|
1038
|
+
for (const node of existingNodes) {
|
|
1039
|
+
if ('Default' in node || 'Override' in node) {
|
|
1040
|
+
const type = 'Default' in node ? 'Default' : 'Override';
|
|
1041
|
+
const attrs = node[':@'] as Record<string, string> | undefined;
|
|
1042
|
+
const key =
|
|
1043
|
+
type === 'Default'
|
|
1044
|
+
? `Default:${attrs?.['@_Extension'] || ''}`
|
|
1045
|
+
: `Override:${attrs?.['@_PartName'] || ''}`;
|
|
1046
|
+
if (!existingKeys.has(key)) {
|
|
1047
|
+
types.push(node);
|
|
1048
|
+
existingKeys.add(key);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const typesNode = createElement(
|
|
1056
|
+
'Types',
|
|
1057
|
+
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/content-types' },
|
|
1058
|
+
types,
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([typesNode])}`;
|
|
1062
|
+
writeZipText(this._files, '[Content_Types].xml', xml);
|
|
1063
|
+
|
|
1064
|
+
// Also ensure _rels/.rels exists
|
|
1065
|
+
const rootRelsXml = readZipText(this._files, '_rels/.rels');
|
|
1066
|
+
if (!rootRelsXml) {
|
|
1067
|
+
const rootRels = createElement(
|
|
1068
|
+
'Relationships',
|
|
1069
|
+
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1070
|
+
[
|
|
1071
|
+
createElement(
|
|
1072
|
+
'Relationship',
|
|
1073
|
+
{
|
|
1074
|
+
Id: 'rId1',
|
|
1075
|
+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument',
|
|
1076
|
+
Target: 'xl/workbook.xml',
|
|
1077
|
+
},
|
|
1078
|
+
[],
|
|
1079
|
+
),
|
|
1080
|
+
],
|
|
1081
|
+
);
|
|
1082
|
+
writeZipText(
|
|
1083
|
+
this._files,
|
|
1084
|
+
'_rels/.rels',
|
|
1085
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([rootRels])}`,
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Generate all pivot table related files
|
|
1092
|
+
*/
|
|
1093
|
+
private _updatePivotTableFiles(): void {
|
|
1094
|
+
// Track which sheets have pivot tables for their .rels files
|
|
1095
|
+
const sheetPivotTables: Map<string, PivotTable[]> = new Map();
|
|
1096
|
+
|
|
1097
|
+
for (const pivotTable of this._pivotTables) {
|
|
1098
|
+
const sheetName = pivotTable.targetSheet;
|
|
1099
|
+
if (!sheetPivotTables.has(sheetName)) {
|
|
1100
|
+
sheetPivotTables.set(sheetName, []);
|
|
1101
|
+
}
|
|
1102
|
+
sheetPivotTables.get(sheetName)!.push(pivotTable);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Generate pivot cache files
|
|
1106
|
+
for (let i = 0; i < this._pivotCaches.length; i++) {
|
|
1107
|
+
const cache = this._pivotCaches[i];
|
|
1108
|
+
|
|
1109
|
+
// Pivot cache definition
|
|
1110
|
+
const definitionPath = `xl/pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`;
|
|
1111
|
+
writeZipText(this._files, definitionPath, cache.toDefinitionXml('rId1'));
|
|
1112
|
+
|
|
1113
|
+
// Pivot cache records
|
|
1114
|
+
const recordsPath = `xl/pivotCache/pivotCacheRecords${cache.fileIndex}.xml`;
|
|
1115
|
+
writeZipText(this._files, recordsPath, cache.toRecordsXml());
|
|
1116
|
+
|
|
1117
|
+
// Pivot cache definition relationships (link to records)
|
|
1118
|
+
const cacheRelsPath = `xl/pivotCache/_rels/pivotCacheDefinition${cache.fileIndex}.xml.rels`;
|
|
1119
|
+
const cacheRels = createElement(
|
|
1120
|
+
'Relationships',
|
|
1121
|
+
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1122
|
+
[
|
|
1123
|
+
createElement(
|
|
1124
|
+
'Relationship',
|
|
1125
|
+
{
|
|
1126
|
+
Id: 'rId1',
|
|
1127
|
+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords',
|
|
1128
|
+
Target: `pivotCacheRecords${cache.fileIndex}.xml`,
|
|
1129
|
+
},
|
|
1130
|
+
[],
|
|
1131
|
+
),
|
|
1132
|
+
],
|
|
1133
|
+
);
|
|
1134
|
+
writeZipText(
|
|
1135
|
+
this._files,
|
|
1136
|
+
cacheRelsPath,
|
|
1137
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([cacheRels])}`,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Generate pivot table files
|
|
1142
|
+
for (let i = 0; i < this._pivotTables.length; i++) {
|
|
1143
|
+
const pivotTable = this._pivotTables[i];
|
|
1144
|
+
const ptIdx = pivotTable.index;
|
|
1145
|
+
|
|
1146
|
+
// Pivot table definition
|
|
1147
|
+
const ptPath = `xl/pivotTables/pivotTable${ptIdx}.xml`;
|
|
1148
|
+
writeZipText(this._files, ptPath, pivotTable.toXml());
|
|
1149
|
+
|
|
1150
|
+
// Pivot table relationships (link to cache definition)
|
|
1151
|
+
const cacheIdx = pivotTable.cacheFileIndex;
|
|
1152
|
+
const ptRelsPath = `xl/pivotTables/_rels/pivotTable${ptIdx}.xml.rels`;
|
|
1153
|
+
const ptRels = createElement(
|
|
1154
|
+
'Relationships',
|
|
1155
|
+
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1156
|
+
[
|
|
1157
|
+
createElement(
|
|
1158
|
+
'Relationship',
|
|
1159
|
+
{
|
|
1160
|
+
Id: 'rId1',
|
|
1161
|
+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
|
|
1162
|
+
Target: `../pivotCache/pivotCacheDefinition${cacheIdx}.xml`,
|
|
1163
|
+
},
|
|
1164
|
+
[],
|
|
1165
|
+
),
|
|
1166
|
+
],
|
|
1167
|
+
);
|
|
1168
|
+
writeZipText(
|
|
1169
|
+
this._files,
|
|
1170
|
+
ptRelsPath,
|
|
1171
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([ptRels])}`,
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Generate worksheet relationships for pivot tables
|
|
1176
|
+
for (const [sheetName, pivotTables] of sheetPivotTables) {
|
|
1177
|
+
const def = this._sheetDefs.find((s) => s.name === sheetName);
|
|
1178
|
+
if (!def) continue;
|
|
1179
|
+
|
|
1180
|
+
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
1181
|
+
if (!rel) continue;
|
|
1182
|
+
|
|
1183
|
+
// Extract sheet file name from target path
|
|
1184
|
+
const sheetFileName = rel.target.split('/').pop();
|
|
1185
|
+
const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
|
|
1186
|
+
|
|
1187
|
+
const existingRelsXml = readZipText(this._files, sheetRelsPath);
|
|
1188
|
+
let relNodes: XmlNode[] = [];
|
|
1189
|
+
let nextRelId = 1;
|
|
1190
|
+
const reservedRelIds = new Set<string>();
|
|
1191
|
+
|
|
1192
|
+
if (existingRelsXml) {
|
|
1193
|
+
const parsed = parseXml(existingRelsXml);
|
|
1194
|
+
const relsElement = findElement(parsed, 'Relationships');
|
|
1195
|
+
if (relsElement) {
|
|
1196
|
+
const existingRelNodes = getChildren(relsElement, 'Relationships');
|
|
1197
|
+
for (const relNode of existingRelNodes) {
|
|
1198
|
+
if ('Relationship' in relNode) {
|
|
1199
|
+
relNodes.push(relNode);
|
|
1200
|
+
const id = getAttr(relNode, 'Id');
|
|
1201
|
+
if (id) {
|
|
1202
|
+
reservedRelIds.add(id);
|
|
1203
|
+
const idNum = parseInt(id.replace('rId', ''), 10);
|
|
1204
|
+
if (idNum >= nextRelId) {
|
|
1205
|
+
nextRelId = idNum + 1;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const allocateRelId = (): string => {
|
|
1214
|
+
while (reservedRelIds.has(`rId${nextRelId}`)) {
|
|
1215
|
+
nextRelId++;
|
|
1216
|
+
}
|
|
1217
|
+
const id = `rId${nextRelId}`;
|
|
1218
|
+
nextRelId++;
|
|
1219
|
+
reservedRelIds.add(id);
|
|
1220
|
+
return id;
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
for (const pt of pivotTables) {
|
|
1224
|
+
const target = `../pivotTables/pivotTable${pt.index}.xml`;
|
|
1225
|
+
const existing = relNodes.some(
|
|
1226
|
+
(node) =>
|
|
1227
|
+
getAttr(node, 'Type') ===
|
|
1228
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable' &&
|
|
1229
|
+
getAttr(node, 'Target') === target,
|
|
1230
|
+
);
|
|
1231
|
+
if (existing) continue;
|
|
1232
|
+
relNodes.push(
|
|
1233
|
+
createElement(
|
|
1234
|
+
'Relationship',
|
|
1235
|
+
{
|
|
1236
|
+
Id: allocateRelId(),
|
|
1237
|
+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable',
|
|
1238
|
+
Target: target,
|
|
1239
|
+
},
|
|
1240
|
+
[],
|
|
1241
|
+
),
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const sheetRels = createElement(
|
|
1246
|
+
'Relationships',
|
|
1247
|
+
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1248
|
+
relNodes,
|
|
1249
|
+
);
|
|
1250
|
+
writeZipText(
|
|
1251
|
+
this._files,
|
|
1252
|
+
sheetRelsPath,
|
|
1253
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([sheetRels])}`,
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Generate all table related files
|
|
1260
|
+
*/
|
|
1261
|
+
private _updateTableFiles(): void {
|
|
1262
|
+
// Collect all tables with their global indices
|
|
1263
|
+
let globalTableIndex = 1;
|
|
1264
|
+
const sheetTables: Map<string, { table: import('./table').Table; globalIndex: number }[]> = new Map();
|
|
1265
|
+
|
|
1266
|
+
for (const def of this._sheetDefs) {
|
|
1267
|
+
const worksheet = this._sheets.get(def.name);
|
|
1268
|
+
if (!worksheet) continue;
|
|
1269
|
+
|
|
1270
|
+
const tables = worksheet.tables;
|
|
1271
|
+
if (tables.length === 0) continue;
|
|
1272
|
+
|
|
1273
|
+
const tableInfos: { table: import('./table').Table; globalIndex: number }[] = [];
|
|
1274
|
+
for (const table of tables) {
|
|
1275
|
+
tableInfos.push({ table, globalIndex: globalTableIndex });
|
|
1276
|
+
globalTableIndex++;
|
|
1277
|
+
}
|
|
1278
|
+
sheetTables.set(def.name, tableInfos);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Generate table files
|
|
1282
|
+
for (const [, tableInfos] of sheetTables) {
|
|
1283
|
+
for (const { table, globalIndex } of tableInfos) {
|
|
1284
|
+
const tablePath = `xl/tables/table${globalIndex}.xml`;
|
|
1285
|
+
writeZipText(this._files, tablePath, table.toXml());
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Generate worksheet relationships for tables
|
|
1290
|
+
for (const [sheetName, tableInfos] of sheetTables) {
|
|
1291
|
+
const def = this._sheetDefs.find((s) => s.name === sheetName);
|
|
1292
|
+
if (!def) continue;
|
|
1293
|
+
|
|
1294
|
+
const rel = this._relationships.find((r) => r.id === def.rId);
|
|
1295
|
+
if (!rel) continue;
|
|
1296
|
+
|
|
1297
|
+
// Extract sheet file name from target path
|
|
1298
|
+
const sheetFileName = rel.target.split('/').pop();
|
|
1299
|
+
const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
|
|
1300
|
+
|
|
1301
|
+
// Check if there are already pivot table relationships for this sheet
|
|
1302
|
+
const existingRelsXml = readZipText(this._files, sheetRelsPath);
|
|
1303
|
+
let nextRelId = 1;
|
|
1304
|
+
const relNodes: XmlNode[] = [];
|
|
1305
|
+
const reservedRelIds = new Set<string>();
|
|
1306
|
+
|
|
1307
|
+
if (existingRelsXml) {
|
|
1308
|
+
// Parse existing rels and find max rId
|
|
1309
|
+
const parsed = parseXml(existingRelsXml);
|
|
1310
|
+
const relsElement = findElement(parsed, 'Relationships');
|
|
1311
|
+
if (relsElement) {
|
|
1312
|
+
const existingRelNodes = getChildren(relsElement, 'Relationships');
|
|
1313
|
+
for (const relNode of existingRelNodes) {
|
|
1314
|
+
if ('Relationship' in relNode) {
|
|
1315
|
+
relNodes.push(relNode);
|
|
1316
|
+
const id = getAttr(relNode, 'Id');
|
|
1317
|
+
if (id) {
|
|
1318
|
+
reservedRelIds.add(id);
|
|
1319
|
+
const idNum = parseInt(id.replace('rId', ''), 10);
|
|
1320
|
+
if (idNum >= nextRelId) {
|
|
1321
|
+
nextRelId = idNum + 1;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const allocateRelId = (): string => {
|
|
1330
|
+
while (reservedRelIds.has(`rId${nextRelId}`)) {
|
|
1331
|
+
nextRelId++;
|
|
1332
|
+
}
|
|
1333
|
+
const id = `rId${nextRelId}`;
|
|
1334
|
+
nextRelId++;
|
|
1335
|
+
reservedRelIds.add(id);
|
|
1336
|
+
return id;
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
// Add table relationships
|
|
1340
|
+
const tableRelIds: string[] = [];
|
|
1341
|
+
for (const { globalIndex } of tableInfos) {
|
|
1342
|
+
const target = `../tables/table${globalIndex}.xml`;
|
|
1343
|
+
const existing = relNodes.some(
|
|
1344
|
+
(node) =>
|
|
1345
|
+
getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' &&
|
|
1346
|
+
getAttr(node, 'Target') === target,
|
|
1347
|
+
);
|
|
1348
|
+
if (existing) {
|
|
1349
|
+
const existingRel = relNodes.find(
|
|
1350
|
+
(node) =>
|
|
1351
|
+
getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' &&
|
|
1352
|
+
getAttr(node, 'Target') === target,
|
|
1353
|
+
);
|
|
1354
|
+
const existingId = existingRel ? getAttr(existingRel, 'Id') : undefined;
|
|
1355
|
+
tableRelIds.push(existingId ?? allocateRelId());
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
const id = allocateRelId();
|
|
1359
|
+
tableRelIds.push(id);
|
|
1360
|
+
relNodes.push(
|
|
1361
|
+
createElement(
|
|
1362
|
+
'Relationship',
|
|
1363
|
+
{
|
|
1364
|
+
Id: id,
|
|
1365
|
+
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table',
|
|
1366
|
+
Target: target,
|
|
1367
|
+
},
|
|
1368
|
+
[],
|
|
1369
|
+
),
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const worksheet = this._sheets.get(sheetName);
|
|
1374
|
+
if (worksheet) {
|
|
1375
|
+
worksheet.setTableRelIds(tableRelIds);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const sheetRels = createElement(
|
|
1379
|
+
'Relationships',
|
|
1380
|
+
{ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
|
|
1381
|
+
relNodes,
|
|
1382
|
+
);
|
|
1383
|
+
writeZipText(
|
|
1384
|
+
this._files,
|
|
1385
|
+
sheetRelsPath,
|
|
1386
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([sheetRels])}`,
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|