@niicojs/excel 0.2.6 → 0.3.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.
package/dist/index.js CHANGED
@@ -38,8 +38,10 @@ import { unzip, strFromU8, zip, strToU8 } from 'fflate';
38
38
  if (!match) {
39
39
  throw new Error(`Invalid cell address: ${address}`);
40
40
  }
41
+ const rowNumber = +match[2];
42
+ if (rowNumber <= 0) throw new Error(`Invalid cell address: ${address}`);
41
43
  const col = letterToCol(match[1].toUpperCase());
42
- const row = parseInt(match[2], 10) - 1; // Convert to 0-based
44
+ const row = rowNumber - 1; // Convert to 0-based
43
45
  return {
44
46
  row,
45
47
  col
@@ -433,12 +435,23 @@ _computedKey = Symbol.iterator;
433
435
  /**
434
436
  * Get all values in the range as a 2D array
435
437
  */ get values() {
438
+ return this.getValues();
439
+ }
440
+ /**
441
+ * Get all values in the range as a 2D array with options
442
+ */ getValues(options = {}) {
443
+ const { createMissing = true } = options;
436
444
  const result = [];
437
445
  for(let r = this._range.start.row; r <= this._range.end.row; r++){
438
446
  const row = [];
439
447
  for(let c = this._range.start.col; c <= this._range.end.col; c++){
440
- const cell = this._worksheet.cell(r, c);
441
- row.push(cell.value);
448
+ if (createMissing) {
449
+ const cell = this._worksheet.cell(r, c);
450
+ row.push(cell.value);
451
+ } else {
452
+ const cell = this._worksheet.getCellIfExists(r, c);
453
+ row.push(cell?.value ?? null);
454
+ }
442
455
  }
443
456
  result.push(row);
444
457
  }
@@ -602,6 +615,322 @@ const builder = new XMLBuilder(builderOptions);
602
615
  };
603
616
  };
604
617
 
