@niicojs/excel 0.1.0

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.
@@ -0,0 +1,845 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import type { SheetDefinition, Relationship, PivotTableConfig, CellValue } from './types';
3
+ import { Worksheet } from './worksheet';
4
+ import { SharedStrings } from './shared-strings';
5
+ import { Styles } from './styles';
6
+ import { PivotTable } from './pivot-table';
7
+ import { PivotCache } from './pivot-cache';
8
+ import { readZip, writeZip, readZipText, writeZipText, ZipFiles } from './utils/zip';
9
+ import { parseAddress, parseRange, toAddress } from './utils/address';
10
+ import { parseXml, findElement, getChildren, getAttr, XmlNode, stringifyXml, createElement } from './utils/xml';
11
+
12
+ /**
13
+ * Represents an Excel workbook (.xlsx file)
14
+ */
15
+ export class Workbook {
16
+ private _files: ZipFiles = new Map();
17
+ private _sheets: Map<string, Worksheet> = new Map();
18
+ private _sheetDefs: SheetDefinition[] = [];
19
+ private _relationships: Relationship[] = [];
20
+ private _sharedStrings: SharedStrings;
21
+ private _styles: Styles;
22
+ private _dirty = false;
23
+
24
+ // Pivot table support
25
+ private _pivotTables: PivotTable[] = [];
26
+ private _pivotCaches: PivotCache[] = [];
27
+ private _nextCacheId = 0;
28
+
29
+ private constructor() {
30
+ this._sharedStrings = new SharedStrings();
31
+ this._styles = Styles.createDefault();
32
+ }
33
+
34
+ /**
35
+ * Load a workbook from a file path
36
+ */
37
+ static async fromFile(path: string): Promise<Workbook> {
38
+ const data = await readFile(path);
39
+ return Workbook.fromBuffer(new Uint8Array(data));
40
+ }
41
+
42
+ /**
43
+ * Load a workbook from a buffer
44
+ */
45
+ static async fromBuffer(data: Uint8Array): Promise<Workbook> {
46
+ const workbook = new Workbook();
47
+ workbook._files = await readZip(data);
48
+
49
+ // Parse workbook.xml for sheet definitions
50
+ const workbookXml = readZipText(workbook._files, 'xl/workbook.xml');
51
+ if (workbookXml) {
52
+ workbook._parseWorkbook(workbookXml);
53
+ }
54
+
55
+ // Parse relationships
56
+ const relsXml = readZipText(workbook._files, 'xl/_rels/workbook.xml.rels');
57
+ if (relsXml) {
58
+ workbook._parseRelationships(relsXml);
59
+ }
60
+
61
+ // Parse shared strings
62
+ const sharedStringsXml = readZipText(workbook._files, 'xl/sharedStrings.xml');
63
+ if (sharedStringsXml) {
64
+ workbook._sharedStrings = SharedStrings.parse(sharedStringsXml);
65
+ }
66
+
67
+ // Parse styles
68
+ const stylesXml = readZipText(workbook._files, 'xl/styles.xml');
69
+ if (stylesXml) {
70
+ workbook._styles = Styles.parse(stylesXml);
71
+ }
72
+
73
+ return workbook;
74
+ }
75
+
76
+ /**
77
+ * Create a new empty workbook
78
+ */
79
+ static create(): Workbook {
80
+ const workbook = new Workbook();
81
+ workbook._dirty = true;
82
+
83
+ // Add default sheet
84
+ workbook.addSheet('Sheet1');
85
+
86
+ return workbook;
87
+ }
88
+
89
+ /**
90
+ * Get sheet names
91
+ */
92
+ get sheetNames(): string[] {
93
+ return this._sheetDefs.map((s) => s.name);
94
+ }
95
+
96
+ /**
97
+ * Get number of sheets
98
+ */
99
+ get sheetCount(): number {
100
+ return this._sheetDefs.length;
101
+ }
102
+
103
+ /**
104
+ * Get shared strings table
105
+ */
106
+ get sharedStrings(): SharedStrings {
107
+ return this._sharedStrings;
108
+ }
109
+
110
+ /**
111
+ * Get styles
112
+ */
113
+ get styles(): Styles {
114
+ return this._styles;
115
+ }
116
+
117
+ /**
118
+ * Get a worksheet by name or index
119
+ */
120
+ sheet(nameOrIndex: string | number): Worksheet {
121
+ let def: SheetDefinition | undefined;
122
+
123
+ if (typeof nameOrIndex === 'number') {
124
+ def = this._sheetDefs[nameOrIndex];
125
+ } else {
126
+ def = this._sheetDefs.find((s) => s.name === nameOrIndex);
127
+ }
128
+
129
+ if (!def) {
130
+ throw new Error(`Sheet not found: ${nameOrIndex}`);
131
+ }
132
+
133
+ // Return cached worksheet if available
134
+ if (this._sheets.has(def.name)) {
135
+ return this._sheets.get(def.name)!;
136
+ }
137
+
138
+ // Load worksheet
139
+ const worksheet = new Worksheet(this, def.name);
140
+
141
+ // Find the relationship to get the file path
142
+ const rel = this._relationships.find((r) => r.id === def.rId);
143
+ if (rel) {
144
+ const sheetPath = `xl/${rel.target}`;
145
+ const sheetXml = readZipText(this._files, sheetPath);
146
+ if (sheetXml) {
147
+ worksheet.parse(sheetXml);
148
+ }
149
+ }
150
+
151
+ this._sheets.set(def.name, worksheet);
152
+ return worksheet;
153
+ }
154
+
155
+ /**
156
+ * Add a new worksheet
157
+ */
158
+ addSheet(name: string, index?: number): Worksheet {
159
+ // Check for duplicate name
160
+ if (this._sheetDefs.some((s) => s.name === name)) {
161
+ throw new Error(`Sheet already exists: ${name}`);
162
+ }
163
+
164
+ this._dirty = true;
165
+
166
+ // Generate new sheet ID and relationship ID
167
+ const sheetId = Math.max(0, ...this._sheetDefs.map((s) => s.sheetId)) + 1;
168
+ const rId = `rId${Math.max(0, ...this._relationships.map((r) => parseInt(r.id.replace('rId', ''), 10) || 0)) + 1}`;
169
+
170
+ const def: SheetDefinition = { name, sheetId, rId };
171
+
172
+ // Add relationship
173
+ this._relationships.push({
174
+ id: rId,
175
+ type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet',
176
+ target: `worksheets/sheet${sheetId}.xml`,
177
+ });
178
+
179
+ // Insert at index or append
180
+ if (index !== undefined && index >= 0 && index < this._sheetDefs.length) {
181
+ this._sheetDefs.splice(index, 0, def);
182
+ } else {
183
+ this._sheetDefs.push(def);
184
+ }
185
+
186
+ // Create worksheet
187
+ const worksheet = new Worksheet(this, name);
188
+ this._sheets.set(name, worksheet);
189
+
190
+ return worksheet;
191
+ }
192
+
193
+ /**
194
+ * Delete a worksheet by name or index
195
+ */
196
+ deleteSheet(nameOrIndex: string | number): void {
197
+ let index: number;
198
+
199
+ if (typeof nameOrIndex === 'number') {
200
+ index = nameOrIndex;
201
+ } else {
202
+ index = this._sheetDefs.findIndex((s) => s.name === nameOrIndex);
203
+ }
204
+
205
+ if (index < 0 || index >= this._sheetDefs.length) {
206
+ throw new Error(`Sheet not found: ${nameOrIndex}`);
207
+ }
208
+
209
+ if (this._sheetDefs.length === 1) {
210
+ throw new Error('Cannot delete the last sheet');
211
+ }
212
+
213
+ this._dirty = true;
214
+
215
+ const def = this._sheetDefs[index];
216
+ this._sheetDefs.splice(index, 1);
217
+ this._sheets.delete(def.name);
218
+
219
+ // Remove relationship
220
+ const relIndex = this._relationships.findIndex((r) => r.id === def.rId);
221
+ if (relIndex >= 0) {
222
+ this._relationships.splice(relIndex, 1);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Rename a worksheet
228
+ */
229
+ renameSheet(oldName: string, newName: string): void {
230
+ const def = this._sheetDefs.find((s) => s.name === oldName);
231
+ if (!def) {
232
+ throw new Error(`Sheet not found: ${oldName}`);
233
+ }
234
+
235
+ if (this._sheetDefs.some((s) => s.name === newName)) {
236
+ throw new Error(`Sheet already exists: ${newName}`);
237
+ }
238
+
239
+ this._dirty = true;
240
+
241
+ // Update cached worksheet
242
+ const worksheet = this._sheets.get(oldName);
243
+ if (worksheet) {
244
+ worksheet.name = newName;
245
+ this._sheets.delete(oldName);
246
+ this._sheets.set(newName, worksheet);
247
+ }
248
+
249
+ def.name = newName;
250
+ }
251
+
252
+ /**
253
+ * Copy a worksheet
254
+ */
255
+ copySheet(sourceName: string, newName: string): Worksheet {
256
+ const source = this.sheet(sourceName);
257
+ const copy = this.addSheet(newName);
258
+
259
+ // Copy all cells
260
+ for (const [address, cell] of source.cells) {
261
+ const newCell = copy.cell(address);
262
+ newCell.value = cell.value;
263
+ if (cell.formula) {
264
+ newCell.formula = cell.formula;
265
+ }
266
+ if (cell.styleIndex !== undefined) {
267
+ newCell.styleIndex = cell.styleIndex;
268
+ }
269
+ }
270
+
271
+ // Copy merged cells
272
+ for (const mergedRange of source.mergedCells) {
273
+ copy.mergeCells(mergedRange);
274
+ }
275
+
276
+ return copy;
277
+ }
278
+
279
+ /**
280
+ * Create a pivot table from source data.
281
+ *
282
+ * @param config - Pivot table configuration
283
+ * @returns PivotTable instance for fluent configuration
284
+ *
285
+ * @example
286
+ * ```typescript
287
+ * const pivot = wb.createPivotTable({
288
+ * name: 'SalesPivot',
289
+ * source: 'DataSheet!A1:D100',
290
+ * target: 'PivotSheet!A3',
291
+ * });
292
+ *
293
+ * pivot
294
+ * .addRowField('Region')
295
+ * .addColumnField('Product')
296
+ * .addValueField('Sales', 'sum', 'Total Sales');
297
+ * ```
298
+ */
299
+ createPivotTable(config: PivotTableConfig): PivotTable {
300
+ this._dirty = true;
301
+
302
+ // Parse source reference (Sheet!Range)
303
+ const { sheetName: sourceSheet, range: sourceRange } = this._parseSheetRef(config.source);
304
+
305
+ // Parse target reference
306
+ const { sheetName: targetSheet, range: targetCell } = this._parseSheetRef(config.target);
307
+
308
+ // Ensure target sheet exists
309
+ if (!this._sheetDefs.some((s) => s.name === targetSheet)) {
310
+ this.addSheet(targetSheet);
311
+ }
312
+
313
+ // Parse target cell address
314
+ const targetAddr = parseAddress(targetCell);
315
+
316
+ // Get source worksheet and extract data
317
+ const sourceWs = this.sheet(sourceSheet);
318
+ const { headers, data } = this._extractSourceData(sourceWs, sourceRange);
319
+
320
+ // Create pivot cache
321
+ const cacheId = this._nextCacheId++;
322
+ const cache = new PivotCache(cacheId, sourceSheet, sourceRange);
323
+ cache.buildFromData(headers, data);
324
+ // refreshOnLoad defaults to true; only disable if explicitly set to false
325
+ if (config.refreshOnLoad === false) {
326
+ cache.refreshOnLoad = false;
327
+ }
328
+ this._pivotCaches.push(cache);
329
+
330
+ // Create pivot table
331
+ const pivotTableIndex = this._pivotTables.length + 1;
332
+ const pivotTable = new PivotTable(
333
+ config.name,
334
+ cache,
335
+ targetSheet,
336
+ targetCell,
337
+ targetAddr.row + 1, // Convert to 1-based
338
+ targetAddr.col,
339
+ pivotTableIndex,
340
+ );
341
+ this._pivotTables.push(pivotTable);
342
+
343
+ return pivotTable;
344
+ }
345
+
346
+ /**
347
+ * Parse a sheet reference like "Sheet1!A1:D100" into sheet name and range
348
+ */
349
+ private _parseSheetRef(ref: string): { sheetName: string; range: string } {
350
+ const match = ref.match(/^(.+?)!(.+)$/);
351
+ if (!match) {
352
+ throw new Error(`Invalid reference format: ${ref}. Expected "SheetName!Range"`);
353
+ }
354
+ return { sheetName: match[1], range: match[2] };
355
+ }
356
+
357
+ /**
358
+ * Extract headers and data from a source range
359
+ */
360
+ private _extractSourceData(sheet: Worksheet, rangeStr: string): { headers: string[]; data: CellValue[][] } {
361
+ const range = parseRange(rangeStr);
362
+ const headers: string[] = [];
363
+ const data: CellValue[][] = [];
364
+
365
+ // First row is headers
366
+ for (let col = range.start.col; col <= range.end.col; col++) {
367
+ const cell = sheet.cell(toAddress(range.start.row, col));
368
+ headers.push(String(cell.value ?? `Column${col + 1}`));
369
+ }
370
+
371
+ // Remaining rows are data
372
+ for (let row = range.start.row + 1; row <= range.end.row; row++) {
373
+ const rowData: CellValue[] = [];
374
+ for (let col = range.start.col; col <= range.end.col; col++) {
375
+ const cell = sheet.cell(toAddress(row, col));
376
+ rowData.push(cell.value);
377
+ }
378
+ data.push(rowData);
379
+ }
380
+
381
+ return { headers, data };
382
+ }
383
+
384
+ /**
385
+ * Save the workbook to a file
386
+ */
387
+ async toFile(path: string): Promise<void> {
388
+ const buffer = await this.toBuffer();
389
+ await writeFile(path, buffer);
390
+ }
391
+
392
+ /**
393
+ * Save the workbook to a buffer
394
+ */
395
+ async toBuffer(): Promise<Uint8Array> {
396
+ // Update files map with modified content
397
+ this._updateFiles();
398
+
399
+ // Write ZIP
400
+ return writeZip(this._files);
401
+ }
402
+
403
+ private _parseWorkbook(xml: string): void {
404
+ const parsed = parseXml(xml);
405
+ const workbook = findElement(parsed, 'workbook');
406
+ if (!workbook) return;
407
+
408
+ const children = getChildren(workbook, 'workbook');
409
+ const sheets = findElement(children, 'sheets');
410
+ if (!sheets) return;
411
+
412
+ for (const child of getChildren(sheets, 'sheets')) {
413
+ if ('sheet' in child) {
414
+ const name = getAttr(child, 'name');
415
+ const sheetId = getAttr(child, 'sheetId');
416
+ const rId = getAttr(child, 'r:id');
417
+
418
+ if (name && sheetId && rId) {
419
+ this._sheetDefs.push({
420
+ name,
421
+ sheetId: parseInt(sheetId, 10),
422
+ rId,
423
+ });
424
+ }
425
+ }
426
+ }
427
+ }
428
+
429
+ private _parseRelationships(xml: string): void {
430
+ const parsed = parseXml(xml);
431
+ const rels = findElement(parsed, 'Relationships');
432
+ if (!rels) return;
433
+
434
+ for (const child of getChildren(rels, 'Relationships')) {
435
+ if ('Relationship' in child) {
436
+ const id = getAttr(child, 'Id');
437
+ const type = getAttr(child, 'Type');
438
+ const target = getAttr(child, 'Target');
439
+
440
+ if (id && type && target) {
441
+ this._relationships.push({ id, type, target });
442
+ }
443
+ }
444
+ }
445
+ }
446
+
447
+ private _updateFiles(): void {
448
+ // Update workbook.xml
449
+ this._updateWorkbookXml();
450
+
451
+ // Update relationships
452
+ this._updateRelationshipsXml();
453
+
454
+ // Update content types
455
+ this._updateContentTypes();
456
+
457
+ // Update shared strings if modified
458
+ if (this._sharedStrings.dirty || this._sharedStrings.count > 0) {
459
+ writeZipText(this._files, 'xl/sharedStrings.xml', this._sharedStrings.toXml());
460
+ }
461
+
462
+ // Update styles if modified or if file doesn't exist yet
463
+ if (this._styles.dirty || this._dirty || !this._files.has('xl/styles.xml')) {
464
+ writeZipText(this._files, 'xl/styles.xml', this._styles.toXml());
465
+ }
466
+
467
+ // Update worksheets
468
+ for (const [name, worksheet] of this._sheets) {
469
+ if (worksheet.dirty || this._dirty) {
470
+ const def = this._sheetDefs.find((s) => s.name === name);
471
+ if (def) {
472
+ const rel = this._relationships.find((r) => r.id === def.rId);
473
+ if (rel) {
474
+ const sheetPath = `xl/${rel.target}`;
475
+ writeZipText(this._files, sheetPath, worksheet.toXml());
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ // Update pivot tables
482
+ if (this._pivotTables.length > 0) {
483
+ this._updatePivotTableFiles();
484
+ }
485
+ }
486
+
487
+ private _updateWorkbookXml(): void {
488
+ const sheetNodes: XmlNode[] = this._sheetDefs.map((def) =>
489
+ createElement('sheet', { name: def.name, sheetId: String(def.sheetId), 'r:id': def.rId }, []),
490
+ );
491
+
492
+ const sheetsNode = createElement('sheets', {}, sheetNodes);
493
+
494
+ const children: XmlNode[] = [sheetsNode];
495
+
496
+ // Add pivot caches if any
497
+ if (this._pivotCaches.length > 0) {
498
+ const pivotCacheNodes: XmlNode[] = this._pivotCaches.map((cache, idx) => {
499
+ // Cache relationship ID is after sheets, sharedStrings, and styles
500
+ const cacheRelId = `rId${this._relationships.length + 3 + idx}`;
501
+ return createElement('pivotCache', { cacheId: String(cache.cacheId), 'r:id': cacheRelId }, []);
502
+ });
503
+ children.push(createElement('pivotCaches', {}, pivotCacheNodes));
504
+ }
505
+
506
+ const workbookNode = createElement(
507
+ 'workbook',
508
+ {
509
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
510
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
511
+ },
512
+ children,
513
+ );
514
+
515
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([workbookNode])}`;
516
+ writeZipText(this._files, 'xl/workbook.xml', xml);
517
+ }
518
+
519
+ private _updateRelationshipsXml(): void {
520
+ const relNodes: XmlNode[] = this._relationships.map((rel) =>
521
+ createElement('Relationship', { Id: rel.id, Type: rel.type, Target: rel.target }, []),
522
+ );
523
+
524
+ let nextRelId = this._relationships.length + 1;
525
+
526
+ // Add shared strings relationship if needed
527
+ if (this._sharedStrings.count > 0) {
528
+ const hasSharedStrings = this._relationships.some(
529
+ (r) => r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings',
530
+ );
531
+ if (!hasSharedStrings) {
532
+ relNodes.push(
533
+ createElement(
534
+ 'Relationship',
535
+ {
536
+ Id: `rId${nextRelId++}`,
537
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings',
538
+ Target: 'sharedStrings.xml',
539
+ },
540
+ [],
541
+ ),
542
+ );
543
+ }
544
+ }
545
+
546
+ // Add styles relationship if needed
547
+ const hasStyles = this._relationships.some(
548
+ (r) => r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles',
549
+ );
550
+ if (!hasStyles) {
551
+ relNodes.push(
552
+ createElement(
553
+ 'Relationship',
554
+ {
555
+ Id: `rId${nextRelId++}`,
556
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles',
557
+ Target: 'styles.xml',
558
+ },
559
+ [],
560
+ ),
561
+ );
562
+ }
563
+
564
+ // Add pivot cache relationships
565
+ for (let i = 0; i < this._pivotCaches.length; i++) {
566
+ relNodes.push(
567
+ createElement(
568
+ 'Relationship',
569
+ {
570
+ Id: `rId${nextRelId++}`,
571
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
572
+ Target: `pivotCache/pivotCacheDefinition${i + 1}.xml`,
573
+ },
574
+ [],
575
+ ),
576
+ );
577
+ }
578
+
579
+ const relsNode = createElement(
580
+ 'Relationships',
581
+ { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
582
+ relNodes,
583
+ );
584
+
585
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([relsNode])}`;
586
+ writeZipText(this._files, 'xl/_rels/workbook.xml.rels', xml);
587
+ }
588
+
589
+ private _updateContentTypes(): void {
590
+ const types: XmlNode[] = [
591
+ createElement(
592
+ 'Default',
593
+ { Extension: 'rels', ContentType: 'application/vnd.openxmlformats-package.relationships+xml' },
594
+ [],
595
+ ),
596
+ createElement('Default', { Extension: 'xml', ContentType: 'application/xml' }, []),
597
+ createElement(
598
+ 'Override',
599
+ {
600
+ PartName: '/xl/workbook.xml',
601
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml',
602
+ },
603
+ [],
604
+ ),
605
+ createElement(
606
+ 'Override',
607
+ {
608
+ PartName: '/xl/styles.xml',
609
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml',
610
+ },
611
+ [],
612
+ ),
613
+ ];
614
+
615
+ // Add shared strings if present
616
+ if (this._sharedStrings.count > 0) {
617
+ types.push(
618
+ createElement(
619
+ 'Override',
620
+ {
621
+ PartName: '/xl/sharedStrings.xml',
622
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml',
623
+ },
624
+ [],
625
+ ),
626
+ );
627
+ }
628
+
629
+ // Add worksheets
630
+ for (const def of this._sheetDefs) {
631
+ const rel = this._relationships.find((r) => r.id === def.rId);
632
+ if (rel) {
633
+ types.push(
634
+ createElement(
635
+ 'Override',
636
+ {
637
+ PartName: `/xl/${rel.target}`,
638
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml',
639
+ },
640
+ [],
641
+ ),
642
+ );
643
+ }
644
+ }
645
+
646
+ // Add pivot cache definitions and records
647
+ for (let i = 0; i < this._pivotCaches.length; i++) {
648
+ types.push(
649
+ createElement(
650
+ 'Override',
651
+ {
652
+ PartName: `/xl/pivotCache/pivotCacheDefinition${i + 1}.xml`,
653
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml',
654
+ },
655
+ [],
656
+ ),
657
+ );
658
+ types.push(
659
+ createElement(
660
+ 'Override',
661
+ {
662
+ PartName: `/xl/pivotCache/pivotCacheRecords${i + 1}.xml`,
663
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml',
664
+ },
665
+ [],
666
+ ),
667
+ );
668
+ }
669
+
670
+ // Add pivot tables
671
+ for (let i = 0; i < this._pivotTables.length; i++) {
672
+ types.push(
673
+ createElement(
674
+ 'Override',
675
+ {
676
+ PartName: `/xl/pivotTables/pivotTable${i + 1}.xml`,
677
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml',
678
+ },
679
+ [],
680
+ ),
681
+ );
682
+ }
683
+
684
+ const typesNode = createElement(
685
+ 'Types',
686
+ { xmlns: 'http://schemas.openxmlformats.org/package/2006/content-types' },
687
+ types,
688
+ );
689
+
690
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([typesNode])}`;
691
+ writeZipText(this._files, '[Content_Types].xml', xml);
692
+
693
+ // Also ensure _rels/.rels exists
694
+ const rootRelsXml = readZipText(this._files, '_rels/.rels');
695
+ if (!rootRelsXml) {
696
+ const rootRels = createElement(
697
+ 'Relationships',
698
+ { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
699
+ [
700
+ createElement(
701
+ 'Relationship',
702
+ {
703
+ Id: 'rId1',
704
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument',
705
+ Target: 'xl/workbook.xml',
706
+ },
707
+ [],
708
+ ),
709
+ ],
710
+ );
711
+ writeZipText(
712
+ this._files,
713
+ '_rels/.rels',
714
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([rootRels])}`,
715
+ );
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Generate all pivot table related files
721
+ */
722
+ private _updatePivotTableFiles(): void {
723
+ // Track which sheets have pivot tables for their .rels files
724
+ const sheetPivotTables: Map<string, PivotTable[]> = new Map();
725
+
726
+ for (const pivotTable of this._pivotTables) {
727
+ const sheetName = pivotTable.targetSheet;
728
+ if (!sheetPivotTables.has(sheetName)) {
729
+ sheetPivotTables.set(sheetName, []);
730
+ }
731
+ sheetPivotTables.get(sheetName)!.push(pivotTable);
732
+ }
733
+
734
+ // Generate pivot cache files
735
+ for (let i = 0; i < this._pivotCaches.length; i++) {
736
+ const cache = this._pivotCaches[i];
737
+ const cacheIdx = i + 1;
738
+
739
+ // Pivot cache definition
740
+ const definitionPath = `xl/pivotCache/pivotCacheDefinition${cacheIdx}.xml`;
741
+ writeZipText(this._files, definitionPath, cache.toDefinitionXml('rId1'));
742
+
743
+ // Pivot cache records
744
+ const recordsPath = `xl/pivotCache/pivotCacheRecords${cacheIdx}.xml`;
745
+ writeZipText(this._files, recordsPath, cache.toRecordsXml());
746
+
747
+ // Pivot cache definition relationships (link to records)
748
+ const cacheRelsPath = `xl/pivotCache/_rels/pivotCacheDefinition${cacheIdx}.xml.rels`;
749
+ const cacheRels = createElement(
750
+ 'Relationships',
751
+ { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
752
+ [
753
+ createElement(
754
+ 'Relationship',
755
+ {
756
+ Id: 'rId1',
757
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords',
758
+ Target: `pivotCacheRecords${cacheIdx}.xml`,
759
+ },
760
+ [],
761
+ ),
762
+ ],
763
+ );
764
+ writeZipText(
765
+ this._files,
766
+ cacheRelsPath,
767
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([cacheRels])}`,
768
+ );
769
+ }
770
+
771
+ // Generate pivot table files
772
+ for (let i = 0; i < this._pivotTables.length; i++) {
773
+ const pivotTable = this._pivotTables[i];
774
+ const ptIdx = i + 1;
775
+
776
+ // Pivot table definition
777
+ const ptPath = `xl/pivotTables/pivotTable${ptIdx}.xml`;
778
+ writeZipText(this._files, ptPath, pivotTable.toXml());
779
+
780
+ // Pivot table relationships (link to cache definition)
781
+ const cacheIdx = this._pivotCaches.indexOf(pivotTable.cache) + 1;
782
+ const ptRelsPath = `xl/pivotTables/_rels/pivotTable${ptIdx}.xml.rels`;
783
+ const ptRels = createElement(
784
+ 'Relationships',
785
+ { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
786
+ [
787
+ createElement(
788
+ 'Relationship',
789
+ {
790
+ Id: 'rId1',
791
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
792
+ Target: `../pivotCache/pivotCacheDefinition${cacheIdx}.xml`,
793
+ },
794
+ [],
795
+ ),
796
+ ],
797
+ );
798
+ writeZipText(
799
+ this._files,
800
+ ptRelsPath,
801
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([ptRels])}`,
802
+ );
803
+ }
804
+
805
+ // Generate worksheet relationships for pivot tables
806
+ for (const [sheetName, pivotTables] of sheetPivotTables) {
807
+ const def = this._sheetDefs.find((s) => s.name === sheetName);
808
+ if (!def) continue;
809
+
810
+ const rel = this._relationships.find((r) => r.id === def.rId);
811
+ if (!rel) continue;
812
+
813
+ // Extract sheet file name from target path
814
+ const sheetFileName = rel.target.split('/').pop();
815
+ const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
816
+
817
+ const relNodes: XmlNode[] = [];
818
+ for (let i = 0; i < pivotTables.length; i++) {
819
+ const pt = pivotTables[i];
820
+ relNodes.push(
821
+ createElement(
822
+ 'Relationship',
823
+ {
824
+ Id: `rId${i + 1}`,
825
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable',
826
+ Target: `../pivotTables/pivotTable${pt.index}.xml`,
827
+ },
828
+ [],
829
+ ),
830
+ );
831
+ }
832
+
833
+ const sheetRels = createElement(
834
+ 'Relationships',
835
+ { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
836
+ relNodes,
837
+ );
838
+ writeZipText(
839
+ this._files,
840
+ sheetRelsPath,
841
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([sheetRels])}`,
842
+ );
843
+ }
844
+ }
845
+ }