@niicojs/excel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2881 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { XMLParser, XMLBuilder } from 'fast-xml-parser';
3
+ import { unzip, strFromU8, zip, strToU8 } from 'fflate';
4
+
5
+ /**
6
+ * Converts a column index (0-based) to Excel column letters (A, B, ..., Z, AA, AB, ...)
7
+ * @param col - 0-based column index
8
+ * @returns Column letter(s)
9
+ */ const colToLetter = (col)=>{
10
+ let result = '';
11
+ let n = col;
12
+ while(n >= 0){
13
+ result = String.fromCharCode(n % 26 + 65) + result;
14
+ n = Math.floor(n / 26) - 1;
15
+ }
16
+ return result;
17
+ };
18
+ /**
19
+ * Converts Excel column letters to a 0-based column index
20
+ * @param letters - Column letter(s) like 'A', 'B', 'AA'
21
+ * @returns 0-based column index
22
+ */ const letterToCol = (letters)=>{
23
+ const upper = letters.toUpperCase();
24
+ let col = 0;
25
+ for(let i = 0; i < upper.length; i++){
26
+ col = col * 26 + (upper.charCodeAt(i) - 64);
27
+ }
28
+ return col - 1;
29
+ };
30
+ /**
31
+ * Parses an Excel cell address (e.g., 'A1', '$B$2') to row/col indices
32
+ * @param address - Cell address string
33
+ * @returns CellAddress with 0-based row and col
34
+ */ const parseAddress = (address)=>{
35
+ // Remove $ signs for absolute references
36
+ const clean = address.replace(/\$/g, '');
37
+ const match = clean.match(/^([A-Z]+)(\d+)$/i);
38
+ if (!match) {
39
+ throw new Error(`Invalid cell address: ${address}`);
40
+ }
41
+ const col = letterToCol(match[1].toUpperCase());
42
+ const row = parseInt(match[2], 10) - 1; // Convert to 0-based
43
+ return {
44
+ row,
45
+ col
46
+ };
47
+ };
48
+ /**
49
+ * Converts row/col indices to an Excel cell address
50
+ * @param row - 0-based row index
51
+ * @param col - 0-based column index
52
+ * @returns Cell address string like 'A1'
53
+ */ const toAddress = (row, col)=>{
54
+ return `${colToLetter(col)}${row + 1}`;
55
+ };
56
+ /**
57
+ * Parses an Excel range (e.g., 'A1:B10') to start/end addresses
58
+ * @param range - Range string
59
+ * @returns RangeAddress with start and end
60
+ */ const parseRange = (range)=>{
61
+ const parts = range.split(':');
62
+ if (parts.length === 1) {
63
+ // Single cell range
64
+ const addr = parseAddress(parts[0]);
65
+ return {
66
+ start: addr,
67
+ end: addr
68
+ };
69
+ }
70
+ if (parts.length !== 2) {
71
+ throw new Error(`Invalid range: ${range}`);
72
+ }
73
+ return {
74
+ start: parseAddress(parts[0]),
75
+ end: parseAddress(parts[1])
76
+ };
77
+ };
78
+ /**
79
+ * Converts a RangeAddress to a range string
80
+ * @param range - RangeAddress object
81
+ * @returns Range string like 'A1:B10'
82
+ */ const toRange = (range)=>{
83
+ const start = toAddress(range.start.row, range.start.col);
84
+ const end = toAddress(range.end.row, range.end.col);
85
+ if (start === end) {
86
+ return start;
87
+ }
88
+ return `${start}:${end}`;
89
+ };
90
+ /**
91
+ * Normalizes a range so start is always top-left and end is bottom-right
92
+ */ const normalizeRange = (range)=>{
93
+ return {
94
+ start: {
95
+ row: Math.min(range.start.row, range.end.row),
96
+ col: Math.min(range.start.col, range.end.col)
97
+ },
98
+ end: {
99
+ row: Math.max(range.start.row, range.end.row),
100
+ col: Math.max(range.start.col, range.end.col)
101
+ }
102
+ };
103
+ };
104
+
105
+ // Excel epoch: December 30, 1899 (accounting for the 1900 leap year bug)
106
+ const EXCEL_EPOCH = new Date(Date.UTC(1899, 11, 30));
107
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
108
+ // Excel error types
109
+ const ERROR_TYPES = new Set([
110
+ '#NULL!',
111
+ '#DIV/0!',
112
+ '#VALUE!',
113
+ '#REF!',
114
+ '#NAME?',
115
+ '#NUM!',
116
+ '#N/A',
117
+ '#GETTING_DATA'
118
+ ]);
119
+ /**
120
+ * Represents a single cell in a worksheet
121
+ */ class Cell {
122
+ constructor(worksheet, row, col, data){
123
+ this._dirty = false;
124
+ this._worksheet = worksheet;
125
+ this._row = row;
126
+ this._col = col;
127
+ this._data = data || {};
128
+ }
129
+ /**
130
+ * Get the cell address (e.g., 'A1')
131
+ */ get address() {
132
+ return toAddress(this._row, this._col);
133
+ }
134
+ /**
135
+ * Get the 0-based row index
136
+ */ get row() {
137
+ return this._row;
138
+ }
139
+ /**
140
+ * Get the 0-based column index
141
+ */ get col() {
142
+ return this._col;
143
+ }
144
+ /**
145
+ * Get the cell type
146
+ */ get type() {
147
+ const t = this._data.t;
148
+ if (!t && this._data.v === undefined && !this._data.f) {
149
+ return 'empty';
150
+ }
151
+ switch(t){
152
+ case 'n':
153
+ return 'number';
154
+ case 's':
155
+ case 'str':
156
+ return 'string';
157
+ case 'b':
158
+ return 'boolean';
159
+ case 'e':
160
+ return 'error';
161
+ case 'd':
162
+ return 'date';
163
+ default:
164
+ // If no type but has value, infer from value
165
+ if (typeof this._data.v === 'number') return 'number';
166
+ if (typeof this._data.v === 'string') return 'string';
167
+ if (typeof this._data.v === 'boolean') return 'boolean';
168
+ return 'empty';
169
+ }
170
+ }
171
+ /**
172
+ * Get the cell value
173
+ */ get value() {
174
+ const t = this._data.t;
175
+ const v = this._data.v;
176
+ if (v === undefined && !this._data.f) {
177
+ return null;
178
+ }
179
+ switch(t){
180
+ case 'n':
181
+ return typeof v === 'number' ? v : parseFloat(String(v));
182
+ case 's':
183
+ // Shared string reference
184
+ if (typeof v === 'number') {
185
+ return this._worksheet.workbook.sharedStrings.getString(v) ?? '';
186
+ }
187
+ return String(v);
188
+ case 'str':
189
+ // Inline string
190
+ return String(v);
191
+ case 'b':
192
+ return v === 1 || v === '1' || v === true;
193
+ case 'e':
194
+ return {
195
+ error: String(v)
196
+ };
197
+ case 'd':
198
+ // ISO 8601 date string
199
+ return new Date(String(v));
200
+ default:
201
+ // No type specified - try to infer
202
+ if (typeof v === 'number') {
203
+ // Check if this might be a date based on number format
204
+ if (this._isDateFormat()) {
205
+ return this._excelDateToJs(v);
206
+ }
207
+ return v;
208
+ }
209
+ if (typeof v === 'string') {
210
+ if (ERROR_TYPES.has(v)) {
211
+ return {
212
+ error: v
213
+ };
214
+ }
215
+ return v;
216
+ }
217
+ if (typeof v === 'boolean') return v;
218
+ return null;
219
+ }
220
+ }
221
+ /**
222
+ * Set the cell value
223
+ */ set value(val) {
224
+ this._dirty = true;
225
+ if (val === null || val === undefined) {
226
+ this._data.v = undefined;
227
+ this._data.t = undefined;
228
+ this._data.f = undefined;
229
+ return;
230
+ }
231
+ if (typeof val === 'number') {
232
+ this._data.v = val;
233
+ this._data.t = 'n';
234
+ } else if (typeof val === 'string') {
235
+ // Store as shared string
236
+ const index = this._worksheet.workbook.sharedStrings.addString(val);
237
+ this._data.v = index;
238
+ this._data.t = 's';
239
+ } else if (typeof val === 'boolean') {
240
+ this._data.v = val ? 1 : 0;
241
+ this._data.t = 'b';
242
+ } else if (val instanceof Date) {
243
+ // Store as ISO date string with 'd' type
244
+ this._data.v = val.toISOString();
245
+ this._data.t = 'd';
246
+ } else if ('error' in val) {
247
+ this._data.v = val.error;
248
+ this._data.t = 'e';
249
+ }
250
+ // Clear formula when setting value directly
251
+ this._data.f = undefined;
252
+ }
253
+ /**
254
+ * Write a 2D array of values starting at this cell
255
+ */ set values(data) {
256
+ for(let r = 0; r < data.length; r++){
257
+ const row = data[r];
258
+ for(let c = 0; c < row.length; c++){
259
+ const cell = this._worksheet.cell(this._row + r, this._col + c);
260
+ cell.value = row[c];
261
+ }
262
+ }
263
+ }
264
+ /**
265
+ * Get the formula (without leading '=')
266
+ */ get formula() {
267
+ return this._data.f;
268
+ }
269
+ /**
270
+ * Set the formula (without leading '=')
271
+ */ set formula(f) {
272
+ this._dirty = true;
273
+ if (f === undefined) {
274
+ this._data.f = undefined;
275
+ } else {
276
+ // Remove leading '=' if present
277
+ this._data.f = f.startsWith('=') ? f.slice(1) : f;
278
+ }
279
+ }
280
+ /**
281
+ * Get the formatted text (as displayed in Excel)
282
+ */ get text() {
283
+ if (this._data.w) {
284
+ return this._data.w;
285
+ }
286
+ const val = this.value;
287
+ if (val === null) return '';
288
+ if (typeof val === 'object' && 'error' in val) return val.error;
289
+ if (val instanceof Date) return val.toISOString().split('T')[0];
290
+ return String(val);
291
+ }
292
+ /**
293
+ * Get the style index
294
+ */ get styleIndex() {
295
+ return this._data.s;
296
+ }
297
+ /**
298
+ * Set the style index
299
+ */ set styleIndex(index) {
300
+ this._dirty = true;
301
+ this._data.s = index;
302
+ }
303
+ /**
304
+ * Get the cell style
305
+ */ get style() {
306
+ if (this._data.s === undefined) {
307
+ return {};
308
+ }
309
+ return this._worksheet.workbook.styles.getStyle(this._data.s);
310
+ }
311
+ /**
312
+ * Set the cell style (merges with existing)
313
+ */ set style(style) {
314
+ this._dirty = true;
315
+ const currentStyle = this.style;
316
+ const merged = {
317
+ ...currentStyle,
318
+ ...style
319
+ };
320
+ this._data.s = this._worksheet.workbook.styles.createStyle(merged);
321
+ }
322
+ /**
323
+ * Check if cell has been modified
324
+ */ get dirty() {
325
+ return this._dirty;
326
+ }
327
+ /**
328
+ * Get internal cell data
329
+ */ get data() {
330
+ return this._data;
331
+ }
332
+ /**
333
+ * Check if this cell has a date number format
334
+ */ _isDateFormat() {
335
+ // TODO: Check actual number format from styles
336
+ // For now, return false - dates should be explicitly typed
337
+ return false;
338
+ }
339
+ /**
340
+ * Convert Excel serial date to JavaScript Date
341
+ * Used when reading dates stored as numbers with date formats
342
+ */ _excelDateToJs(serial) {
343
+ // Excel incorrectly considers 1900 a leap year
344
+ // Dates after Feb 28, 1900 need adjustment
345
+ const adjusted = serial > 60 ? serial - 1 : serial;
346
+ const ms = Math.round((adjusted - 1) * MS_PER_DAY);
347
+ return new Date(EXCEL_EPOCH.getTime() + ms);
348
+ }
349
+ /**
350
+ * Convert JavaScript Date to Excel serial date
351
+ * Used when writing dates as numbers for Excel compatibility
352
+ */ _jsDateToExcel(date) {
353
+ const ms = date.getTime() - EXCEL_EPOCH.getTime();
354
+ let serial = ms / MS_PER_DAY + 1;
355
+ // Account for Excel's 1900 leap year bug
356
+ if (serial > 60) {
357
+ serial += 1;
358
+ }
359
+ return serial;
360
+ }
361
+ }
362
+ /**
363
+ * Parse a cell address or row/col to get row and col indices
364
+ */ const parseCellRef = (rowOrAddress, col)=>{
365
+ if (typeof rowOrAddress === 'string') {
366
+ return parseAddress(rowOrAddress);
367
+ }
368
+ if (col === undefined) {
369
+ throw new Error('Column must be provided when row is a number');
370
+ }
371
+ return {
372
+ row: rowOrAddress,
373
+ col
374
+ };
375
+ };
376
+
377
+ var _computedKey;
378
+ _computedKey = Symbol.iterator;
379
+ /**
380
+ * Represents a range of cells in a worksheet
381
+ */ class Range {
382
+ constructor(worksheet, range){
383
+ this._worksheet = worksheet;
384
+ this._range = normalizeRange(range);
385
+ }
386
+ /**
387
+ * Get the range address as a string
388
+ */ get address() {
389
+ const start = toAddress(this._range.start.row, this._range.start.col);
390
+ const end = toAddress(this._range.end.row, this._range.end.col);
391
+ if (start === end) return start;
392
+ return `${start}:${end}`;
393
+ }
394
+ /**
395
+ * Get the number of rows in the range
396
+ */ get rowCount() {
397
+ return this._range.end.row - this._range.start.row + 1;
398
+ }
399
+ /**
400
+ * Get the number of columns in the range
401
+ */ get colCount() {
402
+ return this._range.end.col - this._range.start.col + 1;
403
+ }
404
+ /**
405
+ * Get all values in the range as a 2D array
406
+ */ get values() {
407
+ const result = [];
408
+ for(let r = this._range.start.row; r <= this._range.end.row; r++){
409
+ const row = [];
410
+ for(let c = this._range.start.col; c <= this._range.end.col; c++){
411
+ const cell = this._worksheet.cell(r, c);
412
+ row.push(cell.value);
413
+ }
414
+ result.push(row);
415
+ }
416
+ return result;
417
+ }
418
+ /**
419
+ * Set values in the range from a 2D array
420
+ */ set values(data) {
421
+ for(let r = 0; r < data.length && r < this.rowCount; r++){
422
+ const row = data[r];
423
+ for(let c = 0; c < row.length && c < this.colCount; c++){
424
+ const cell = this._worksheet.cell(this._range.start.row + r, this._range.start.col + c);
425
+ cell.value = row[c];
426
+ }
427
+ }
428
+ }
429
+ /**
430
+ * Get all formulas in the range as a 2D array
431
+ */ get formulas() {
432
+ const result = [];
433
+ for(let r = this._range.start.row; r <= this._range.end.row; r++){
434
+ const row = [];
435
+ for(let c = this._range.start.col; c <= this._range.end.col; c++){
436
+ const cell = this._worksheet.cell(r, c);
437
+ row.push(cell.formula);
438
+ }
439
+ result.push(row);
440
+ }
441
+ return result;
442
+ }
443
+ /**
444
+ * Set formulas in the range from a 2D array
445
+ */ set formulas(data) {
446
+ for(let r = 0; r < data.length && r < this.rowCount; r++){
447
+ const row = data[r];
448
+ for(let c = 0; c < row.length && c < this.colCount; c++){
449
+ const cell = this._worksheet.cell(this._range.start.row + r, this._range.start.col + c);
450
+ cell.formula = row[c];
451
+ }
452
+ }
453
+ }
454
+ /**
455
+ * Get the style of the top-left cell
456
+ */ get style() {
457
+ return this._worksheet.cell(this._range.start.row, this._range.start.col).style;
458
+ }
459
+ /**
460
+ * Set style for all cells in the range
461
+ */ set style(style) {
462
+ for(let r = this._range.start.row; r <= this._range.end.row; r++){
463
+ for(let c = this._range.start.col; c <= this._range.end.col; c++){
464
+ const cell = this._worksheet.cell(r, c);
465
+ cell.style = style;
466
+ }
467
+ }
468
+ }
469
+ /**
470
+ * Iterate over all cells in the range
471
+ */ *[_computedKey]() {
472
+ for(let r = this._range.start.row; r <= this._range.end.row; r++){
473
+ for(let c = this._range.start.col; c <= this._range.end.col; c++){
474
+ yield this._worksheet.cell(r, c);
475
+ }
476
+ }
477
+ }
478
+ /**
479
+ * Iterate over cells row by row
480
+ */ *rows() {
481
+ for(let r = this._range.start.row; r <= this._range.end.row; r++){
482
+ const row = [];
483
+ for(let c = this._range.start.col; c <= this._range.end.col; c++){
484
+ row.push(this._worksheet.cell(r, c));
485
+ }
486
+ yield row;
487
+ }
488
+ }
489
+ }
490
+
491
+ // Parser options that preserve structure and attributes
492
+ const parserOptions = {
493
+ ignoreAttributes: false,
494
+ attributeNamePrefix: '@_',
495
+ textNodeName: '#text',
496
+ preserveOrder: true,
497
+ commentPropName: '#comment',
498
+ cdataPropName: '#cdata',
499
+ trimValues: false,
500
+ parseTagValue: false,
501
+ parseAttributeValue: false
502
+ };
503
+ // Builder options matching parser for round-trip compatibility
504
+ const builderOptions = {
505
+ ignoreAttributes: false,
506
+ attributeNamePrefix: '@_',
507
+ textNodeName: '#text',
508
+ preserveOrder: true,
509
+ commentPropName: '#comment',
510
+ cdataPropName: '#cdata',
511
+ format: false,
512
+ suppressEmptyNode: false,
513
+ suppressBooleanAttributes: false
514
+ };
515
+ const parser = new XMLParser(parserOptions);
516
+ const builder = new XMLBuilder(builderOptions);
517
+ /**
518
+ * Parses an XML string into a JavaScript object
519
+ * Preserves element order and attributes for round-trip compatibility
520
+ */ const parseXml = (xml)=>{
521
+ return parser.parse(xml);
522
+ };
523
+ /**
524
+ * Converts a JavaScript object back to an XML string
525
+ */ const stringifyXml = (obj)=>{
526
+ return builder.build(obj);
527
+ };
528
+ /**
529
+ * Finds the first element with the given tag name in the XML tree
530
+ */ const findElement = (nodes, tagName)=>{
531
+ for (const node of nodes){
532
+ if (tagName in node) {
533
+ return node;
534
+ }
535
+ }
536
+ return undefined;
537
+ };
538
+ /**
539
+ * Gets the children of an element
540
+ */ const getChildren = (node, tagName)=>{
541
+ const children = node[tagName];
542
+ if (Array.isArray(children)) {
543
+ return children;
544
+ }
545
+ return [];
546
+ };
547
+ /**
548
+ * Gets an attribute value from a node
549
+ */ const getAttr = (node, name)=>{
550
+ const attrs = node[':@'];
551
+ return attrs?.[`@_${name}`];
552
+ };
553
+ /**
554
+ * Creates a new XML element
555
+ */ const createElement = (tagName, attrs, children)=>{
556
+ const node = {
557
+ [tagName]: children || []
558
+ };
559
+ if (attrs && Object.keys(attrs).length > 0) {
560
+ const attrObj = {};
561
+ for (const [key, value] of Object.entries(attrs)){
562
+ attrObj[`@_${key}`] = value;
563
+ }
564
+ node[':@'] = attrObj;
565
+ }
566
+ return node;
567
+ };
568
+ /**
569
+ * Creates a text node
570
+ */ const createText = (text)=>{
571
+ return {
572
+ '#text': text
573
+ };
574
+ };
575
+
576
+ /**
577
+ * Represents a worksheet in a workbook
578
+ */ class Worksheet {
579
+ constructor(workbook, name){
580
+ this._cells = new Map();
581
+ this._xmlNodes = null;
582
+ this._dirty = false;
583
+ this._mergedCells = new Set();
584
+ this._sheetData = [];
585
+ this._workbook = workbook;
586
+ this._name = name;
587
+ }
588
+ /**
589
+ * Get the workbook this sheet belongs to
590
+ */ get workbook() {
591
+ return this._workbook;
592
+ }
593
+ /**
594
+ * Get the sheet name
595
+ */ get name() {
596
+ return this._name;
597
+ }
598
+ /**
599
+ * Set the sheet name
600
+ */ set name(value) {
601
+ this._name = value;
602
+ this._dirty = true;
603
+ }
604
+ /**
605
+ * Parse worksheet XML content
606
+ */ parse(xml) {
607
+ this._xmlNodes = parseXml(xml);
608
+ const worksheet = findElement(this._xmlNodes, 'worksheet');
609
+ if (!worksheet) return;
610
+ const worksheetChildren = getChildren(worksheet, 'worksheet');
611
+ // Parse sheet data (cells)
612
+ const sheetData = findElement(worksheetChildren, 'sheetData');
613
+ if (sheetData) {
614
+ this._sheetData = getChildren(sheetData, 'sheetData');
615
+ this._parseSheetData(this._sheetData);
616
+ }
617
+ // Parse merged cells
618
+ const mergeCells = findElement(worksheetChildren, 'mergeCells');
619
+ if (mergeCells) {
620
+ const mergeChildren = getChildren(mergeCells, 'mergeCells');
621
+ for (const mergeCell of mergeChildren){
622
+ if ('mergeCell' in mergeCell) {
623
+ const ref = getAttr(mergeCell, 'ref');
624
+ if (ref) {
625
+ this._mergedCells.add(ref);
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+ /**
632
+ * Parse the sheetData element to extract cells
633
+ */ _parseSheetData(rows) {
634
+ for (const rowNode of rows){
635
+ if (!('row' in rowNode)) continue;
636
+ const rowChildren = getChildren(rowNode, 'row');
637
+ for (const cellNode of rowChildren){
638
+ if (!('c' in cellNode)) continue;
639
+ const ref = getAttr(cellNode, 'r');
640
+ if (!ref) continue;
641
+ const { row, col } = parseAddress(ref);
642
+ const cellData = this._parseCellNode(cellNode);
643
+ const cell = new Cell(this, row, col, cellData);
644
+ this._cells.set(ref, cell);
645
+ }
646
+ }
647
+ }
648
+ /**
649
+ * Parse a cell XML node to CellData
650
+ */ _parseCellNode(node) {
651
+ const data = {};
652
+ // Type attribute
653
+ const t = getAttr(node, 't');
654
+ if (t) {
655
+ data.t = t;
656
+ }
657
+ // Style attribute
658
+ const s = getAttr(node, 's');
659
+ if (s) {
660
+ data.s = parseInt(s, 10);
661
+ }
662
+ const children = getChildren(node, 'c');
663
+ // Value element
664
+ const vNode = findElement(children, 'v');
665
+ if (vNode) {
666
+ const vChildren = getChildren(vNode, 'v');
667
+ for (const child of vChildren){
668
+ if ('#text' in child) {
669
+ const text = child['#text'];
670
+ // Parse based on type
671
+ if (data.t === 's') {
672
+ data.v = parseInt(text, 10); // Shared string index
673
+ } else if (data.t === 'b') {
674
+ data.v = text === '1' ? 1 : 0;
675
+ } else if (data.t === 'e' || data.t === 'str') {
676
+ data.v = text;
677
+ } else {
678
+ // Number or default
679
+ data.v = parseFloat(text);
680
+ }
681
+ break;
682
+ }
683
+ }
684
+ }
685
+ // Formula element
686
+ const fNode = findElement(children, 'f');
687
+ if (fNode) {
688
+ const fChildren = getChildren(fNode, 'f');
689
+ for (const child of fChildren){
690
+ if ('#text' in child) {
691
+ data.f = child['#text'];
692
+ break;
693
+ }
694
+ }
695
+ // Check for shared formula
696
+ const si = getAttr(fNode, 'si');
697
+ if (si) {
698
+ data.si = parseInt(si, 10);
699
+ }
700
+ // Check for array formula range
701
+ const ref = getAttr(fNode, 'ref');
702
+ if (ref) {
703
+ data.F = ref;
704
+ }
705
+ }
706
+ // Inline string (is element)
707
+ const isNode = findElement(children, 'is');
708
+ if (isNode) {
709
+ data.t = 'str';
710
+ const isChildren = getChildren(isNode, 'is');
711
+ const tNode = findElement(isChildren, 't');
712
+ if (tNode) {
713
+ const tChildren = getChildren(tNode, 't');
714
+ for (const child of tChildren){
715
+ if ('#text' in child) {
716
+ data.v = child['#text'];
717
+ break;
718
+ }
719
+ }
720
+ }
721
+ }
722
+ return data;
723
+ }
724
+ /**
725
+ * Get a cell by address or row/col
726
+ */ cell(rowOrAddress, col) {
727
+ const { row, col: c } = parseCellRef(rowOrAddress, col);
728
+ const address = toAddress(row, c);
729
+ let cell = this._cells.get(address);
730
+ if (!cell) {
731
+ cell = new Cell(this, row, c);
732
+ this._cells.set(address, cell);
733
+ }
734
+ return cell;
735
+ }
736
+ range(startRowOrRange, startCol, endRow, endCol) {
737
+ let rangeAddr;
738
+ if (typeof startRowOrRange === 'string') {
739
+ rangeAddr = parseRange(startRowOrRange);
740
+ } else {
741
+ if (startCol === undefined || endRow === undefined || endCol === undefined) {
742
+ throw new Error('All range parameters must be provided');
743
+ }
744
+ rangeAddr = {
745
+ start: {
746
+ row: startRowOrRange,
747
+ col: startCol
748
+ },
749
+ end: {
750
+ row: endRow,
751
+ col: endCol
752
+ }
753
+ };
754
+ }
755
+ return new Range(this, rangeAddr);
756
+ }
757
+ /**
758
+ * Merge cells in the given range
759
+ */ mergeCells(rangeOrStart, end) {
760
+ let rangeStr;
761
+ if (end) {
762
+ rangeStr = `${rangeOrStart}:${end}`;
763
+ } else {
764
+ rangeStr = rangeOrStart;
765
+ }
766
+ this._mergedCells.add(rangeStr);
767
+ this._dirty = true;
768
+ }
769
+ /**
770
+ * Unmerge cells in the given range
771
+ */ unmergeCells(rangeStr) {
772
+ this._mergedCells.delete(rangeStr);
773
+ this._dirty = true;
774
+ }
775
+ /**
776
+ * Get all merged cell ranges
777
+ */ get mergedCells() {
778
+ return Array.from(this._mergedCells);
779
+ }
780
+ /**
781
+ * Check if the worksheet has been modified
782
+ */ get dirty() {
783
+ if (this._dirty) return true;
784
+ for (const cell of this._cells.values()){
785
+ if (cell.dirty) return true;
786
+ }
787
+ return false;
788
+ }
789
+ /**
790
+ * Get all cells in the worksheet
791
+ */ get cells() {
792
+ return this._cells;
793
+ }
794
+ /**
795
+ * Generate XML for this worksheet
796
+ */ toXml() {
797
+ // Build sheetData from cells
798
+ const rowMap = new Map();
799
+ for (const cell of this._cells.values()){
800
+ const row = cell.row;
801
+ if (!rowMap.has(row)) {
802
+ rowMap.set(row, []);
803
+ }
804
+ rowMap.get(row).push(cell);
805
+ }
806
+ // Sort rows and cells
807
+ const sortedRows = Array.from(rowMap.entries()).sort((a, b)=>a[0] - b[0]);
808
+ const rowNodes = [];
809
+ for (const [rowIdx, cells] of sortedRows){
810
+ cells.sort((a, b)=>a.col - b.col);
811
+ const cellNodes = [];
812
+ for (const cell of cells){
813
+ const cellNode = this._buildCellNode(cell);
814
+ cellNodes.push(cellNode);
815
+ }
816
+ const rowNode = createElement('row', {
817
+ r: String(rowIdx + 1)
818
+ }, cellNodes);
819
+ rowNodes.push(rowNode);
820
+ }
821
+ const sheetDataNode = createElement('sheetData', {}, rowNodes);
822
+ // Build worksheet structure
823
+ const worksheetChildren = [
824
+ sheetDataNode
825
+ ];
826
+ // Add merged cells if any
827
+ if (this._mergedCells.size > 0) {
828
+ const mergeCellNodes = [];
829
+ for (const ref of this._mergedCells){
830
+ mergeCellNodes.push(createElement('mergeCell', {
831
+ ref
832
+ }, []));
833
+ }
834
+ const mergeCellsNode = createElement('mergeCells', {
835
+ count: String(this._mergedCells.size)
836
+ }, mergeCellNodes);
837
+ worksheetChildren.push(mergeCellsNode);
838
+ }
839
+ const worksheetNode = createElement('worksheet', {
840
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
841
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
842
+ }, worksheetChildren);
843
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
844
+ worksheetNode
845
+ ])}`;
846
+ }
847
+ /**
848
+ * Build a cell XML node from a Cell object
849
+ */ _buildCellNode(cell) {
850
+ const data = cell.data;
851
+ const attrs = {
852
+ r: cell.address
853
+ };
854
+ if (data.t && data.t !== 'n') {
855
+ attrs.t = data.t;
856
+ }
857
+ if (data.s !== undefined) {
858
+ attrs.s = String(data.s);
859
+ }
860
+ const children = [];
861
+ // Formula
862
+ if (data.f) {
863
+ const fAttrs = {};
864
+ if (data.F) fAttrs.ref = data.F;
865
+ if (data.si !== undefined) fAttrs.si = String(data.si);
866
+ children.push(createElement('f', fAttrs, [
867
+ createText(data.f)
868
+ ]));
869
+ }
870
+ // Value
871
+ if (data.v !== undefined) {
872
+ children.push(createElement('v', {}, [
873
+ createText(String(data.v))
874
+ ]));
875
+ }
876
+ return createElement('c', attrs, children);
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Manages the shared strings table from xl/sharedStrings.xml
882
+ * Excel stores strings in a shared table to reduce file size
883
+ */ class SharedStrings {
884
+ /**
885
+ * Parse shared strings from XML content
886
+ */ static parse(xml) {
887
+ const ss = new SharedStrings();
888
+ const parsed = parseXml(xml);
889
+ const sst = findElement(parsed, 'sst');
890
+ if (!sst) return ss;
891
+ const children = getChildren(sst, 'sst');
892
+ for (const child of children){
893
+ if ('si' in child) {
894
+ const siChildren = getChildren(child, 'si');
895
+ const text = ss.extractText(siChildren);
896
+ ss.strings.push(text);
897
+ ss.stringToIndex.set(text, ss.strings.length - 1);
898
+ }
899
+ }
900
+ return ss;
901
+ }
902
+ /**
903
+ * Extract text from a string item (si element)
904
+ * Handles both simple <t> elements and rich text <r> elements
905
+ */ extractText(nodes) {
906
+ let text = '';
907
+ for (const node of nodes){
908
+ if ('t' in node) {
909
+ // Simple text: <t>value</t>
910
+ const tChildren = getChildren(node, 't');
911
+ for (const child of tChildren){
912
+ if ('#text' in child) {
913
+ text += child['#text'];
914
+ }
915
+ }
916
+ } else if ('r' in node) {
917
+ // Rich text: <r><t>value</t></r>
918
+ const rChildren = getChildren(node, 'r');
919
+ for (const rChild of rChildren){
920
+ if ('t' in rChild) {
921
+ const tChildren = getChildren(rChild, 't');
922
+ for (const child of tChildren){
923
+ if ('#text' in child) {
924
+ text += child['#text'];
925
+ }
926
+ }
927
+ }
928
+ }
929
+ }
930
+ }
931
+ return text;
932
+ }
933
+ /**
934
+ * Get a string by index
935
+ */ getString(index) {
936
+ return this.strings[index];
937
+ }
938
+ /**
939
+ * Add a string and return its index
940
+ * If the string already exists, returns the existing index
941
+ */ addString(str) {
942
+ const existing = this.stringToIndex.get(str);
943
+ if (existing !== undefined) {
944
+ return existing;
945
+ }
946
+ const index = this.strings.length;
947
+ this.strings.push(str);
948
+ this.stringToIndex.set(str, index);
949
+ this._dirty = true;
950
+ return index;
951
+ }
952
+ /**
953
+ * Check if the shared strings table has been modified
954
+ */ get dirty() {
955
+ return this._dirty;
956
+ }
957
+ /**
958
+ * Get the count of strings
959
+ */ get count() {
960
+ return this.strings.length;
961
+ }
962
+ /**
963
+ * Generate XML for the shared strings table
964
+ */ toXml() {
965
+ const siElements = [];
966
+ for (const str of this.strings){
967
+ const tElement = createElement('t', str.startsWith(' ') || str.endsWith(' ') ? {
968
+ 'xml:space': 'preserve'
969
+ } : {}, [
970
+ createText(str)
971
+ ]);
972
+ const siElement = createElement('si', {}, [
973
+ tElement
974
+ ]);
975
+ siElements.push(siElement);
976
+ }
977
+ const sst = createElement('sst', {
978
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
979
+ count: String(this.strings.length),
980
+ uniqueCount: String(this.strings.length)
981
+ }, siElements);
982
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
983
+ sst
984
+ ])}`;
985
+ }
986
+ constructor(){
987
+ this.strings = [];
988
+ this.stringToIndex = new Map();
989
+ this._dirty = false;
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Normalize a color to ARGB format (8 hex chars).
995
+ * Accepts: "#RGB", "#RRGGBB", "RGB", "RRGGBB", "AARRGGBB", "#AARRGGBB"
996
+ */ const normalizeColor = (color)=>{
997
+ let c = color.replace(/^#/, '').toUpperCase();
998
+ // Handle shorthand 3-char format (e.g., "FFF" -> "FFFFFF")
999
+ if (c.length === 3) {
1000
+ c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
1001
+ }
1002
+ // Add alpha channel if not present (6 chars -> 8 chars)
1003
+ if (c.length === 6) {
1004
+ c = 'FF' + c;
1005
+ }
1006
+ return c;
1007
+ };
1008
+ /**
1009
+ * Manages the styles (xl/styles.xml)
1010
+ */ class Styles {
1011
+ /**
1012
+ * Parse styles from XML content
1013
+ */ static parse(xml) {
1014
+ const styles = new Styles();
1015
+ styles._xmlNodes = parseXml(xml);
1016
+ const styleSheet = findElement(styles._xmlNodes, 'styleSheet');
1017
+ if (!styleSheet) return styles;
1018
+ const children = getChildren(styleSheet, 'styleSheet');
1019
+ // Parse number formats
1020
+ const numFmts = findElement(children, 'numFmts');
1021
+ if (numFmts) {
1022
+ for (const child of getChildren(numFmts, 'numFmts')){
1023
+ if ('numFmt' in child) {
1024
+ const id = parseInt(getAttr(child, 'numFmtId') || '0', 10);
1025
+ const code = getAttr(child, 'formatCode') || '';
1026
+ styles._numFmts.set(id, code);
1027
+ }
1028
+ }
1029
+ }
1030
+ // Parse fonts
1031
+ const fonts = findElement(children, 'fonts');
1032
+ if (fonts) {
1033
+ for (const child of getChildren(fonts, 'fonts')){
1034
+ if ('font' in child) {
1035
+ styles._fonts.push(styles._parseFont(child));
1036
+ }
1037
+ }
1038
+ }
1039
+ // Parse fills
1040
+ const fills = findElement(children, 'fills');
1041
+ if (fills) {
1042
+ for (const child of getChildren(fills, 'fills')){
1043
+ if ('fill' in child) {
1044
+ styles._fills.push(styles._parseFill(child));
1045
+ }
1046
+ }
1047
+ }
1048
+ // Parse borders
1049
+ const borders = findElement(children, 'borders');
1050
+ if (borders) {
1051
+ for (const child of getChildren(borders, 'borders')){
1052
+ if ('border' in child) {
1053
+ styles._borders.push(styles._parseBorder(child));
1054
+ }
1055
+ }
1056
+ }
1057
+ // Parse cellXfs (cell formats)
1058
+ const cellXfs = findElement(children, 'cellXfs');
1059
+ if (cellXfs) {
1060
+ for (const child of getChildren(cellXfs, 'cellXfs')){
1061
+ if ('xf' in child) {
1062
+ styles._cellXfs.push(styles._parseCellXf(child));
1063
+ }
1064
+ }
1065
+ }
1066
+ return styles;
1067
+ }
1068
+ /**
1069
+ * Create an empty styles object with defaults
1070
+ */ static createDefault() {
1071
+ const styles = new Styles();
1072
+ // Default font (Calibri 11)
1073
+ styles._fonts.push({
1074
+ bold: false,
1075
+ italic: false,
1076
+ underline: false,
1077
+ strike: false,
1078
+ size: 11,
1079
+ name: 'Calibri',
1080
+ color: undefined
1081
+ });
1082
+ // Default fills (none and gray125 pattern are required)
1083
+ styles._fills.push({
1084
+ type: 'none'
1085
+ });
1086
+ styles._fills.push({
1087
+ type: 'gray125'
1088
+ });
1089
+ // Default border (none)
1090
+ styles._borders.push({});
1091
+ // Default cell format
1092
+ styles._cellXfs.push({
1093
+ fontId: 0,
1094
+ fillId: 0,
1095
+ borderId: 0,
1096
+ numFmtId: 0
1097
+ });
1098
+ return styles;
1099
+ }
1100
+ _parseFont(node) {
1101
+ const font = {
1102
+ bold: false,
1103
+ italic: false,
1104
+ underline: false,
1105
+ strike: false
1106
+ };
1107
+ const children = getChildren(node, 'font');
1108
+ for (const child of children){
1109
+ if ('b' in child) font.bold = true;
1110
+ if ('i' in child) font.italic = true;
1111
+ if ('u' in child) font.underline = true;
1112
+ if ('strike' in child) font.strike = true;
1113
+ if ('sz' in child) font.size = parseFloat(getAttr(child, 'val') || '11');
1114
+ if ('name' in child) font.name = getAttr(child, 'val');
1115
+ if ('color' in child) {
1116
+ font.color = getAttr(child, 'rgb') || getAttr(child, 'theme');
1117
+ }
1118
+ }
1119
+ return font;
1120
+ }
1121
+ _parseFill(node) {
1122
+ const fill = {
1123
+ type: 'none'
1124
+ };
1125
+ const children = getChildren(node, 'fill');
1126
+ for (const child of children){
1127
+ if ('patternFill' in child) {
1128
+ const pattern = getAttr(child, 'patternType');
1129
+ fill.type = pattern || 'none';
1130
+ const pfChildren = getChildren(child, 'patternFill');
1131
+ for (const pfChild of pfChildren){
1132
+ if ('fgColor' in pfChild) {
1133
+ fill.fgColor = getAttr(pfChild, 'rgb') || getAttr(pfChild, 'theme');
1134
+ }
1135
+ if ('bgColor' in pfChild) {
1136
+ fill.bgColor = getAttr(pfChild, 'rgb') || getAttr(pfChild, 'theme');
1137
+ }
1138
+ }
1139
+ }
1140
+ }
1141
+ return fill;
1142
+ }
1143
+ _parseBorder(node) {
1144
+ const border = {};
1145
+ const children = getChildren(node, 'border');
1146
+ for (const child of children){
1147
+ const style = getAttr(child, 'style');
1148
+ if ('left' in child && style) border.left = style;
1149
+ if ('right' in child && style) border.right = style;
1150
+ if ('top' in child && style) border.top = style;
1151
+ if ('bottom' in child && style) border.bottom = style;
1152
+ }
1153
+ return border;
1154
+ }
1155
+ _parseCellXf(node) {
1156
+ return {
1157
+ fontId: parseInt(getAttr(node, 'fontId') || '0', 10),
1158
+ fillId: parseInt(getAttr(node, 'fillId') || '0', 10),
1159
+ borderId: parseInt(getAttr(node, 'borderId') || '0', 10),
1160
+ numFmtId: parseInt(getAttr(node, 'numFmtId') || '0', 10),
1161
+ alignment: this._parseAlignment(node)
1162
+ };
1163
+ }
1164
+ _parseAlignment(node) {
1165
+ const children = getChildren(node, 'xf');
1166
+ const alignNode = findElement(children, 'alignment');
1167
+ if (!alignNode) return undefined;
1168
+ return {
1169
+ horizontal: getAttr(alignNode, 'horizontal'),
1170
+ vertical: getAttr(alignNode, 'vertical'),
1171
+ wrapText: getAttr(alignNode, 'wrapText') === '1',
1172
+ textRotation: parseInt(getAttr(alignNode, 'textRotation') || '0', 10)
1173
+ };
1174
+ }
1175
+ /**
1176
+ * Get a style by index
1177
+ */ getStyle(index) {
1178
+ const xf = this._cellXfs[index];
1179
+ if (!xf) return {};
1180
+ const font = this._fonts[xf.fontId];
1181
+ const fill = this._fills[xf.fillId];
1182
+ const border = this._borders[xf.borderId];
1183
+ const numFmt = this._numFmts.get(xf.numFmtId);
1184
+ const style = {};
1185
+ if (font) {
1186
+ if (font.bold) style.bold = true;
1187
+ if (font.italic) style.italic = true;
1188
+ if (font.underline) style.underline = true;
1189
+ if (font.strike) style.strike = true;
1190
+ if (font.size) style.fontSize = font.size;
1191
+ if (font.name) style.fontName = font.name;
1192
+ if (font.color) style.fontColor = font.color;
1193
+ }
1194
+ if (fill && fill.fgColor) {
1195
+ style.fill = fill.fgColor;
1196
+ }
1197
+ if (border) {
1198
+ if (border.top || border.bottom || border.left || border.right) {
1199
+ style.border = {
1200
+ top: border.top,
1201
+ bottom: border.bottom,
1202
+ left: border.left,
1203
+ right: border.right
1204
+ };
1205
+ }
1206
+ }
1207
+ if (numFmt) {
1208
+ style.numberFormat = numFmt;
1209
+ }
1210
+ if (xf.alignment) {
1211
+ style.alignment = {
1212
+ horizontal: xf.alignment.horizontal,
1213
+ vertical: xf.alignment.vertical,
1214
+ wrapText: xf.alignment.wrapText,
1215
+ textRotation: xf.alignment.textRotation
1216
+ };
1217
+ }
1218
+ return style;
1219
+ }
1220
+ /**
1221
+ * Create a style and return its index
1222
+ * Uses caching to deduplicate identical styles
1223
+ */ createStyle(style) {
1224
+ const key = JSON.stringify(style);
1225
+ const cached = this._styleCache.get(key);
1226
+ if (cached !== undefined) {
1227
+ return cached;
1228
+ }
1229
+ this._dirty = true;
1230
+ // Create or find font
1231
+ const fontId = this._findOrCreateFont(style);
1232
+ // Create or find fill
1233
+ const fillId = this._findOrCreateFill(style);
1234
+ // Create or find border
1235
+ const borderId = this._findOrCreateBorder(style);
1236
+ // Create or find number format
1237
+ const numFmtId = style.numberFormat ? this._findOrCreateNumFmt(style.numberFormat) : 0;
1238
+ // Create cell format
1239
+ const xf = {
1240
+ fontId,
1241
+ fillId,
1242
+ borderId,
1243
+ numFmtId
1244
+ };
1245
+ if (style.alignment) {
1246
+ xf.alignment = {
1247
+ horizontal: style.alignment.horizontal,
1248
+ vertical: style.alignment.vertical,
1249
+ wrapText: style.alignment.wrapText,
1250
+ textRotation: style.alignment.textRotation
1251
+ };
1252
+ }
1253
+ const index = this._cellXfs.length;
1254
+ this._cellXfs.push(xf);
1255
+ this._styleCache.set(key, index);
1256
+ return index;
1257
+ }
1258
+ _findOrCreateFont(style) {
1259
+ const font = {
1260
+ bold: style.bold || false,
1261
+ italic: style.italic || false,
1262
+ underline: style.underline === true || style.underline === 'single' || style.underline === 'double',
1263
+ strike: style.strike || false,
1264
+ size: style.fontSize,
1265
+ name: style.fontName,
1266
+ color: style.fontColor
1267
+ };
1268
+ // Try to find existing font
1269
+ for(let i = 0; i < this._fonts.length; i++){
1270
+ const f = this._fonts[i];
1271
+ 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) {
1272
+ return i;
1273
+ }
1274
+ }
1275
+ // Create new font
1276
+ this._fonts.push(font);
1277
+ return this._fonts.length - 1;
1278
+ }
1279
+ _findOrCreateFill(style) {
1280
+ if (!style.fill) return 0;
1281
+ // Try to find existing fill
1282
+ for(let i = 0; i < this._fills.length; i++){
1283
+ const f = this._fills[i];
1284
+ if (f.fgColor === style.fill) {
1285
+ return i;
1286
+ }
1287
+ }
1288
+ // Create new fill
1289
+ this._fills.push({
1290
+ type: 'solid',
1291
+ fgColor: style.fill
1292
+ });
1293
+ return this._fills.length - 1;
1294
+ }
1295
+ _findOrCreateBorder(style) {
1296
+ if (!style.border) return 0;
1297
+ const border = {
1298
+ top: style.border.top,
1299
+ bottom: style.border.bottom,
1300
+ left: style.border.left,
1301
+ right: style.border.right
1302
+ };
1303
+ // Try to find existing border
1304
+ for(let i = 0; i < this._borders.length; i++){
1305
+ const b = this._borders[i];
1306
+ if (b.top === border.top && b.bottom === border.bottom && b.left === border.left && b.right === border.right) {
1307
+ return i;
1308
+ }
1309
+ }
1310
+ // Create new border
1311
+ this._borders.push(border);
1312
+ return this._borders.length - 1;
1313
+ }
1314
+ _findOrCreateNumFmt(format) {
1315
+ // Check if already exists
1316
+ for (const [id, code] of this._numFmts){
1317
+ if (code === format) return id;
1318
+ }
1319
+ // Create new (custom formats start at 164)
1320
+ const id = Math.max(164, ...Array.from(this._numFmts.keys())) + 1;
1321
+ this._numFmts.set(id, format);
1322
+ return id;
1323
+ }
1324
+ /**
1325
+ * Check if styles have been modified
1326
+ */ get dirty() {
1327
+ return this._dirty;
1328
+ }
1329
+ /**
1330
+ * Generate XML for styles
1331
+ */ toXml() {
1332
+ const children = [];
1333
+ // Number formats
1334
+ if (this._numFmts.size > 0) {
1335
+ const numFmtNodes = [];
1336
+ for (const [id, code] of this._numFmts){
1337
+ numFmtNodes.push(createElement('numFmt', {
1338
+ numFmtId: String(id),
1339
+ formatCode: code
1340
+ }, []));
1341
+ }
1342
+ children.push(createElement('numFmts', {
1343
+ count: String(numFmtNodes.length)
1344
+ }, numFmtNodes));
1345
+ }
1346
+ // Fonts
1347
+ const fontNodes = this._fonts.map((font)=>this._buildFontNode(font));
1348
+ children.push(createElement('fonts', {
1349
+ count: String(fontNodes.length)
1350
+ }, fontNodes));
1351
+ // Fills
1352
+ const fillNodes = this._fills.map((fill)=>this._buildFillNode(fill));
1353
+ children.push(createElement('fills', {
1354
+ count: String(fillNodes.length)
1355
+ }, fillNodes));
1356
+ // Borders
1357
+ const borderNodes = this._borders.map((border)=>this._buildBorderNode(border));
1358
+ children.push(createElement('borders', {
1359
+ count: String(borderNodes.length)
1360
+ }, borderNodes));
1361
+ // Cell style xfs (required but we just add a default)
1362
+ children.push(createElement('cellStyleXfs', {
1363
+ count: '1'
1364
+ }, [
1365
+ createElement('xf', {
1366
+ numFmtId: '0',
1367
+ fontId: '0',
1368
+ fillId: '0',
1369
+ borderId: '0'
1370
+ }, [])
1371
+ ]));
1372
+ // Cell xfs
1373
+ const xfNodes = this._cellXfs.map((xf)=>this._buildXfNode(xf));
1374
+ children.push(createElement('cellXfs', {
1375
+ count: String(xfNodes.length)
1376
+ }, xfNodes));
1377
+ // Cell styles (required)
1378
+ children.push(createElement('cellStyles', {
1379
+ count: '1'
1380
+ }, [
1381
+ createElement('cellStyle', {
1382
+ name: 'Normal',
1383
+ xfId: '0',
1384
+ builtinId: '0'
1385
+ }, [])
1386
+ ]));
1387
+ const styleSheet = createElement('styleSheet', {
1388
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
1389
+ }, children);
1390
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
1391
+ styleSheet
1392
+ ])}`;
1393
+ }
1394
+ _buildFontNode(font) {
1395
+ const children = [];
1396
+ if (font.bold) children.push(createElement('b', {}, []));
1397
+ if (font.italic) children.push(createElement('i', {}, []));
1398
+ if (font.underline) children.push(createElement('u', {}, []));
1399
+ if (font.strike) children.push(createElement('strike', {}, []));
1400
+ if (font.size) children.push(createElement('sz', {
1401
+ val: String(font.size)
1402
+ }, []));
1403
+ if (font.color) children.push(createElement('color', {
1404
+ rgb: normalizeColor(font.color)
1405
+ }, []));
1406
+ if (font.name) children.push(createElement('name', {
1407
+ val: font.name
1408
+ }, []));
1409
+ return createElement('font', {}, children);
1410
+ }
1411
+ _buildFillNode(fill) {
1412
+ const patternChildren = [];
1413
+ if (fill.fgColor) {
1414
+ const rgb = normalizeColor(fill.fgColor);
1415
+ patternChildren.push(createElement('fgColor', {
1416
+ rgb
1417
+ }, []));
1418
+ // For solid fills, bgColor is required (indexed 64 = system background)
1419
+ if (fill.type === 'solid') {
1420
+ patternChildren.push(createElement('bgColor', {
1421
+ indexed: '64'
1422
+ }, []));
1423
+ }
1424
+ }
1425
+ if (fill.bgColor && fill.type !== 'solid') {
1426
+ const rgb = normalizeColor(fill.bgColor);
1427
+ patternChildren.push(createElement('bgColor', {
1428
+ rgb
1429
+ }, []));
1430
+ }
1431
+ const patternFill = createElement('patternFill', {
1432
+ patternType: fill.type || 'none'
1433
+ }, patternChildren);
1434
+ return createElement('fill', {}, [
1435
+ patternFill
1436
+ ]);
1437
+ }
1438
+ _buildBorderNode(border) {
1439
+ const children = [];
1440
+ if (border.left) children.push(createElement('left', {
1441
+ style: border.left
1442
+ }, []));
1443
+ if (border.right) children.push(createElement('right', {
1444
+ style: border.right
1445
+ }, []));
1446
+ if (border.top) children.push(createElement('top', {
1447
+ style: border.top
1448
+ }, []));
1449
+ if (border.bottom) children.push(createElement('bottom', {
1450
+ style: border.bottom
1451
+ }, []));
1452
+ // Add empty elements if not present (required by Excel)
1453
+ if (!border.left) children.push(createElement('left', {}, []));
1454
+ if (!border.right) children.push(createElement('right', {}, []));
1455
+ if (!border.top) children.push(createElement('top', {}, []));
1456
+ if (!border.bottom) children.push(createElement('bottom', {}, []));
1457
+ children.push(createElement('diagonal', {}, []));
1458
+ return createElement('border', {}, children);
1459
+ }
1460
+ _buildXfNode(xf) {
1461
+ const attrs = {
1462
+ numFmtId: String(xf.numFmtId),
1463
+ fontId: String(xf.fontId),
1464
+ fillId: String(xf.fillId),
1465
+ borderId: String(xf.borderId)
1466
+ };
1467
+ if (xf.fontId > 0) attrs.applyFont = '1';
1468
+ if (xf.fillId > 0) attrs.applyFill = '1';
1469
+ if (xf.borderId > 0) attrs.applyBorder = '1';
1470
+ if (xf.numFmtId > 0) attrs.applyNumberFormat = '1';
1471
+ const children = [];
1472
+ if (xf.alignment) {
1473
+ const alignAttrs = {};
1474
+ if (xf.alignment.horizontal) alignAttrs.horizontal = xf.alignment.horizontal;
1475
+ if (xf.alignment.vertical) alignAttrs.vertical = xf.alignment.vertical;
1476
+ if (xf.alignment.wrapText) alignAttrs.wrapText = '1';
1477
+ if (xf.alignment.textRotation) alignAttrs.textRotation = String(xf.alignment.textRotation);
1478
+ children.push(createElement('alignment', alignAttrs, []));
1479
+ attrs.applyAlignment = '1';
1480
+ }
1481
+ return createElement('xf', attrs, children);
1482
+ }
1483
+ constructor(){
1484
+ this._numFmts = new Map();
1485
+ this._fonts = [];
1486
+ this._fills = [];
1487
+ this._borders = [];
1488
+ this._cellXfs = []; // Cell formats (combined style index)
1489
+ this._xmlNodes = null;
1490
+ this._dirty = false;
1491
+ // Cache for style deduplication
1492
+ this._styleCache = new Map();
1493
+ }
1494
+ }
1495
+
1496
+ /**
1497
+ * Represents an Excel pivot table with a fluent API for configuration.
1498
+ */ class PivotTable {
1499
+ constructor(name, cache, targetSheet, targetCell, targetRow, targetCol, pivotTableIndex){
1500
+ this._rowFields = [];
1501
+ this._columnFields = [];
1502
+ this._valueFields = [];
1503
+ this._filterFields = [];
1504
+ this._name = name;
1505
+ this._cache = cache;
1506
+ this._targetSheet = targetSheet;
1507
+ this._targetCell = targetCell;
1508
+ this._targetRow = targetRow;
1509
+ this._targetCol = targetCol;
1510
+ this._pivotTableIndex = pivotTableIndex;
1511
+ }
1512
+ /**
1513
+ * Get the pivot table name
1514
+ */ get name() {
1515
+ return this._name;
1516
+ }
1517
+ /**
1518
+ * Get the target sheet name
1519
+ */ get targetSheet() {
1520
+ return this._targetSheet;
1521
+ }
1522
+ /**
1523
+ * Get the target cell address
1524
+ */ get targetCell() {
1525
+ return this._targetCell;
1526
+ }
1527
+ /**
1528
+ * Get the pivot cache
1529
+ */ get cache() {
1530
+ return this._cache;
1531
+ }
1532
+ /**
1533
+ * Get the pivot table index (for file naming)
1534
+ */ get index() {
1535
+ return this._pivotTableIndex;
1536
+ }
1537
+ /**
1538
+ * Add a field to the row area
1539
+ * @param fieldName - Name of the source field (column header)
1540
+ */ addRowField(fieldName) {
1541
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
1542
+ if (fieldIndex < 0) {
1543
+ throw new Error(`Field not found in source data: ${fieldName}`);
1544
+ }
1545
+ this._rowFields.push({
1546
+ fieldName,
1547
+ fieldIndex,
1548
+ axis: 'row'
1549
+ });
1550
+ return this;
1551
+ }
1552
+ /**
1553
+ * Add a field to the column area
1554
+ * @param fieldName - Name of the source field (column header)
1555
+ */ addColumnField(fieldName) {
1556
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
1557
+ if (fieldIndex < 0) {
1558
+ throw new Error(`Field not found in source data: ${fieldName}`);
1559
+ }
1560
+ this._columnFields.push({
1561
+ fieldName,
1562
+ fieldIndex,
1563
+ axis: 'column'
1564
+ });
1565
+ return this;
1566
+ }
1567
+ /**
1568
+ * Add a field to the values area with aggregation
1569
+ * @param fieldName - Name of the source field (column header)
1570
+ * @param aggregation - Aggregation function (sum, count, average, min, max)
1571
+ * @param displayName - Optional display name (defaults to "Sum of FieldName")
1572
+ */ addValueField(fieldName, aggregation = 'sum', displayName) {
1573
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
1574
+ if (fieldIndex < 0) {
1575
+ throw new Error(`Field not found in source data: ${fieldName}`);
1576
+ }
1577
+ const defaultName = `${aggregation.charAt(0).toUpperCase() + aggregation.slice(1)} of ${fieldName}`;
1578
+ this._valueFields.push({
1579
+ fieldName,
1580
+ fieldIndex,
1581
+ axis: 'value',
1582
+ aggregation,
1583
+ displayName: displayName || defaultName
1584
+ });
1585
+ return this;
1586
+ }
1587
+ /**
1588
+ * Add a field to the filter (page) area
1589
+ * @param fieldName - Name of the source field (column header)
1590
+ */ addFilterField(fieldName) {
1591
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
1592
+ if (fieldIndex < 0) {
1593
+ throw new Error(`Field not found in source data: ${fieldName}`);
1594
+ }
1595
+ this._filterFields.push({
1596
+ fieldName,
1597
+ fieldIndex,
1598
+ axis: 'filter'
1599
+ });
1600
+ return this;
1601
+ }
1602
+ /**
1603
+ * Generate the pivotTableDefinition XML
1604
+ */ toXml() {
1605
+ const children = [];
1606
+ // Calculate location (estimate based on fields)
1607
+ const locationRef = this._calculateLocationRef();
1608
+ // Calculate first data row/col offsets (1-based, relative to pivot table)
1609
+ // firstHeaderRow: row offset of column headers (usually 1)
1610
+ // firstDataRow: row offset where data starts (after filters and column headers)
1611
+ // firstDataCol: column offset where data starts (after row labels)
1612
+ const filterRowCount = this._filterFields.length > 0 ? this._filterFields.length + 1 : 0;
1613
+ const headerRows = this._columnFields.length > 0 ? 1 : 0;
1614
+ const firstDataRow = filterRowCount + headerRows + 1;
1615
+ const firstDataCol = this._rowFields.length > 0 ? this._rowFields.length : 1;
1616
+ const locationNode = createElement('location', {
1617
+ ref: locationRef,
1618
+ firstHeaderRow: String(filterRowCount + 1),
1619
+ firstDataRow: String(firstDataRow),
1620
+ firstDataCol: String(firstDataCol)
1621
+ }, []);
1622
+ children.push(locationNode);
1623
+ // Build pivotFields (one per source field)
1624
+ const pivotFieldNodes = [];
1625
+ for (const cacheField of this._cache.fields){
1626
+ const fieldNode = this._buildPivotFieldNode(cacheField.index);
1627
+ pivotFieldNodes.push(fieldNode);
1628
+ }
1629
+ children.push(createElement('pivotFields', {
1630
+ count: String(pivotFieldNodes.length)
1631
+ }, pivotFieldNodes));
1632
+ // Row fields
1633
+ if (this._rowFields.length > 0) {
1634
+ const rowFieldNodes = this._rowFields.map((f)=>createElement('field', {
1635
+ x: String(f.fieldIndex)
1636
+ }, []));
1637
+ children.push(createElement('rowFields', {
1638
+ count: String(rowFieldNodes.length)
1639
+ }, rowFieldNodes));
1640
+ // Row items
1641
+ const rowItemNodes = this._buildRowItems();
1642
+ children.push(createElement('rowItems', {
1643
+ count: String(rowItemNodes.length)
1644
+ }, rowItemNodes));
1645
+ }
1646
+ // Column fields
1647
+ if (this._columnFields.length > 0) {
1648
+ const colFieldNodes = this._columnFields.map((f)=>createElement('field', {
1649
+ x: String(f.fieldIndex)
1650
+ }, []));
1651
+ // If we have multiple value fields, add -2 to indicate where "Values" header goes
1652
+ if (this._valueFields.length > 1) {
1653
+ colFieldNodes.push(createElement('field', {
1654
+ x: '-2'
1655
+ }, []));
1656
+ }
1657
+ children.push(createElement('colFields', {
1658
+ count: String(colFieldNodes.length)
1659
+ }, colFieldNodes));
1660
+ // Column items - need to account for multiple value fields
1661
+ const colItemNodes = this._buildColItems();
1662
+ children.push(createElement('colItems', {
1663
+ count: String(colItemNodes.length)
1664
+ }, colItemNodes));
1665
+ } else if (this._valueFields.length > 1) {
1666
+ // If no column fields but we have multiple values, need colFields with -2 (data field indicator)
1667
+ children.push(createElement('colFields', {
1668
+ count: '1'
1669
+ }, [
1670
+ createElement('field', {
1671
+ x: '-2'
1672
+ }, [])
1673
+ ]));
1674
+ // Column items for each value field
1675
+ const colItemNodes = [];
1676
+ for(let i = 0; i < this._valueFields.length; i++){
1677
+ colItemNodes.push(createElement('i', {}, [
1678
+ createElement('x', i === 0 ? {} : {
1679
+ v: String(i)
1680
+ }, [])
1681
+ ]));
1682
+ }
1683
+ children.push(createElement('colItems', {
1684
+ count: String(colItemNodes.length)
1685
+ }, colItemNodes));
1686
+ } else if (this._valueFields.length === 1) {
1687
+ // Single value field - just add a single column item
1688
+ children.push(createElement('colItems', {
1689
+ count: '1'
1690
+ }, [
1691
+ createElement('i', {}, [])
1692
+ ]));
1693
+ }
1694
+ // Page (filter) fields
1695
+ if (this._filterFields.length > 0) {
1696
+ const pageFieldNodes = this._filterFields.map((f)=>createElement('pageField', {
1697
+ fld: String(f.fieldIndex),
1698
+ hier: '-1'
1699
+ }, []));
1700
+ children.push(createElement('pageFields', {
1701
+ count: String(pageFieldNodes.length)
1702
+ }, pageFieldNodes));
1703
+ }
1704
+ // Data fields (values)
1705
+ if (this._valueFields.length > 0) {
1706
+ const dataFieldNodes = this._valueFields.map((f)=>createElement('dataField', {
1707
+ name: f.displayName || f.fieldName,
1708
+ fld: String(f.fieldIndex),
1709
+ baseField: '0',
1710
+ baseItem: '0',
1711
+ subtotal: f.aggregation || 'sum'
1712
+ }, []));
1713
+ children.push(createElement('dataFields', {
1714
+ count: String(dataFieldNodes.length)
1715
+ }, dataFieldNodes));
1716
+ }
1717
+ // Pivot table style
1718
+ children.push(createElement('pivotTableStyleInfo', {
1719
+ name: 'PivotStyleMedium9',
1720
+ showRowHeaders: '1',
1721
+ showColHeaders: '1',
1722
+ showRowStripes: '0',
1723
+ showColStripes: '0',
1724
+ showLastColumn: '1'
1725
+ }, []));
1726
+ const pivotTableNode = createElement('pivotTableDefinition', {
1727
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
1728
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
1729
+ name: this._name,
1730
+ cacheId: String(this._cache.cacheId),
1731
+ applyNumberFormats: '0',
1732
+ applyBorderFormats: '0',
1733
+ applyFontFormats: '0',
1734
+ applyPatternFormats: '0',
1735
+ applyAlignmentFormats: '0',
1736
+ applyWidthHeightFormats: '1',
1737
+ dataCaption: 'Values',
1738
+ updatedVersion: '8',
1739
+ minRefreshableVersion: '3',
1740
+ useAutoFormatting: '1',
1741
+ rowGrandTotals: '1',
1742
+ colGrandTotals: '1',
1743
+ itemPrintTitles: '1',
1744
+ createdVersion: '8',
1745
+ indent: '0',
1746
+ outline: '1',
1747
+ outlineData: '1',
1748
+ multipleFieldFilters: '0'
1749
+ }, children);
1750
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
1751
+ pivotTableNode
1752
+ ])}`;
1753
+ }
1754
+ /**
1755
+ * Build a pivotField node for a given field index
1756
+ */ _buildPivotFieldNode(fieldIndex) {
1757
+ const attrs = {};
1758
+ const children = [];
1759
+ // Check if this field is assigned to an axis
1760
+ const rowField = this._rowFields.find((f)=>f.fieldIndex === fieldIndex);
1761
+ const colField = this._columnFields.find((f)=>f.fieldIndex === fieldIndex);
1762
+ const filterField = this._filterFields.find((f)=>f.fieldIndex === fieldIndex);
1763
+ const valueField = this._valueFields.find((f)=>f.fieldIndex === fieldIndex);
1764
+ if (rowField) {
1765
+ attrs.axis = 'axisRow';
1766
+ attrs.showAll = '0';
1767
+ // Add items for shared values
1768
+ const cacheField = this._cache.fields[fieldIndex];
1769
+ if (cacheField && cacheField.sharedItems.length > 0) {
1770
+ const itemNodes = [];
1771
+ for(let i = 0; i < cacheField.sharedItems.length; i++){
1772
+ itemNodes.push(createElement('item', {
1773
+ x: String(i)
1774
+ }, []));
1775
+ }
1776
+ // Add default subtotal item
1777
+ itemNodes.push(createElement('item', {
1778
+ t: 'default'
1779
+ }, []));
1780
+ children.push(createElement('items', {
1781
+ count: String(itemNodes.length)
1782
+ }, itemNodes));
1783
+ }
1784
+ } else if (colField) {
1785
+ attrs.axis = 'axisCol';
1786
+ attrs.showAll = '0';
1787
+ const cacheField = this._cache.fields[fieldIndex];
1788
+ if (cacheField && cacheField.sharedItems.length > 0) {
1789
+ const itemNodes = [];
1790
+ for(let i = 0; i < cacheField.sharedItems.length; i++){
1791
+ itemNodes.push(createElement('item', {
1792
+ x: String(i)
1793
+ }, []));
1794
+ }
1795
+ itemNodes.push(createElement('item', {
1796
+ t: 'default'
1797
+ }, []));
1798
+ children.push(createElement('items', {
1799
+ count: String(itemNodes.length)
1800
+ }, itemNodes));
1801
+ }
1802
+ } else if (filterField) {
1803
+ attrs.axis = 'axisPage';
1804
+ attrs.showAll = '0';
1805
+ const cacheField = this._cache.fields[fieldIndex];
1806
+ if (cacheField && cacheField.sharedItems.length > 0) {
1807
+ const itemNodes = [];
1808
+ for(let i = 0; i < cacheField.sharedItems.length; i++){
1809
+ itemNodes.push(createElement('item', {
1810
+ x: String(i)
1811
+ }, []));
1812
+ }
1813
+ itemNodes.push(createElement('item', {
1814
+ t: 'default'
1815
+ }, []));
1816
+ children.push(createElement('items', {
1817
+ count: String(itemNodes.length)
1818
+ }, itemNodes));
1819
+ }
1820
+ } else if (valueField) {
1821
+ attrs.dataField = '1';
1822
+ attrs.showAll = '0';
1823
+ } else {
1824
+ attrs.showAll = '0';
1825
+ }
1826
+ return createElement('pivotField', attrs, children);
1827
+ }
1828
+ /**
1829
+ * Build row items based on unique values in row fields
1830
+ */ _buildRowItems() {
1831
+ const items = [];
1832
+ if (this._rowFields.length === 0) return items;
1833
+ // Get unique values from first row field
1834
+ const firstRowField = this._rowFields[0];
1835
+ const cacheField = this._cache.fields[firstRowField.fieldIndex];
1836
+ if (cacheField && cacheField.sharedItems.length > 0) {
1837
+ for(let i = 0; i < cacheField.sharedItems.length; i++){
1838
+ items.push(createElement('i', {}, [
1839
+ createElement('x', i === 0 ? {} : {
1840
+ v: String(i)
1841
+ }, [])
1842
+ ]));
1843
+ }
1844
+ }
1845
+ // Add grand total row
1846
+ items.push(createElement('i', {
1847
+ t: 'grand'
1848
+ }, [
1849
+ createElement('x', {}, [])
1850
+ ]));
1851
+ return items;
1852
+ }
1853
+ /**
1854
+ * Build column items based on unique values in column fields
1855
+ */ _buildColItems() {
1856
+ const items = [];
1857
+ if (this._columnFields.length === 0) return items;
1858
+ // Get unique values from first column field
1859
+ const firstColField = this._columnFields[0];
1860
+ const cacheField = this._cache.fields[firstColField.fieldIndex];
1861
+ if (cacheField && cacheField.sharedItems.length > 0) {
1862
+ if (this._valueFields.length > 1) {
1863
+ // Multiple value fields - need nested items for each column value + value field combination
1864
+ for(let colIdx = 0; colIdx < cacheField.sharedItems.length; colIdx++){
1865
+ for(let valIdx = 0; valIdx < this._valueFields.length; valIdx++){
1866
+ const xNodes = [
1867
+ createElement('x', colIdx === 0 ? {} : {
1868
+ v: String(colIdx)
1869
+ }, []),
1870
+ createElement('x', valIdx === 0 ? {} : {
1871
+ v: String(valIdx)
1872
+ }, [])
1873
+ ];
1874
+ items.push(createElement('i', {}, xNodes));
1875
+ }
1876
+ }
1877
+ } else {
1878
+ // Single value field - simple column items
1879
+ for(let i = 0; i < cacheField.sharedItems.length; i++){
1880
+ items.push(createElement('i', {}, [
1881
+ createElement('x', i === 0 ? {} : {
1882
+ v: String(i)
1883
+ }, [])
1884
+ ]));
1885
+ }
1886
+ }
1887
+ }
1888
+ // Add grand total column(s)
1889
+ if (this._valueFields.length > 1) {
1890
+ // Grand total for each value field
1891
+ for(let valIdx = 0; valIdx < this._valueFields.length; valIdx++){
1892
+ const xNodes = [
1893
+ createElement('x', {}, []),
1894
+ createElement('x', valIdx === 0 ? {} : {
1895
+ v: String(valIdx)
1896
+ }, [])
1897
+ ];
1898
+ items.push(createElement('i', {
1899
+ t: 'grand'
1900
+ }, xNodes));
1901
+ }
1902
+ } else {
1903
+ items.push(createElement('i', {
1904
+ t: 'grand'
1905
+ }, [
1906
+ createElement('x', {}, [])
1907
+ ]));
1908
+ }
1909
+ return items;
1910
+ }
1911
+ /**
1912
+ * Calculate the location reference for the pivot table output
1913
+ */ _calculateLocationRef() {
1914
+ // Estimate output size based on fields
1915
+ const numRows = this._estimateRowCount();
1916
+ const numCols = this._estimateColCount();
1917
+ const startRow = this._targetRow;
1918
+ const startCol = this._targetCol;
1919
+ const endRow = startRow + numRows - 1;
1920
+ const endCol = startCol + numCols - 1;
1921
+ return `${this._colToLetter(startCol)}${startRow}:${this._colToLetter(endCol)}${endRow}`;
1922
+ }
1923
+ /**
1924
+ * Estimate number of rows in pivot table output
1925
+ */ _estimateRowCount() {
1926
+ let count = 1; // Header row
1927
+ // Add filter area rows
1928
+ count += this._filterFields.length;
1929
+ // Add row labels (unique values in row fields)
1930
+ if (this._rowFields.length > 0) {
1931
+ const firstRowField = this._rowFields[0];
1932
+ const cacheField = this._cache.fields[firstRowField.fieldIndex];
1933
+ count += (cacheField?.sharedItems.length || 1) + 1; // +1 for grand total
1934
+ } else {
1935
+ count += 1; // At least one data row
1936
+ }
1937
+ return Math.max(count, 3);
1938
+ }
1939
+ /**
1940
+ * Estimate number of columns in pivot table output
1941
+ */ _estimateColCount() {
1942
+ let count = 0;
1943
+ // Row label columns
1944
+ count += Math.max(this._rowFields.length, 1);
1945
+ // Column labels (unique values in column fields)
1946
+ if (this._columnFields.length > 0) {
1947
+ const firstColField = this._columnFields[0];
1948
+ const cacheField = this._cache.fields[firstColField.fieldIndex];
1949
+ count += (cacheField?.sharedItems.length || 1) + 1; // +1 for grand total
1950
+ } else {
1951
+ // Value columns
1952
+ count += Math.max(this._valueFields.length, 1);
1953
+ }
1954
+ return Math.max(count, 2);
1955
+ }
1956
+ /**
1957
+ * Convert 0-based column index to letter (A, B, ..., Z, AA, etc.)
1958
+ */ _colToLetter(col) {
1959
+ let result = '';
1960
+ let n = col;
1961
+ while(n >= 0){
1962
+ result = String.fromCharCode(n % 26 + 65) + result;
1963
+ n = Math.floor(n / 26) - 1;
1964
+ }
1965
+ return result;
1966
+ }
1967
+ }
1968
+
1969
+ /**
1970
+ * Manages the pivot cache (definition and records) for a pivot table.
1971
+ * The cache stores source data metadata and cached values.
1972
+ */ class PivotCache {
1973
+ constructor(cacheId, sourceSheet, sourceRange){
1974
+ this._fields = [];
1975
+ this._records = [];
1976
+ this._recordCount = 0;
1977
+ this._refreshOnLoad = true; // Default to true
1978
+ this._cacheId = cacheId;
1979
+ this._sourceSheet = sourceSheet;
1980
+ this._sourceRange = sourceRange;
1981
+ }
1982
+ /**
1983
+ * Get the cache ID
1984
+ */ get cacheId() {
1985
+ return this._cacheId;
1986
+ }
1987
+ /**
1988
+ * Set refreshOnLoad option
1989
+ */ set refreshOnLoad(value) {
1990
+ this._refreshOnLoad = value;
1991
+ }
1992
+ /**
1993
+ * Get refreshOnLoad option
1994
+ */ get refreshOnLoad() {
1995
+ return this._refreshOnLoad;
1996
+ }
1997
+ /**
1998
+ * Get the source sheet name
1999
+ */ get sourceSheet() {
2000
+ return this._sourceSheet;
2001
+ }
2002
+ /**
2003
+ * Get the source range
2004
+ */ get sourceRange() {
2005
+ return this._sourceRange;
2006
+ }
2007
+ /**
2008
+ * Get the full source reference (Sheet!Range)
2009
+ */ get sourceRef() {
2010
+ return `${this._sourceSheet}!${this._sourceRange}`;
2011
+ }
2012
+ /**
2013
+ * Get the fields in this cache
2014
+ */ get fields() {
2015
+ return this._fields;
2016
+ }
2017
+ /**
2018
+ * Get the number of data records
2019
+ */ get recordCount() {
2020
+ return this._recordCount;
2021
+ }
2022
+ /**
2023
+ * Build the cache from source data.
2024
+ * @param headers - Array of column header names
2025
+ * @param data - 2D array of data rows (excluding headers)
2026
+ */ buildFromData(headers, data) {
2027
+ this._recordCount = data.length;
2028
+ // Initialize fields from headers
2029
+ this._fields = headers.map((name, index)=>({
2030
+ name,
2031
+ index,
2032
+ isNumeric: true,
2033
+ isDate: false,
2034
+ sharedItems: [],
2035
+ minValue: undefined,
2036
+ maxValue: undefined
2037
+ }));
2038
+ // Analyze data to determine field types and collect unique values
2039
+ for (const row of data){
2040
+ for(let colIdx = 0; colIdx < row.length && colIdx < this._fields.length; colIdx++){
2041
+ const value = row[colIdx];
2042
+ const field = this._fields[colIdx];
2043
+ if (value === null || value === undefined) {
2044
+ continue;
2045
+ }
2046
+ if (typeof value === 'string') {
2047
+ field.isNumeric = false;
2048
+ if (!field.sharedItems.includes(value)) {
2049
+ field.sharedItems.push(value);
2050
+ }
2051
+ } else if (typeof value === 'number') {
2052
+ if (field.minValue === undefined || value < field.minValue) {
2053
+ field.minValue = value;
2054
+ }
2055
+ if (field.maxValue === undefined || value > field.maxValue) {
2056
+ field.maxValue = value;
2057
+ }
2058
+ } else if (value instanceof Date) {
2059
+ field.isDate = true;
2060
+ field.isNumeric = false;
2061
+ } else if (typeof value === 'boolean') {
2062
+ field.isNumeric = false;
2063
+ }
2064
+ }
2065
+ }
2066
+ // Store records
2067
+ this._records = data;
2068
+ }
2069
+ /**
2070
+ * Get field by name
2071
+ */ getField(name) {
2072
+ return this._fields.find((f)=>f.name === name);
2073
+ }
2074
+ /**
2075
+ * Get field index by name
2076
+ */ getFieldIndex(name) {
2077
+ const field = this._fields.find((f)=>f.name === name);
2078
+ return field ? field.index : -1;
2079
+ }
2080
+ /**
2081
+ * Generate the pivotCacheDefinition XML
2082
+ */ toDefinitionXml(recordsRelId) {
2083
+ const cacheFieldNodes = this._fields.map((field)=>{
2084
+ const sharedItemsAttrs = {};
2085
+ const sharedItemChildren = [];
2086
+ if (field.sharedItems.length > 0) {
2087
+ // String field with shared items - Excel just uses count attribute
2088
+ sharedItemsAttrs.count = String(field.sharedItems.length);
2089
+ for (const item of field.sharedItems){
2090
+ sharedItemChildren.push(createElement('s', {
2091
+ v: item
2092
+ }, []));
2093
+ }
2094
+ } else if (field.isNumeric) {
2095
+ // Numeric field - use "0"/"1" for boolean attributes as Excel expects
2096
+ sharedItemsAttrs.containsSemiMixedTypes = '0';
2097
+ sharedItemsAttrs.containsString = '0';
2098
+ sharedItemsAttrs.containsNumber = '1';
2099
+ // Check if all values are integers
2100
+ if (field.minValue !== undefined && field.maxValue !== undefined) {
2101
+ const isInteger = Number.isInteger(field.minValue) && Number.isInteger(field.maxValue);
2102
+ if (isInteger) {
2103
+ sharedItemsAttrs.containsInteger = '1';
2104
+ }
2105
+ sharedItemsAttrs.minValue = String(field.minValue);
2106
+ sharedItemsAttrs.maxValue = String(field.maxValue);
2107
+ }
2108
+ }
2109
+ const sharedItemsNode = createElement('sharedItems', sharedItemsAttrs, sharedItemChildren);
2110
+ return createElement('cacheField', {
2111
+ name: field.name,
2112
+ numFmtId: '0'
2113
+ }, [
2114
+ sharedItemsNode
2115
+ ]);
2116
+ });
2117
+ const cacheFieldsNode = createElement('cacheFields', {
2118
+ count: String(this._fields.length)
2119
+ }, cacheFieldNodes);
2120
+ const worksheetSourceNode = createElement('worksheetSource', {
2121
+ ref: this._sourceRange,
2122
+ sheet: this._sourceSheet
2123
+ }, []);
2124
+ const cacheSourceNode = createElement('cacheSource', {
2125
+ type: 'worksheet'
2126
+ }, [
2127
+ worksheetSourceNode
2128
+ ]);
2129
+ // Build attributes - refreshOnLoad should come early per OOXML schema
2130
+ const definitionAttrs = {
2131
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
2132
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
2133
+ 'r:id': recordsRelId
2134
+ };
2135
+ // Add refreshOnLoad early in attributes (default is true)
2136
+ if (this._refreshOnLoad) {
2137
+ definitionAttrs.refreshOnLoad = '1';
2138
+ }
2139
+ // Continue with remaining attributes
2140
+ definitionAttrs.refreshedBy = 'User';
2141
+ definitionAttrs.refreshedVersion = '8';
2142
+ definitionAttrs.minRefreshableVersion = '3';
2143
+ definitionAttrs.createdVersion = '8';
2144
+ definitionAttrs.recordCount = String(this._recordCount);
2145
+ const definitionNode = createElement('pivotCacheDefinition', definitionAttrs, [
2146
+ cacheSourceNode,
2147
+ cacheFieldsNode
2148
+ ]);
2149
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
2150
+ definitionNode
2151
+ ])}`;
2152
+ }
2153
+ /**
2154
+ * Generate the pivotCacheRecords XML
2155
+ */ toRecordsXml() {
2156
+ const recordNodes = [];
2157
+ for (const row of this._records){
2158
+ const fieldNodes = [];
2159
+ for(let colIdx = 0; colIdx < this._fields.length; colIdx++){
2160
+ const field = this._fields[colIdx];
2161
+ const value = colIdx < row.length ? row[colIdx] : null;
2162
+ if (value === null || value === undefined) {
2163
+ // Missing value
2164
+ fieldNodes.push(createElement('m', {}, []));
2165
+ } else if (typeof value === 'string') {
2166
+ // String value - use index into sharedItems
2167
+ const idx = field.sharedItems.indexOf(value);
2168
+ if (idx >= 0) {
2169
+ fieldNodes.push(createElement('x', {
2170
+ v: String(idx)
2171
+ }, []));
2172
+ } else {
2173
+ // Direct string value (shouldn't happen if cache is built correctly)
2174
+ fieldNodes.push(createElement('s', {
2175
+ v: value
2176
+ }, []));
2177
+ }
2178
+ } else if (typeof value === 'number') {
2179
+ fieldNodes.push(createElement('n', {
2180
+ v: String(value)
2181
+ }, []));
2182
+ } else if (typeof value === 'boolean') {
2183
+ fieldNodes.push(createElement('b', {
2184
+ v: value ? '1' : '0'
2185
+ }, []));
2186
+ } else if (value instanceof Date) {
2187
+ fieldNodes.push(createElement('d', {
2188
+ v: value.toISOString()
2189
+ }, []));
2190
+ } else {
2191
+ // Unknown type, treat as missing
2192
+ fieldNodes.push(createElement('m', {}, []));
2193
+ }
2194
+ }
2195
+ recordNodes.push(createElement('r', {}, fieldNodes));
2196
+ }
2197
+ const recordsNode = createElement('pivotCacheRecords', {
2198
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
2199
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
2200
+ count: String(this._recordCount)
2201
+ }, recordNodes);
2202
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
2203
+ recordsNode
2204
+ ])}`;
2205
+ }
2206
+ }
2207
+
2208
+ /**
2209
+ * Reads a ZIP file and returns a map of path -> content
2210
+ * @param data - ZIP file as Uint8Array
2211
+ * @returns Promise resolving to a map of file paths to contents
2212
+ */ const readZip = (data)=>{
2213
+ return new Promise((resolve, reject)=>{
2214
+ unzip(data, (err, result)=>{
2215
+ if (err) {
2216
+ reject(err);
2217
+ return;
2218
+ }
2219
+ const files = new Map();
2220
+ for (const [path, content] of Object.entries(result)){
2221
+ files.set(path, content);
2222
+ }
2223
+ resolve(files);
2224
+ });
2225
+ });
2226
+ };
2227
+ /**
2228
+ * Creates a ZIP file from a map of path -> content
2229
+ * @param files - Map of file paths to contents
2230
+ * @returns Promise resolving to ZIP file as Uint8Array
2231
+ */ const writeZip = (files)=>{
2232
+ return new Promise((resolve, reject)=>{
2233
+ const zipData = {};
2234
+ for (const [path, content] of files){
2235
+ zipData[path] = content;
2236
+ }
2237
+ zip(zipData, (err, result)=>{
2238
+ if (err) {
2239
+ reject(err);
2240
+ return;
2241
+ }
2242
+ resolve(result);
2243
+ });
2244
+ });
2245
+ };
2246
+ /**
2247
+ * Reads a file from the ZIP as a UTF-8 string
2248
+ */ const readZipText = (files, path)=>{
2249
+ const data = files.get(path);
2250
+ if (!data) return undefined;
2251
+ return strFromU8(data);
2252
+ };
2253
+ /**
2254
+ * Writes a UTF-8 string to the ZIP files map
2255
+ */ const writeZipText = (files, path, content)=>{
2256
+ files.set(path, strToU8(content));
2257
+ };
2258
+
2259
+ /**
2260
+ * Represents an Excel workbook (.xlsx file)
2261
+ */ class Workbook {
2262
+ constructor(){
2263
+ this._files = new Map();
2264
+ this._sheets = new Map();
2265
+ this._sheetDefs = [];
2266
+ this._relationships = [];
2267
+ this._dirty = false;
2268
+ // Pivot table support
2269
+ this._pivotTables = [];
2270
+ this._pivotCaches = [];
2271
+ this._nextCacheId = 0;
2272
+ this._sharedStrings = new SharedStrings();
2273
+ this._styles = Styles.createDefault();
2274
+ }
2275
+ /**
2276
+ * Load a workbook from a file path
2277
+ */ static async fromFile(path) {
2278
+ const data = await readFile(path);
2279
+ return Workbook.fromBuffer(new Uint8Array(data));
2280
+ }
2281
+ /**
2282
+ * Load a workbook from a buffer
2283
+ */ static async fromBuffer(data) {
2284
+ const workbook = new Workbook();
2285
+ workbook._files = await readZip(data);
2286
+ // Parse workbook.xml for sheet definitions
2287
+ const workbookXml = readZipText(workbook._files, 'xl/workbook.xml');
2288
+ if (workbookXml) {
2289
+ workbook._parseWorkbook(workbookXml);
2290
+ }
2291
+ // Parse relationships
2292
+ const relsXml = readZipText(workbook._files, 'xl/_rels/workbook.xml.rels');
2293
+ if (relsXml) {
2294
+ workbook._parseRelationships(relsXml);
2295
+ }
2296
+ // Parse shared strings
2297
+ const sharedStringsXml = readZipText(workbook._files, 'xl/sharedStrings.xml');
2298
+ if (sharedStringsXml) {
2299
+ workbook._sharedStrings = SharedStrings.parse(sharedStringsXml);
2300
+ }
2301
+ // Parse styles
2302
+ const stylesXml = readZipText(workbook._files, 'xl/styles.xml');
2303
+ if (stylesXml) {
2304
+ workbook._styles = Styles.parse(stylesXml);
2305
+ }
2306
+ return workbook;
2307
+ }
2308
+ /**
2309
+ * Create a new empty workbook
2310
+ */ static create() {
2311
+ const workbook = new Workbook();
2312
+ workbook._dirty = true;
2313
+ // Add default sheet
2314
+ workbook.addSheet('Sheet1');
2315
+ return workbook;
2316
+ }
2317
+ /**
2318
+ * Get sheet names
2319
+ */ get sheetNames() {
2320
+ return this._sheetDefs.map((s)=>s.name);
2321
+ }
2322
+ /**
2323
+ * Get number of sheets
2324
+ */ get sheetCount() {
2325
+ return this._sheetDefs.length;
2326
+ }
2327
+ /**
2328
+ * Get shared strings table
2329
+ */ get sharedStrings() {
2330
+ return this._sharedStrings;
2331
+ }
2332
+ /**
2333
+ * Get styles
2334
+ */ get styles() {
2335
+ return this._styles;
2336
+ }
2337
+ /**
2338
+ * Get a worksheet by name or index
2339
+ */ sheet(nameOrIndex) {
2340
+ let def;
2341
+ if (typeof nameOrIndex === 'number') {
2342
+ def = this._sheetDefs[nameOrIndex];
2343
+ } else {
2344
+ def = this._sheetDefs.find((s)=>s.name === nameOrIndex);
2345
+ }
2346
+ if (!def) {
2347
+ throw new Error(`Sheet not found: ${nameOrIndex}`);
2348
+ }
2349
+ // Return cached worksheet if available
2350
+ if (this._sheets.has(def.name)) {
2351
+ return this._sheets.get(def.name);
2352
+ }
2353
+ // Load worksheet
2354
+ const worksheet = new Worksheet(this, def.name);
2355
+ // Find the relationship to get the file path
2356
+ const rel = this._relationships.find((r)=>r.id === def.rId);
2357
+ if (rel) {
2358
+ const sheetPath = `xl/${rel.target}`;
2359
+ const sheetXml = readZipText(this._files, sheetPath);
2360
+ if (sheetXml) {
2361
+ worksheet.parse(sheetXml);
2362
+ }
2363
+ }
2364
+ this._sheets.set(def.name, worksheet);
2365
+ return worksheet;
2366
+ }
2367
+ /**
2368
+ * Add a new worksheet
2369
+ */ addSheet(name, index) {
2370
+ // Check for duplicate name
2371
+ if (this._sheetDefs.some((s)=>s.name === name)) {
2372
+ throw new Error(`Sheet already exists: ${name}`);
2373
+ }
2374
+ this._dirty = true;
2375
+ // Generate new sheet ID and relationship ID
2376
+ const sheetId = Math.max(0, ...this._sheetDefs.map((s)=>s.sheetId)) + 1;
2377
+ const rId = `rId${Math.max(0, ...this._relationships.map((r)=>parseInt(r.id.replace('rId', ''), 10) || 0)) + 1}`;
2378
+ const def = {
2379
+ name,
2380
+ sheetId,
2381
+ rId
2382
+ };
2383
+ // Add relationship
2384
+ this._relationships.push({
2385
+ id: rId,
2386
+ type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet',
2387
+ target: `worksheets/sheet${sheetId}.xml`
2388
+ });
2389
+ // Insert at index or append
2390
+ if (index !== undefined && index >= 0 && index < this._sheetDefs.length) {
2391
+ this._sheetDefs.splice(index, 0, def);
2392
+ } else {
2393
+ this._sheetDefs.push(def);
2394
+ }
2395
+ // Create worksheet
2396
+ const worksheet = new Worksheet(this, name);
2397
+ this._sheets.set(name, worksheet);
2398
+ return worksheet;
2399
+ }
2400
+ /**
2401
+ * Delete a worksheet by name or index
2402
+ */ deleteSheet(nameOrIndex) {
2403
+ let index;
2404
+ if (typeof nameOrIndex === 'number') {
2405
+ index = nameOrIndex;
2406
+ } else {
2407
+ index = this._sheetDefs.findIndex((s)=>s.name === nameOrIndex);
2408
+ }
2409
+ if (index < 0 || index >= this._sheetDefs.length) {
2410
+ throw new Error(`Sheet not found: ${nameOrIndex}`);
2411
+ }
2412
+ if (this._sheetDefs.length === 1) {
2413
+ throw new Error('Cannot delete the last sheet');
2414
+ }
2415
+ this._dirty = true;
2416
+ const def = this._sheetDefs[index];
2417
+ this._sheetDefs.splice(index, 1);
2418
+ this._sheets.delete(def.name);
2419
+ // Remove relationship
2420
+ const relIndex = this._relationships.findIndex((r)=>r.id === def.rId);
2421
+ if (relIndex >= 0) {
2422
+ this._relationships.splice(relIndex, 1);
2423
+ }
2424
+ }
2425
+ /**
2426
+ * Rename a worksheet
2427
+ */ renameSheet(oldName, newName) {
2428
+ const def = this._sheetDefs.find((s)=>s.name === oldName);
2429
+ if (!def) {
2430
+ throw new Error(`Sheet not found: ${oldName}`);
2431
+ }
2432
+ if (this._sheetDefs.some((s)=>s.name === newName)) {
2433
+ throw new Error(`Sheet already exists: ${newName}`);
2434
+ }
2435
+ this._dirty = true;
2436
+ // Update cached worksheet
2437
+ const worksheet = this._sheets.get(oldName);
2438
+ if (worksheet) {
2439
+ worksheet.name = newName;
2440
+ this._sheets.delete(oldName);
2441
+ this._sheets.set(newName, worksheet);
2442
+ }
2443
+ def.name = newName;
2444
+ }
2445
+ /**
2446
+ * Copy a worksheet
2447
+ */ copySheet(sourceName, newName) {
2448
+ const source = this.sheet(sourceName);
2449
+ const copy = this.addSheet(newName);
2450
+ // Copy all cells
2451
+ for (const [address, cell] of source.cells){
2452
+ const newCell = copy.cell(address);
2453
+ newCell.value = cell.value;
2454
+ if (cell.formula) {
2455
+ newCell.formula = cell.formula;
2456
+ }
2457
+ if (cell.styleIndex !== undefined) {
2458
+ newCell.styleIndex = cell.styleIndex;
2459
+ }
2460
+ }
2461
+ // Copy merged cells
2462
+ for (const mergedRange of source.mergedCells){
2463
+ copy.mergeCells(mergedRange);
2464
+ }
2465
+ return copy;
2466
+ }
2467
+ /**
2468
+ * Create a pivot table from source data.
2469
+ *
2470
+ * @param config - Pivot table configuration
2471
+ * @returns PivotTable instance for fluent configuration
2472
+ *
2473
+ * @example
2474
+ * ```typescript
2475
+ * const pivot = wb.createPivotTable({
2476
+ * name: 'SalesPivot',
2477
+ * source: 'DataSheet!A1:D100',
2478
+ * target: 'PivotSheet!A3',
2479
+ * });
2480
+ *
2481
+ * pivot
2482
+ * .addRowField('Region')
2483
+ * .addColumnField('Product')
2484
+ * .addValueField('Sales', 'sum', 'Total Sales');
2485
+ * ```
2486
+ */ createPivotTable(config) {
2487
+ this._dirty = true;
2488
+ // Parse source reference (Sheet!Range)
2489
+ const { sheetName: sourceSheet, range: sourceRange } = this._parseSheetRef(config.source);
2490
+ // Parse target reference
2491
+ const { sheetName: targetSheet, range: targetCell } = this._parseSheetRef(config.target);
2492
+ // Ensure target sheet exists
2493
+ if (!this._sheetDefs.some((s)=>s.name === targetSheet)) {
2494
+ this.addSheet(targetSheet);
2495
+ }
2496
+ // Parse target cell address
2497
+ const targetAddr = parseAddress(targetCell);
2498
+ // Get source worksheet and extract data
2499
+ const sourceWs = this.sheet(sourceSheet);
2500
+ const { headers, data } = this._extractSourceData(sourceWs, sourceRange);
2501
+ // Create pivot cache
2502
+ const cacheId = this._nextCacheId++;
2503
+ const cache = new PivotCache(cacheId, sourceSheet, sourceRange);
2504
+ cache.buildFromData(headers, data);
2505
+ // refreshOnLoad defaults to true; only disable if explicitly set to false
2506
+ if (config.refreshOnLoad === false) {
2507
+ cache.refreshOnLoad = false;
2508
+ }
2509
+ this._pivotCaches.push(cache);
2510
+ // Create pivot table
2511
+ const pivotTableIndex = this._pivotTables.length + 1;
2512
+ const pivotTable = new PivotTable(config.name, cache, targetSheet, targetCell, targetAddr.row + 1, targetAddr.col, pivotTableIndex);
2513
+ this._pivotTables.push(pivotTable);
2514
+ return pivotTable;
2515
+ }
2516
+ /**
2517
+ * Parse a sheet reference like "Sheet1!A1:D100" into sheet name and range
2518
+ */ _parseSheetRef(ref) {
2519
+ const match = ref.match(/^(.+?)!(.+)$/);
2520
+ if (!match) {
2521
+ throw new Error(`Invalid reference format: ${ref}. Expected "SheetName!Range"`);
2522
+ }
2523
+ return {
2524
+ sheetName: match[1],
2525
+ range: match[2]
2526
+ };
2527
+ }
2528
+ /**
2529
+ * Extract headers and data from a source range
2530
+ */ _extractSourceData(sheet, rangeStr) {
2531
+ const range = parseRange(rangeStr);
2532
+ const headers = [];
2533
+ const data = [];
2534
+ // First row is headers
2535
+ for(let col = range.start.col; col <= range.end.col; col++){
2536
+ const cell = sheet.cell(toAddress(range.start.row, col));
2537
+ headers.push(String(cell.value ?? `Column${col + 1}`));
2538
+ }
2539
+ // Remaining rows are data
2540
+ for(let row = range.start.row + 1; row <= range.end.row; row++){
2541
+ const rowData = [];
2542
+ for(let col = range.start.col; col <= range.end.col; col++){
2543
+ const cell = sheet.cell(toAddress(row, col));
2544
+ rowData.push(cell.value);
2545
+ }
2546
+ data.push(rowData);
2547
+ }
2548
+ return {
2549
+ headers,
2550
+ data
2551
+ };
2552
+ }
2553
+ /**
2554
+ * Save the workbook to a file
2555
+ */ async toFile(path) {
2556
+ const buffer = await this.toBuffer();
2557
+ await writeFile(path, buffer);
2558
+ }
2559
+ /**
2560
+ * Save the workbook to a buffer
2561
+ */ async toBuffer() {
2562
+ // Update files map with modified content
2563
+ this._updateFiles();
2564
+ // Write ZIP
2565
+ return writeZip(this._files);
2566
+ }
2567
+ _parseWorkbook(xml) {
2568
+ const parsed = parseXml(xml);
2569
+ const workbook = findElement(parsed, 'workbook');
2570
+ if (!workbook) return;
2571
+ const children = getChildren(workbook, 'workbook');
2572
+ const sheets = findElement(children, 'sheets');
2573
+ if (!sheets) return;
2574
+ for (const child of getChildren(sheets, 'sheets')){
2575
+ if ('sheet' in child) {
2576
+ const name = getAttr(child, 'name');
2577
+ const sheetId = getAttr(child, 'sheetId');
2578
+ const rId = getAttr(child, 'r:id');
2579
+ if (name && sheetId && rId) {
2580
+ this._sheetDefs.push({
2581
+ name,
2582
+ sheetId: parseInt(sheetId, 10),
2583
+ rId
2584
+ });
2585
+ }
2586
+ }
2587
+ }
2588
+ }
2589
+ _parseRelationships(xml) {
2590
+ const parsed = parseXml(xml);
2591
+ const rels = findElement(parsed, 'Relationships');
2592
+ if (!rels) return;
2593
+ for (const child of getChildren(rels, 'Relationships')){
2594
+ if ('Relationship' in child) {
2595
+ const id = getAttr(child, 'Id');
2596
+ const type = getAttr(child, 'Type');
2597
+ const target = getAttr(child, 'Target');
2598
+ if (id && type && target) {
2599
+ this._relationships.push({
2600
+ id,
2601
+ type,
2602
+ target
2603
+ });
2604
+ }
2605
+ }
2606
+ }
2607
+ }
2608
+ _updateFiles() {
2609
+ // Update workbook.xml
2610
+ this._updateWorkbookXml();
2611
+ // Update relationships
2612
+ this._updateRelationshipsXml();
2613
+ // Update content types
2614
+ this._updateContentTypes();
2615
+ // Update shared strings if modified
2616
+ if (this._sharedStrings.dirty || this._sharedStrings.count > 0) {
2617
+ writeZipText(this._files, 'xl/sharedStrings.xml', this._sharedStrings.toXml());
2618
+ }
2619
+ // Update styles if modified or if file doesn't exist yet
2620
+ if (this._styles.dirty || this._dirty || !this._files.has('xl/styles.xml')) {
2621
+ writeZipText(this._files, 'xl/styles.xml', this._styles.toXml());
2622
+ }
2623
+ // Update worksheets
2624
+ for (const [name, worksheet] of this._sheets){
2625
+ if (worksheet.dirty || this._dirty) {
2626
+ const def = this._sheetDefs.find((s)=>s.name === name);
2627
+ if (def) {
2628
+ const rel = this._relationships.find((r)=>r.id === def.rId);
2629
+ if (rel) {
2630
+ const sheetPath = `xl/${rel.target}`;
2631
+ writeZipText(this._files, sheetPath, worksheet.toXml());
2632
+ }
2633
+ }
2634
+ }
2635
+ }
2636
+ // Update pivot tables
2637
+ if (this._pivotTables.length > 0) {
2638
+ this._updatePivotTableFiles();
2639
+ }
2640
+ }
2641
+ _updateWorkbookXml() {
2642
+ const sheetNodes = this._sheetDefs.map((def)=>createElement('sheet', {
2643
+ name: def.name,
2644
+ sheetId: String(def.sheetId),
2645
+ 'r:id': def.rId
2646
+ }, []));
2647
+ const sheetsNode = createElement('sheets', {}, sheetNodes);
2648
+ const children = [
2649
+ sheetsNode
2650
+ ];
2651
+ // Add pivot caches if any
2652
+ if (this._pivotCaches.length > 0) {
2653
+ const pivotCacheNodes = this._pivotCaches.map((cache, idx)=>{
2654
+ // Cache relationship ID is after sheets, sharedStrings, and styles
2655
+ const cacheRelId = `rId${this._relationships.length + 3 + idx}`;
2656
+ return createElement('pivotCache', {
2657
+ cacheId: String(cache.cacheId),
2658
+ 'r:id': cacheRelId
2659
+ }, []);
2660
+ });
2661
+ children.push(createElement('pivotCaches', {}, pivotCacheNodes));
2662
+ }
2663
+ const workbookNode = createElement('workbook', {
2664
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
2665
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
2666
+ }, children);
2667
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
2668
+ workbookNode
2669
+ ])}`;
2670
+ writeZipText(this._files, 'xl/workbook.xml', xml);
2671
+ }
2672
+ _updateRelationshipsXml() {
2673
+ const relNodes = this._relationships.map((rel)=>createElement('Relationship', {
2674
+ Id: rel.id,
2675
+ Type: rel.type,
2676
+ Target: rel.target
2677
+ }, []));
2678
+ let nextRelId = this._relationships.length + 1;
2679
+ // Add shared strings relationship if needed
2680
+ if (this._sharedStrings.count > 0) {
2681
+ const hasSharedStrings = this._relationships.some((r)=>r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings');
2682
+ if (!hasSharedStrings) {
2683
+ relNodes.push(createElement('Relationship', {
2684
+ Id: `rId${nextRelId++}`,
2685
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings',
2686
+ Target: 'sharedStrings.xml'
2687
+ }, []));
2688
+ }
2689
+ }
2690
+ // Add styles relationship if needed
2691
+ const hasStyles = this._relationships.some((r)=>r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles');
2692
+ if (!hasStyles) {
2693
+ relNodes.push(createElement('Relationship', {
2694
+ Id: `rId${nextRelId++}`,
2695
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles',
2696
+ Target: 'styles.xml'
2697
+ }, []));
2698
+ }
2699
+ // Add pivot cache relationships
2700
+ for(let i = 0; i < this._pivotCaches.length; i++){
2701
+ relNodes.push(createElement('Relationship', {
2702
+ Id: `rId${nextRelId++}`,
2703
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
2704
+ Target: `pivotCache/pivotCacheDefinition${i + 1}.xml`
2705
+ }, []));
2706
+ }
2707
+ const relsNode = createElement('Relationships', {
2708
+ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
2709
+ }, relNodes);
2710
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
2711
+ relsNode
2712
+ ])}`;
2713
+ writeZipText(this._files, 'xl/_rels/workbook.xml.rels', xml);
2714
+ }
2715
+ _updateContentTypes() {
2716
+ const types = [
2717
+ createElement('Default', {
2718
+ Extension: 'rels',
2719
+ ContentType: 'application/vnd.openxmlformats-package.relationships+xml'
2720
+ }, []),
2721
+ createElement('Default', {
2722
+ Extension: 'xml',
2723
+ ContentType: 'application/xml'
2724
+ }, []),
2725
+ createElement('Override', {
2726
+ PartName: '/xl/workbook.xml',
2727
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml'
2728
+ }, []),
2729
+ createElement('Override', {
2730
+ PartName: '/xl/styles.xml',
2731
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml'
2732
+ }, [])
2733
+ ];
2734
+ // Add shared strings if present
2735
+ if (this._sharedStrings.count > 0) {
2736
+ types.push(createElement('Override', {
2737
+ PartName: '/xl/sharedStrings.xml',
2738
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml'
2739
+ }, []));
2740
+ }
2741
+ // Add worksheets
2742
+ for (const def of this._sheetDefs){
2743
+ const rel = this._relationships.find((r)=>r.id === def.rId);
2744
+ if (rel) {
2745
+ types.push(createElement('Override', {
2746
+ PartName: `/xl/${rel.target}`,
2747
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml'
2748
+ }, []));
2749
+ }
2750
+ }
2751
+ // Add pivot cache definitions and records
2752
+ for(let i = 0; i < this._pivotCaches.length; i++){
2753
+ types.push(createElement('Override', {
2754
+ PartName: `/xl/pivotCache/pivotCacheDefinition${i + 1}.xml`,
2755
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml'
2756
+ }, []));
2757
+ types.push(createElement('Override', {
2758
+ PartName: `/xl/pivotCache/pivotCacheRecords${i + 1}.xml`,
2759
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml'
2760
+ }, []));
2761
+ }
2762
+ // Add pivot tables
2763
+ for(let i = 0; i < this._pivotTables.length; i++){
2764
+ types.push(createElement('Override', {
2765
+ PartName: `/xl/pivotTables/pivotTable${i + 1}.xml`,
2766
+ ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml'
2767
+ }, []));
2768
+ }
2769
+ const typesNode = createElement('Types', {
2770
+ xmlns: 'http://schemas.openxmlformats.org/package/2006/content-types'
2771
+ }, types);
2772
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
2773
+ typesNode
2774
+ ])}`;
2775
+ writeZipText(this._files, '[Content_Types].xml', xml);
2776
+ // Also ensure _rels/.rels exists
2777
+ const rootRelsXml = readZipText(this._files, '_rels/.rels');
2778
+ if (!rootRelsXml) {
2779
+ const rootRels = createElement('Relationships', {
2780
+ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
2781
+ }, [
2782
+ createElement('Relationship', {
2783
+ Id: 'rId1',
2784
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument',
2785
+ Target: 'xl/workbook.xml'
2786
+ }, [])
2787
+ ]);
2788
+ writeZipText(this._files, '_rels/.rels', `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
2789
+ rootRels
2790
+ ])}`);
2791
+ }
2792
+ }
2793
+ /**
2794
+ * Generate all pivot table related files
2795
+ */ _updatePivotTableFiles() {
2796
+ // Track which sheets have pivot tables for their .rels files
2797
+ const sheetPivotTables = new Map();
2798
+ for (const pivotTable of this._pivotTables){
2799
+ const sheetName = pivotTable.targetSheet;
2800
+ if (!sheetPivotTables.has(sheetName)) {
2801
+ sheetPivotTables.set(sheetName, []);
2802
+ }
2803
+ sheetPivotTables.get(sheetName).push(pivotTable);
2804
+ }
2805
+ // Generate pivot cache files
2806
+ for(let i = 0; i < this._pivotCaches.length; i++){
2807
+ const cache = this._pivotCaches[i];
2808
+ const cacheIdx = i + 1;
2809
+ // Pivot cache definition
2810
+ const definitionPath = `xl/pivotCache/pivotCacheDefinition${cacheIdx}.xml`;
2811
+ writeZipText(this._files, definitionPath, cache.toDefinitionXml('rId1'));
2812
+ // Pivot cache records
2813
+ const recordsPath = `xl/pivotCache/pivotCacheRecords${cacheIdx}.xml`;
2814
+ writeZipText(this._files, recordsPath, cache.toRecordsXml());
2815
+ // Pivot cache definition relationships (link to records)
2816
+ const cacheRelsPath = `xl/pivotCache/_rels/pivotCacheDefinition${cacheIdx}.xml.rels`;
2817
+ const cacheRels = createElement('Relationships', {
2818
+ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
2819
+ }, [
2820
+ createElement('Relationship', {
2821
+ Id: 'rId1',
2822
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords',
2823
+ Target: `pivotCacheRecords${cacheIdx}.xml`
2824
+ }, [])
2825
+ ]);
2826
+ writeZipText(this._files, cacheRelsPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
2827
+ cacheRels
2828
+ ])}`);
2829
+ }
2830
+ // Generate pivot table files
2831
+ for(let i = 0; i < this._pivotTables.length; i++){
2832
+ const pivotTable = this._pivotTables[i];
2833
+ const ptIdx = i + 1;
2834
+ // Pivot table definition
2835
+ const ptPath = `xl/pivotTables/pivotTable${ptIdx}.xml`;
2836
+ writeZipText(this._files, ptPath, pivotTable.toXml());
2837
+ // Pivot table relationships (link to cache definition)
2838
+ const cacheIdx = this._pivotCaches.indexOf(pivotTable.cache) + 1;
2839
+ const ptRelsPath = `xl/pivotTables/_rels/pivotTable${ptIdx}.xml.rels`;
2840
+ const ptRels = createElement('Relationships', {
2841
+ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
2842
+ }, [
2843
+ createElement('Relationship', {
2844
+ Id: 'rId1',
2845
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition',
2846
+ Target: `../pivotCache/pivotCacheDefinition${cacheIdx}.xml`
2847
+ }, [])
2848
+ ]);
2849
+ writeZipText(this._files, ptRelsPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
2850
+ ptRels
2851
+ ])}`);
2852
+ }
2853
+ // Generate worksheet relationships for pivot tables
2854
+ for (const [sheetName, pivotTables] of sheetPivotTables){
2855
+ const def = this._sheetDefs.find((s)=>s.name === sheetName);
2856
+ if (!def) continue;
2857
+ const rel = this._relationships.find((r)=>r.id === def.rId);
2858
+ if (!rel) continue;
2859
+ // Extract sheet file name from target path
2860
+ const sheetFileName = rel.target.split('/').pop();
2861
+ const sheetRelsPath = `xl/worksheets/_rels/${sheetFileName}.rels`;
2862
+ const relNodes = [];
2863
+ for(let i = 0; i < pivotTables.length; i++){
2864
+ const pt = pivotTables[i];
2865
+ relNodes.push(createElement('Relationship', {
2866
+ Id: `rId${i + 1}`,
2867
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable',
2868
+ Target: `../pivotTables/pivotTable${pt.index}.xml`
2869
+ }, []));
2870
+ }
2871
+ const sheetRels = createElement('Relationships', {
2872
+ xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships'
2873
+ }, relNodes);
2874
+ writeZipText(this._files, sheetRelsPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([
2875
+ sheetRels
2876
+ ])}`);
2877
+ }
2878
+ }
2879
+ }
2880
+
2881
+ export { Cell, PivotCache, PivotTable, Range, SharedStrings, Styles, Workbook, Worksheet, parseAddress, parseRange, toAddress, toRange };