618
+ /**
619
+ * Maps table total function names to SUBTOTAL function numbers
620
+ * SUBTOTAL uses 101-111 for functions that ignore hidden values
621
+ */ const TOTAL_FUNCTION_NUMBERS = {
622
+ average: 101,
623
+ count: 102,
624
+ countNums: 103,
625
+ max: 104,
626
+ min: 105,
627
+ stdDev: 107,
628
+ sum: 109,
629
+ var: 110,
630
+ none: 0
631
+ };
632
+ /**
633
+ * Maps table total function names to XML attribute values
634
+ */ const TOTAL_FUNCTION_NAMES = {
635
+ average: 'average',
636
+ count: 'count',
637
+ countNums: 'countNums',
638
+ max: 'max',
639
+ min: 'min',
640
+ stdDev: 'stdDev',
641
+ sum: 'sum',
642
+ var: 'var',
643
+ none: 'none'
644
+ };
645
+ /**
646
+ * Represents an Excel Table (ListObject) with auto-filter, banded styling, and total row.
647
+ */ class Table {
648
+ constructor(worksheet, config, tableId){
649
+ this._columns = [];
650
+ this._dirty = true;
651
+ this._worksheet = worksheet;
652
+ this._name = config.name;
653
+ this._displayName = config.name;
654
+ this._range = parseRange(config.range);
655
+ this._baseRange = {
656
+ start: {
657
+ ...this._range.start
658
+ },
659
+ end: {
660
+ ...this._range.end
661
+ }
662
+ };
663
+ this._totalRow = config.totalRow === true; // Default false
664
+ this._autoFilter = true; // Tables have auto-filter by default
665
+ this._headerRow = config.headerRow !== false;
666
+ this._id = tableId;
667
+ // Expand range to include total row if enabled
668
+ if (this._totalRow) {
669
+ this._range.end.row++;
670
+ }
671
+ // Set default style
672
+ this._style = {
673
+ name: config.style?.name ?? 'TableStyleMedium2',
674
+ showRowStripes: config.style?.showRowStripes !== false,
675
+ showColumnStripes: config.style?.showColumnStripes === true,
676
+ showFirstColumn: config.style?.showFirstColumn === true,
677
+ showLastColumn: config.style?.showLastColumn === true
678
+ };
679
+ // Extract column names from worksheet headers
680
+ this._extractColumns();
681
+ }
682
+ /**
683
+ * Get the table name
684
+ */ get name() {
685
+ return this._name;
686
+ }
687
+ /**
688
+ * Get the table display name
689
+ */ get displayName() {
690
+ return this._displayName;
691
+ }
692
+ /**
693
+ * Get the table ID
694
+ */ get id() {
695
+ return this._id;
696
+ }
697
+ /**
698
+ * Get the worksheet this table belongs to
699
+ */ get worksheet() {
700
+ return this._worksheet;
701
+ }
702
+ /**
703
+ * Get the table range address string
704
+ */ get range() {
705
+ return toRange(this._range);
706
+ }
707
+ /**
708
+ * Get the base range excluding total row
709
+ */ get baseRange() {
710
+ return toRange(this._baseRange);
711
+ }
712
+ /**
713
+ * Get the table range as RangeAddress
714
+ */ get rangeAddress() {
715
+ return {
716
+ ...this._range
717
+ };
718
+ }
719
+ /**
720
+ * Get column names
721
+ */ get columns() {
722
+ return this._columns.map((c)=>c.name);
723
+ }
724
+ /**
725
+ * Check if table has a total row
726
+ */ get hasTotalRow() {
727
+ return this._totalRow;
728
+ }
729
+ /**
730
+ * Check if table has a header row
731
+ */ get hasHeaderRow() {
732
+ return this._headerRow;
733
+ }
734
+ /**
735
+ * Check if table has auto-filter enabled
736
+ */ get hasAutoFilter() {
737
+ return this._autoFilter;
738
+ }
739
+ /**
740
+ * Get the current style configuration
741
+ */ get style() {
742
+ return {
743
+ ...this._style
744
+ };
745
+ }
746
+ /**
747
+ * Check if the table has been modified
748
+ */ get dirty() {
749
+ return this._dirty;
750
+ }
751
+ /**
752
+ * Set a total function for a column
753
+ * @param columnName - Name of the column (header text)
754
+ * @param fn - Aggregation function to use
755
+ * @returns this for method chaining
756
+ */ setTotalFunction(columnName, fn) {
757
+ if (!this._totalRow) {
758
+ throw new Error('Cannot set total function: table does not have a total row enabled');
759
+ }
760
+ const column = this._columns.find((c)=>c.name === columnName);
761
+ if (!column) {
762
+ throw new Error(`Column not found: ${columnName}`);
763
+ }
764
+ column.totalFunction = fn;
765
+ this._dirty = true;
766
+ // Write the formula to the total row cell
767
+ this._writeTotalRowFormula(column);
768
+ return this;
769
+ }
770
+ /**
771
+ * Get total function for a column if set
772
+ */ getTotalFunction(columnName) {
773
+ const column = this._columns.find((c)=>c.name === columnName);
774
+ return column?.totalFunction;
775
+ }
776
+ /**
777
+ * Enable or disable auto-filter
778
+ * @param enabled - Whether auto-filter should be enabled
779
+ * @returns this for method chaining
780
+ */ setAutoFilter(enabled) {
781
+ this._autoFilter = enabled;
782
+ this._dirty = true;
783
+ return this;
784
+ }
785
+ /**
786
+ * Update table style configuration
787
+ * @param style - Style options to apply
788
+ * @returns this for method chaining
789
+ */ setStyle(style) {
790
+ if (style.name !== undefined) this._style.name = style.name;
791
+ if (style.showRowStripes !== undefined) this._style.showRowStripes = style.showRowStripes;
792
+ if (style.showColumnStripes !== undefined) this._style.showColumnStripes = style.showColumnStripes;
793
+ if (style.showFirstColumn !== undefined) this._style.showFirstColumn = style.showFirstColumn;
794
+ if (style.showLastColumn !== undefined) this._style.showLastColumn = style.showLastColumn;
795
+ this._dirty = true;
796
+ return this;
797
+ }
798
+ /**
799
+ * Enable or disable the total row
800
+ * @param enabled - Whether total row should be shown
801
+ * @returns this for method chaining
802
+ */ setTotalRow(enabled) {
803
+ if (this._totalRow === enabled) return this;
804
+ this._totalRow = enabled;
805
+ this._dirty = true;
806
+ if (enabled) {
807
+ this._range = {
808
+ start: {
809
+ ...this._baseRange.start
810
+ },
811
+ end: {
812
+ ...this._baseRange.end
813
+ }
814
+ };
815
+ this._range.end.row++;
816
+ } else {
817
+ this._range = {
818
+ start: {
819
+ ...this._baseRange.start
820
+ },
821
+ end: {
822
+ ...this._baseRange.end
823
+ }
824
+ };
825
+ for (const col of this._columns){
826
+ col.totalFunction = undefined;
827
+ }
828
+ }
829
+ return this;
830
+ }
831
+ /**
832
+ * Extract column names from the header row of the worksheet
833
+ */ _extractColumns() {
834
+ const headerRow = this._range.start.row;
835
+ const startCol = this._range.start.col;
836
+ const endCol = this._range.end.col;
837
+ for(let col = startCol; col <= endCol; col++){
838
+ const cell = this._headerRow ? this._worksheet.getCellIfExists(headerRow, col) : undefined;
839
+ const value = cell?.value;
840
+ const name = value != null ? String(value) : `Column${col - startCol + 1}`;
841
+ this._columns.push({
842
+ id: col - startCol + 1,
843
+ name,
844
+ colIndex: col
845
+ });
846
+ }
847
+ }
848
+ /**
849
+ * Write the SUBTOTAL formula to a total row cell
850
+ */ _writeTotalRowFormula(column) {
851
+ if (!this._totalRow || !column.totalFunction || column.totalFunction === 'none') {
852
+ return;
853
+ }
854
+ const totalRowIndex = this._range.end.row;
855
+ const cell = this._worksheet.cell(totalRowIndex, column.colIndex);
856
+ // Generate SUBTOTAL formula with structured reference
857
+ const funcNum = TOTAL_FUNCTION_NUMBERS[column.totalFunction];
858
+ // Use structured reference: SUBTOTAL(109,[ColumnName])
859
+ const formula = `SUBTOTAL(${funcNum},[${column.name}])`;
860
+ cell.formula = formula;
861
+ }
862
+ /**
863
+ * Get the auto-filter range (excludes total row if present)
864
+ */ _getAutoFilterRange() {
865
+ const start = toAddress(this._range.start.row, this._range.start.col);
866
+ // Auto-filter excludes the total row
867
+ let endRow = this._range.end.row;
868
+ if (this._totalRow) {
869
+ endRow--;
870
+ }
871
+ const end = toAddress(endRow, this._range.end.col);
872
+ return `${start}:${end}`;
873
+ }
874
+ /**
875
+ * Generate the table definition XML
876
+ */ toXml() {
877
+ const children = [];
878
+ // Auto-filter element
879
+ if (this._autoFilter) {
880
+ const autoFilterRef = this._getAutoFilterRange();
881
+ children.push(createElement('autoFilter', {
882
+ ref: autoFilterRef
883
+ }, []));
884
+ }
885
+ // Table columns
886
+ const columnNodes = this._columns.map((col)=>{
887
+ const attrs = {
888
+ id: String(col.id),
889
+ name: col.name
890
+ };
891
+ // Add total function if specified
892
+ if (this._totalRow && col.totalFunction && col.totalFunction !== 'none') {
893
+ attrs.totalsRowFunction = TOTAL_FUNCTION_NAMES[col.totalFunction];
894
+ }
895
+ return createElement('tableColumn', attrs, []);
896
+ });
897
+ children.push(createElement('tableColumns', {
898
+ count: String(columnNodes.length)
899
+ }, columnNodes));
900
+ // Table style info
901
+ const styleAttrs = {
902
+ name: this._style.name || 'TableStyleMedium2',
903
+ showFirstColumn: this._style.showFirstColumn ? '1' : '0',
904
+ showLastColumn: this._style.showLastColumn ? '1' : '0',
905
+ showRowStripes: this._style.showRowStripes !== false ? '1' : '0',
906
+ showColumnStripes: this._style.showColumnStripes ? '1' : '0'
907
+ };
908
+ children.push(createElement('tableStyleInfo', styleAttrs, []));
909
+ // Build table attributes
910
+ const tableRef = toRange(this._range);
911
+ const tableAttrs = {
912
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
913
+ id: String(this._id),
914
+ name: this._name,
915
+ displayName: this._displayName,
916
+ ref: tableRef
917
+ };
918
+ if (!this._headerRow) {
919
+ tableAttrs.headerRowCount = '0';
920
+ }
921
+ if (this._totalRow) {
922
+ tableAttrs.totalsRowCount = '1';
923
+ } else {
924
+ tableAttrs.totalsRowShown = '0';
925
+ }
926
+ // Build complete table node
927
+ const tableNode = createElement('table', tableAttrs, children);
928
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
929
+ tableNode
930
+ ])}`;
931
+ }
932
+ }
933
+
605
934
  /**
606
935
  * Represents a worksheet in a workbook
607
936
  */ class Worksheet {
@@ -611,6 +940,17 @@ const builder = new XMLBuilder(builderOptions);
611
940
  this._dirty = false;
612
941
  this._mergedCells = new Set();
613
942
  this._sheetData = [];
943
+ this._columnWidths = new Map();
944
+ this._rowHeights = new Map();
945
+ this._frozenPane = null;
946
+ this._dataBoundsCache = null;
947
+ this._boundsDirty = true;
948
+ this._tables = [];
949
+ this._preserveXml = false;
950
+ this._tableRelIds = null;
951
+ this._sheetViewsDirty = false;
952
+ this._colsDirty = false;
953
+ this._tablePartsDirty = false;
614
954
  this._workbook = workbook;
615
955
  this._name = name;
616
956
  }
@@ -634,15 +974,53 @@ const builder = new XMLBuilder(builderOptions);
634
974
  * Parse worksheet XML content
635
975
  */ parse(xml) {
636
976
  this._xmlNodes = parseXml(xml);
977
+ this._preserveXml = true;
637
978
  const worksheet = findElement(this._xmlNodes, 'worksheet');
638
979
  if (!worksheet) return;
639
980
  const worksheetChildren = getChildren(worksheet, 'worksheet');
981
+ // Parse sheet views (freeze panes)
982
+ const sheetViews = findElement(worksheetChildren, 'sheetViews');
983
+ if (sheetViews) {
984
+ const viewChildren = getChildren(sheetViews, 'sheetViews');
985
+ const sheetView = findElement(viewChildren, 'sheetView');
986
+ if (sheetView) {
987
+ const sheetViewChildren = getChildren(sheetView, 'sheetView');
988
+ const pane = findElement(sheetViewChildren, 'pane');
989
+ if (pane && getAttr(pane, 'state') === 'frozen') {
990
+ const xSplit = parseInt(getAttr(pane, 'xSplit') || '0', 10);
991
+ const ySplit = parseInt(getAttr(pane, 'ySplit') || '0', 10);
992
+ if (xSplit > 0 || ySplit > 0) {
993
+ this._frozenPane = {
994
+ row: ySplit,
995
+ col: xSplit
996
+ };
997
+ }
998
+ }
999
+ }
1000
+ }
640
1001
  // Parse sheet data (cells)
641
1002
  const sheetData = findElement(worksheetChildren, 'sheetData');
642
1003
  if (sheetData) {
643
1004
  this._sheetData = getChildren(sheetData, 'sheetData');
644
1005
  this._parseSheetData(this._sheetData);
645
1006
  }
1007
+ // Parse column widths
1008
+ const cols = findElement(worksheetChildren, 'cols');
1009
+ if (cols) {
1010
+ const colChildren = getChildren(cols, 'cols');
1011
+ for (const col of colChildren){
1012
+ if (!('col' in col)) continue;
1013
+ const min = parseInt(getAttr(col, 'min') || '0', 10);
1014
+ const max = parseInt(getAttr(col, 'max') || '0', 10);
1015
+ const width = parseFloat(getAttr(col, 'width') || '0');
1016
+ if (!Number.isFinite(width) || width <= 0) continue;
1017
+ if (min > 0 && max > 0) {
1018
+ for(let idx = min; idx <= max; idx++){
1019
+ this._columnWidths.set(idx - 1, width);
1020
+ }
1021
+ }
1022
+ }
1023
+ }
646
1024
  // Parse merged cells
647
1025
  const mergeCells = findElement(worksheetChildren, 'mergeCells');
648
1026
  if (mergeCells) {
@@ -662,6 +1040,11 @@ const builder = new XMLBuilder(builderOptions);
662
1040
  */ _parseSheetData(rows) {
663
1041
  for (const rowNode of rows){
664
1042
  if (!('row' in rowNode)) continue;
1043
+ const rowIndex = parseInt(getAttr(rowNode, 'r') || '0', 10) - 1;
1044
+ const rowHeight = parseFloat(getAttr(rowNode, 'ht') || '0');
1045
+ if (rowIndex >= 0 && Number.isFinite(rowHeight) && rowHeight > 0) {
1046
+ this._rowHeights.set(rowIndex, rowHeight);
1047
+ }
665
1048
  const rowChildren = getChildren(rowNode, 'row');
666
1049
  for (const cellNode of rowChildren){
667
1050
  if (!('c' in cellNode)) continue;
@@ -673,6 +1056,7 @@ const builder = new XMLBuilder(builderOptions);
673
1056
  this._cells.set(ref, cell);
674
1057
  }
675
1058
  }
1059
+ this._boundsDirty = true;
676
1060
  }
677
1061
  /**
678
1062
  * Parse a cell XML node to CellData
@@ -759,9 +1143,17 @@ const builder = new XMLBuilder(builderOptions);
759
1143
  if (!cell) {
760
1144
  cell = new Cell(this, row, c);
761
1145
  this._cells.set(address, cell);
1146
+ this._boundsDirty = true;
762
1147
  }
763
1148
  return cell;
764
1149
  }
1150
+ /**
1151
+ * Get an existing cell without creating it.
1152
+ */ getCellIfExists(rowOrAddress, col) {
1153
+ const { row, col: c } = parseCellRef(rowOrAddress, col);
1154
+ const address = toAddress(row, c);
1155
+ return this._cells.get(address);
1156
+ }
765
1157
  range(startRowOrRange, startCol, endRow, endCol) {
766
1158
  let rangeAddr;
767
1159
  if (typeof startRowOrRange === 'string') {
@@ -821,6 +1213,145 @@ const builder = new XMLBuilder(builderOptions);
821
1213
  return this._cells;
822
1214
  }
823
1215
  /**
1216
+ * Set a column width (0-based index or column letter)
1217
+ */ setColumnWidth(col, width) {
1218
+ if (!Number.isFinite(width) || width <= 0) {
1219
+ throw new Error('Column width must be a positive number');
1220
+ }
1221
+ const colIndex = typeof col === 'number' ? col : letterToCol(col);
1222
+ if (colIndex < 0) {
1223
+ throw new Error(`Invalid column: ${col}`);
1224
+ }
1225
+ this._columnWidths.set(colIndex, width);
1226
+ this._colsDirty = true;
1227
+ this._dirty = true;
1228
+ }
1229
+ /**
1230
+ * Get a column width if set
1231
+ */ getColumnWidth(col) {
1232
+ const colIndex = typeof col === 'number' ? col : letterToCol(col);
1233
+ return this._columnWidths.get(colIndex);
1234
+ }
1235
+ /**
1236
+ * Set a row height (0-based index)
1237
+ */ setRowHeight(row, height) {
1238
+ if (!Number.isFinite(height) || height <= 0) {
1239
+ throw new Error('Row height must be a positive number');
1240
+ }
1241
+ if (row < 0) {
1242
+ throw new Error('Row index must be >= 0');
1243
+ }
1244
+ this._rowHeights.set(row, height);
1245
+ this._colsDirty = true;
1246
+ this._dirty = true;
1247
+ }
1248
+ /**
1249
+ * Get a row height if set
1250
+ */ getRowHeight(row) {
1251
+ return this._rowHeights.get(row);
1252
+ }
1253
+ /**
1254
+ * Freeze panes at a given row/column split (counts from top-left)
1255
+ */ freezePane(rowSplit, colSplit) {
1256
+ if (rowSplit < 0 || colSplit < 0) {
1257
+ throw new Error('Freeze pane splits must be >= 0');
1258
+ }
1259
+ if (rowSplit === 0 && colSplit === 0) {
1260
+ this._frozenPane = null;
1261
+ } else {
1262
+ this._frozenPane = {
1263
+ row: rowSplit,
1264
+ col: colSplit
1265
+ };
1266
+ }
1267
+ this._sheetViewsDirty = true;
1268
+ this._dirty = true;
1269
+ }
1270
+ /**
1271
+ * Get current frozen pane configuration
1272
+ */ getFrozenPane() {
1273
+ return this._frozenPane ? {
1274
+ ...this._frozenPane
1275
+ } : null;
1276
+ }
1277
+ /**
1278
+ * Get all tables in the worksheet
1279
+ */ get tables() {
1280
+ return [
1281
+ ...this._tables
1282
+ ];
1283
+ }
1284
+ /**
1285
+ * Get column width entries
1286
+ * @internal
1287
+ */ getColumnWidths() {
1288
+ return new Map(this._columnWidths);
1289
+ }
1290
+ /**
1291
+ * Get row height entries
1292
+ * @internal
1293
+ */ getRowHeights() {
1294
+ return new Map(this._rowHeights);
1295
+ }
1296
+ /**
1297
+ * Set table relationship IDs for tableParts generation.
1298
+ * @internal
1299
+ */ setTableRelIds(ids) {
1300
+ this._tableRelIds = ids ? [
1301
+ ...ids
1302
+ ] : null;
1303
+ this._tablePartsDirty = true;
1304
+ }
1305
+ /**
1306
+ * Create an Excel Table (ListObject) from a data range.
1307
+ *
1308
+ * Tables provide structured data features like auto-filter, banded styling,
1309
+ * and total row with aggregation functions.
1310
+ *
1311
+ * @param config - Table configuration
1312
+ * @returns Table instance for method chaining
1313
+ *
1314
+ * @example
1315
+ * ```typescript
1316
+ * // Create a table with default styling
1317
+ * const table = sheet.createTable({
1318
+ * name: 'SalesData',
1319
+ * range: 'A1:D10',
1320
+ * });
1321
+ *
1322
+ * // Create a table with total row
1323
+ * const table = sheet.createTable({
1324
+ * name: 'SalesData',
1325
+ * range: 'A1:D10',
1326
+ * totalRow: true,
1327
+ * style: { name: 'TableStyleMedium2' }
1328
+ * });
1329
+ *
1330
+ * table.setTotalFunction('Sales', 'sum');
1331
+ * ```
1332
+ */ createTable(config) {
1333
+ // Validate table name is unique within the workbook
1334
+ for (const sheet of this._workbook.sheetNames){
1335
+ const ws = this._workbook.sheet(sheet);
1336
+ for (const table of ws._tables){
1337
+ if (table.name === config.name) {
1338
+ throw new Error(`Table name already exists: ${config.name}`);
1339
+ }
1340
+ }
1341
+ }
1342
+ // Validate table name format (Excel rules: no spaces at start/end, alphanumeric + underscore)
1343
+ if (!config.name || !/^[A-Za-z_\\][A-Za-z0-9_.\\]*$/.test(config.name)) {
1344
+ throw new Error(`Invalid table name: ${config.name}. Names must start with a letter or underscore and contain only alphanumeric characters, underscores, or periods.`);
1345
+ }
1346
+ // Create the table with a unique ID from the workbook
1347
+ const tableId = this._workbook.getNextTableId();
1348
+ const table = new Table(this, config, tableId);
1349
+ this._tables.push(table);
1350
+ this._tablePartsDirty = true;
1351
+ this._dirty = true;
1352
+ return table;
1353
+ }
1354
+ /**
824
1355
  * Convert sheet data to an array of JSON objects.
825
1356
  *
826
1357
  * @param config - Configuration options
@@ -838,7 +1369,7 @@ const builder = new XMLBuilder(builderOptions);
838
1369
  * const data = sheet.toJson({ startRow: 2, startCol: 1 });
839
1370
  * ```
840
1371
  */ toJson(config = {}) {
841
- const { fields, startRow = 0, startCol = 0, endRow, endCol, stopOnEmptyRow = true } = config;
1372
+ const { fields, startRow = 0, startCol = 0, endRow, endCol, stopOnEmptyRow = true, dateHandling = this._workbook.dateHandling } = config;
842
1373
  // Get the bounds of data in the sheet
843
1374
  const bounds = this._getDataBounds();
844
1375
  if (!bounds) {
@@ -871,7 +1402,10 @@ const builder = new XMLBuilder(builderOptions);
871
1402
  for(let colOffset = 0; colOffset < fieldNames.length; colOffset++){
872
1403
  const col = startCol + colOffset;
873
1404
  const cell = this._cells.get(toAddress(row, col));
874
- const value = cell?.value ?? null;
1405
+ let value = cell?.value ?? null;
1406
+ if (value instanceof Date) {
1407
+ value = this._serializeDate(value, dateHandling, cell);
1408
+ }
875
1409
  if (value !== null) {
876
1410
  hasData = true;
877
1411
  }
@@ -888,10 +1422,24 @@ const builder = new XMLBuilder(builderOptions);
888
1422
  }
889
1423
  return result;
890
1424
  }
1425
+ _serializeDate(value, dateHandling, cell) {
1426
+ if (dateHandling === 'excelSerial') {
1427
+ return cell?._jsDateToExcel(value) ?? value;
1428
+ }
1429
+ if (dateHandling === 'isoString') {
1430
+ return value.toISOString();
1431
+ }
1432
+ return value;
1433
+ }
891
1434
  /**
892
1435
  * Get the bounds of data in the sheet (min/max row and column with data)
893
1436
  */ _getDataBounds() {
1437
+ if (!this._boundsDirty && this._dataBoundsCache) {
1438
+ return this._dataBoundsCache;
1439
+ }
894
1440
  if (this._cells.size === 0) {
1441
+ this._dataBoundsCache = null;
1442
+ this._boundsDirty = false;
895
1443
  return null;
896
1444
  }
897
1445
  let minRow = Infinity;
@@ -907,47 +1455,78 @@ const builder = new XMLBuilder(builderOptions);
907
1455
  }
908
1456
  }
