@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/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
+ }