@niicojs/excel 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/workbook.ts CHANGED
@@ -2,67 +2,70 @@ import { readFile, writeFile } from 'fs/promises';
2
2
  import type {
3
3
  SheetDefinition,
4
4
  Relationship,
5
- PivotTableConfig,
6
5
  CellValue,
7
6
  SheetFromDataConfig,
8
7
  ColumnConfig,
9
8
  RichCellValue,
10
9
  DateHandling,
10
+ PivotTableConfig,
11
+ RangeAddress,
12
+ WorkbookReadOptions,
11
13
  } from './types';
12
14
  import { Worksheet } from './worksheet';
13
15
  import { SharedStrings } from './shared-strings';
14
16
  import { Styles } from './styles';
15
17
  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';
18
+ import { readZip, writeZip, readZipText, writeZipText, ZipStore, createZipStore } from './utils/zip';
19
+ import { parseAddress, parseSheetAddress, parseSheetRange } from './utils/address';
19
20
  import { parseXml, findElement, getChildren, getAttr, XmlNode, stringifyXml, createElement } from './utils/xml';
20
21
 
21
22
  /**
22
23
  * Represents an Excel workbook (.xlsx file)
23
24
  */
24
25
  export class Workbook {
25
- private _files: ZipFiles = new Map();
26
+ private _files: ZipStore = createZipStore();
26
27
  private _sheets: Map<string, Worksheet> = new Map();
27
28
  private _sheetDefs: SheetDefinition[] = [];
28
29
  private _relationships: Relationship[] = [];
29
- private _sharedStrings: SharedStrings;
30
- private _styles: Styles;
30
+ private _sharedStrings: SharedStrings | null = null;
31
+ private _styles: Styles | null = null;
32
+ private _sharedStringsXml: string | null = null;
33
+ private _stylesXml: string | null = null;
34
+ private _lazy = true;
31
35
  private _dirty = false;
32
36
 
33
- // Pivot table support
34
- private _pivotTables: PivotTable[] = [];
35
- private _pivotCaches: PivotCache[] = [];
36
- private _nextCacheId = 5;
37
- private _nextCacheFileIndex = 1;
38
-
39
37
  // Table support
40
38
  private _nextTableId = 1;
41
39
 
40
+ // Pivot table support
41
+ private _pivotTables: PivotTable[] = [];
42
+ private _nextPivotTableId = 1;
43
+ private _nextPivotCacheId = 1;
44
+
42
45
  // Date serialization handling
43
46
  private _dateHandling: DateHandling = 'jsDate';
44
47
 
45
48
  private _locale = 'fr-FR';
46
49
 
47
50
  private constructor() {
48
- this._sharedStrings = new SharedStrings();
49
- this._styles = Styles.createDefault();
51
+ // Lazy init
50
52
  }
51
53
 
52
54
  /**
53
55
  * Load a workbook from a file path
54
56
  */
55
- static async fromFile(path: string): Promise<Workbook> {
57
+ static async fromFile(path: string, options: WorkbookReadOptions = {}): Promise<Workbook> {
56
58
  const data = await readFile(path);
57
- return Workbook.fromBuffer(new Uint8Array(data));
59
+ return Workbook.fromBuffer(new Uint8Array(data), options);
58
60
  }
59
61
 
60
62
  /**
61
63
  * Load a workbook from a buffer
62
64
  */
63
- static async fromBuffer(data: Uint8Array): Promise<Workbook> {
65
+ static async fromBuffer(data: Uint8Array, options: WorkbookReadOptions = {}): Promise<Workbook> {
64
66
  const workbook = new Workbook();
65
- workbook._files = await readZip(data);
67
+ workbook._lazy = options.lazy ?? true;
68
+ workbook._files = await readZip(data, { lazy: workbook._lazy });
66
69
 
67
70
  // Parse workbook.xml for sheet definitions
68
71
  const workbookXml = readZipText(workbook._files, 'xl/workbook.xml');
@@ -77,16 +80,9 @@ export class Workbook {
77
80
  }
78
81
 
79
82
  // Parse shared strings
80
- const sharedStringsXml = readZipText(workbook._files, 'xl/sharedStrings.xml');
81
- if (sharedStringsXml) {
82
- workbook._sharedStrings = SharedStrings.parse(sharedStringsXml);
83
- }
84
-
85
- // Parse styles
86
- const stylesXml = readZipText(workbook._files, 'xl/styles.xml');
87
- if (stylesXml) {
88
- workbook._styles = Styles.parse(stylesXml);
89
- }
83
+ // Store shared strings/styles XML for lazy parse
84
+ workbook._sharedStringsXml = readZipText(workbook._files, 'xl/sharedStrings.xml') ?? null;
85
+ workbook._stylesXml = readZipText(workbook._files, 'xl/styles.xml') ?? null;
90
86
 
91
87
  return workbook;
92
88
  }
@@ -97,6 +93,9 @@ export class Workbook {
97
93
  static create(): Workbook {
98
94
  const workbook = new Workbook();
99
95
  workbook._dirty = true;
96
+ workbook._lazy = false;
97
+ workbook._sharedStrings = new SharedStrings();
98
+ workbook._styles = Styles.createDefault();
100
99
 
101
100
  return workbook;
102
101
  }
@@ -119,6 +118,13 @@ export class Workbook {
119
118
  * Get shared strings table
120
119
  */
121
120
  get sharedStrings(): SharedStrings {
121
+ if (!this._sharedStrings) {
122
+ if (this._sharedStringsXml) {
123
+ this._sharedStrings = SharedStrings.parse(this._sharedStringsXml);
124
+ } else {
125
+ this._sharedStrings = new SharedStrings();
126
+ }
127
+ }
122
128
  return this._sharedStrings;
123
129
  }
124
130
 
@@ -126,6 +132,13 @@ export class Workbook {
126
132
  * Get styles
127
133
  */
128
134
  get styles(): Styles {
135
+ if (!this._styles) {
136
+ if (this._stylesXml) {
137
+ this._styles = Styles.parse(this._stylesXml);
138
+ } else {
139
+ this._styles = Styles.createDefault();
140
+ }
141
+ }
129
142
  return this._styles;
130
143
  }
131
144
 
@@ -166,6 +179,102 @@ export class Workbook {
166
179
  return this._nextTableId++;
167
180
  }
168
181
 
182
+ /**
183
+ * Get all pivot tables in the workbook.
184
+ */
185
+ get pivotTables(): PivotTable[] {
186
+ return [...this._pivotTables];
187
+ }
188
+
189
+ /**
190
+ * Create a new pivot table.
191
+ */
192
+ createPivotTable(config: PivotTableConfig): PivotTable {
193
+ if (!config.name || config.name.trim().length === 0) {
194
+ throw new Error('Pivot table name is required');
195
+ }
196
+
197
+ if (this._pivotTables.some((pivot) => pivot.name === config.name)) {
198
+ throw new Error(`Pivot table name already exists: ${config.name}`);
199
+ }
200
+
201
+ const sourceRef = parseSheetRange(config.source);
202
+ const targetRef = parseSheetAddress(config.target);
203
+
204
+ const sourceSheet = this.sheet(sourceRef.sheet);
205
+ this.sheet(targetRef.sheet);
206
+
207
+ const sourceRange = this._normalizeRange(sourceRef.range);
208
+ if (sourceRange.start.row >= sourceRange.end.row) {
209
+ throw new Error('Pivot source range must include a header row and at least one data row');
210
+ }
211
+
212
+ const fields = this._extractPivotFields(sourceSheet, sourceRange);
213
+
214
+ const cacheId = this._nextPivotCacheId++;
215
+ const pivotId = this._nextPivotTableId++;
216
+ const cachePartIndex = this._pivotTables.length + 1;
217
+
218
+ const pivot = new PivotTable(
219
+ this,
220
+ config,
221
+ sourceRef.sheet,
222
+ sourceSheet,
223
+ sourceRange,
224
+ targetRef.sheet,
225
+ targetRef.address,
226
+ cacheId,
227
+ pivotId,
228
+ cachePartIndex,
229
+ fields,
230
+ );
231
+
232
+ this._pivotTables.push(pivot);
233
+ this._dirty = true;
234
+
235
+ return pivot;
236
+ }
237
+
238
+ private _extractPivotFields(
239
+ sourceSheet: Worksheet,
240
+ sourceRange: RangeAddress,
241
+ ): { name: string; sourceCol: number }[] {
242
+ const fields: { name: string; sourceCol: number }[] = [];
243
+ const seen = new Set<string>();
244
+
245
+ for (let col = sourceRange.start.col; col <= sourceRange.end.col; col++) {
246
+ const headerCell = sourceSheet.getCellIfExists(sourceRange.start.row, col);
247
+ const rawHeader = headerCell?.value;
248
+ const name = rawHeader == null ? `Column${col - sourceRange.start.col + 1}` : String(rawHeader).trim();
249
+
250
+ if (!name) {
251
+ throw new Error(`Pivot source header is empty at column ${col + 1}`);
252
+ }
253
+
254
+ if (seen.has(name)) {
255
+ throw new Error(`Duplicate pivot source header: ${name}`);
256
+ }
257
+
258
+ seen.add(name);
259
+ fields.push({ name, sourceCol: col });
260
+ }
261
+
262
+ return fields;
263
+ }
264
+
265
+ private _normalizeRange(range: RangeAddress): RangeAddress {
266
+ return {
267
+ start: {
268
+ row: Math.min(range.start.row, range.end.row),
269
+ col: Math.min(range.start.col, range.end.col),
270
+ },
271
+ end: {
272
+ row: Math.max(range.start.row, range.end.row),
273
+ col: Math.max(range.start.col, range.end.col),
274
+ },
275
+ };
276
+ }
277
+
169
278
  /**
170
279
  * Get a worksheet by name or index
171
280
  */
@@ -196,7 +305,7 @@ export class Workbook {
196
305
  const sheetPath = `xl/${rel.target}`;
197
306
  const sheetXml = readZipText(this._files, sheetPath);
198
307
  if (sheetXml) {
199
- worksheet.parse(sheetXml);
308
+ worksheet.parse(sheetXml, { lazy: this._lazy });
200
309
  }
201
310
  }
202
311
 
@@ -559,123 +668,6 @@ export class Workbook {
559
668
  return String(value);
560
669
  }
561
670
 
562
- /**
563
- * Create a pivot table from source data.
564
- *
565
- * @param config - Pivot table configuration
566
- * @returns PivotTable instance for fluent configuration
567
- *
568
- * @example
569
- * ```typescript
570
- * const pivot = wb.createPivotTable({
571
- * name: 'SalesPivot',
572
- * source: 'DataSheet!A1:D100',
573
- * target: 'PivotSheet!A3',
574
- * });
575
- *
576
- * pivot
577
- * .addRowField('Region')
578
- * .addColumnField('Product')
579
- * .addValueField('Sales', 'sum', 'Total Sales');
580
- * ```
581
- */
582
- createPivotTable(config: PivotTableConfig): PivotTable {
583
- this._dirty = true;
584
-
585
- // Parse source reference (Sheet!Range)
586
- const { sheetName: sourceSheet, range: sourceRange } = this._parseSheetRef(config.source);
587
-
588
- // Parse target reference
589
- const { sheetName: targetSheet, range: targetCell } = this._parseSheetRef(config.target);
590
-
591
- // Ensure target sheet exists
592
- if (!this._sheetDefs.some((s) => s.name === targetSheet)) {
593
- this.addSheet(targetSheet);
594
- }
595
-
596
- // Parse target cell address
597
- const targetAddr = parseAddress(targetCell);
598
-
599
- // Get source worksheet and extract data
600
- const sourceWs = this.sheet(sourceSheet);
601
- const { headers, data } = this._extractSourceData(sourceWs, sourceRange);
602
-
603
- // Create pivot cache
604
- const cacheId = this._nextCacheId++;
605
- const cacheFileIndex = this._nextCacheFileIndex++;
606
- const cache = new PivotCache(cacheId, sourceSheet, sourceRange, cacheFileIndex);
607
- cache.setStyles(this._styles);
608
- cache.buildFromData(headers, data);
609
- // refreshOnLoad defaults to true; only disable if explicitly set to false
610
- if (config.refreshOnLoad === false) {
611
- cache.refreshOnLoad = false;
612
- }
613
- // saveData defaults to true; only disable if explicitly set to false
614
- if (config.saveData === false) {
615
- cache.saveData = false;
616
- }
617
- this._pivotCaches.push(cache);
618
-
619
- // Create pivot table
620
- const pivotTableIndex = this._pivotTables.length + 1;
621
- const pivotTable = new PivotTable(
622
- config.name,
623
- cache,
624
- targetSheet,
625
- targetCell,
626
- targetAddr.row + 1, // Convert to 1-based
627
- targetAddr.col,
628
- pivotTableIndex,
629
- cacheFileIndex,
630
- );
631
-
632
- // Set styles reference for number format resolution
633
- pivotTable.setStyles(this._styles);
634
-
635
- this._pivotTables.push(pivotTable);
636
-
637
- return pivotTable;
638
- }
639
-
640
- /**
641
- * Parse a sheet reference like "Sheet1!A1:D100" into sheet name and range
642
- */
643
- private _parseSheetRef(ref: string): { sheetName: string; range: string } {
644
- const match = ref.match(/^(.+?)!(.+)$/);
645
- if (!match) {
646
- throw new Error(`Invalid reference format: ${ref}. Expected "SheetName!Range"`);
647
- }
648
- return { sheetName: match[1], range: match[2] };
649
- }
650
-
651
- /**
652
- * Extract headers and data from a source range
653
- */
654
- private _extractSourceData(sheet: Worksheet, rangeStr: string): { headers: string[]; data: CellValue[][] } {
655
- const range = parseRange(rangeStr);
656
- const headers: string[] = [];
657
- const data: CellValue[][] = [];
658
-
659
- // First row is headers
660
- for (let col = range.start.col; col <= range.end.col; col++) {
661
- const cell = sheet.cell(toAddress(range.start.row, col));
662
- headers.push(String(cell.value ?? `Column${col + 1}`));
663
- }
664
-
665
- // Remaining rows are data
666
- for (let row = range.start.row + 1; row <= range.end.row; row++) {
667
- const rowData: CellValue[] = [];
668
- for (let col = range.start.col; col <= range.end.col; col++) {
669
- const cell = sheet.cell(toAddress(row, col));
670
- rowData.push(cell.value);
671
- }
672
- data.push(rowData);
673
- }
674
-
675
- return { headers, data };
676
- }
677
-
678
-
679
671
  /**
680
672
  * Save the workbook to a file
681
673
  */
@@ -743,7 +735,7 @@ export class Workbook {
743
735
  const relationshipInfo = this._buildRelationshipInfo();
744
736
 
745
737
  // Update workbook.xml
746
- this._updateWorkbookXml(relationshipInfo.pivotCacheRelIds);
738
+ this._updateWorkbookXml(relationshipInfo.pivotCacheRelByTarget);
747
739
 
748
740
  // Update relationships
749
741
  this._updateRelationshipsXml(relationshipInfo.relNodes);
@@ -752,16 +744,24 @@ export class Workbook {
752
744
  this._updateContentTypes();
753
745
 
754
746
  // Update shared strings if modified
755
- if (this._sharedStrings.dirty || this._sharedStrings.count > 0) {
756
- writeZipText(this._files, 'xl/sharedStrings.xml', this._sharedStrings.toXml());
747
+ if (this._sharedStrings) {
748
+ if (this._sharedStrings.dirty || this._sharedStrings.count > 0) {
749
+ writeZipText(this._files, 'xl/sharedStrings.xml', this._sharedStrings.toXml());
750
+ }
751
+ } else if (this._sharedStringsXml) {
752
+ writeZipText(this._files, 'xl/sharedStrings.xml', this._sharedStringsXml);
757
753
  }
758
754
 
759
755
  // Update styles if modified or if file doesn't exist yet
760
- if (this._styles.dirty || this._dirty || !this._files.has('xl/styles.xml')) {
761
- writeZipText(this._files, 'xl/styles.xml', this._styles.toXml());
756
+ if (this._styles) {
757
+ if (this._styles.dirty || this._dirty || !this._files.has('xl/styles.xml')) {
758
+ writeZipText(this._files, 'xl/styles.xml', this._styles.toXml());
759
+ }
760
+ } else if (this._stylesXml) {
761
+ writeZipText(this._files, 'xl/styles.xml', this._stylesXml);
762
762
  }
763
763
 
764
- // Update worksheets (needed for pivot table targets)
764
+ // Update worksheets
765
765
  for (const [name, worksheet] of this._sheets) {
766
766
  if (worksheet.dirty || this._dirty || worksheet.tables.length > 0) {
767
767
  const def = this._sheetDefs.find((s) => s.name === name);
@@ -775,17 +775,15 @@ export class Workbook {
775
775
  }
776
776
  }
777
777
 
778
- // Update pivot tables
779
- if (this._pivotTables.length > 0) {
780
- this._updatePivotTableFiles();
781
- }
782
-
783
778
  // Update tables (sets table rel IDs for tableParts)
784
779
  this._updateTableFiles();
785
780
 
781
+ // Update pivot tables (sets pivot rel IDs for pivotTableParts)
782
+ this._updatePivotFiles();
783
+
786
784
  // Update worksheets to align tableParts with relationship IDs
787
785
  for (const [name, worksheet] of this._sheets) {
788
- if (worksheet.dirty || this._dirty || worksheet.tables.length > 0) {
786
+ if (worksheet.dirty || this._dirty || worksheet.tables.length > 0 || this._pivotTables.length > 0) {
789
787
  const def = this._sheetDefs.find((s) => s.name === name);
790
788
  if (def) {
791
789
  const rel = this._relationships.find((r) => r.id === def.rId);
@@ -798,7 +796,7 @@ export class Workbook {
798
796
  }
799
797
  }
800
798
 
801
- private _updateWorkbookXml(pivotCacheRelIds: Map<number, string>): void {
799
+ private _updateWorkbookXml(pivotCacheRelByTarget: Map<string, string>): void {
802
800
  const sheetNodes: XmlNode[] = this._sheetDefs.map((def) =>
803
801
  createElement('sheet', { name: def.name, sheetId: String(def.sheetId), 'r:id': def.rId }, []),
804
802
  );
@@ -807,16 +805,18 @@ export class Workbook {
807
805
 
808
806
  const children: XmlNode[] = [sheetsNode];
809
807
 
810
- // Add pivot caches if any
811
- if (this._pivotCaches.length > 0) {
812
- const pivotCacheNodes: XmlNode[] = this._pivotCaches.map((cache) => {
813
- const cacheRelId = pivotCacheRelIds.get(cache.cacheId);
814
- if (!cacheRelId) {
815
- throw new Error(`Missing pivot cache relationship ID for cache ${cache.cacheId}`);
816
- }
817
- return createElement('pivotCache', { cacheId: String(cache.cacheId), 'r:id': cacheRelId }, []);
818
- });
819
- children.push(createElement('pivotCaches', {}, pivotCacheNodes));
808
+ if (this._pivotTables.length > 0) {
809
+ const pivotCacheNodes: XmlNode[] = [];
810
+ for (const pivot of this._pivotTables) {
811
+ const target = `pivotCache/pivotCacheDefinition${pivot.cachePartIndex}.xml`;
812
+ const relId = pivotCacheRelByTarget.get(target);
813
+ if (!relId) continue;
814
+ pivotCacheNodes.push(createElement('pivotCache', { cacheId: String(pivot.cacheId), 'r:id': relId }, []));
815
+ }
816
+
817
+ if (pivotCacheNodes.length > 0) {
818
+ children.push(createElement('pivotCaches', {}, pivotCacheNodes));
819
+ }
820
820
  }
821
821
 
822
822
  const workbookNode = createElement(
@@ -843,10 +843,11 @@ export class Workbook {
843
843
  writeZipText(this._files, 'xl/_rels/workbook.xml.rels', xml);
844
844
  }
845
845
 
846
- private _buildRelationshipInfo(): { relNodes: XmlNode[]; pivotCacheRelIds: Map<number, string> } {
846
+ private _buildRelationshipInfo(): { relNodes: XmlNode[]; pivotCacheRelByTarget: Map<string, string> } {
847
847
  const relNodes: XmlNode[] = this._relationships.map((rel) =>
848
848
  createElement('Relationship', { Id: rel.id, Type: rel.type, Target: rel.target }, []),
849
849
  );
850
+ const pivotCacheRelByTarget = new Map<string, string>();
850
851
 
851
852
  const reservedRelIds = new Set<string>(relNodes.map((node) => getAttr(node, 'Id') || '').filter(Boolean));
852
853
  let nextRelId = Math.max(0, ...this._relationships.map((r) => parseInt(r.id.replace('rId', ''), 10) || 0)) + 1;
@@ -862,7 +863,8 @@ export class Workbook {
862
863
  };
863
864
 
864
865
  // Add shared strings relationship if needed
865
- if (this._sharedStrings.count > 0) {
866
+ const shouldIncludeSharedStrings = (this._sharedStrings?.count ?? 0) > 0 || this._sharedStringsXml !== null;
867
+ if (shouldIncludeSharedStrings) {
866
868
  const hasSharedStrings = this._relationships.some(
867
869
  (r) => r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings',
868
870
  );
@@ -899,28 +901,48 @@ export class Workbook {
899
901
  );
900
902
  }
901
903
 
902
- // Add pivot cache relationships
903
- const pivotCacheRelIds = new Map<number, string>();
904
- for (const cache of this._pivotCaches) {
905
- const id = allocateRelId();
906
- pivotCacheRelIds.set(cache.cacheId, id);
907
- relNodes.push(
908
- createElement(
909
- 'Relationship',
910
- {
911
- Id: id,
912
- Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
913
- Target: `pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`,
914
- },
915
- [],
916
- ),
904
+ for (const pivot of this._pivotTables) {
905
+ const target = `pivotCache/pivotCacheDefinition${pivot.cachePartIndex}.xml`;
906
+ const hasPivotCacheRel = relNodes.some(
907
+ (node) =>
908
+ getAttr(node, 'Type') ===
909
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition' &&
910
+ getAttr(node, 'Target') === target,
917
911
  );
912
+
913
+ if (!hasPivotCacheRel) {
914
+ const id = allocateRelId();
915
+ pivotCacheRelByTarget.set(target, id);
916
+ relNodes.push(
917
+ createElement(
918
+ 'Relationship',
919
+ {
920
+ Id: id,
921
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
922
+ Target: target,
923
+ },
924
+ [],
925
+ ),
926
+ );
927
+ } else {
928
+ const existing = relNodes.find(
929
+ (node) =>
930
+ getAttr(node, 'Type') ===
931
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition' &&
932
+ getAttr(node, 'Target') === target,
933
+ );
934
+ const existingId = existing ? getAttr(existing, 'Id') : undefined;
935
+ if (existingId) {
936
+ pivotCacheRelByTarget.set(target, existingId);
937
+ }
938
+ }
918
939
  }
919
940
 
920
- return { relNodes, pivotCacheRelIds };
941
+ return { relNodes, pivotCacheRelByTarget };
921
942
  }
922
943
 
923
944
  private _updateContentTypes(): void {
945
+ const shouldIncludeSharedStrings = (this._sharedStrings?.count ?? 0) > 0 || this._sharedStringsXml !== null;
924
946
  const types: XmlNode[] = [
925
947
  createElement(
926
948
  'Default',
@@ -947,7 +969,7 @@ export class Workbook {
947
969
  ];
948
970
 
949
971
  // Add shared strings if present
950
- if (this._sharedStrings.count > 0) {
972
+ if (shouldIncludeSharedStrings) {
951
973
  types.push(
952
974
  createElement(
953
975
  'Override',
@@ -977,37 +999,56 @@ export class Workbook {
977
999
  }
978
1000
  }
979
1001
 
980
- // Add pivot cache definitions and records
981
- for (const cache of this._pivotCaches) {
1002
+ // Add tables
1003
+ let tableIndex = 1;
1004
+ for (const def of this._sheetDefs) {
1005
+ const worksheet = this._sheets.get(def.name);
1006
+ if (worksheet) {
1007
+ for (let i = 0; i < worksheet.tables.length; i++) {
1008
+ types.push(
1009
+ createElement(
1010
+ 'Override',
1011
+ {
1012
+ PartName: `/xl/tables/table${tableIndex}.xml`,
1013
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml',
1014
+ },
1015
+ [],
1016
+ ),
1017
+ );
1018
+ tableIndex++;
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ // Add pivot caches and pivot tables
1024
+ for (const pivot of this._pivotTables) {
982
1025
  types.push(
983
1026
  createElement(
984
1027
  'Override',
985
1028
  {
986
- PartName: `/xl/pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`,
1029
+ PartName: `/xl/pivotCache/pivotCacheDefinition${pivot.cachePartIndex}.xml`,
987
1030
  ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml',
988
1031
  },
989
1032
  [],
990
1033
  ),
991
1034
  );
1035
+
992
1036
  types.push(
993
1037
  createElement(
994
1038
  'Override',
995
1039
  {
996
- PartName: `/xl/pivotCache/pivotCacheRecords${cache.fileIndex}.xml`,
1040
+ PartName: `/xl/pivotCache/pivotCacheRecords${pivot.cachePartIndex}.xml`,
997
1041
  ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml',
998
1042
  },
999
1043
  [],
1000
1044
  ),
1001
1045
  );
1002
- }
1003
1046
 
1004
- // Add pivot tables
1005
- for (const pivotTable of this._pivotTables) {
1006
1047
  types.push(
1007
1048
  createElement(
1008
1049
  'Override',
1009
1050
  {
1010
- PartName: `/xl/pivotTables/pivotTable${pivotTable.index}.xml`,
1051
+ PartName: `/xl/pivotTables/pivotTable${pivot.pivotId}.xml`,
1011
1052
  ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml',
1012
1053
  },
1013
1054
  [],
@@ -1015,27 +1056,6 @@ export class Workbook {
1015
1056
  );
1016
1057
  }
1017
1058
 
1018
- // Add tables
1019
- let tableIndex = 1;
1020
- for (const def of this._sheetDefs) {
1021
- const worksheet = this._sheets.get(def.name);
1022
- if (worksheet) {
1023
- for (let i = 0; i < worksheet.tables.length; i++) {
1024
- types.push(
1025
- createElement(
1026
- 'Override',
1027
- {
1028
- PartName: `/xl/tables/table${tableIndex}.xml`,
1029
- ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml',
1030
- },
1031
- [],
1032
- ),
1033
- );
1034
- tableIndex++;
1035
- }
1036
- }
1037
- }
1038
-
1039
1059
  const existingTypesXml = readZipText(this._files, '[Content_Types].xml');
1040
1060
  const existingKeys = new Set(
1041
1061
  types
@@ -1110,92 +1130,38 @@ export class Workbook {
1110
1130
  }
1111
1131
 
1112
1132
  /**
1113
- * Generate all pivot table related files
1133
+ * Generate all table related files
1114
1134
  */
1115
- private _updatePivotTableFiles(): void {
1116
- // Track which sheets have pivot tables for their .rels files
1117
- const sheetPivotTables: Map<string, PivotTable[]> = new Map();
1118
-
1119
- for (const pivotTable of this._pivotTables) {
1120
- const sheetName = pivotTable.targetSheet;
1121
- if (!sheetPivotTables.has(sheetName)) {
1122
- sheetPivotTables.set(sheetName, []);
1123
- }
1124
- sheetPivotTables.get(sheetName)!.push(pivotTable);
1125
- }
1126
-
1127
- // Generate pivot cache files
1128
- for (let i = 0; i < this._pivotCaches.length; i++) {
1129
- const cache = this._pivotCaches[i];
1135
+ private _updateTableFiles(): void {
1136
+ // Collect all tables with their global indices
1137
+ let globalTableIndex = 1;
1138
+ const sheetTables: Map<string, { table: import('./table').Table; globalIndex: number }[]> = new Map();
1130
1139
 
1131
- // Pivot cache definition
1132
- const definitionPath = `xl/pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`;
1133
- writeZipText(this._files, definitionPath, cache.toDefinitionXml('rId1'));
1140
+ for (const def of this._sheetDefs) {
1141
+ const worksheet = this._sheets.get(def.name);
1142
+ if (!worksheet) continue;
1134
1143
 
1135
- // Pivot cache records
1136
- const recordsPath = `xl/pivotCache/pivotCacheRecords${cache.fileIndex}.xml`;
1137
- writeZipText(this._files, recordsPath, cache.toRecordsXml());
1144
+ const tables = worksheet.tables;
1145
+ if (tables.length === 0) continue;
1138
1146
 
1139
- // Pivot cache definition relationships (link to records)
1140
- const cacheRelsPath = `xl/pivotCache/_rels/pivotCacheDefinition${cache.fileIndex}.xml.rels`;
1141
- const cacheRels = createElement(
1142
- 'Relationships',
1143
- { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
1144
- [
1145
- createElement(
1146
- 'Relationship',
1147
- {
1148
- Id: 'rId1',
1149
- Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords',
1150
- Target: `pivotCacheRecords${cache.fileIndex}.xml`,
1151
- },
1152
- [],
1153
- ),
1154
- ],
1155
- );
1156
- writeZipText(
1157
- this._files,
1158
- cacheRelsPath,
1159
- `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([cacheRels])}`,
1160
- );
1147
+ const tableInfos: { table: import('./table').Table; globalIndex: number }[] = [];
1148
+ for (const table of tables) {
1149
+ tableInfos.push({ table, globalIndex: globalTableIndex });
1150
+ globalTableIndex++;
1151
+ }
1152
+ sheetTables.set(def.name, tableInfos);
1161
1153
  }
1162
1154
 
1163
- // Generate pivot table files
1164
- for (let i = 0; i < this._pivotTables.length; i++) {
1165
- const pivotTable = this._pivotTables[i];
1166
- const ptIdx = pivotTable.index;
1167
-
1168
- // Pivot table definition
1169
- const ptPath = `xl/pivotTables/pivotTable${ptIdx}.xml`;
1170
- writeZipText(this._files, ptPath, pivotTable.toXml());
1171
-
1172
- // Pivot table relationships (link to cache definition)
1173
- const cacheIdx = pivotTable.cacheFileIndex;
1174
- const ptRelsPath = `xl/pivotTables/_rels/pivotTable${ptIdx}.xml.rels`;
1175
- const ptRels = createElement(
1176
- 'Relationships',
1177
- { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
1178
- [
1179
- createElement(
1180
- 'Relationship',
1181
- {
1182
- Id: 'rId1',
1183
- Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
1184
- Target: `../pivotCache/pivotCacheDefinition${cacheIdx}.xml`,
1185
- },
1186
- [],
1187
- ),
1188
- ],
1189
- );
1190
- writeZipText(
1191
- this._files,
1192
- ptRelsPath,
1193
- `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([ptRels])}`,
1194
- );
1155
+ // Generate table files
1156
+ for (const [, tableInfos] of sheetTables) {
1157
+ for (const { table, globalIndex } of tableInfos) {
1158
+ const tablePath = `xl/tables/table${globalIndex}.xml`;
1159
+ writeZipText(this._files, tablePath, table.toXml());
1160
+ }
1195
1161
  }
1196
1162
 
1197
- // Generate worksheet relationships for pivot tables
1198
- for (const [sheetName, pivotTables] of sheetPivotTables) {
1163
+ // Generate worksheet relationships for tables
1164
+ for (const [sheetName, tableInfos] of sheetTables) {
1199
1165
  const def = this._sheetDefs.find((s) => s.name === sheetName);
1200
1166
  if (!def) continue;
1201
1167
 
@@ -1206,12 +1172,14 @@ export class Workbook {
1206
1172
  const sheetFileName = rel.target.split('/').pop();
1207
1173
  const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
1208
1174
 
1175
+ // Check if there are already pivot table relationships for this sheet
1209
1176
  const existingRelsXml = readZipText(this._files, sheetRelsPath);
1210
- let relNodes: XmlNode[] = [];
1211
1177
  let nextRelId = 1;
1178
+ const relNodes: XmlNode[] = [];
1212
1179
  const reservedRelIds = new Set<string>();
1213
1180
 
1214
1181
  if (existingRelsXml) {
1182
+ // Parse existing rels and find max rId
1215
1183
  const parsed = parseXml(existingRelsXml);
1216
1184
  const relsElement = findElement(parsed, 'Relationships');
1217
1185
  if (relsElement) {
@@ -1242,21 +1210,33 @@ export class Workbook {
1242
1210
  return id;
1243
1211
  };
1244
1212
 
1245
- for (const pt of pivotTables) {
1246
- const target = `../pivotTables/pivotTable${pt.index}.xml`;
1213
+ // Add table relationships
1214
+ const tableRelIds: string[] = [];
1215
+ for (const { globalIndex } of tableInfos) {
1216
+ const target = `../tables/table${globalIndex}.xml`;
1247
1217
  const existing = relNodes.some(
1248
1218
  (node) =>
1249
- getAttr(node, 'Type') ===
1250
- 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable' &&
1219
+ getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' &&
1251
1220
  getAttr(node, 'Target') === target,
1252
1221
  );
1253
- if (existing) continue;
1222
+ if (existing) {
1223
+ const existingRel = relNodes.find(
1224
+ (node) =>
1225
+ getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' &&
1226
+ getAttr(node, 'Target') === target,
1227
+ );
1228
+ const existingId = existingRel ? getAttr(existingRel, 'Id') : undefined;
1229
+ tableRelIds.push(existingId ?? allocateRelId());
1230
+ continue;
1231
+ }
1232
+ const id = allocateRelId();
1233
+ tableRelIds.push(id);
1254
1234
  relNodes.push(
1255
1235
  createElement(
1256
1236
  'Relationship',
1257
1237
  {
1258
- Id: allocateRelId(),
1259
- Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable',
1238
+ Id: id,
1239
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table',
1260
1240
  Target: target,
1261
1241
  },
1262
1242
  [],
@@ -1264,6 +1244,11 @@ export class Workbook {
1264
1244
  );
1265
1245
  }
1266
1246
 
1247
+ const worksheet = this._sheets.get(sheetName);
1248
+ if (worksheet) {
1249
+ worksheet.setTableRelIds(tableRelIds);
1250
+ }
1251
+
1267
1252
  const sheetRels = createElement(
1268
1253
  'Relationships',
1269
1254
  { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
@@ -1278,61 +1263,58 @@ export class Workbook {
1278
1263
  }
1279
1264
 
1280
1265
  /**
1281
- * Generate all table related files
1266
+ * Generate pivot cache/table parts and worksheet relationships.
1282
1267
  */
1283
- private _updateTableFiles(): void {
1284
- // Collect all tables with their global indices
1285
- let globalTableIndex = 1;
1286
- const sheetTables: Map<string, { table: import('./table').Table; globalIndex: number }[]> = new Map();
1268
+ private _updatePivotFiles(): void {
1269
+ if (this._pivotTables.length === 0) {
1270
+ return;
1271
+ }
1287
1272
 
1288
- for (const def of this._sheetDefs) {
1289
- const worksheet = this._sheets.get(def.name);
1290
- if (!worksheet) continue;
1273
+ for (const pivot of this._pivotTables) {
1274
+ const pivotParts = pivot.buildPivotPartsXml();
1291
1275
 
1292
- const tables = worksheet.tables;
1293
- if (tables.length === 0) continue;
1276
+ const pivotCachePath = `xl/pivotCache/pivotCacheDefinition${pivot.cachePartIndex}.xml`;
1277
+ writeZipText(this._files, pivotCachePath, pivotParts.cacheDefinitionXml);
1294
1278
 
1295
- const tableInfos: { table: import('./table').Table; globalIndex: number }[] = [];
1296
- for (const table of tables) {
1297
- tableInfos.push({ table, globalIndex: globalTableIndex });
1298
- globalTableIndex++;
1299
- }
1300
- sheetTables.set(def.name, tableInfos);
1279
+ const pivotCacheRecordsPath = `xl/pivotCache/pivotCacheRecords${pivot.cachePartIndex}.xml`;
1280
+ writeZipText(this._files, pivotCacheRecordsPath, pivotParts.cacheRecordsXml);
1281
+
1282
+ const pivotCacheRelsPath = `xl/pivotCache/_rels/pivotCacheDefinition${pivot.cachePartIndex}.xml.rels`;
1283
+ writeZipText(this._files, pivotCacheRelsPath, pivotParts.cacheRelsXml);
1284
+
1285
+ const pivotTablePath = `xl/pivotTables/pivotTable${pivot.pivotId}.xml`;
1286
+ writeZipText(this._files, pivotTablePath, pivotParts.pivotTableXml);
1301
1287
  }
1302
1288
 
1303
- // Generate table files
1304
- for (const [, tableInfos] of sheetTables) {
1305
- for (const { table, globalIndex } of tableInfos) {
1306
- const tablePath = `xl/tables/table${globalIndex}.xml`;
1307
- writeZipText(this._files, tablePath, table.toXml());
1308
- }
1289
+ const pivotsBySheet = new Map<string, PivotTable[]>();
1290
+ for (const pivot of this._pivotTables) {
1291
+ const existing = pivotsBySheet.get(pivot.targetSheetName) ?? [];
1292
+ existing.push(pivot);
1293
+ pivotsBySheet.set(pivot.targetSheetName, existing);
1309
1294
  }
1310
1295
 
1311
- // Generate worksheet relationships for tables
1312
- for (const [sheetName, tableInfos] of sheetTables) {
1296
+ for (const [sheetName, pivots] of pivotsBySheet) {
1313
1297
  const def = this._sheetDefs.find((s) => s.name === sheetName);
1314
1298
  if (!def) continue;
1315
1299
 
1316
1300
  const rel = this._relationships.find((r) => r.id === def.rId);
1317
1301
  if (!rel) continue;
1318
1302
 
1319
- // Extract sheet file name from target path
1320
1303
  const sheetFileName = rel.target.split('/').pop();
1321
- const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
1304
+ if (!sheetFileName) continue;
1322
1305
 
1323
- // Check if there are already pivot table relationships for this sheet
1306
+ const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
1324
1307
  const existingRelsXml = readZipText(this._files, sheetRelsPath);
1308
+
1325
1309
  let nextRelId = 1;
1326
1310
  const relNodes: XmlNode[] = [];
1327
1311
  const reservedRelIds = new Set<string>();
1328
1312
 
1329
1313
  if (existingRelsXml) {
1330
- // Parse existing rels and find max rId
1331
1314
  const parsed = parseXml(existingRelsXml);
1332
1315
  const relsElement = findElement(parsed, 'Relationships');
1333
1316
  if (relsElement) {
1334
- const existingRelNodes = getChildren(relsElement, 'Relationships');
1335
- for (const relNode of existingRelNodes) {
1317
+ for (const relNode of getChildren(relsElement, 'Relationships')) {
1336
1318
  if ('Relationship' in relNode) {
1337
1319
  relNodes.push(relNode);
1338
1320
  const id = getAttr(relNode, 'Id');
@@ -1358,33 +1340,30 @@ export class Workbook {
1358
1340
  return id;
1359
1341
  };
1360
1342
 
1361
- // Add table relationships
1362
- const tableRelIds: string[] = [];
1363
- for (const { globalIndex } of tableInfos) {
1364
- const target = `../tables/table${globalIndex}.xml`;
1365
- const existing = relNodes.some(
1343
+ const pivotRelIds: string[] = [];
1344
+ for (const pivot of pivots) {
1345
+ const target = `../pivotTables/pivotTable${pivot.pivotId}.xml`;
1346
+ const existing = relNodes.find(
1366
1347
  (node) =>
1367
- getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' &&
1348
+ getAttr(node, 'Type') ===
1349
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable' &&
1368
1350
  getAttr(node, 'Target') === target,
1369
1351
  );
1352
+
1370
1353
  if (existing) {
1371
- const existingRel = relNodes.find(
1372
- (node) =>
1373
- getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' &&
1374
- getAttr(node, 'Target') === target,
1375
- );
1376
- const existingId = existingRel ? getAttr(existingRel, 'Id') : undefined;
1377
- tableRelIds.push(existingId ?? allocateRelId());
1354
+ const existingId = getAttr(existing, 'Id');
1355
+ pivotRelIds.push(existingId ?? allocateRelId());
1378
1356
  continue;
1379
1357
  }
1358
+
1380
1359
  const id = allocateRelId();
1381
- tableRelIds.push(id);
1360
+ pivotRelIds.push(id);
1382
1361
  relNodes.push(
1383
1362
  createElement(
1384
1363
  'Relationship',
1385
1364
  {
1386
1365
  Id: id,
1387
- Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table',
1366
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable',
1388
1367
  Target: target,
1389
1368
  },
1390
1369
  [],
@@ -1394,7 +1373,7 @@ export class Workbook {
1394
1373
 
1395
1374
  const worksheet = this._sheets.get(sheetName);
1396
1375
  if (worksheet) {
1397
- worksheet.setTableRelIds(tableRelIds);
1376
+ worksheet.setPivotTableRelIds(pivotRelIds);
1398
1377
  }
1399
1378
 
1400
1379
  const sheetRels = createElement(
@@ -1402,6 +1381,7 @@ export class Workbook {
1402
1381
  { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
1403
1382
  relNodes,
1404
1383
  );
1384
+
1405
1385
  writeZipText(
1406
1386
  this._files,
1407
1387
  sheetRelsPath,