909
1457
  if (minRow === Infinity) {
1458
+ this._dataBoundsCache = null;
1459
+ this._boundsDirty = false;
910
1460
  return null;
911
1461
  }
912
- return {
1462
+ this._dataBoundsCache = {
913
1463
  minRow,
914
1464
  maxRow,
915
1465
  minCol,
916
1466
  maxCol
917
1467
  };
1468
+ this._boundsDirty = false;
1469
+ return this._dataBoundsCache;
918
1470
  }
919
1471
  /**
920
1472
  * Generate XML for this worksheet
921
1473
  */ toXml() {
1474
+ const preserved = this._preserveXml && this._xmlNodes ? this._buildPreservedWorksheet() : null;
922
1475
  // Build sheetData from cells
923
- const rowMap = new Map();
924
- for (const cell of this._cells.values()){
925
- const row = cell.row;
926
- if (!rowMap.has(row)) {
927
- rowMap.set(row, []);
1476
+ const sheetDataNode = this._buildSheetDataNode();
1477
+ // Build worksheet structure
1478
+ const worksheetChildren = [];
1479
+ // Sheet views (freeze panes)
1480
+ if (this._frozenPane) {
1481
+ const paneAttrs = {
1482
+ state: 'frozen'
1483
+ };
1484
+ const topLeftCell = toAddress(this._frozenPane.row, this._frozenPane.col);
1485
+ paneAttrs.topLeftCell = topLeftCell;
1486
+ if (this._frozenPane.col > 0) {
1487
+ paneAttrs.xSplit = String(this._frozenPane.col);
928
1488
  }
929
- rowMap.get(row).push(cell);
1489
+ if (this._frozenPane.row > 0) {
1490
+ paneAttrs.ySplit = String(this._frozenPane.row);
1491
+ }
1492
+ let activePane = 'bottomRight';
1493
+ if (this._frozenPane.row > 0 && this._frozenPane.col === 0) {
1494
+ activePane = 'bottomLeft';
1495
+ } else if (this._frozenPane.row === 0 && this._frozenPane.col > 0) {
1496
+ activePane = 'topRight';
1497
+ }
1498
+ paneAttrs.activePane = activePane;
1499
+ const paneNode = createElement('pane', paneAttrs, []);
1500
+ const selectionNode = createElement('selection', {
1501
+ pane: activePane,
1502
+ activeCell: topLeftCell,
1503
+ sqref: topLeftCell
1504
+ }, []);
1505
+ const sheetViewNode = createElement('sheetView', {
1506
+ workbookViewId: '0'
1507
+ }, [
1508
+ paneNode,
1509
+ selectionNode
1510
+ ]);
1511
+ worksheetChildren.push(createElement('sheetViews', {}, [
1512
+ sheetViewNode
1513
+ ]));
930
1514
  }
931
- // Sort rows and cells
932
- const sortedRows = Array.from(rowMap.entries()).sort((a, b)=>a[0] - b[0]);
933
- const rowNodes = [];
934
- for (const [rowIdx, cells] of sortedRows){
935
- cells.sort((a, b)=>a.col - b.col);
936
- const cellNodes = [];
937
- for (const cell of cells){
938
- const cellNode = this._buildCellNode(cell);
939
- cellNodes.push(cellNode);
1515
+ // Column widths
1516
+ if (this._columnWidths.size > 0) {
1517
+ const colNodes = [];
1518
+ const entries = Array.from(this._columnWidths.entries()).sort((a, b)=>a[0] - b[0]);
1519
+ for (const [colIndex, width] of entries){
1520
+ colNodes.push(createElement('col', {
1521
+ min: String(colIndex + 1),
1522
+ max: String(colIndex + 1),
1523
+ width: String(width),
1524
+ customWidth: '1'
1525
+ }, []));
940
1526
  }
941
- const rowNode = createElement('row', {
942
- r: String(rowIdx + 1)
943
- }, cellNodes);
944
- rowNodes.push(rowNode);
1527
+ worksheetChildren.push(createElement('cols', {}, colNodes));
945
1528
  }
946
- const sheetDataNode = createElement('sheetData', {}, rowNodes);
947
- // Build worksheet structure
948
- const worksheetChildren = [
949
- sheetDataNode
950
- ];
1529
+ worksheetChildren.push(sheetDataNode);
951
1530
  // Add merged cells if any
952
1531
  if (this._mergedCells.size > 0) {
953
1532
  const mergeCellNodes = [];
@@ -961,14 +1540,170 @@ const builder = new XMLBuilder(builderOptions);
961
1540
  }, mergeCellNodes);
962
1541
  worksheetChildren.push(mergeCellsNode);
963
1542
  }
1543
+ // Add table parts if any tables exist
1544
+ const tablePartsNode = this._buildTablePartsNode();
1545
+ if (tablePartsNode) {
1546
+ worksheetChildren.push(tablePartsNode);
1547
+ }
964
1548
  const worksheetNode = createElement('worksheet', {
965
1549
  xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
966
1550
  'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
967
1551
  }, worksheetChildren);
1552
+ if (preserved) {
1553
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
1554
+ preserved
1555
+ ])}`;
1556
+ }
968
1557
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
969
1558
  worksheetNode
970
1559
  ])}`;
971
1560
  }
1561
+ _buildSheetDataNode() {
1562
+ const rowMap = new Map();
1563
+ for (const cell of this._cells.values()){
1564
+ const row = cell.row;
1565
+ if (!rowMap.has(row)) {
1566
+ rowMap.set(row, []);
1567
+ }
1568
+ rowMap.get(row).push(cell);
1569
+ }
1570
+ for (const rowIdx of this._rowHeights.keys()){
1571
+ if (!rowMap.has(rowIdx)) {
1572
+ rowMap.set(rowIdx, []);
1573
+ }
1574
+ }
1575
+ const sortedRows = Array.from(rowMap.entries()).sort((a, b)=>a[0] - b[0]);
1576
+ const rowNodes = [];
1577
+ for (const [rowIdx, cells] of sortedRows){
1578
+ cells.sort((a, b)=>a.col - b.col);
1579
+ const cellNodes = [];
1580
+ for (const cell of cells){
1581
+ const cellNode = this._buildCellNode(cell);
1582
+ cellNodes.push(cellNode);
1583
+ }
1584
+ const rowAttrs = {
1585
+ r: String(rowIdx + 1)
1586
+ };
1587
+ const rowHeight = this._rowHeights.get(rowIdx);
1588
+ if (rowHeight !== undefined) {
1589
+ rowAttrs.ht = String(rowHeight);
1590
+ rowAttrs.customHeight = '1';
1591
+ }
1592
+ const rowNode = createElement('row', rowAttrs, cellNodes);
1593
+ rowNodes.push(rowNode);
1594
+ }
1595
+ return createElement('sheetData', {}, rowNodes);
1596
+ }
1597
+ _buildSheetViewsNode() {
1598
+ if (!this._frozenPane) return null;
1599
+ const paneAttrs = {
1600
+ state: 'frozen'
1601
+ };
1602
+ const topLeftCell = toAddress(this._frozenPane.row, this._frozenPane.col);
1603
+ paneAttrs.topLeftCell = topLeftCell;
1604
+ if (this._frozenPane.col > 0) {
1605
+ paneAttrs.xSplit = String(this._frozenPane.col);
1606
+ }
1607
+ if (this._frozenPane.row > 0) {
1608
+ paneAttrs.ySplit = String(this._frozenPane.row);
1609
+ }
1610
+ let activePane = 'bottomRight';
1611
+ if (this._frozenPane.row > 0 && this._frozenPane.col === 0) {
1612
+ activePane = 'bottomLeft';
1613
+ } else if (this._frozenPane.row === 0 && this._frozenPane.col > 0) {
1614
+ activePane = 'topRight';
1615
+ }
1616
+ paneAttrs.activePane = activePane;
1617
+ const paneNode = createElement('pane', paneAttrs, []);
1618
+ const selectionNode = createElement('selection', {
1619
+ pane: activePane,
1620
+ activeCell: topLeftCell,
1621
+ sqref: topLeftCell
1622
+ }, []);
1623
+ const sheetViewNode = createElement('sheetView', {
1624
+ workbookViewId: '0'
1625
+ }, [
1626
+ paneNode,
1627
+ selectionNode
1628
+ ]);
1629
+ return createElement('sheetViews', {}, [
1630
+ sheetViewNode
1631
+ ]);
1632
+ }
1633
+ _buildColsNode() {
1634
+ if (this._columnWidths.size === 0) return null;
1635
+ const colNodes = [];
1636
+ const entries = Array.from(this._columnWidths.entries()).sort((a, b)=>a[0] - b[0]);
1637
+ for (const [colIndex, width] of entries){
1638
+ colNodes.push(createElement('col', {
1639
+ min: String(colIndex + 1),
1640
+ max: String(colIndex + 1),
1641
+ width: String(width),
1642
+ customWidth: '1'
1643
+ }, []));
1644
+ }
1645
+ return createElement('cols', {}, colNodes);
1646
+ }
1647
+ _buildMergeCellsNode() {
1648
+ if (this._mergedCells.size === 0) return null;
1649
+ const mergeCellNodes = [];
1650
+ for (const ref of this._mergedCells){
1651
+ mergeCellNodes.push(createElement('mergeCell', {
1652
+ ref
1653
+ }, []));
1654
+ }
1655
+ return createElement('mergeCells', {
1656
+ count: String(this._mergedCells.size)
1657
+ }, mergeCellNodes);
1658
+ }
1659
+ _buildTablePartsNode() {
1660
+ if (this._tables.length === 0) return null;
1661
+ const tablePartNodes = [];
1662
+ for(let i = 0; i < this._tables.length; i++){
1663
+ const relId = this._tableRelIds && this._tableRelIds.length === this._tables.length ? this._tableRelIds[i] : `rId${i + 1}`;
1664
+ tablePartNodes.push(createElement('tablePart', {
1665
+ 'r:id': relId
1666
+ }, []));
1667
+ }
1668
+ return createElement('tableParts', {
1669
+ count: String(this._tables.length)
1670
+ }, tablePartNodes);
1671
+ }
1672
+ _buildPreservedWorksheet() {
1673
+ if (!this._xmlNodes) return null;
1674
+ const worksheet = findElement(this._xmlNodes, 'worksheet');
1675
+ if (!worksheet) return null;
1676
+ const children = getChildren(worksheet, 'worksheet');
1677
+ const upsertChild = (tag, node)=>{
1678
+ const existingIndex = children.findIndex((child)=>tag in child);
1679
+ if (node) {
1680
+ if (existingIndex >= 0) {
1681
+ children[existingIndex] = node;
1682
+ } else {
1683
+ children.push(node);
1684
+ }
1685
+ } else if (existingIndex >= 0) {
1686
+ children.splice(existingIndex, 1);
1687
+ }
1688
+ };
1689
+ if (this._sheetViewsDirty) {
1690
+ const sheetViewsNode = this._buildSheetViewsNode();
1691
+ upsertChild('sheetViews', sheetViewsNode);
1692
+ }
1693
+ if (this._colsDirty) {
1694
+ const colsNode = this._buildColsNode();
1695
+ upsertChild('cols', colsNode);
1696
+ }
1697
+ const sheetDataNode = this._buildSheetDataNode();
1698
+ upsertChild('sheetData', sheetDataNode);
1699
+ const mergeCellsNode = this._buildMergeCellsNode();
1700
+ upsertChild('mergeCells', mergeCellsNode);
1701
+ if (this._tablePartsDirty) {
1702
+ const tablePartsNode = this._buildTablePartsNode();
1703
+ upsertChild('tableParts', tablePartsNode);
1704
+ }
1705
+ return worksheet;
1706
+ }
972
1707
  /**
973
1708
  * Build a cell XML node from a Cell object
974
1709
  */ _buildCellNode(cell) {
@@ -1013,15 +1748,28 @@ const builder = new XMLBuilder(builderOptions);
1013
1748
  const parsed = parseXml(xml);
1014
1749
  const sst = findElement(parsed, 'sst');
1015
1750
  if (!sst) return ss;
1751
+ const countAttr = getAttr(sst, 'count');
1752
+ if (countAttr) {
1753
+ const total = parseInt(countAttr, 10);
1754
+ if (Number.isFinite(total) && total >= 0) {
1755
+ ss._totalCount = total;
1756
+ }
1757
+ }
1016
1758
  const children = getChildren(sst, 'sst');
1017
1759
  for (const child of children){
1018
1760
  if ('si' in child) {
1019
1761
  const siChildren = getChildren(child, 'si');
1020
1762
  const text = ss.extractText(siChildren);
1021
- ss.strings.push(text);
1022
- ss.stringToIndex.set(text, ss.strings.length - 1);
1763
+ ss.entries.push({
1764
+ text,
1765
+ node: child
1766
+ });
1767
+ ss.stringToIndex.set(text, ss.entries.length - 1);
1023
1768
  }
1024
1769
  }
1770
+ if (ss._totalCount === 0 && ss.entries.length > 0) {
1771
+ ss._totalCount = ss.entries.length;
1772
+ }
1025
1773
  return ss;
1026
1774
  }
1027
1775
  /**
@@ -1058,7 +1806,7 @@ const builder = new XMLBuilder(builderOptions);
1058
1806
  /**
1059
1807
  * Get a string by index
1060
1808
  */ getString(index) {
1061
- return this.strings[index];
1809
+ return this.entries[index]?.text;
1062
1810
  }
1063
1811
  /**
1064
1812
  * Add a string and return its index
@@ -1066,11 +1814,25 @@ const builder = new XMLBuilder(builderOptions);
1066
1814
  */ addString(str) {
1067
1815
  const existing = this.stringToIndex.get(str);
1068
1816
  if (existing !== undefined) {
1817
+ this._totalCount++;
1818
+ this._dirty = true;
1069
1819
  return existing;
1070
1820
  }
1071
- const index = this.strings.length;
1072
- this.strings.push(str);
1821
+ const index = this.entries.length;
1822
+ const tElement = createElement('t', str.startsWith(' ') || str.endsWith(' ') ? {
1823
+ 'xml:space': 'preserve'
1824
+ } : {}, [
1825
+ createText(str)
1826
+ ]);
1827
+ const siElement = createElement('si', {}, [
1828
+ tElement
1829
+ ]);
1830
+ this.entries.push({
1831
+ text: str,
1832
+ node: siElement
1833
+ });
1073
1834
  this.stringToIndex.set(str, index);
1835
+ this._totalCount++;
1074
1836
  this._dirty = true;
1075
1837
  return index;
1076
1838
  }
@@ -1082,36 +1844,48 @@ const builder = new XMLBuilder(builderOptions);
1082
1844
  /**
1083
1845
  * Get the count of strings
1084
1846
  */ get count() {
1085
- return this.strings.length;
1847
+ return this.entries.length;
1848
+ }
1849
+ /**
1850
+ * Get total usage count of shared strings
1851
+ */ get totalCount() {
1852
+ return Math.max(this._totalCount, this.entries.length);
1086
1853
  }
1087
1854
  /**
1088
1855
  * Generate XML for the shared strings table
1089
1856
  */ toXml() {
1090
1857
  const siElements = [];
1091
- for (const str of this.strings){
1092
- const tElement = createElement('t', str.startsWith(' ') || str.endsWith(' ') ? {
1093
- 'xml:space': 'preserve'
1094
- } : {}, [
1095
- createText(str)
1096
- ]);
1097
- const siElement = createElement('si', {}, [
1098
- tElement
1099
- ]);
1100
- siElements.push(siElement);
1858
+ for (const entry of this.entries){
1859
+ if (entry.node) {
1860
+ siElements.push(entry.node);
1861
+ } else {
1862
+ const str = entry.text;
1863
+ const tElement = createElement('t', str.startsWith(' ') || str.endsWith(' ') ? {
1864
+ 'xml:space': 'preserve'
1865
+ } : {}, [
1866
+ createText(str)
1867
+ ]);
1868
+ const siElement = createElement('si', {}, [
1869
+ tElement
1870
+ ]);
1871
+ siElements.push(siElement);
1872
+ }
1101
1873
  }
1874
+ const totalCount = Math.max(this._totalCount, this.entries.length);
1102
1875
  const sst = createElement('sst', {
1103
1876
  xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
1104
- count: String(this.strings.length),
1105
- uniqueCount: String(this.strings.length)
1877
+ count: String(totalCount),
1878
+ uniqueCount: String(this.entries.length)
1106
1879
  }, siElements);
1107
1880
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
1108
1881
  sst
1109
1882
  ])}`;
1110
1883
  }
1111
1884
  constructor(){
1112
- this.strings = [];
1885
+ this.entries = [];
1113
1886
  this.stringToIndex = new Map();
1114
1887
  this._dirty = false;
1888
+ this._totalCount = 0;
1115
1889
  }
1116
1890
  }
1117
1891
 
@@ -1257,6 +2031,50 @@ const builder = new XMLBuilder(builderOptions);
1257
2031
  * Manages the styles (xl/styles.xml)
1258
2032
  */ class Styles {
1259
2033
  /**
2034
+ * Generate a deterministic cache key for a style object.
2035
+ * More efficient than JSON.stringify as it avoids the overhead of
2036
+ * full JSON serialization and produces a consistent key regardless
2037
+ * of property order.
2038
+ */ _getStyleKey(style) {
2039
+ // Use a delimiter that won't appear in values
2040
+ const SEP = '\x00';
2041
+ // Build key from all style properties in a fixed order
2042
+ const parts = [
2043
+ style.bold ? '1' : '0',
2044
+ style.italic ? '1' : '0',
2045
+ style.underline === true ? '1' : style.underline === 'single' ? 's' : style.underline === 'double' ? 'd' : '0',
2046
+ style.strike ? '1' : '0',
2047
+ style.fontSize?.toString() ?? '',
2048
+ style.fontName ?? '',
2049
+ style.fontColor ?? '',
2050
+ style.fontColorTheme?.toString() ?? '',
2051
+ style.fontColorTint?.toString() ?? '',
2052
+ style.fontColorIndexed?.toString() ?? '',
2053
+ style.fill ?? '',
2054
+ style.fillTheme?.toString() ?? '',
2055
+ style.fillTint?.toString() ?? '',
2056
+ style.fillIndexed?.toString() ?? '',
2057
+ style.fillBgColor ?? '',
2058
+ style.fillBgTheme?.toString() ?? '',
2059
+ style.fillBgTint?.toString() ?? '',
2060
+ style.fillBgIndexed?.toString() ?? '',
2061
+ style.numberFormat ?? ''
2062
+ ];
2063
+ // Border properties
2064
+ if (style.border) {
2065
+ parts.push(style.border.top ?? '', style.border.bottom ?? '', style.border.left ?? '', style.border.right ?? '');
2066
+ } else {
2067
+ parts.push('', '', '', '');
2068
+ }
2069
+ // Alignment properties
2070
+ if (style.alignment) {
2071
+ parts.push(style.alignment.horizontal ?? '', style.alignment.vertical ?? '', style.alignment.wrapText ? '1' : '0', style.alignment.textRotation?.toString() ?? '');
2072
+ } else {
2073
+ parts.push('', '', '0', '');
2074
+ }
2075
+ return parts.join(SEP);
2076
+ }
2077
+ /**
1260
2078
  * Parse styles from XML content
1261
2079
  */ static parse(xml) {
1262
2080
  const styles = new Styles();
@@ -1361,7 +2179,18 @@ const builder = new XMLBuilder(builderOptions);
1361
2179
  if ('sz' in child) font.size = parseFloat(getAttr(child, 'val') || '11');
1362
2180
  if ('name' in child) font.name = getAttr(child, 'val');
1363
2181
  if ('color' in child) {
1364
- font.color = getAttr(child, 'rgb') || getAttr(child, 'theme');
2182
+ const color = {};
2183
+ const rgb = getAttr(child, 'rgb');
2184
+ const theme = getAttr(child, 'theme');
2185
+ const tint = getAttr(child, 'tint');
2186
+ const indexed = getAttr(child, 'indexed');
2187
+ if (rgb) color.rgb = rgb;
2188
+ if (theme) color.theme = theme;
2189
+ if (tint) color.tint = tint;
2190
+ if (indexed) color.indexed = indexed;
2191
+ if (color.rgb || color.theme || color.tint || color.indexed) {
2192
+ font.color = color;
2193
+ }
1365
2194
  }
1366
2195
  }
1367
2196
  return font;
@@ -1378,10 +2207,32 @@ const builder = new XMLBuilder(builderOptions);
1378
2207
  const pfChildren = getChildren(child, 'patternFill');
1379
2208
  for (const pfChild of pfChildren){
1380
2209
  if ('fgColor' in pfChild) {
1381
- fill.fgColor = getAttr(pfChild, 'rgb') || getAttr(pfChild, 'theme');
2210
+ const color = {};
2211
+ const rgb = getAttr(pfChild, 'rgb');
2212
+ const theme = getAttr(pfChild, 'theme');
2213
+ const tint = getAttr(pfChild, 'tint');
2214
+ const indexed = getAttr(pfChild, 'indexed');
2215
+ if (rgb) color.rgb = rgb;
2216
+ if (theme) color.theme = theme;
2217
+ if (tint) color.tint = tint;
2218
+ if (indexed) color.indexed = indexed;
2219
+ if (color.rgb || color.theme || color.tint || color.indexed) {
2220
+ fill.fgColor = color;
2221
+ }
1382
2222
  }
1383
2223
  if ('bgColor' in pfChild) {
1384
- fill.bgColor = getAttr(pfChild, 'rgb') || getAttr(pfChild, 'theme');
2224
+ const color = {};
2225
+ const rgb = getAttr(pfChild, 'rgb');
2226
+ const theme = getAttr(pfChild, 'theme');
2227
+ const tint = getAttr(pfChild, 'tint');
2228
+ const indexed = getAttr(pfChild, 'indexed');
2229
+ if (rgb) color.rgb = rgb;
2230
+ if (theme) color.theme = theme;
2231
+ if (tint) color.tint = tint;
2232
+ if (indexed) color.indexed = indexed;
2233
+ if (color.rgb || color.theme || color.tint || color.indexed) {
2234
+ fill.bgColor = color;
2235
+ }
1385
2236
  }
1386
2237
  }
1387
2238
  }
@@ -1423,6 +2274,10 @@ const builder = new XMLBuilder(builderOptions);
1423
2274
  /**
1424
2275
  * Get a style by index
1425
2276
  */ getStyle(index) {
2277
+ const cached = this._styleObjectCache.get(index);
2278
+ if (cached) return {
2279
+ ...cached
2280
+ };
1426
2281
  const xf = this._cellXfs[index];
1427
2282
  if (!xf) return {};
1428
2283
  const font = this._fonts[xf.fontId];
@@ -1438,10 +2293,22 @@ const builder = new XMLBuilder(builderOptions);
1438
2293
  if (font.strike) style.strike = true;
1439
2294
  if (font.size) style.fontSize = font.size;
1440
2295
  if (font.name) style.fontName = font.name;
1441
- if (font.color) style.fontColor = font.color;
2296
+ if (font.color?.rgb) style.fontColor = font.color.rgb;
2297
+ if (font.color?.theme) style.fontColorTheme = Number(font.color.theme);
2298
+ if (font.color?.tint) style.fontColorTint = Number(font.color.tint);
2299
+ if (font.color?.indexed) style.fontColorIndexed = Number(font.color.indexed);
1442
2300
  }
1443
2301
  if (fill && fill.fgColor) {
1444
- style.fill = fill.fgColor;
2302
+ if (fill.fgColor.rgb) style.fill = fill.fgColor.rgb;
2303
+ if (fill.fgColor.theme) style.fillTheme = Number(fill.fgColor.theme);
2304
+ if (fill.fgColor.tint) style.fillTint = Number(fill.fgColor.tint);
2305
+ if (fill.fgColor.indexed) style.fillIndexed = Number(fill.fgColor.indexed);
2306
+ }
2307
+ if (fill && fill.bgColor) {
2308
+ if (fill.bgColor.rgb) style.fillBgColor = fill.bgColor.rgb;
2309
+ if (fill.bgColor.theme) style.fillBgTheme = Number(fill.bgColor.theme);
2310
+ if (fill.bgColor.tint) style.fillBgTint = Number(fill.bgColor.tint);
2311
+ if (fill.bgColor.indexed) style.fillBgIndexed = Number(fill.bgColor.indexed);
1445
2312
  }
1446
2313
  if (border) {
1447
2314
  if (border.top || border.bottom || border.left || border.right) {
@@ -1464,13 +2331,16 @@ const builder = new XMLBuilder(builderOptions);
1464
2331
  textRotation: xf.alignment.textRotation
1465
2332
  };
1466
2333
  }
2334
+ this._styleObjectCache.set(index, {
2335
+ ...style
2336
+ });
1467
2337
  return style;
1468
2338
  }
1469
2339
  /**
1470
2340
  * Create a style and return its index
1471
2341
  * Uses caching to deduplicate identical styles
1472
2342
  */ createStyle(style) {
1473
- const key = JSON.stringify(style);
2343
+ const key = this._getStyleKey(style);
1474
2344
  const cached = this._styleCache.get(key);
1475
2345
  if (cached !== undefined) {
1476
2346
  return cached;
@@ -1502,9 +2372,22 @@ const builder = new XMLBuilder(builderOptions);
1502
2372
  const index = this._cellXfs.length;
1503
2373
  this._cellXfs.push(xf);
1504
2374
  this._styleCache.set(key, index);
2375
+ this._styleObjectCache.set(index, {
2376
+ ...style
2377
+ });
1505
2378
  return index;
1506
2379
  }
2380
+ /**
2381
+ * Clone an existing style by index, optionally overriding fields.
2382
+ */ cloneStyle(index, overrides = {}) {
2383
+ const baseStyle = this.getStyle(index);
2384
+ return this.createStyle({
2385
+ ...baseStyle,
2386
+ ...overrides
2387
+ });
2388
+ }
1507
2389
  _findOrCreateFont(style) {
2390
+ const color = this._toStyleColor(style.fontColor, style.fontColorTheme, style.fontColorTint, style.fontColorIndexed);
1508
2391
  const font = {
1509
2392
  bold: style.bold || false,
1510
2393
  italic: style.italic || false,
@@ -1512,12 +2395,12 @@ const builder = new XMLBuilder(builderOptions);
1512
2395
  strike: style.strike || false,
1513
2396
  size: style.fontSize,
1514
2397
  name: style.fontName,
1515
- color: style.fontColor
2398
+ color
1516
2399
  };
1517
2400
  // Try to find existing font
1518
2401
  for(let i = 0; i < this._fonts.length; i++){
1519
2402
  const f = this._fonts[i];
1520
- if (f.bold === font.bold && f.italic === font.italic && f.underline === font.underline && f.strike === font.strike && f.size === font.size && f.name === font.name && f.color === font.color) {
2403
+ if (f.bold === font.bold && f.italic === font.italic && f.underline === font.underline && f.strike === font.strike && f.size === font.size && f.name === font.name && this._colorsEqual(f.color, font.color)) {
1521
2404
  return i;
1522
2405
  }
1523
2406
  }
@@ -1526,18 +2409,21 @@ const builder = new XMLBuilder(builderOptions);
1526
2409
  return this._fonts.length - 1;
1527
2410
  }
1528
2411
  _findOrCreateFill(style) {
1529
- if (!style.fill) return 0;
2412
+ const fgColor = this._toStyleColor(style.fill, style.fillTheme, style.fillTint, style.fillIndexed);
2413
+ const bgColor = this._toStyleColor(style.fillBgColor, style.fillBgTheme, style.fillBgTint, style.fillBgIndexed);
2414
+ if (!fgColor && !bgColor) return 0;
1530
2415
  // Try to find existing fill
1531
2416
  for(let i = 0; i < this._fills.length; i++){
1532
2417
  const f = this._fills[i];
1533
- if (f.fgColor === style.fill) {
2418
+ if (this._colorsEqual(f.fgColor, fgColor) && this._colorsEqual(f.bgColor, bgColor)) {
1534
2419
  return i;
1535
2420
  }
1536
2421
  }
1537
2422
  // Create new fill
1538
2423
  this._fills.push({
1539
2424
  type: 'solid',
1540
- fgColor: style.fill
2425
+ fgColor: fgColor || undefined,
2426
+ bgColor: bgColor || undefined
1541
2427
  });
1542
2428
  return this._fills.length - 1;
1543
2429
  }
@@ -1663,9 +2549,16 @@ const builder = new XMLBuilder(builderOptions);
1663
2549
  if (font.size) children.push(createElement('sz', {
1664
2550
  val: String(font.size)
1665
2551
  }, []));
1666
- if (font.color) children.push(createElement('color', {
1667
- rgb: normalizeColor(font.color)
1668
- }, []));
2552
+ if (font.color) {
2553
+ const attrs = {};
2554
+ if (font.color.rgb) attrs.rgb = normalizeColor(font.color.rgb);
2555
+ if (font.color.theme) attrs.theme = font.color.theme;
2556
+ if (font.color.tint) attrs.tint = font.color.tint;
2557
+ if (font.color.indexed) attrs.indexed = font.color.indexed;
2558
+ if (Object.keys(attrs).length > 0) {
2559
+ children.push(createElement('color', attrs, []));
2560
+ }
2561
+ }
1669
2562
  if (font.name) children.push(createElement('name', {
1670
2563
  val: font.name
1671
2564
  }, []));
@@ -1674,22 +2567,30 @@ const builder = new XMLBuilder(builderOptions);
1674
2567
  _buildFillNode(fill) {
1675
2568
  const patternChildren = [];
1676
2569
  if (fill.fgColor) {
1677
- const rgb = normalizeColor(fill.fgColor);
1678
- patternChildren.push(createElement('fgColor', {
1679
- rgb
1680
- }, []));
2570
+ const attrs = {};
2571
+ if (fill.fgColor.rgb) attrs.rgb = normalizeColor(fill.fgColor.rgb);
2572
+ if (fill.fgColor.theme) attrs.theme = fill.fgColor.theme;
2573
+ if (fill.fgColor.tint) attrs.tint = fill.fgColor.tint;
2574
+ if (fill.fgColor.indexed) attrs.indexed = fill.fgColor.indexed;
2575
+ if (Object.keys(attrs).length > 0) {
2576
+ patternChildren.push(createElement('fgColor', attrs, []));
2577
+ }
1681
2578
  // For solid fills, bgColor is required (indexed 64 = system background)
1682
- if (fill.type === 'solid') {
2579
+ if (fill.type === 'solid' && !fill.bgColor) {
1683
2580
  patternChildren.push(createElement('bgColor', {
1684
2581
  indexed: '64'
1685
2582
  }, []));
1686
2583
  }
1687
2584
  }
1688
- if (fill.bgColor && fill.type !== 'solid') {
1689
- const rgb = normalizeColor(fill.bgColor);
1690
- patternChildren.push(createElement('bgColor', {
1691
- rgb
1692
- }, []));
2585
+ if (fill.bgColor) {
2586
+ const attrs = {};
2587
+ if (fill.bgColor.rgb) attrs.rgb = normalizeColor(fill.bgColor.rgb);
2588
+ if (fill.bgColor.theme) attrs.theme = fill.bgColor.theme;
2589
+ if (fill.bgColor.tint) attrs.tint = fill.bgColor.tint;
2590
+ if (fill.bgColor.indexed) attrs.indexed = fill.bgColor.indexed;
2591
+ if (Object.keys(attrs).length > 0) {
2592
+ patternChildren.push(createElement('bgColor', attrs, []));
2593
+ }
1693
2594
  }
1694
2595
  const patternFill = createElement('patternFill', {
1695
2596
  patternType: fill.type || 'none'
@@ -1698,6 +2599,24 @@ const builder = new XMLBuilder(builderOptions);
1698
2599
  patternFill
1699
2600
  ]);
1700
2601
  }
2602
+ _toStyleColor(rgb, theme, tint, indexed) {
2603
+ if (rgb) {
2604
+ return {
2605
+ rgb
2606
+ };
2607
+ }
2608
+ const color = {};
2609
+ if (theme !== undefined) color.theme = String(theme);
2610
+ if (tint !== undefined) color.tint = String(tint);
2611
+ if (indexed !== undefined) color.indexed = String(indexed);
2612
+ if (color.theme || color.tint || color.indexed) return color;
2613
+ return undefined;
2614
+ }
2615
+ _colorsEqual(a, b) {
2616
+ if (!a && !b) return true;
2617
+ if (!a || !b) return false;
2618
+ return a.rgb === b.rgb && a.theme === b.theme && a.tint === b.tint && a.indexed === b.indexed;
2619
+ }
1701
2620
  _buildBorderNode(border) {
1702
2621
  const children = [];
1703
2622
  if (border.left) children.push(createElement('left', {
@@ -1753,17 +2672,19 @@ const builder = new XMLBuilder(builderOptions);
1753
2672
  this._dirty = false;
1754
2673
  // Cache for style deduplication
1755
2674
  this._styleCache = new Map();
2675
+ this._styleObjectCache = new Map();
1756
2676
  }
1757
2677
  }
1758
2678
 
1759
2679
  /**
1760
2680
  * Represents an Excel pivot table with a fluent API for configuration.
1761
2681
  */ class PivotTable {
1762
- constructor(name, cache, targetSheet, targetCell, targetRow, targetCol, pivotTableIndex){
2682
+ constructor(name, cache, targetSheet, targetCell, targetRow, targetCol, pivotTableIndex, cacheFileIndex){
1763
2683
  this._rowFields = [];
1764
2684
  this._columnFields = [];
1765
2685
  this._valueFields = [];
1766
2686
  this._filterFields = [];
2687
+ this._fieldAssignments = new Map();
1767
2688
  this._styles = null;
1768
2689
  this._name = name;
1769
2690
  this._cache = cache;
@@ -1772,6 +2693,7 @@ const builder = new XMLBuilder(builderOptions);
1772
2693
  this._targetRow = targetRow;
1773
2694
  this._targetCol = targetCol;
1774
2695
  this._pivotTableIndex = pivotTableIndex;
2696
+ this._cacheFileIndex = cacheFileIndex;
1775
2697
  }
1776
2698
  /**
1777
2699
  * Get the pivot table name
@@ -1799,6 +2721,12 @@ const builder = new XMLBuilder(builderOptions);
1799
2721
  return this._pivotTableIndex;
1800
2722
  }
1801
2723
  /**
2724
+ * Get the pivot cache file index used for rels.
2725
+ * @internal
2726
+ */ get cacheFileIndex() {
2727
+ return this._cacheFileIndex;
2728
+ }
2729
+ /**
1802
2730
  * Set the styles reference for number format resolution
1803
2731
  * @internal
1804
2732
  */ setStyles(styles) {
@@ -1813,11 +2741,13 @@ const builder = new XMLBuilder(builderOptions);
1813
2741
  if (fieldIndex < 0) {
1814
2742
  throw new Error(`Field not found in source data: ${fieldName}`);
1815
2743
  }
1816
- this._rowFields.push({
2744
+ const assignment = {
1817
2745
  fieldName,
1818
2746
  fieldIndex,
1819
2747
  axis: 'row'
1820
- });
2748
+ };
2749
+ this._rowFields.push(assignment);
2750
+ this._fieldAssignments.set(fieldIndex, assignment);
1821
2751
  return this;
1822
2752
  }
1823
2753
  /**
@@ -1828,33 +2758,52 @@ const builder = new XMLBuilder(builderOptions);
1828
2758
  if (fieldIndex < 0) {
1829
2759
  throw new Error(`Field not found in source data: ${fieldName}`);
1830
2760
  }
1831
- this._columnFields.push({
2761
+ const assignment = {
1832
2762
  fieldName,
1833
2763
  fieldIndex,
1834
2764
  axis: 'column'
1835
- });
2765
+ };
2766
+ this._columnFields.push(assignment);
2767
+ this._fieldAssignments.set(fieldIndex, assignment);
1836
2768
  return this;
1837
2769
  }
1838
- /**
1839
- * Add a field to the values area with aggregation
1840
- * @param fieldName - Name of the source field (column header)
1841
- * @param aggregation - Aggregation function (sum, count, average, min, max)
1842
- * @param displayName - Optional display name (defaults to "Sum of FieldName")
1843
- * @param numberFormat - Optional number format (e.g., '$#,##0.00', '0.00%')
1844
- */ addValueField(fieldName, aggregation = 'sum', displayName, numberFormat) {
2770
+ addValueField(fieldNameOrConfig, aggregation = 'sum', displayName, numberFormat) {
2771
+ // Normalize arguments to a common form
2772
+ let fieldName;
2773
+ let agg;
2774
+ let name;
2775
+ let format;
2776
+ if (typeof fieldNameOrConfig === 'object') {
2777
+ fieldName = fieldNameOrConfig.field;
2778
+ agg = fieldNameOrConfig.aggregation ?? 'sum';
2779
+ name = fieldNameOrConfig.name;
2780
+ format = fieldNameOrConfig.numberFormat;
2781
+ } else {
2782
+ fieldName = fieldNameOrConfig;
2783
+ agg = aggregation;
2784
+ name = displayName;
2785
+ format = numberFormat;
2786
+ }
1845
2787
  const fieldIndex = this._cache.getFieldIndex(fieldName);
1846
2788
  if (fieldIndex < 0) {
1847
2789
  throw new Error(`Field not found in source data: ${fieldName}`);
1848
2790
  }
1849
- const defaultName = `${aggregation.charAt(0).toUpperCase() + aggregation.slice(1)} of ${fieldName}`;
1850
- this._valueFields.push({
2791
+ const defaultName = `${agg.charAt(0).toUpperCase() + agg.slice(1)} of ${fieldName}`;
2792
+ // Resolve numFmtId immediately if format is provided and styles are available
2793
+ let numFmtId;
2794
+ if (format && this._styles) {
2795
+ numFmtId = this._styles.getOrCreateNumFmtId(format);
2796
+ }
2797
+ const assignment = {
1851
2798
  fieldName,
1852
2799
  fieldIndex,
1853
2800
  axis: 'value',
1854
- aggregation,
1855
- displayName: displayName || defaultName,
1856
- numberFormat
1857
- });
2801
+ aggregation: agg,
2802
+ displayName: name || defaultName,
2803
+ numFmtId
2804
+ };
2805
+ this._valueFields.push(assignment);
2806
+ this._fieldAssignments.set(fieldIndex, assignment);
1858
2807
  return this;
1859
2808
  }
1860
2809
  /**
@@ -1865,11 +2814,51 @@ const builder = new XMLBuilder(builderOptions);
1865
2814
  if (fieldIndex < 0) {
1866
2815
  throw new Error(`Field not found in source data: ${fieldName}`);
1867
2816
  }
1868
- this._filterFields.push({
2817
+ const assignment = {
1869
2818
  fieldName,
1870
2819
  fieldIndex,
1871
2820
  axis: 'filter'
1872
- });
2821
+ };
2822
+ this._filterFields.push(assignment);
2823
+ this._fieldAssignments.set(fieldIndex, assignment);
2824
+ return this;
2825
+ }
2826
+ /**
2827
+ * Set a sort order for a row or column field
2828
+ * @param fieldName - Name of the field to sort
2829
+ * @param order - Sort order ('asc' or 'desc')
2830
+ */ sortField(fieldName, order) {
2831
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
2832
+ if (fieldIndex < 0) {
2833
+ throw new Error(`Field not found in source data: ${fieldName}`);
2834
+ }
2835
+ const assignment = this._fieldAssignments.get(fieldIndex);
2836
+ if (!assignment) {
2837
+ throw new Error(`Field is not assigned to pivot table: ${fieldName}`);
2838
+ }
2839
+ if (assignment.axis !== 'row' && assignment.axis !== 'column') {
2840
+ throw new Error(`Sort is only supported for row or column fields: ${fieldName}`);
2841
+ }
2842
+ assignment.sortOrder = order;
2843
+ return this;
2844
+ }
2845
+ /**
2846
+ * Filter items for a row, column, or filter field
2847
+ * @param fieldName - Name of the field to filter
2848
+ * @param filter - Filter configuration with include or exclude list
2849
+ */ filterField(fieldName, filter) {
2850
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
2851
+ if (fieldIndex < 0) {
2852
+ throw new Error(`Field not found in source data: ${fieldName}`);
2853
+ }
2854
+ const assignment = this._fieldAssignments.get(fieldIndex);
2855
+ if (!assignment) {
2856
+ throw new Error(`Field is not assigned to pivot table: ${fieldName}`);
2857
+ }
2858
+ if (filter.include && filter.exclude) {
2859
+ throw new Error('Cannot use both include and exclude in the same filter');
2860
+ }
2861
+ assignment.filter = filter;
1873
2862
  return this;
1874
2863
  }
1875
2864
  /**
@@ -1984,9 +2973,9 @@ const builder = new XMLBuilder(builderOptions);
1984
2973
  baseItem: '0',
1985
2974
  subtotal: f.aggregation || 'sum'
1986
2975
  };
1987
- // Add numFmtId if format specified and styles available
1988
- if (f.numberFormat && this._styles) {
1989
- attrs.numFmtId = String(this._styles.getOrCreateNumFmtId(f.numberFormat));
2976
+ // Add numFmtId if it was resolved during addValueField
2977
+ if (f.numFmtId !== undefined) {
2978
+ attrs.numFmtId = String(f.numFmtId);
1990
2979
  }
1991
2980
  return createElement('dataField', attrs, []);
1992
2981
  });
@@ -1995,7 +2984,7 @@ const builder = new XMLBuilder(builderOptions);
1995
2984
  }, dataFieldNodes));
1996
2985
  }
1997
2986
  // Check if any value field has a number format
1998
- const hasNumberFormats = this._valueFields.some((f)=>f.numberFormat);
2987
+ const hasNumberFormats = this._valueFields.some((f)=>f.numFmtId !== undefined);
1999
2988
  // Pivot table style
2000
2989
  children.push(createElement('pivotTableStyleInfo', {
2001
2990
  name: 'PivotStyleMedium9',
@@ -2043,22 +3032,19 @@ const builder = new XMLBuilder(builderOptions);
2043
3032
  const colField = this._columnFields.find((f)=>f.fieldIndex === fieldIndex);
2044
3033
  const filterField = this._filterFields.find((f)=>f.fieldIndex === fieldIndex);
2045
3034
  const valueField = this._valueFields.find((f)=>f.fieldIndex === fieldIndex);
3035
+ // Get the assignment to check for sort/filter options
3036
+ const assignment = rowField || colField || filterField;
2046
3037
  if (rowField) {
2047
3038
  attrs.axis = 'axisRow';
2048
3039
  attrs.showAll = '0';
3040
+ // Add sort order if specified
3041
+ if (rowField.sortOrder) {
3042
+ attrs.sortType = rowField.sortOrder === 'asc' ? 'ascending' : 'descending';
3043
+ }
2049
3044
  // Add items for shared values
2050
3045
  const cacheField = this._cache.fields[fieldIndex];
2051
3046
  if (cacheField && cacheField.sharedItems.length > 0) {
2052
- const itemNodes = [];
2053
- for(let i = 0; i < cacheField.sharedItems.length; i++){
2054
- itemNodes.push(createElement('item', {
2055
- x: String(i)
2056
- }, []));
2057
- }
2058
- // Add default subtotal item
2059
- itemNodes.push(createElement('item', {
2060
- t: 'default'
2061
- }, []));
3047
+ const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
2062
3048
  children.push(createElement('items', {
2063
3049
  count: String(itemNodes.length)
2064
3050
  }, itemNodes));
@@ -2066,17 +3052,13 @@ const builder = new XMLBuilder(builderOptions);
2066
3052
  } else if (colField) {
2067
3053
  attrs.axis = 'axisCol';
2068
3054
  attrs.showAll = '0';
3055
+ // Add sort order if specified
3056
+ if (colField.sortOrder) {
3057
+ attrs.sortType = colField.sortOrder === 'asc' ? 'ascending' : 'descending';
3058
+ }
2069
3059
  const cacheField = this._cache.fields[fieldIndex];
2070
3060
  if (cacheField && cacheField.sharedItems.length > 0) {
2071
- const itemNodes = [];
2072
- for(let i = 0; i < cacheField.sharedItems.length; i++){
2073
- itemNodes.push(createElement('item', {
2074
- x: String(i)
2075
- }, []));
2076
- }
2077
- itemNodes.push(createElement('item', {
2078
- t: 'default'
2079
- }, []));
3061
+ const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
2080
3062
  children.push(createElement('items', {
2081
3063
  count: String(itemNodes.length)
2082
3064
  }, itemNodes));
@@ -2086,15 +3068,7 @@ const builder = new XMLBuilder(builderOptions);
2086
3068
  attrs.showAll = '0';
2087
3069
  const cacheField = this._cache.fields[fieldIndex];
2088
3070
  if (cacheField && cacheField.sharedItems.length > 0) {
2089
- const itemNodes = [];
2090
- for(let i = 0; i < cacheField.sharedItems.length; i++){
2091
- itemNodes.push(createElement('item', {
2092
- x: String(i)
2093
- }, []));
2094
- }
2095
- itemNodes.push(createElement('item', {
2096
- t: 'default'
2097
- }, []));
3071
+ const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
2098
3072
  children.push(createElement('items', {
2099
3073
  count: String(itemNodes.length)
2100
3074
  }, itemNodes));
@@ -2108,6 +3082,35 @@ const builder = new XMLBuilder(builderOptions);
2108
3082
  return createElement('pivotField', attrs, children);
2109
3083
  }
2110
3084
  /**
3085
+ * Build item nodes for a pivot field, with optional filtering
3086
+ */ _buildItemNodes(sharedItems, filter) {
3087
+ const itemNodes = [];
3088
+ for(let i = 0; i < sharedItems.length; i++){
3089
+ const itemValue = sharedItems[i];
3090
+ const itemAttrs = {
3091
+ x: String(i)
3092
+ };
3093
+ // Check if this item should be hidden
3094
+ if (filter) {
3095
+ let hidden = false;
3096
+ if (filter.exclude && filter.exclude.includes(itemValue)) {
3097
+ hidden = true;
3098
+ } else if (filter.include && !filter.include.includes(itemValue)) {
3099
+ hidden = true;
3100
+ }
3101
+ if (hidden) {
3102
+ itemAttrs.h = '1';
3103
+ }
3104
+ }
3105
+ itemNodes.push(createElement('item', itemAttrs, []));
3106
+ }
3107
+ // Add default subtotal item
3108
+ itemNodes.push(createElement('item', {
3109
+ t: 'default'
3110
+ }, []));
3111
+ return itemNodes;
3112
+ }
3113
+ /**
2111
3114
  * Build row items based on unique values in row fields
2112
3115
  */ _buildRowItems() {
2113
3116
  const items = [];
@@ -2252,7 +3255,7 @@ const builder = new XMLBuilder(builderOptions);
2252
3255
  * Manages the pivot cache (definition and records) for a pivot table.
2253
3256
  * The cache stores source data metadata and cached values.
2254
3257
  */ class PivotCache {
2255
- constructor(cacheId, sourceSheet, sourceRange){
3258
+ constructor(cacheId, sourceSheet, sourceRange, fileIndex){
2256
3259
  this._fields = [];
2257
3260
  this._records = [];
2258
3261
  this._recordCount = 0;
@@ -2260,6 +3263,7 @@ const builder = new XMLBuilder(builderOptions);
2260
3263
  // Optimized lookup: Map<fieldIndex, Map<stringValue, sharedItemsIndex>>
2261
3264
  this._sharedItemsIndexMap = new Map();
2262
3265
  this._cacheId = cacheId;
3266
+ this._fileIndex = fileIndex;
2263
3267
  this._sourceSheet = sourceSheet;
2264
3268
  this._sourceRange = sourceRange;
2265
3269
  }
@@ -2269,6 +3273,11 @@ const builder = new XMLBuilder(builderOptions);
2269
3273
  return this._cacheId;
2270
3274
  }
2271
3275
  /**
3276
+ * Get the file index for this cache (used for file naming).
3277
+ */ get fileIndex() {
3278
+ return this._fileIndex;
3279
+ }
3280
+ /**
2272
3281
  * Set refreshOnLoad option
2273
3282
  */ set refreshOnLoad(value) {
2274
3283
  this._refreshOnLoad = value;
@@ -2570,6 +3579,11 @@ const builder = new XMLBuilder(builderOptions);
2570
3579
  this._pivotTables = [];
2571
3580
  this._pivotCaches = [];
2572
3581
  this._nextCacheId = 0;
3582
+ this._nextCacheFileIndex = 1;
3583
+ // Table support
3584
+ this._nextTableId = 1;
3585
+ // Date serialization handling
3586
+ this._dateHandling = 'jsDate';
2573
3587
  this._sharedStrings = new SharedStrings();
2574
3588
  this._styles = Styles.createDefault();
2575
3589
  }
@@ -2634,6 +3648,23 @@ const builder = new XMLBuilder(builderOptions);
2634
3648
  return this._styles;
2635
3649
  }
2636
3650
  /**
3651
+ * Get the workbook date handling strategy.
3652
+ */ get dateHandling() {
3653
+ return this._dateHandling;
3654
+ }
3655
+ /**
3656
+ * Set the workbook date handling strategy.
3657
+ */ set dateHandling(value) {
3658
+ this._dateHandling = value;
3659
+ }
3660
+ /**
3661
+ * Get the next unique table ID for this workbook.
3662
+ * Table IDs must be unique across all worksheets.
3663
+ * @internal
3664
+ */ getNextTableId() {
3665
+ return this._nextTableId++;
3666
+ }
3667
+ /**
2637
3668
  * Get a worksheet by name or index
2638
3669
  */ sheet(nameOrIndex) {
2639
3670
  let def;
@@ -2715,6 +3746,11 @@ const builder = new XMLBuilder(builderOptions);
2715
3746
  const def = this._sheetDefs[index];
2716
3747
  this._sheetDefs.splice(index, 1);
2717
3748
  this._sheets.delete(def.name);
3749
+ const rel = this._relationships.find((r)=>r.id === def.rId);
3750
+ if (rel) {
3751
+ const sheetPath = `xl/${rel.target}`;
3752
+ this._files.delete(sheetPath);
3753
+ }
2718
3754
  // Remove relationship
2719
3755
  const relIndex = this._relationships.findIndex((r)=>r.id === def.rId);
2720
3756
  if (relIndex >= 0) {
@@ -2757,12 +3793,76 @@ const builder = new XMLBuilder(builderOptions);
2757
3793
  newCell.styleIndex = cell.styleIndex;
2758
3794
  }
2759
3795
  }
3796
+ // Copy column widths
3797
+ for (const [col, width] of source.getColumnWidths()){
3798
+ copy.setColumnWidth(col, width);
3799
+ }
3800
+ // Copy row heights
3801
+ for (const [row, height] of source.getRowHeights()){
3802
+ copy.setRowHeight(row, height);
3803
+ }
3804
+ // Copy frozen panes
3805
+ const frozen = source.getFrozenPane();
3806
+ if (frozen) {
3807
+ copy.freezePane(frozen.row, frozen.col);
3808
+ }
2760
3809
  // Copy merged cells
2761
3810
  for (const mergedRange of source.mergedCells){
2762
3811
  copy.mergeCells(mergedRange);
2763
3812
  }
3813
+ // Copy tables
3814
+ for (const table of source.tables){
3815
+ const tableName = this._createUniqueTableName(table.name, newName);
3816
+ const newTable = copy.createTable({
3817
+ name: tableName,
3818
+ range: table.baseRange,
3819
+ totalRow: table.hasTotalRow,
3820
+ headerRow: table.hasHeaderRow,
3821
+ style: table.style
3822
+ });
3823
+ if (!table.hasAutoFilter) {
3824
+ newTable.setAutoFilter(false);
3825
+ }
3826
+ if (table.hasTotalRow) {
3827
+ for (const columnName of table.columns){
3828
+ const fn = table.getTotalFunction(columnName);
3829
+ if (fn) {
3830
+ newTable.setTotalFunction(columnName, fn);
3831
+ }
3832
+ }
3833
+ }
3834
+ }
2764
3835
  return copy;
2765
3836
  }
3837
+ _createUniqueTableName(base, sheetName) {
3838
+ const normalizedSheet = sheetName.replace(/[^A-Za-z0-9_.]/g, '_');
3839
+ const sanitizedBase = this._sanitizeTableName(`${base}_${normalizedSheet || 'Sheet'}`);
3840
+ let candidate = sanitizedBase;
3841
+ let counter = 1;
3842
+ while(this._hasTableName(candidate)){
3843
+ candidate = `${sanitizedBase}_${counter++}`;
3844
+ }
3845
+ return candidate;
3846
+ }
3847
+ _sanitizeTableName(name) {
3848
+ let result = name.replace(/[^A-Za-z0-9_.]/g, '_');
3849
+ if (!/^[A-Za-z_]/.test(result)) {
3850
+ result = `_${result}`;
3851
+ }
3852
+ if (result.length === 0) {
3853
+ result = 'Table';
3854
+ }
3855
+ return result;
3856
+ }
3857
+ _hasTableName(name) {
3858
+ for (const sheetName of this.sheetNames){
3859
+ const ws = this.sheet(sheetName);
3860
+ for (const table of ws.tables){
3861
+ if (table.name === name) return true;
3862
+ }
3863
+ }
3864
+ return false;
3865
+ }
2766
3866
  /**
2767
3867
  * Create a new worksheet from an array of objects.
2768
3868
  *
@@ -2934,7 +4034,8 @@ const builder = new XMLBuilder(builderOptions);
2934
4034
  const { headers, data } = this._extractSourceData(sourceWs, sourceRange);
2935
4035
  // Create pivot cache
2936
4036
  const cacheId = this._nextCacheId++;
2937
- const cache = new PivotCache(cacheId, sourceSheet, sourceRange);
4037
+ const cacheFileIndex = this._nextCacheFileIndex++;
4038
+ const cache = new PivotCache(cacheId, sourceSheet, sourceRange, cacheFileIndex);
2938
4039
  cache.buildFromData(headers, data);
2939
4040
  // refreshOnLoad defaults to true; only disable if explicitly set to false
2940
4041
  if (config.refreshOnLoad === false) {
@@ -2943,7 +4044,7 @@ const builder = new XMLBuilder(builderOptions);
2943
4044
  this._pivotCaches.push(cache);
2944
4045
  // Create pivot table
2945
4046
  const pivotTableIndex = this._pivotTables.length + 1;
2946
- const pivotTable = new PivotTable(config.name, cache, targetSheet, targetCell, targetAddr.row + 1, targetAddr.col, pivotTableIndex);
4047
+ const pivotTable = new PivotTable(config.name, cache, targetSheet, targetCell, targetAddr.row + 1, targetAddr.col, pivotTableIndex, cacheFileIndex);
2947
4048
  // Set styles reference for number format resolution
2948
4049
  pivotTable.setStyles(this._styles);
2949
4050
  this._pivotTables.push(pivotTable);
@@ -3042,10 +4143,11 @@ const builder = new XMLBuilder(builderOptions);
3042
4143
  }
3043
4144
  }
3044
4145
  _updateFiles() {
4146
+ const relationshipInfo = this._buildRelationshipInfo();
3045
4147
  // Update workbook.xml
3046
- this._updateWorkbookXml();
4148
+ this._updateWorkbookXml(relationshipInfo.pivotCacheRelIds);
3047
4149
  // Update relationships
3048
- this._updateRelationshipsXml();
4150
+ this._updateRelationshipsXml(relationshipInfo.relNodes);
3049
4151
  // Update content types
3050
4152
  this._updateContentTypes();
3051
4153
  // Update shared strings if modified
@@ -3056,9 +4158,9 @@ const builder = new XMLBuilder(builderOptions);
3056
4158
  if (this._styles.dirty || this._dirty || !this._files.has('xl/styles.xml')) {
3057
4159
  writeZipText(this._files, 'xl/styles.xml', this._styles.toXml());
3058
4160
  }
3059
- // Update worksheets
4161
+ // Update worksheets (needed for pivot table targets)
3060
4162
  for (const [name, worksheet] of this._sheets){
3061
- if (worksheet.dirty || this._dirty) {
4163
+ if (worksheet.dirty || this._dirty || worksheet.tables.length > 0) {
3062
4164
  const def = this._sheetDefs.find((s)=>s.name === name);
3063
4165
  if (def) {
3064
4166
  const rel = this._relationships.find((r)=>r.id === def.rId);
@@ -3073,8 +4175,23 @@ const builder = new XMLBuilder(builderOptions);
3073
4175
  if (this._pivotTables.length > 0) {
3074
4176
  this._updatePivotTableFiles();
3075
4177
  }
4178
+ // Update tables (sets table rel IDs for tableParts)
4179
+ this._updateTableFiles();
4180
+ // Update worksheets to align tableParts with relationship IDs
4181
+ for (const [name, worksheet] of this._sheets){
4182
+ if (worksheet.dirty || this._dirty || worksheet.tables.length > 0) {
4183
+ const def = this._sheetDefs.find((s)=>s.name === name);
4184
+ if (def) {
4185
+ const rel = this._relationships.find((r)=>r.id === def.rId);
4186
+ if (rel) {
4187
+ const sheetPath = `xl/${rel.target}`;
4188
+ writeZipText(this._files, sheetPath, worksheet.toXml());
4189
+ }
4190
+ }
4191
+ }
4192
+ }
3076
4193
  }
3077
- _updateWorkbookXml() {
4194
+ _updateWorkbookXml(pivotCacheRelIds) {
3078
4195
  const sheetNodes = this._sheetDefs.map((def)=>createElement('sheet', {
3079
4196
  name: def.name,
3080
4197
  sheetId: String(def.sheetId),
@@ -3086,9 +4203,11 @@ const builder = new XMLBuilder(builderOptions);
3086
4203
  ];
3087
4204
  // Add pivot caches if any
3088
4205
  if (this._pivotCaches.length > 0) {
3089
- const pivotCacheNodes = this._pivotCaches.map((cache, idx)=>{
3090
- // Cache relationship ID is after sheets, sharedStrings, and styles
3091
- const cacheRelId = `rId${this._relationships.length + 3 + idx}`;
4206
+ const pivotCacheNodes = this._pivotCaches.map((cache)=>{
4207
+ const cacheRelId = pivotCacheRelIds.get(cache.cacheId);
4208
+ if (!cacheRelId) {
4209
+ throw new Error(`Missing pivot cache relationship ID for cache ${cache.cacheId}`);
4210
+ }
3092
4211
  return createElement('pivotCache', {
3093
4212
  cacheId: String(cache.cacheId),
3094
4213
  'r:id': cacheRelId
@@ -3105,20 +4224,38 @@ const builder = new XMLBuilder(builderOptions);
3105
4224
  ])}`;
3106
4225
  writeZipText(this._files, 'xl/workbook.xml', xml);
3107
4226
  }
3108
- _updateRelationshipsXml() {
4227
+ _updateRelationshipsXml(relNodes) {
4228
+ const relsNode = createElement('Relationships', {
4229
+ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
4230
+ }, relNodes);
4231
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
4232
+ relsNode
4233
+ ])}`;
4234
+ writeZipText(this._files, 'xl/_rels/workbook.xml.rels', xml);
4235
+ }
4236
+ _buildRelationshipInfo() {
3109
4237
  const relNodes = this._relationships.map((rel)=>createElement('Relationship', {
3110
4238
  Id: rel.id,
3111
4239
  Type: rel.type,
3112
4240
  Target: rel.target
3113
4241
  }, []));
3114
- // Calculate next available relationship ID based on existing max ID
4242
+ const reservedRelIds = new Set(relNodes.map((node)=>getAttr(node, 'Id') || '').filter(Boolean));
3115
4243
  let nextRelId = Math.max(0, ...this._relationships.map((r)=>parseInt(r.id.replace('rId', ''), 10) || 0)) + 1;
4244
+ const allocateRelId = ()=>{
4245
+ while(reservedRelIds.has(`rId${nextRelId}`)){
4246
+ nextRelId++;
4247
+ }
4248
+ const id = `rId${nextRelId}`;
4249
+ nextRelId++;
4250
+ reservedRelIds.add(id);
4251
+ return id;
4252
+ };
3116
4253
  // Add shared strings relationship if needed
3117
4254
  if (this._sharedStrings.count > 0) {
3118
4255
  const hasSharedStrings = this._relationships.some((r)=>r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings');
3119
4256
  if (!hasSharedStrings) {
3120
4257
  relNodes.push(createElement('Relationship', {
3121
- Id: `rId${nextRelId++}`,
4258
+ Id: allocateRelId(),
3122
4259
  Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings',
3123
4260
  Target: 'sharedStrings.xml'
3124
4261
  }, []));
@@ -3128,26 +4265,26 @@ const builder = new XMLBuilder(builderOptions);
3128
4265
  const hasStyles = this._relationships.some((r)=>r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles');
3129
4266
  if (!hasStyles) {
3130
4267
  relNodes.push(createElement('Relationship', {
3131
- Id: `rId${nextRelId++}`,
4268
+ Id: allocateRelId(),
3132
4269
  Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles',
3133
4270
  Target: 'styles.xml'
3134
4271
  }, []));
3135
4272
  }
3136
4273
  // Add pivot cache relationships
3137
- for(let i = 0; i < this._pivotCaches.length; i++){
4274
+ const pivotCacheRelIds = new Map();
4275
+ for (const cache of this._pivotCaches){
4276
+ const id = allocateRelId();
4277
+ pivotCacheRelIds.set(cache.cacheId, id);
3138
4278
  relNodes.push(createElement('Relationship', {
3139
- Id: `rId${nextRelId++}`,
4279
+ Id: id,
3140
4280
  Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
3141
- Target: `pivotCache/pivotCacheDefinition${i + 1}.xml`
4281
+ Target: `pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`
3142
4282
  }, []));
3143
4283
  }
3144
- const relsNode = createElement('Relationships', {
3145
- xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
3146
- }, relNodes);
3147
- const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
3148
- relsNode
3149
- ])}`;
3150
- writeZipText(this._files, 'xl/_rels/workbook.xml.rels', xml);
4284
+ return {
4285
+ relNodes,
4286
+ pivotCacheRelIds
4287
+ };
3151
4288
  }
3152
4289
  _updateContentTypes() {
3153
4290
  const types = [
@@ -3186,23 +4323,67 @@ const builder = new XMLBuilder(builderOptions);
3186
4323
  }
3187
4324
  }
3188
4325
  // Add pivot cache definitions and records
3189
- for(let i = 0; i < this._pivotCaches.length; i++){
4326
+ for (const cache of this._pivotCaches){
3190
4327
  types.push(createElement('Override', {
3191
- PartName: `/xl/pivotCache/pivotCacheDefinition${i + 1}.xml`,
4328
+ PartName: `/xl/pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`,
3192
4329
  ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml'
3193
4330
  }, []));
3194
4331
  types.push(createElement('Override', {
3195
- PartName: `/xl/pivotCache/pivotCacheRecords${i + 1}.xml`,
4332
+ PartName: `/xl/pivotCache/pivotCacheRecords${cache.fileIndex}.xml`,
3196
4333
  ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml'
3197
4334
  }, []));
3198
4335
  }
3199
4336
  // Add pivot tables
3200
- for(let i = 0; i < this._pivotTables.length; i++){
4337
+ for (const pivotTable of this._pivotTables){
3201
4338
  types.push(createElement('Override', {
3202
- PartName: `/xl/pivotTables/pivotTable${i + 1}.xml`,
4339
+ PartName: `/xl/pivotTables/pivotTable${pivotTable.index}.xml`,
3203
4340
  ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml'
3204
4341
  }, []));
3205
4342
  }
4343
+ // Add tables
4344
+ let tableIndex = 1;
4345
+ for (const def of this._sheetDefs){
4346
+ const worksheet = this._sheets.get(def.name);
4347
+ if (worksheet) {
4348
+ for(let i = 0; i < worksheet.tables.length; i++){
4349
+ types.push(createElement('Override', {
4350
+ PartName: `/xl/tables/table${tableIndex}.xml`,
4351
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml'
4352
+ }, []));
4353
+ tableIndex++;
4354
+ }
4355
+ }
4356
+ }
4357
+ const existingTypesXml = readZipText(this._files, '[Content_Types].xml');
4358
+ const existingKeys = new Set(types.map((t)=>{
4359
+ if ('Default' in t) {
4360
+ const a = t[':@'];
4361
+ return `Default:${a?.['@_Extension'] || ''}`;
4362
+ }
4363
+ if ('Override' in t) {
4364
+ const a = t[':@'];
4365
+ return `Override:${a?.['@_PartName'] || ''}`;
4366
+ }
4367
+ return '';
4368
+ }).filter(Boolean));
4369
+ if (existingTypesXml) {
4370
+ const parsed = parseXml(existingTypesXml);
4371
+ const typesElement = findElement(parsed, 'Types');
4372
+ if (typesElement) {
4373
+ const existingNodes = getChildren(typesElement, 'Types');
4374
+ for (const node of existingNodes){
4375
+ if ('Default' in node || 'Override' in node) {
4376
+ const type = 'Default' in node ? 'Default' : 'Override';
4377
+ const attrs = node[':@'];
4378
+ const key = type === 'Default' ? `Default:${attrs?.['@_Extension'] || ''}` : `Override:${attrs?.['@_PartName'] || ''}`;
4379
+ if (!existingKeys.has(key)) {
4380
+ types.push(node);
4381
+ existingKeys.add(key);
4382
+ }
4383
+ }
4384
+ }
4385
+ }
4386
+ }
3206
4387
  const typesNode = createElement('Types', {
3207
4388
  xmlns: 'http://schemas.openxmlformats.org/package/2006/content-types'
3208
4389
  }, types);
@@ -3242,22 +4423,21 @@ const builder = new XMLBuilder(builderOptions);
3242
4423
  // Generate pivot cache files
3243
4424
  for(let i = 0; i < this._pivotCaches.length; i++){
3244
4425
  const cache = this._pivotCaches[i];
3245
- const cacheIdx = i + 1;
3246
4426
  // Pivot cache definition
3247
- const definitionPath = `xl/pivotCache/pivotCacheDefinition${cacheIdx}.xml`;
4427
+ const definitionPath = `xl/pivotCache/pivotCacheDefinition${cache.fileIndex}.xml`;
3248
4428
  writeZipText(this._files, definitionPath, cache.toDefinitionXml('rId1'));
3249
4429
  // Pivot cache records
3250
- const recordsPath = `xl/pivotCache/pivotCacheRecords${cacheIdx}.xml`;
4430
+ const recordsPath = `xl/pivotCache/pivotCacheRecords${cache.fileIndex}.xml`;
3251
4431
  writeZipText(this._files, recordsPath, cache.toRecordsXml());
3252
4432
  // Pivot cache definition relationships (link to records)
3253
- const cacheRelsPath = `xl/pivotCache/_rels/pivotCacheDefinition${cacheIdx}.xml.rels`;
4433
+ const cacheRelsPath = `xl/pivotCache/_rels/pivotCacheDefinition${cache.fileIndex}.xml.rels`;
3254
4434
  const cacheRels = createElement('Relationships', {
3255
4435
  xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
3256
4436
  }, [
3257
4437
  createElement('Relationship', {
3258
4438
  Id: 'rId1',
3259
4439
  Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords',
3260
- Target: `pivotCacheRecords${cacheIdx}.xml`
4440
+ Target: `pivotCacheRecords${cache.fileIndex}.xml`
3261
4441
  }, [])
3262
4442
  ]);
3263
4443
  writeZipText(this._files, cacheRelsPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
@@ -3267,12 +4447,12 @@ const builder = new XMLBuilder(builderOptions);
3267
4447
  // Generate pivot table files
3268
4448
  for(let i = 0; i < this._pivotTables.length; i++){
3269
4449
  const pivotTable = this._pivotTables[i];
3270
- const ptIdx = i + 1;
4450
+ const ptIdx = pivotTable.index;
3271
4451
  // Pivot table definition
3272
4452
  const ptPath = `xl/pivotTables/pivotTable${ptIdx}.xml`;
3273
4453
  writeZipText(this._files, ptPath, pivotTable.toXml());
3274
4454
  // Pivot table relationships (link to cache definition)
3275
- const cacheIdx = this._pivotCaches.indexOf(pivotTable.cache) + 1;
4455
+ const cacheIdx = pivotTable.cacheFileIndex;
3276
4456
  const ptRelsPath = `xl/pivotTables/_rels/pivotTable${ptIdx}.xml.rels`;
3277
4457
  const ptRels = createElement('Relationships', {
3278
4458
  xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
@@ -3296,13 +4476,47 @@ const builder = new XMLBuilder(builderOptions);
3296
4476
  // Extract sheet file name from target path
3297
4477
  const sheetFileName = rel.target.split('/').pop();
3298
4478
  const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
3299
- const relNodes = [];
3300
- for(let i = 0; i < pivotTables.length; i++){
3301
- const pt = pivotTables[i];
4479
+ const existingRelsXml = readZipText(this._files, sheetRelsPath);
4480
+ let relNodes = [];
4481
+ let nextRelId = 1;
4482
+ const reservedRelIds = new Set();
4483
+ if (existingRelsXml) {
4484
+ const parsed = parseXml(existingRelsXml);
4485
+ const relsElement = findElement(parsed, 'Relationships');
4486
+ if (relsElement) {
4487
+ const existingRelNodes = getChildren(relsElement, 'Relationships');
4488
+ for (const relNode of existingRelNodes){
4489
+ if ('Relationship' in relNode) {
4490
+ relNodes.push(relNode);
4491
+ const id = getAttr(relNode, 'Id');
4492
+ if (id) {
4493
+ reservedRelIds.add(id);
4494
+ const idNum = parseInt(id.replace('rId', ''), 10);
4495
+ if (idNum >= nextRelId) {
4496
+ nextRelId = idNum + 1;
4497
+ }
4498
+ }
4499
+ }
4500
+ }
4501
+ }
4502
+ }
4503
+ const allocateRelId = ()=>{
4504
+ while(reservedRelIds.has(`rId${nextRelId}`)){
4505
+ nextRelId++;
4506
+ }
4507
+ const id = `rId${nextRelId}`;
4508
+ nextRelId++;
4509
+ reservedRelIds.add(id);
4510
+ return id;
4511
+ };
4512
+ for (const pt of pivotTables){
4513
+ const target = `../pivotTables/pivotTable${pt.index}.xml`;
4514
+ const existing = relNodes.some((node)=>getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable' && getAttr(node, 'Target') === target);
4515
+ if (existing) continue;
3302
4516
  relNodes.push(createElement('Relationship', {
3303
- Id: `rId${i + 1}`,
4517
+ Id: allocateRelId(),
3304
4518
  Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable',
3305
- Target: `../pivotTables/pivotTable${pt.index}.xml`
4519
+ Target: target
3306
4520
  }, []));
3307
4521
  }
3308
4522
  const sheetRels = createElement('Relationships', {
@@ -3313,6 +4527,109 @@ const builder = new XMLBuilder(builderOptions);
3313
4527
  ])}`);
3314
4528
  }
3315
4529
  }
4530
+ /**
4531
+ * Generate all table related files
4532
+ */ _updateTableFiles() {
4533
+ // Collect all tables with their global indices
4534
+ let globalTableIndex = 1;
4535
+ const sheetTables = new Map();
4536
+ for (const def of this._sheetDefs){
4537
+ const worksheet = this._sheets.get(def.name);
4538
+ if (!worksheet) continue;
4539
+ const tables = worksheet.tables;
4540
+ if (tables.length === 0) continue;
4541
+ const tableInfos = [];
4542
+ for (const table of tables){
4543
+ tableInfos.push({
4544
+ table,
4545
+ globalIndex: globalTableIndex
4546
+ });
4547
+ globalTableIndex++;
4548
+ }
4549
+ sheetTables.set(def.name, tableInfos);
4550
+ }
4551
+ // Generate table files
4552
+ for (const [, tableInfos] of sheetTables){
4553
+ for (const { table, globalIndex } of tableInfos){
4554
+ const tablePath = `xl/tables/table${globalIndex}.xml`;
4555
+ writeZipText(this._files, tablePath, table.toXml());
4556
+ }
4557
+ }
4558
+ // Generate worksheet relationships for tables
4559
+ for (const [sheetName, tableInfos] of sheetTables){
4560
+ const def = this._sheetDefs.find((s)=>s.name === sheetName);
4561
+ if (!def) continue;
4562
+ const rel = this._relationships.find((r)=>r.id === def.rId);
4563
+ if (!rel) continue;
4564
+ // Extract sheet file name from target path
4565
+ const sheetFileName = rel.target.split('/').pop();
4566
+ const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
4567
+ // Check if there are already pivot table relationships for this sheet
4568
+ const existingRelsXml = readZipText(this._files, sheetRelsPath);
4569
+ let nextRelId = 1;
4570
+ const relNodes = [];
4571
+ const reservedRelIds = new Set();
4572
+ if (existingRelsXml) {
4573
+ // Parse existing rels and find max rId
4574
+ const parsed = parseXml(existingRelsXml);
4575
+ const relsElement = findElement(parsed, 'Relationships');
4576
+ if (relsElement) {
4577
+ const existingRelNodes = getChildren(relsElement, 'Relationships');
4578
+ for (const relNode of existingRelNodes){
4579
+ if ('Relationship' in relNode) {
4580
+ relNodes.push(relNode);
4581
+ const id = getAttr(relNode, 'Id');
4582
+ if (id) {
4583
+ reservedRelIds.add(id);
4584
+ const idNum = parseInt(id.replace('rId', ''), 10);
4585
+ if (idNum >= nextRelId) {
4586
+ nextRelId = idNum + 1;
4587
+ }
4588
+ }
4589
+ }
4590
+ }
4591
+ }
4592
+ }
4593
+ const allocateRelId = ()=>{
4594
+ while(reservedRelIds.has(`rId${nextRelId}`)){
4595
+ nextRelId++;
4596
+ }
4597
+ const id = `rId${nextRelId}`;
4598
+ nextRelId++;
4599
+ reservedRelIds.add(id);
4600
+ return id;
4601
+ };
4602
+ // Add table relationships
4603
+ const tableRelIds = [];
4604
+ for (const { globalIndex } of tableInfos){
4605
+ const target = `../tables/table${globalIndex}.xml`;
4606
+ const existing = relNodes.some((node)=>getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' && getAttr(node, 'Target') === target);
4607
+ if (existing) {
4608
+ const existingRel = relNodes.find((node)=>getAttr(node, 'Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table' && getAttr(node, 'Target') === target);
4609
+ const existingId = existingRel ? getAttr(existingRel, 'Id') : undefined;
4610
+ tableRelIds.push(existingId ?? allocateRelId());
4611
+ continue;
4612
+ }
4613
+ const id = allocateRelId();
4614
+ tableRelIds.push(id);
4615
+ relNodes.push(createElement('Relationship', {
4616
+ Id: id,
4617
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table',
4618
+ Target: target
4619
+ }, []));
4620
+ }
4621
+ const worksheet = this._sheets.get(sheetName);
4622
+ if (worksheet) {
4623
+ worksheet.setTableRelIds(tableRelIds);
4624
+ }
4625
+ const sheetRels = createElement('Relationships', {
4626
+ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
4627
+ }, relNodes);
4628
+ writeZipText(this._files, sheetRelsPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
4629
+ sheetRels
4630
+ ])}`);
4631
+ }
4632
+ }
3316
4633
  }
3317
4634
 
3318
- export { Cell, PivotCache, PivotTable, Range, SharedStrings, Styles, Workbook, Worksheet, parseAddress, parseRange, toAddress, toRange };
4635
+ export { Cell, PivotCache, PivotTable, Range, SharedStrings, Styles, Table, Workbook, Worksheet, parseAddress, parseRange, toAddress, toRange };