@niicojs/excel 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/worksheet.ts CHANGED
@@ -1,951 +1,1019 @@
1
- import type { CellData, RangeAddress, SheetToJsonConfig, CellValue, DateHandling, TableConfig } from './types';
2
- import type { Workbook } from './workbook';
3
- import { Cell, parseCellRef } from './cell';
4
- import { Range } from './range';
5
- import { Table } from './table';
6
- import { parseRange, toAddress, parseAddress, letterToCol } from './utils/address';
7
- import {
8
- parseXml,
9
- findElement,
10
- getChildren,
11
- getAttr,
12
- XmlNode,
13
- stringifyXml,
14
- createElement,
15
- createText,
16
- } from './utils/xml';
17
-
18
- /**
19
- * Represents a worksheet in a workbook
20
- */
21
- export class Worksheet {
22
- private _name: string;
23
- private _workbook: Workbook;
24
- private _cells: Map<string, Cell> = new Map();
25
- private _xmlNodes: XmlNode[] | null = null;
26
- private _dirty = false;
27
- private _mergedCells: Set<string> = new Set();
28
- private _sheetData: XmlNode[] = [];
29
- private _columnWidths: Map<number, number> = new Map();
30
- private _rowHeights: Map<number, number> = new Map();
31
- private _frozenPane: { row: number; col: number } | null = null;
32
- private _dataBoundsCache: { minRow: number; maxRow: number; minCol: number; maxCol: number } | null = null;
33
- private _boundsDirty = true;
34
- private _tables: Table[] = [];
35
- private _preserveXml = false;
36
- private _tableRelIds: string[] | null = null;
37
- private _sheetViewsDirty = false;
38
- private _colsDirty = false;
39
- private _tablePartsDirty = false;
40
-
41
- constructor(workbook: Workbook, name: string) {
42
- this._workbook = workbook;
43
- this._name = name;
44
- }
45
-
46
- /**
47
- * Get the workbook this sheet belongs to
48
- */
49
- get workbook(): Workbook {
50
- return this._workbook;
51
- }
52
-
53
- /**
54
- * Get the sheet name
55
- */
56
- get name(): string {
57
- return this._name;
58
- }
59
-
60
- /**
61
- * Set the sheet name
62
- */
63
- set name(value: string) {
64
- this._name = value;
65
- this._dirty = true;
66
- }
67
-
68
- /**
69
- * Parse worksheet XML content
70
- */
71
- parse(xml: string): void {
72
- this._xmlNodes = parseXml(xml);
73
- this._preserveXml = true;
74
- const worksheet = findElement(this._xmlNodes, 'worksheet');
75
- if (!worksheet) return;
76
-
77
- const worksheetChildren = getChildren(worksheet, 'worksheet');
78
-
79
- // Parse sheet views (freeze panes)
80
- const sheetViews = findElement(worksheetChildren, 'sheetViews');
81
- if (sheetViews) {
82
- const viewChildren = getChildren(sheetViews, 'sheetViews');
83
- const sheetView = findElement(viewChildren, 'sheetView');
84
- if (sheetView) {
85
- const sheetViewChildren = getChildren(sheetView, 'sheetView');
86
- const pane = findElement(sheetViewChildren, 'pane');
87
- if (pane && getAttr(pane, 'state') === 'frozen') {
88
- const xSplit = parseInt(getAttr(pane, 'xSplit') || '0', 10);
89
- const ySplit = parseInt(getAttr(pane, 'ySplit') || '0', 10);
90
- if (xSplit > 0 || ySplit > 0) {
91
- this._frozenPane = { row: ySplit, col: xSplit };
92
- }
93
- }
94
- }
95
- }
96
-
97
- // Parse sheet data (cells)
98
- const sheetData = findElement(worksheetChildren, 'sheetData');
99
- if (sheetData) {
100
- this._sheetData = getChildren(sheetData, 'sheetData');
101
- this._parseSheetData(this._sheetData);
102
- }
103
-
104
- // Parse column widths
105
- const cols = findElement(worksheetChildren, 'cols');
106
- if (cols) {
107
- const colChildren = getChildren(cols, 'cols');
108
- for (const col of colChildren) {
109
- if (!('col' in col)) continue;
110
- const min = parseInt(getAttr(col, 'min') || '0', 10);
111
- const max = parseInt(getAttr(col, 'max') || '0', 10);
112
- const width = parseFloat(getAttr(col, 'width') || '0');
113
- if (!Number.isFinite(width) || width <= 0) continue;
114
- if (min > 0 && max > 0) {
115
- for (let idx = min; idx <= max; idx++) {
116
- this._columnWidths.set(idx - 1, width);
117
- }
118
- }
119
- }
120
- }
121
-
122
- // Parse merged cells
123
- const mergeCells = findElement(worksheetChildren, 'mergeCells');
124
- if (mergeCells) {
125
- const mergeChildren = getChildren(mergeCells, 'mergeCells');
126
- for (const mergeCell of mergeChildren) {
127
- if ('mergeCell' in mergeCell) {
128
- const ref = getAttr(mergeCell, 'ref');
129
- if (ref) {
130
- this._mergedCells.add(ref);
131
- }
132
- }
133
- }
134
- }
135
- }
136
-
137
- /**
138
- * Parse the sheetData element to extract cells
139
- */
140
- private _parseSheetData(rows: XmlNode[]): void {
141
- for (const rowNode of rows) {
142
- if (!('row' in rowNode)) continue;
143
-
144
- const rowIndex = parseInt(getAttr(rowNode, 'r') || '0', 10) - 1;
145
- const rowHeight = parseFloat(getAttr(rowNode, 'ht') || '0');
146
- if (rowIndex >= 0 && Number.isFinite(rowHeight) && rowHeight > 0) {
147
- this._rowHeights.set(rowIndex, rowHeight);
148
- }
149
-
150
- const rowChildren = getChildren(rowNode, 'row');
151
- for (const cellNode of rowChildren) {
152
- if (!('c' in cellNode)) continue;
153
-
154
- const ref = getAttr(cellNode, 'r');
155
- if (!ref) continue;
156
-
157
- const { row, col } = parseAddress(ref);
158
- const cellData = this._parseCellNode(cellNode);
159
- const cell = new Cell(this, row, col, cellData);
160
- this._cells.set(ref, cell);
161
- }
162
- }
163
-
164
- this._boundsDirty = true;
165
- }
166
-
167
- /**
168
- * Parse a cell XML node to CellData
169
- */
170
- private _parseCellNode(node: XmlNode): CellData {
171
- const data: CellData = {};
172
-
173
- // Type attribute
174
- const t = getAttr(node, 't');
175
- if (t) {
176
- data.t = t as CellData['t'];
177
- }
178
-
179
- // Style attribute
180
- const s = getAttr(node, 's');
181
- if (s) {
182
- data.s = parseInt(s, 10);
183
- }
184
-
185
- const children = getChildren(node, 'c');
186
-
187
- // Value element
188
- const vNode = findElement(children, 'v');
189
- if (vNode) {
190
- const vChildren = getChildren(vNode, 'v');
191
- for (const child of vChildren) {
192
- if ('#text' in child) {
193
- const text = child['#text'] as string;
194
- // Parse based on type
195
- if (data.t === 's') {
196
- data.v = parseInt(text, 10); // Shared string index
197
- } else if (data.t === 'b') {
198
- data.v = text === '1' ? 1 : 0;
199
- } else if (data.t === 'e' || data.t === 'str') {
200
- data.v = text;
201
- } else {
202
- // Number or default
203
- data.v = parseFloat(text);
204
- }
205
- break;
206
- }
207
- }
208
- }
209
-
210
- // Formula element
211
- const fNode = findElement(children, 'f');
212
- if (fNode) {
213
- const fChildren = getChildren(fNode, 'f');
214
- for (const child of fChildren) {
215
- if ('#text' in child) {
216
- data.f = child['#text'] as string;
217
- break;
218
- }
219
- }
220
-
221
- // Check for shared formula
222
- const si = getAttr(fNode, 'si');
223
- if (si) {
224
- data.si = parseInt(si, 10);
225
- }
226
-
227
- // Check for array formula range
228
- const ref = getAttr(fNode, 'ref');
229
- if (ref) {
230
- data.F = ref;
231
- }
232
- }
233
-
234
- // Inline string (is element)
235
- const isNode = findElement(children, 'is');
236
- if (isNode) {
237
- data.t = 'str';
238
- const isChildren = getChildren(isNode, 'is');
239
- const tNode = findElement(isChildren, 't');
240
- if (tNode) {
241
- const tChildren = getChildren(tNode, 't');
242
- for (const child of tChildren) {
243
- if ('#text' in child) {
244
- data.v = child['#text'] as string;
245
- break;
246
- }
247
- }
248
- }
249
- }
250
-
251
- return data;
252
- }
253
-
254
- /**
255
- * Get a cell by address or row/col
256
- */
257
- cell(rowOrAddress: number | string, col?: number): Cell {
258
- const { row, col: c } = parseCellRef(rowOrAddress, col);
259
- const address = toAddress(row, c);
260
-
261
- let cell = this._cells.get(address);
262
- if (!cell) {
263
- cell = new Cell(this, row, c);
264
- this._cells.set(address, cell);
265
- this._boundsDirty = true;
266
- }
267
-
268
- return cell;
269
- }
270
-
271
- /**
272
- * Get an existing cell without creating it.
273
- */
274
- getCellIfExists(rowOrAddress: number | string, col?: number): Cell | undefined {
275
- const { row, col: c } = parseCellRef(rowOrAddress, col);
276
- const address = toAddress(row, c);
277
- return this._cells.get(address);
278
- }
279
-
280
- /**
281
- * Get a range of cells
282
- */
283
- range(rangeStr: string): Range;
284
- range(startRow: number, startCol: number, endRow: number, endCol: number): Range;
285
- range(startRowOrRange: number | string, startCol?: number, endRow?: number, endCol?: number): Range {
286
- let rangeAddr: RangeAddress;
287
-
288
- if (typeof startRowOrRange === 'string') {
289
- rangeAddr = parseRange(startRowOrRange);
290
- } else {
291
- if (startCol === undefined || endRow === undefined || endCol === undefined) {
292
- throw new Error('All range parameters must be provided');
293
- }
294
- rangeAddr = {
295
- start: { row: startRowOrRange, col: startCol },
296
- end: { row: endRow, col: endCol },
297
- };
298
- }
299
-
300
- return new Range(this, rangeAddr);
301
- }
302
-
303
- /**
304
- * Merge cells in the given range
305
- */
306
- mergeCells(rangeOrStart: string, end?: string): void {
307
- let rangeStr: string;
308
- if (end) {
309
- rangeStr = `${rangeOrStart}:${end}`;
310
- } else {
311
- rangeStr = rangeOrStart;
312
- }
313
- this._mergedCells.add(rangeStr);
314
- this._dirty = true;
315
- }
316
-
317
- /**
318
- * Unmerge cells in the given range
319
- */
320
- unmergeCells(rangeStr: string): void {
321
- this._mergedCells.delete(rangeStr);
322
- this._dirty = true;
323
- }
324
-
325
- /**
326
- * Get all merged cell ranges
327
- */
328
- get mergedCells(): string[] {
329
- return Array.from(this._mergedCells);
330
- }
331
-
332
- /**
333
- * Check if the worksheet has been modified
334
- */
335
- get dirty(): boolean {
336
- if (this._dirty) return true;
337
- for (const cell of this._cells.values()) {
338
- if (cell.dirty) return true;
339
- }
340
- return false;
341
- }
342
-
343
- /**
344
- * Get all cells in the worksheet
345
- */
346
- get cells(): Map<string, Cell> {
347
- return this._cells;
348
- }
349
-
350
- /**
351
- * Set a column width (0-based index or column letter)
352
- */
353
- setColumnWidth(col: number | string, width: number): void {
354
- if (!Number.isFinite(width) || width <= 0) {
355
- throw new Error('Column width must be a positive number');
356
- }
357
-
358
- const colIndex = typeof col === 'number' ? col : letterToCol(col);
359
- if (colIndex < 0) {
360
- throw new Error(`Invalid column: ${col}`);
361
- }
362
-
363
- this._columnWidths.set(colIndex, width);
364
- this._colsDirty = true;
365
- this._dirty = true;
366
- }
367
-
368
- /**
369
- * Get a column width if set
370
- */
371
- getColumnWidth(col: number | string): number | undefined {
372
- const colIndex = typeof col === 'number' ? col : letterToCol(col);
373
- return this._columnWidths.get(colIndex);
374
- }
375
-
376
- /**
377
- * Set a row height (0-based index)
378
- */
379
- setRowHeight(row: number, height: number): void {
380
- if (!Number.isFinite(height) || height <= 0) {
381
- throw new Error('Row height must be a positive number');
382
- }
383
- if (row < 0) {
384
- throw new Error('Row index must be >= 0');
385
- }
386
-
387
- this._rowHeights.set(row, height);
388
- this._colsDirty = true;
389
- this._dirty = true;
390
- }
391
-
392
- /**
393
- * Get a row height if set
394
- */
395
- getRowHeight(row: number): number | undefined {
396
- return this._rowHeights.get(row);
397
- }
398
-
399
- /**
400
- * Freeze panes at a given row/column split (counts from top-left)
401
- */
402
- freezePane(rowSplit: number, colSplit: number): void {
403
- if (rowSplit < 0 || colSplit < 0) {
404
- throw new Error('Freeze pane splits must be >= 0');
405
- }
406
- if (rowSplit === 0 && colSplit === 0) {
407
- this._frozenPane = null;
408
- } else {
409
- this._frozenPane = { row: rowSplit, col: colSplit };
410
- }
411
- this._sheetViewsDirty = true;
412
- this._dirty = true;
413
- }
414
-
415
- /**
416
- * Get current frozen pane configuration
417
- */
418
- getFrozenPane(): { row: number; col: number } | null {
419
- return this._frozenPane ? { ...this._frozenPane } : null;
420
- }
421
-
422
- /**
423
- * Get all tables in the worksheet
424
- */
425
- get tables(): Table[] {
426
- return [...this._tables];
427
- }
428
-
429
- /**
430
- * Get column width entries
431
- * @internal
432
- */
433
- getColumnWidths(): Map<number, number> {
434
- return new Map(this._columnWidths);
435
- }
436
-
437
- /**
438
- * Get row height entries
439
- * @internal
440
- */
441
- getRowHeights(): Map<number, number> {
442
- return new Map(this._rowHeights);
443
- }
444
-
445
- /**
446
- * Set table relationship IDs for tableParts generation.
447
- * @internal
448
- */
449
- setTableRelIds(ids: string[] | null): void {
450
- this._tableRelIds = ids ? [...ids] : null;
451
- this._tablePartsDirty = true;
452
- }
453
-
454
- /**
455
- * Create an Excel Table (ListObject) from a data range.
456
- *
457
- * Tables provide structured data features like auto-filter, banded styling,
458
- * and total row with aggregation functions.
459
- *
460
- * @param config - Table configuration
461
- * @returns Table instance for method chaining
462
- *
463
- * @example
464
- * ```typescript
465
- * // Create a table with default styling
466
- * const table = sheet.createTable({
467
- * name: 'SalesData',
468
- * range: 'A1:D10',
469
- * });
470
- *
471
- * // Create a table with total row
472
- * const table = sheet.createTable({
473
- * name: 'SalesData',
474
- * range: 'A1:D10',
475
- * totalRow: true,
476
- * style: { name: 'TableStyleMedium2' }
477
- * });
478
- *
479
- * table.setTotalFunction('Sales', 'sum');
480
- * ```
481
- */
482
- createTable(config: TableConfig): Table {
483
- // Validate table name is unique within the workbook
484
- for (const sheet of this._workbook.sheetNames) {
485
- const ws = this._workbook.sheet(sheet);
486
- for (const table of ws._tables) {
487
- if (table.name === config.name) {
488
- throw new Error(`Table name already exists: ${config.name}`);
489
- }
490
- }
491
- }
492
-
493
- // Validate table name format (Excel rules: no spaces at start/end, alphanumeric + underscore)
494
- if (!config.name || !/^[A-Za-z_\\][A-Za-z0-9_.\\]*$/.test(config.name)) {
495
- throw new Error(
496
- `Invalid table name: ${config.name}. Names must start with a letter or underscore and contain only alphanumeric characters, underscores, or periods.`,
497
- );
498
- }
499
-
500
- // Create the table with a unique ID from the workbook
501
- const tableId = this._workbook.getNextTableId();
502
- const table = new Table(this, config, tableId);
503
-
504
- this._tables.push(table);
505
- this._tablePartsDirty = true;
506
- this._dirty = true;
507
-
508
- return table;
509
- }
510
-
511
- /**
1
+ import type { CellData, RangeAddress, SheetToJsonConfig, CellValue, DateHandling, TableConfig } from './types';
2
+ import type { Workbook } from './workbook';
3
+ import { Cell, parseCellRef } from './cell';
4
+ import { Range } from './range';
5
+ import { Table } from './table';
6
+ import { parseRange, toAddress, parseAddress, letterToCol } from './utils/address';
7
+ import { findElement, getChildren, getAttr, XmlNode, stringifyXml, createElement, createText, parseXml } from './utils/xml';
8
+
9
+ /**
10
+ * Represents a worksheet in a workbook
11
+ */
12
+ export class Worksheet {
13
+ private _name: string;
14
+ private _workbook: Workbook;
15
+ private _cells: Map<string, Cell> = new Map();
16
+ private _xmlNodes: XmlNode[] | null = null;
17
+ private _dirty = false;
18
+ private _mergedCells: Set<string> = new Set();
19
+ private _columnWidths: Map<number, number> = new Map();
20
+ private _rowHeights: Map<number, number> = new Map();
21
+ private _frozenPane: { row: number; col: number } | null = null;
22
+ private _dataBoundsCache: { minRow: number; maxRow: number; minCol: number; maxCol: number } | null = null;
23
+ private _boundsDirty = true;
24
+ private _tables: Table[] = [];
25
+ private _preserveXml = false;
26
+ private _rawXml: string | null = null;
27
+ private _lazyParse = false;
28
+ private _tableRelIds: string[] | null = null;
29
+ private _pivotTableRelIds: string[] | null = null;
30
+ private _sheetViewsDirty = false;
31
+ private _colsDirty = false;
32
+ private _tablePartsDirty = false;
33
+ private _pivotTablePartsDirty = false;
34
+
35
+ constructor(workbook: Workbook, name: string) {
36
+ this._workbook = workbook;
37
+ this._name = name;
38
+ }
39
+
40
+ /**
41
+ * Get the workbook this sheet belongs to
42
+ */
43
+ get workbook(): Workbook {
44
+ return this._workbook;
45
+ }
46
+
47
+ /**
48
+ * Get the sheet name
49
+ */
50
+ get name(): string {
51
+ return this._name;
52
+ }
53
+
54
+ /**
55
+ * Set the sheet name
56
+ */
57
+ set name(value: string) {
58
+ this._name = value;
59
+ this._dirty = true;
60
+ }
61
+
62
+ /**
63
+ * Parse worksheet XML content
64
+ */
65
+ parse(xml: string, options: { lazy?: boolean } = {}): void {
66
+ this._rawXml = xml;
67
+ this._xmlNodes = null;
68
+ this._preserveXml = true;
69
+ this._lazyParse = options.lazy ?? true;
70
+ if (!this._lazyParse) {
71
+ this._ensureParsed();
72
+ }
73
+ }
74
+
75
+ private _ensureParsed(): void {
76
+ if (!this._lazyParse) return;
77
+ if (!this._rawXml) {
78
+ this._lazyParse = false;
79
+ return;
80
+ }
81
+
82
+ this._xmlNodes = parseXml(this._rawXml);
83
+ this._preserveXml = true;
84
+ const worksheet = findElement(this._xmlNodes, 'worksheet');
85
+ if (!worksheet) {
86
+ this._lazyParse = false;
87
+ return;
88
+ }
89
+
90
+ const worksheetChildren = getChildren(worksheet, 'worksheet');
91
+
92
+ // Parse sheet views (freeze panes)
93
+ const sheetViews = findElement(worksheetChildren, 'sheetViews');
94
+ if (sheetViews) {
95
+ const viewChildren = getChildren(sheetViews, 'sheetViews');
96
+ const sheetView = findElement(viewChildren, 'sheetView');
97
+ if (sheetView) {
98
+ const sheetViewChildren = getChildren(sheetView, 'sheetView');
99
+ const pane = findElement(sheetViewChildren, 'pane');
100
+ if (pane && getAttr(pane, 'state') === 'frozen') {
101
+ const xSplit = parseInt(getAttr(pane, 'xSplit') || '0', 10);
102
+ const ySplit = parseInt(getAttr(pane, 'ySplit') || '0', 10);
103
+ if (xSplit > 0 || ySplit > 0) {
104
+ this._frozenPane = { row: ySplit, col: xSplit };
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ // Parse sheet data (cells)
111
+ const sheetData = findElement(worksheetChildren, 'sheetData');
112
+ if (sheetData) {
113
+ const rows = getChildren(sheetData, 'sheetData');
114
+ this._parseSheetData(rows);
115
+ }
116
+
117
+ // Parse column widths
118
+ const cols = findElement(worksheetChildren, 'cols');
119
+ if (cols) {
120
+ const colChildren = getChildren(cols, 'cols');
121
+ for (const col of colChildren) {
122
+ if (!('col' in col)) continue;
123
+ const min = parseInt(getAttr(col, 'min') || '0', 10);
124
+ const max = parseInt(getAttr(col, 'max') || '0', 10);
125
+ const width = parseFloat(getAttr(col, 'width') || '0');
126
+ if (!Number.isFinite(width) || width <= 0) continue;
127
+ if (min > 0 && max > 0) {
128
+ for (let idx = min; idx <= max; idx++) {
129
+ this._columnWidths.set(idx - 1, width);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // Parse merged cells
136
+ const mergeCells = findElement(worksheetChildren, 'mergeCells');
137
+ if (mergeCells) {
138
+ const mergeChildren = getChildren(mergeCells, 'mergeCells');
139
+ for (const mergeCell of mergeChildren) {
140
+ if ('mergeCell' in mergeCell) {
141
+ const ref = getAttr(mergeCell, 'ref');
142
+ if (ref) {
143
+ this._mergedCells.add(ref);
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ this._lazyParse = false;
150
+ }
151
+
152
+ /**
153
+ * Parse the sheetData element to extract cells
154
+ */
155
+ private _parseSheetData(rows: XmlNode[]): void {
156
+ for (const rowNode of rows) {
157
+ if (!('row' in rowNode)) continue;
158
+
159
+ const rowIndex = parseInt(getAttr(rowNode, 'r') || '0', 10) - 1;
160
+ const rowHeight = parseFloat(getAttr(rowNode, 'ht') || '0');
161
+ if (rowIndex >= 0 && Number.isFinite(rowHeight) && rowHeight > 0) {
162
+ this._rowHeights.set(rowIndex, rowHeight);
163
+ }
164
+
165
+ const rowChildren = getChildren(rowNode, 'row');
166
+ for (const cellNode of rowChildren) {
167
+ if (!('c' in cellNode)) continue;
168
+
169
+ const ref = getAttr(cellNode, 'r');
170
+ if (!ref) continue;
171
+
172
+ const { row, col } = parseAddress(ref);
173
+ const cellData = this._parseCellNode(cellNode);
174
+ const cell = new Cell(this, row, col, cellData);
175
+ this._cells.set(ref, cell);
176
+ }
177
+ }
178
+
179
+ this._boundsDirty = true;
180
+ }
181
+
182
+ /**
183
+ * Parse a cell XML node to CellData
184
+ */
185
+ private _parseCellNode(node: XmlNode): CellData {
186
+ const data: CellData = {};
187
+
188
+ // Type attribute
189
+ const t = getAttr(node, 't');
190
+ if (t) {
191
+ data.t = t as CellData['t'];
192
+ }
193
+
194
+ // Style attribute
195
+ const s = getAttr(node, 's');
196
+ if (s) {
197
+ data.s = parseInt(s, 10);
198
+ }
199
+
200
+ const children = getChildren(node, 'c');
201
+
202
+ // Value element
203
+ const vNode = findElement(children, 'v');
204
+ if (vNode) {
205
+ const vChildren = getChildren(vNode, 'v');
206
+ for (const child of vChildren) {
207
+ if ('#text' in child) {
208
+ const text = child['#text'] as string;
209
+ // Parse based on type
210
+ if (data.t === 's') {
211
+ data.v = parseInt(text, 10); // Shared string index
212
+ } else if (data.t === 'b') {
213
+ data.v = text === '1' ? 1 : 0;
214
+ } else if (data.t === 'e' || data.t === 'str') {
215
+ data.v = text;
216
+ } else {
217
+ // Number or default
218
+ data.v = parseFloat(text);
219
+ }
220
+ break;
221
+ }
222
+ }
223
+ }
224
+
225
+ // Formula element
226
+ const fNode = findElement(children, 'f');
227
+ if (fNode) {
228
+ const fChildren = getChildren(fNode, 'f');
229
+ for (const child of fChildren) {
230
+ if ('#text' in child) {
231
+ data.f = child['#text'] as string;
232
+ break;
233
+ }
234
+ }
235
+
236
+ // Check for shared formula
237
+ const si = getAttr(fNode, 'si');
238
+ if (si) {
239
+ data.si = parseInt(si, 10);
240
+ }
241
+
242
+ // Check for array formula range
243
+ const ref = getAttr(fNode, 'ref');
244
+ if (ref) {
245
+ data.F = ref;
246
+ }
247
+ }
248
+
249
+ // Inline string (is element)
250
+ const isNode = findElement(children, 'is');
251
+ if (isNode) {
252
+ data.t = 'str';
253
+ const isChildren = getChildren(isNode, 'is');
254
+ const tNode = findElement(isChildren, 't');
255
+ if (tNode) {
256
+ const tChildren = getChildren(tNode, 't');
257
+ for (const child of tChildren) {
258
+ if ('#text' in child) {
259
+ data.v = child['#text'] as string;
260
+ break;
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ return data;
267
+ }
268
+
269
+ /**
270
+ * Get a cell by address or row/col
271
+ */
272
+ cell(rowOrAddress: number | string, col?: number): Cell {
273
+ this._ensureParsed();
274
+ const { row, col: c } = parseCellRef(rowOrAddress, col);
275
+ const address = toAddress(row, c);
276
+
277
+ let cell = this._cells.get(address);
278
+ if (!cell) {
279
+ cell = new Cell(this, row, c);
280
+ this._cells.set(address, cell);
281
+ this._boundsDirty = true;
282
+ }
283
+
284
+ return cell;
285
+ }
286
+
287
+ /**
288
+ * Get an existing cell without creating it.
289
+ */
290
+ getCellIfExists(rowOrAddress: number | string, col?: number): Cell | undefined {
291
+ this._ensureParsed();
292
+ const { row, col: c } = parseCellRef(rowOrAddress, col);
293
+ const address = toAddress(row, c);
294
+ return this._cells.get(address);
295
+ }
296
+
297
+ /**
298
+ * Get a range of cells
299
+ */
300
+ range(rangeStr: string): Range;
301
+ range(startRow: number, startCol: number, endRow: number, endCol: number): Range;
302
+ range(startRowOrRange: number | string, startCol?: number, endRow?: number, endCol?: number): Range {
303
+ this._ensureParsed();
304
+ let rangeAddr: RangeAddress;
305
+
306
+ if (typeof startRowOrRange === 'string') {
307
+ rangeAddr = parseRange(startRowOrRange);
308
+ } else {
309
+ if (startCol === undefined || endRow === undefined || endCol === undefined) {
310
+ throw new Error('All range parameters must be provided');
311
+ }
312
+ rangeAddr = {
313
+ start: { row: startRowOrRange, col: startCol },
314
+ end: { row: endRow, col: endCol },
315
+ };
316
+ }
317
+
318
+ return new Range(this, rangeAddr);
319
+ }
320
+
321
+ /**
322
+ * Merge cells in the given range
323
+ */
324
+ mergeCells(rangeOrStart: string, end?: string): void {
325
+ this._ensureParsed();
326
+ let rangeStr: string;
327
+ if (end) {
328
+ rangeStr = `${rangeOrStart}:${end}`;
329
+ } else {
330
+ rangeStr = rangeOrStart;
331
+ }
332
+ this._mergedCells.add(rangeStr);
333
+ this._dirty = true;
334
+ }
335
+
336
+ /**
337
+ * Unmerge cells in the given range
338
+ */
339
+ unmergeCells(rangeStr: string): void {
340
+ this._ensureParsed();
341
+ this._mergedCells.delete(rangeStr);
342
+ this._dirty = true;
343
+ }
344
+
345
+ /**
346
+ * Get all merged cell ranges
347
+ */
348
+ get mergedCells(): string[] {
349
+ this._ensureParsed();
350
+ return Array.from(this._mergedCells);
351
+ }
352
+
353
+ /**
354
+ * Check if the worksheet has been modified
355
+ */
356
+ get dirty(): boolean {
357
+ this._ensureParsed();
358
+ if (this._dirty) return true;
359
+ for (const cell of this._cells.values()) {
360
+ if (cell.dirty) return true;
361
+ }
362
+ return false;
363
+ }
364
+
365
+ /**
366
+ * Get all cells in the worksheet
367
+ */
368
+ get cells(): Map<string, Cell> {
369
+ this._ensureParsed();
370
+ return this._cells;
371
+ }
372
+
373
+ /**
374
+ * Set a column width (0-based index or column letter)
375
+ */
376
+ setColumnWidth(col: number | string, width: number): void {
377
+ this._ensureParsed();
378
+ if (!Number.isFinite(width) || width <= 0) {
379
+ throw new Error('Column width must be a positive number');
380
+ }
381
+
382
+ const colIndex = typeof col === 'number' ? col : letterToCol(col);
383
+ if (colIndex < 0) {
384
+ throw new Error(`Invalid column: ${col}`);
385
+ }
386
+
387
+ this._columnWidths.set(colIndex, width);
388
+ this._colsDirty = true;
389
+ this._dirty = true;
390
+ }
391
+
392
+ /**
393
+ * Get a column width if set
394
+ */
395
+ getColumnWidth(col: number | string): number | undefined {
396
+ this._ensureParsed();
397
+ const colIndex = typeof col === 'number' ? col : letterToCol(col);
398
+ return this._columnWidths.get(colIndex);
399
+ }
400
+
401
+ /**
402
+ * Set a row height (0-based index)
403
+ */
404
+ setRowHeight(row: number, height: number): void {
405
+ this._ensureParsed();
406
+ if (!Number.isFinite(height) || height <= 0) {
407
+ throw new Error('Row height must be a positive number');
408
+ }
409
+ if (row < 0) {
410
+ throw new Error('Row index must be >= 0');
411
+ }
412
+
413
+ this._rowHeights.set(row, height);
414
+ this._colsDirty = true;
415
+ this._dirty = true;
416
+ }
417
+
418
+ /**
419
+ * Get a row height if set
420
+ */
421
+ getRowHeight(row: number): number | undefined {
422
+ this._ensureParsed();
423
+ return this._rowHeights.get(row);
424
+ }
425
+
426
+ /**
427
+ * Freeze panes at a given row/column split (counts from top-left)
428
+ */
429
+ freezePane(rowSplit: number, colSplit: number): void {
430
+ this._ensureParsed();
431
+ if (rowSplit < 0 || colSplit < 0) {
432
+ throw new Error('Freeze pane splits must be >= 0');
433
+ }
434
+ if (rowSplit === 0 && colSplit === 0) {
435
+ this._frozenPane = null;
436
+ } else {
437
+ this._frozenPane = { row: rowSplit, col: colSplit };
438
+ }
439
+ this._sheetViewsDirty = true;
440
+ this._dirty = true;
441
+ }
442
+
443
+ /**
444
+ * Get current frozen pane configuration
445
+ */
446
+ getFrozenPane(): { row: number; col: number } | null {
447
+ this._ensureParsed();
448
+ return this._frozenPane ? { ...this._frozenPane } : null;
449
+ }
450
+
451
+ /**
452
+ * Get all tables in the worksheet
453
+ */
454
+ get tables(): Table[] {
455
+ this._ensureParsed();
456
+ return [...this._tables];
457
+ }
458
+
459
+ /**
460
+ * Get column width entries
461
+ * @internal
462
+ */
463
+ getColumnWidths(): Map<number, number> {
464
+ this._ensureParsed();
465
+ return new Map(this._columnWidths);
466
+ }
467
+
468
+ /**
469
+ * Get row height entries
470
+ * @internal
471
+ */
472
+ getRowHeights(): Map<number, number> {
473
+ this._ensureParsed();
474
+ return new Map(this._rowHeights);
475
+ }
476
+
477
+ /**
478
+ * Set table relationship IDs for tableParts generation.
479
+ * @internal
480
+ */
481
+ setTableRelIds(ids: string[] | null): void {
482
+ this._ensureParsed();
483
+ this._tableRelIds = ids ? [...ids] : null;
484
+ this._tablePartsDirty = true;
485
+ }
486
+
487
+ /**
488
+ * Set pivot table relationship IDs for pivotTableParts generation.
489
+ * @internal
490
+ */
491
+ setPivotTableRelIds(ids: string[] | null): void {
492
+ this._ensureParsed();
493
+ this._pivotTableRelIds = ids ? [...ids] : null;
494
+ this._pivotTablePartsDirty = true;
495
+ }
496
+
497
+ /**
498
+ * Create an Excel Table (ListObject) from a data range.
499
+ *
500
+ * Tables provide structured data features like auto-filter, banded styling,
501
+ * and total row with aggregation functions.
502
+ *
503
+ * @param config - Table configuration
504
+ * @returns Table instance for method chaining
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * // Create a table with default styling
509
+ * const table = sheet.createTable({
510
+ * name: 'SalesData',
511
+ * range: 'A1:D10',
512
+ * });
513
+ *
514
+ * // Create a table with total row
515
+ * const table = sheet.createTable({
516
+ * name: 'SalesData',
517
+ * range: 'A1:D10',
518
+ * totalRow: true,
519
+ * style: { name: 'TableStyleMedium2' }
520
+ * });
521
+ *
522
+ * table.setTotalFunction('Sales', 'sum');
523
+ * ```
524
+ */
525
+ createTable(config: TableConfig): Table {
526
+ this._ensureParsed();
527
+ // Validate table name is unique within the workbook
528
+ for (const sheet of this._workbook.sheetNames) {
529
+ const ws = this._workbook.sheet(sheet);
530
+ for (const table of ws._tables) {
531
+ if (table.name === config.name) {
532
+ throw new Error(`Table name already exists: ${config.name}`);
533
+ }
534
+ }
535
+ }
536
+
537
+ // Validate table name format (Excel rules: no spaces at start/end, alphanumeric + underscore)
538
+ if (!config.name || !/^[A-Za-z_\\][A-Za-z0-9_.\\]*$/.test(config.name)) {
539
+ throw new Error(
540
+ `Invalid table name: ${config.name}. Names must start with a letter or underscore and contain only alphanumeric characters, underscores, or periods.`,
541
+ );
542
+ }
543
+
544
+ // Create the table with a unique ID from the workbook
545
+ const tableId = this._workbook.getNextTableId();
546
+ const table = new Table(this, config, tableId);
547
+
548
+ this._tables.push(table);
549
+ this._tablePartsDirty = true;
550
+ this._dirty = true;
551
+
552
+ return table;
553
+ }
554
+
555
+ /**
512
556
  * Convert sheet data to an array of JSON objects.
513
- *
557
+ *
514
558
  * @param config - Configuration options
515
559
  * @returns Array of objects where keys are field names and values are cell values
516
560
  *
517
561
  * @example
518
- * ```typescript
519
- * // Using first row as headers
520
- * const data = sheet.toJson();
521
- *
522
- * // Using custom field names
523
- * const data = sheet.toJson({ fields: ['name', 'age', 'city'] });
524
- *
525
- * // Starting from a specific row/column
526
- * const data = sheet.toJson({ startRow: 2, startCol: 1 });
527
- * ```
528
- */
529
- toJson<T = Record<string, CellValue>>(config: SheetToJsonConfig = {}): T[] {
530
- const {
531
- fields,
532
- startRow = 0,
533
- startCol = 0,
534
- endRow,
535
- endCol,
562
+ * ```typescript
563
+ * // Using first row as headers
564
+ * const data = sheet.toJson();
565
+ *
566
+ * // Using custom field names
567
+ * const data = sheet.toJson({ fields: ['name', 'age', 'city'] });
568
+ *
569
+ * // Starting from a specific row/column
570
+ * const data = sheet.toJson({ startRow: 2, startCol: 1 });
571
+ * ```
572
+ */
573
+ toJson<T = Record<string, CellValue>>(config: SheetToJsonConfig = {}): T[] {
574
+ this._ensureParsed();
575
+ const {
576
+ fields,
577
+ startRow = 0,
578
+ startCol = 0,
579
+ endRow,
580
+ endCol,
536
581
  stopOnEmptyRow = true,
537
582
  dateHandling = this._workbook.dateHandling,
538
583
  asText = false,
539
584
  locale,
540
585
  } = config;
541
-
542
- // Get the bounds of data in the sheet
543
- const bounds = this._getDataBounds();
544
- if (!bounds) {
545
- return [];
546
- }
547
-
548
- const effectiveEndRow = endRow ?? bounds.maxRow;
549
- const effectiveEndCol = endCol ?? bounds.maxCol;
550
-
551
- // Determine field names
552
- let fieldNames: string[];
553
- let dataStartRow: number;
554
-
555
- if (fields) {
556
- // Use provided field names, data starts at startRow
557
- fieldNames = fields;
558
- dataStartRow = startRow;
559
- } else {
560
- // Use first row as headers
561
- fieldNames = [];
562
- for (let col = startCol; col <= effectiveEndCol; col++) {
563
- const cell = this._cells.get(toAddress(startRow, col));
564
- const value = cell?.value;
565
- fieldNames.push(value != null ? String(value) : `column${col}`);
566
- }
567
- dataStartRow = startRow + 1;
568
- }
569
-
570
- // Read data rows
571
- const result: T[] = [];
572
-
573
- for (let row = dataStartRow; row <= effectiveEndRow; row++) {
574
- const obj: Record<string, CellValue | string> = {};
575
- let hasData = false;
576
-
577
- for (let colOffset = 0; colOffset < fieldNames.length; colOffset++) {
578
- const col = startCol + colOffset;
579
- const cell = this._cells.get(toAddress(row, col));
580
-
581
- let value: CellValue | string;
582
-
586
+
587
+ // Get the bounds of data in the sheet
588
+ const bounds = this._getDataBounds();
589
+ if (!bounds) {
590
+ return [];
591
+ }
592
+
593
+ const effectiveEndRow = endRow ?? bounds.maxRow;
594
+ const effectiveEndCol = endCol ?? bounds.maxCol;
595
+
596
+ // Determine field names
597
+ let fieldNames: string[];
598
+ let dataStartRow: number;
599
+
600
+ if (fields) {
601
+ // Use provided field names, data starts at startRow
602
+ fieldNames = fields;
603
+ dataStartRow = startRow;
604
+ } else {
605
+ // Use first row as headers
606
+ fieldNames = [];
607
+ for (let col = startCol; col <= effectiveEndCol; col++) {
608
+ const cell = this._cells.get(toAddress(startRow, col));
609
+ const value = cell?.value;
610
+ fieldNames.push(value != null ? String(value) : `column${col}`);
611
+ }
612
+ dataStartRow = startRow + 1;
613
+ }
614
+
615
+ // Read data rows
616
+ const result: T[] = [];
617
+
618
+ for (let row = dataStartRow; row <= effectiveEndRow; row++) {
619
+ const obj: Record<string, CellValue | string> = {};
620
+ let hasData = false;
621
+
622
+ for (let colOffset = 0; colOffset < fieldNames.length; colOffset++) {
623
+ const col = startCol + colOffset;
624
+ const cell = this._cells.get(toAddress(row, col));
625
+
626
+ let value: CellValue | string;
627
+
583
628
  if (asText) {
584
629
  // Return formatted text instead of raw value
585
630
  value = cell?.textWithLocale(locale) ?? '';
586
631
  if (value !== '') {
587
632
  hasData = true;
588
633
  }
589
- } else {
590
- value = cell?.value ?? null;
591
- if (value instanceof Date) {
592
- value = this._serializeDate(value, dateHandling, cell);
593
- }
594
- if (value !== null) {
595
- hasData = true;
596
- }
597
- }
598
-
599
- const fieldName = fieldNames[colOffset];
600
- if (fieldName) {
601
- obj[fieldName] = value;
602
- }
603
- }
604
-
605
- // Stop on empty row if configured
606
- if (stopOnEmptyRow && !hasData) {
607
- break;
608
- }
609
-
610
- result.push(obj as T);
611
- }
612
-
613
- return result;
614
- }
615
-
616
- private _serializeDate(value: Date, dateHandling: DateHandling, cell?: Cell | null): CellValue | number | string {
617
- if (dateHandling === 'excelSerial') {
618
- return cell?._jsDateToExcel(value) ?? value;
619
- }
620
-
621
- if (dateHandling === 'isoString') {
622
- return value.toISOString();
623
- }
624
-
625
- return value;
626
- }
627
-
628
- /**
629
- * Get the bounds of data in the sheet (min/max row and column with data)
630
- */
631
- private _getDataBounds(): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null {
632
- if (!this._boundsDirty && this._dataBoundsCache) {
633
- return this._dataBoundsCache;
634
- }
635
-
636
- if (this._cells.size === 0) {
637
- this._dataBoundsCache = null;
638
- this._boundsDirty = false;
639
- return null;
640
- }
641
-
642
- let minRow = Infinity;
643
- let maxRow = -Infinity;
644
- let minCol = Infinity;
645
- let maxCol = -Infinity;
646
-
647
- for (const cell of this._cells.values()) {
648
- if (cell.value !== null) {
649
- minRow = Math.min(minRow, cell.row);
650
- maxRow = Math.max(maxRow, cell.row);
651
- minCol = Math.min(minCol, cell.col);
652
- maxCol = Math.max(maxCol, cell.col);
653
- }
654
- }
655
-
656
- if (minRow === Infinity) {
657
- this._dataBoundsCache = null;
658
- this._boundsDirty = false;
659
- return null;
660
- }
661
-
662
- this._dataBoundsCache = { minRow, maxRow, minCol, maxCol };
663
- this._boundsDirty = false;
664
- return this._dataBoundsCache;
665
- }
666
-
667
- /**
668
- * Generate XML for this worksheet
669
- */
670
- toXml(): string {
671
- const preserved = this._preserveXml && this._xmlNodes ? this._buildPreservedWorksheet() : null;
672
- // Build sheetData from cells
673
- const sheetDataNode = this._buildSheetDataNode();
674
-
675
- // Build worksheet structure
676
- const worksheetChildren: XmlNode[] = [];
677
-
678
- // Sheet views (freeze panes)
679
- if (this._frozenPane) {
680
- const paneAttrs: Record<string, string> = { state: 'frozen' };
681
- const topLeftCell = toAddress(this._frozenPane.row, this._frozenPane.col);
682
- paneAttrs.topLeftCell = topLeftCell;
683
- if (this._frozenPane.col > 0) {
684
- paneAttrs.xSplit = String(this._frozenPane.col);
685
- }
686
- if (this._frozenPane.row > 0) {
687
- paneAttrs.ySplit = String(this._frozenPane.row);
688
- }
689
-
690
- let activePane = 'bottomRight';
691
- if (this._frozenPane.row > 0 && this._frozenPane.col === 0) {
692
- activePane = 'bottomLeft';
693
- } else if (this._frozenPane.row === 0 && this._frozenPane.col > 0) {
694
- activePane = 'topRight';
695
- }
696
-
697
- paneAttrs.activePane = activePane;
698
- const paneNode = createElement('pane', paneAttrs, []);
699
- const selectionNode = createElement(
700
- 'selection',
701
- { pane: activePane, activeCell: topLeftCell, sqref: topLeftCell },
702
- [],
703
- );
704
-
705
- const sheetViewNode = createElement('sheetView', { workbookViewId: '0' }, [paneNode, selectionNode]);
706
- worksheetChildren.push(createElement('sheetViews', {}, [sheetViewNode]));
707
- }
708
-
709
- // Column widths
710
- if (this._columnWidths.size > 0) {
711
- const colNodes: XmlNode[] = [];
712
- const entries = Array.from(this._columnWidths.entries()).sort((a, b) => a[0] - b[0]);
713
- for (const [colIndex, width] of entries) {
714
- colNodes.push(
715
- createElement(
716
- 'col',
717
- {
718
- min: String(colIndex + 1),
719
- max: String(colIndex + 1),
720
- width: String(width),
721
- customWidth: '1',
722
- },
723
- [],
724
- ),
725
- );
726
- }
727
- worksheetChildren.push(createElement('cols', {}, colNodes));
728
- }
729
-
730
- worksheetChildren.push(sheetDataNode);
731
-
732
- // Add merged cells if any
733
- if (this._mergedCells.size > 0) {
734
- const mergeCellNodes: XmlNode[] = [];
735
- for (const ref of this._mergedCells) {
736
- mergeCellNodes.push(createElement('mergeCell', { ref }, []));
737
- }
738
- const mergeCellsNode = createElement('mergeCells', { count: String(this._mergedCells.size) }, mergeCellNodes);
739
- worksheetChildren.push(mergeCellsNode);
740
- }
741
-
742
- // Add table parts if any tables exist
743
- const tablePartsNode = this._buildTablePartsNode();
744
- if (tablePartsNode) {
745
- worksheetChildren.push(tablePartsNode);
746
- }
747
-
748
- const worksheetNode = createElement(
749
- 'worksheet',
750
- {
751
- xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
752
- 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
753
- },
754
- worksheetChildren,
755
- );
756
-
757
- if (preserved) {
758
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([preserved])}`;
759
- }
760
-
761
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([worksheetNode])}`;
762
- }
763
-
764
- private _buildSheetDataNode(): XmlNode {
765
- const rowMap = new Map<number, Cell[]>();
766
- for (const cell of this._cells.values()) {
767
- const row = cell.row;
768
- if (!rowMap.has(row)) {
769
- rowMap.set(row, []);
770
- }
771
- rowMap.get(row)!.push(cell);
772
- }
773
-
774
- for (const rowIdx of this._rowHeights.keys()) {
775
- if (!rowMap.has(rowIdx)) {
776
- rowMap.set(rowIdx, []);
777
- }
778
- }
779
-
780
- const sortedRows = Array.from(rowMap.entries()).sort((a, b) => a[0] - b[0]);
781
- const rowNodes: XmlNode[] = [];
782
- for (const [rowIdx, cells] of sortedRows) {
783
- cells.sort((a, b) => a.col - b.col);
784
-
785
- const cellNodes: XmlNode[] = [];
786
- for (const cell of cells) {
787
- const cellNode = this._buildCellNode(cell);
788
- cellNodes.push(cellNode);
789
- }
790
-
791
- const rowAttrs: Record<string, string> = { r: String(rowIdx + 1) };
792
- const rowHeight = this._rowHeights.get(rowIdx);
793
- if (rowHeight !== undefined) {
794
- rowAttrs.ht = String(rowHeight);
795
- rowAttrs.customHeight = '1';
796
- }
797
- const rowNode = createElement('row', rowAttrs, cellNodes);
798
- rowNodes.push(rowNode);
799
- }
800
-
801
- return createElement('sheetData', {}, rowNodes);
802
- }
803
-
804
- private _buildSheetViewsNode(): XmlNode | null {
805
- if (!this._frozenPane) return null;
806
- const paneAttrs: Record<string, string> = { state: 'frozen' };
807
- const topLeftCell = toAddress(this._frozenPane.row, this._frozenPane.col);
808
- paneAttrs.topLeftCell = topLeftCell;
809
- if (this._frozenPane.col > 0) {
810
- paneAttrs.xSplit = String(this._frozenPane.col);
811
- }
812
- if (this._frozenPane.row > 0) {
813
- paneAttrs.ySplit = String(this._frozenPane.row);
814
- }
815
-
816
- let activePane = 'bottomRight';
817
- if (this._frozenPane.row > 0 && this._frozenPane.col === 0) {
818
- activePane = 'bottomLeft';
819
- } else if (this._frozenPane.row === 0 && this._frozenPane.col > 0) {
820
- activePane = 'topRight';
821
- }
822
-
823
- paneAttrs.activePane = activePane;
824
- const paneNode = createElement('pane', paneAttrs, []);
825
- const selectionNode = createElement(
826
- 'selection',
827
- { pane: activePane, activeCell: topLeftCell, sqref: topLeftCell },
828
- [],
829
- );
830
-
831
- const sheetViewNode = createElement('sheetView', { workbookViewId: '0' }, [paneNode, selectionNode]);
832
- return createElement('sheetViews', {}, [sheetViewNode]);
833
- }
834
-
835
- private _buildColsNode(): XmlNode | null {
836
- if (this._columnWidths.size === 0) return null;
837
- const colNodes: XmlNode[] = [];
838
- const entries = Array.from(this._columnWidths.entries()).sort((a, b) => a[0] - b[0]);
839
- for (const [colIndex, width] of entries) {
840
- colNodes.push(
841
- createElement(
842
- 'col',
843
- {
844
- min: String(colIndex + 1),
845
- max: String(colIndex + 1),
846
- width: String(width),
847
- customWidth: '1',
848
- },
849
- [],
850
- ),
851
- );
852
- }
853
- return createElement('cols', {}, colNodes);
854
- }
855
-
856
- private _buildMergeCellsNode(): XmlNode | null {
857
- if (this._mergedCells.size === 0) return null;
858
- const mergeCellNodes: XmlNode[] = [];
859
- for (const ref of this._mergedCells) {
860
- mergeCellNodes.push(createElement('mergeCell', { ref }, []));
861
- }
862
- return createElement('mergeCells', { count: String(this._mergedCells.size) }, mergeCellNodes);
863
- }
864
-
865
- private _buildTablePartsNode(): XmlNode | null {
866
- if (this._tables.length === 0) return null;
867
- const tablePartNodes: XmlNode[] = [];
868
- for (let i = 0; i < this._tables.length; i++) {
869
- const relId =
870
- this._tableRelIds && this._tableRelIds.length === this._tables.length ? this._tableRelIds[i] : `rId${i + 1}`;
871
- tablePartNodes.push(createElement('tablePart', { 'r:id': relId }, []));
872
- }
873
- return createElement('tableParts', { count: String(this._tables.length) }, tablePartNodes);
874
- }
875
-
876
- private _buildPreservedWorksheet(): XmlNode | null {
877
- if (!this._xmlNodes) return null;
878
- const worksheet = findElement(this._xmlNodes, 'worksheet');
879
- if (!worksheet) return null;
880
-
881
- const children = getChildren(worksheet, 'worksheet');
882
-
883
- const upsertChild = (tag: string, node: XmlNode | null) => {
884
- const existingIndex = children.findIndex((child) => tag in child);
885
- if (node) {
886
- if (existingIndex >= 0) {
887
- children[existingIndex] = node;
888
- } else {
889
- children.push(node);
890
- }
891
- } else if (existingIndex >= 0) {
892
- children.splice(existingIndex, 1);
893
- }
894
- };
895
-
896
- if (this._sheetViewsDirty) {
897
- const sheetViewsNode = this._buildSheetViewsNode();
898
- upsertChild('sheetViews', sheetViewsNode);
899
- }
900
-
901
- if (this._colsDirty) {
902
- const colsNode = this._buildColsNode();
903
- upsertChild('cols', colsNode);
904
- }
905
-
906
- const sheetDataNode = this._buildSheetDataNode();
907
- upsertChild('sheetData', sheetDataNode);
908
-
909
- const mergeCellsNode = this._buildMergeCellsNode();
910
- upsertChild('mergeCells', mergeCellsNode);
911
-
912
- if (this._tablePartsDirty) {
913
- const tablePartsNode = this._buildTablePartsNode();
914
- upsertChild('tableParts', tablePartsNode);
915
- }
916
-
917
- return worksheet;
918
- }
919
-
920
- /**
921
- * Build a cell XML node from a Cell object
922
- */
923
- private _buildCellNode(cell: Cell): XmlNode {
924
- const data = cell.data;
925
- const attrs: Record<string, string> = { r: cell.address };
926
-
927
- if (data.t && data.t !== 'n') {
928
- attrs.t = data.t;
929
- }
930
- if (data.s !== undefined) {
931
- attrs.s = String(data.s);
932
- }
933
-
934
- const children: XmlNode[] = [];
935
-
936
- // Formula
937
- if (data.f) {
938
- const fAttrs: Record<string, string> = {};
939
- if (data.F) fAttrs.ref = data.F;
940
- if (data.si !== undefined) fAttrs.si = String(data.si);
941
- children.push(createElement('f', fAttrs, [createText(data.f)]));
942
- }
943
-
944
- // Value
945
- if (data.v !== undefined) {
946
- children.push(createElement('v', {}, [createText(String(data.v))]));
947
- }
948
-
949
- return createElement('c', attrs, children);
950
- }
951
- }
634
+ } else {
635
+ value = cell?.value ?? null;
636
+ if (value instanceof Date) {
637
+ value = this._serializeDate(value, dateHandling, cell);
638
+ }
639
+ if (value !== null) {
640
+ hasData = true;
641
+ }
642
+ }
643
+
644
+ const fieldName = fieldNames[colOffset];
645
+ if (fieldName) {
646
+ obj[fieldName] = value;
647
+ }
648
+ }
649
+
650
+ // Stop on empty row if configured
651
+ if (stopOnEmptyRow && !hasData) {
652
+ break;
653
+ }
654
+
655
+ result.push(obj as T);
656
+ }
657
+
658
+ return result;
659
+ }
660
+
661
+ private _serializeDate(value: Date, dateHandling: DateHandling, cell?: Cell | null): CellValue | number | string {
662
+ if (dateHandling === 'excelSerial') {
663
+ return cell?._jsDateToExcel(value) ?? value;
664
+ }
665
+
666
+ if (dateHandling === 'isoString') {
667
+ return value.toISOString();
668
+ }
669
+
670
+ return value;
671
+ }
672
+
673
+ /**
674
+ * Get the bounds of data in the sheet (min/max row and column with data)
675
+ */
676
+ private _getDataBounds(): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null {
677
+ if (!this._boundsDirty && this._dataBoundsCache) {
678
+ return this._dataBoundsCache;
679
+ }
680
+
681
+ if (this._cells.size === 0) {
682
+ this._dataBoundsCache = null;
683
+ this._boundsDirty = false;
684
+ return null;
685
+ }
686
+
687
+ let minRow = Infinity;
688
+ let maxRow = -Infinity;
689
+ let minCol = Infinity;
690
+ let maxCol = -Infinity;
691
+
692
+ for (const cell of this._cells.values()) {
693
+ if (cell.value !== null) {
694
+ minRow = Math.min(minRow, cell.row);
695
+ maxRow = Math.max(maxRow, cell.row);
696
+ minCol = Math.min(minCol, cell.col);
697
+ maxCol = Math.max(maxCol, cell.col);
698
+ }
699
+ }
700
+
701
+ if (minRow === Infinity) {
702
+ this._dataBoundsCache = null;
703
+ this._boundsDirty = false;
704
+ return null;
705
+ }
706
+
707
+ this._dataBoundsCache = { minRow, maxRow, minCol, maxCol };
708
+ this._boundsDirty = false;
709
+ return this._dataBoundsCache;
710
+ }
711
+
712
+ /**
713
+ * Generate XML for this worksheet
714
+ */
715
+ toXml(): string {
716
+ if (this._lazyParse && !this._dirty && this._rawXml) {
717
+ return this._rawXml;
718
+ }
719
+
720
+ this._ensureParsed();
721
+ const preserved = this._preserveXml && this._xmlNodes ? this._buildPreservedWorksheet() : null;
722
+ // Build sheetData from cells
723
+ const sheetDataNode = this._buildSheetDataNode();
724
+
725
+ // Build worksheet structure
726
+ const worksheetChildren: XmlNode[] = [];
727
+
728
+ // Sheet views (freeze panes)
729
+ if (this._frozenPane) {
730
+ const paneAttrs: Record<string, string> = { state: 'frozen' };
731
+ const topLeftCell = toAddress(this._frozenPane.row, this._frozenPane.col);
732
+ paneAttrs.topLeftCell = topLeftCell;
733
+ if (this._frozenPane.col > 0) {
734
+ paneAttrs.xSplit = String(this._frozenPane.col);
735
+ }
736
+ if (this._frozenPane.row > 0) {
737
+ paneAttrs.ySplit = String(this._frozenPane.row);
738
+ }
739
+
740
+ let activePane = 'bottomRight';
741
+ if (this._frozenPane.row > 0 && this._frozenPane.col === 0) {
742
+ activePane = 'bottomLeft';
743
+ } else if (this._frozenPane.row === 0 && this._frozenPane.col > 0) {
744
+ activePane = 'topRight';
745
+ }
746
+
747
+ paneAttrs.activePane = activePane;
748
+ const paneNode = createElement('pane', paneAttrs, []);
749
+ const selectionNode = createElement(
750
+ 'selection',
751
+ { pane: activePane, activeCell: topLeftCell, sqref: topLeftCell },
752
+ [],
753
+ );
754
+
755
+ const sheetViewNode = createElement('sheetView', { workbookViewId: '0' }, [paneNode, selectionNode]);
756
+ worksheetChildren.push(createElement('sheetViews', {}, [sheetViewNode]));
757
+ }
758
+
759
+ // Column widths
760
+ if (this._columnWidths.size > 0) {
761
+ const colNodes: XmlNode[] = [];
762
+ const entries = Array.from(this._columnWidths.entries()).sort((a, b) => a[0] - b[0]);
763
+ for (const [colIndex, width] of entries) {
764
+ colNodes.push(
765
+ createElement(
766
+ 'col',
767
+ {
768
+ min: String(colIndex + 1),
769
+ max: String(colIndex + 1),
770
+ width: String(width),
771
+ customWidth: '1',
772
+ },
773
+ [],
774
+ ),
775
+ );
776
+ }
777
+ worksheetChildren.push(createElement('cols', {}, colNodes));
778
+ }
779
+
780
+ worksheetChildren.push(sheetDataNode);
781
+
782
+ // Add merged cells if any
783
+ if (this._mergedCells.size > 0) {
784
+ const mergeCellNodes: XmlNode[] = [];
785
+ for (const ref of this._mergedCells) {
786
+ mergeCellNodes.push(createElement('mergeCell', { ref }, []));
787
+ }
788
+ const mergeCellsNode = createElement('mergeCells', { count: String(this._mergedCells.size) }, mergeCellNodes);
789
+ worksheetChildren.push(mergeCellsNode);
790
+ }
791
+
792
+ // Add table parts if any tables exist
793
+ const tablePartsNode = this._buildTablePartsNode();
794
+ if (tablePartsNode) {
795
+ worksheetChildren.push(tablePartsNode);
796
+ }
797
+
798
+ const pivotTablePartsNode = this._buildPivotTablePartsNode();
799
+ if (pivotTablePartsNode) {
800
+ worksheetChildren.push(pivotTablePartsNode);
801
+ }
802
+
803
+ const worksheetNode = createElement(
804
+ 'worksheet',
805
+ {
806
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
807
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
808
+ },
809
+ worksheetChildren,
810
+ );
811
+
812
+ if (preserved) {
813
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([preserved])}`;
814
+ }
815
+
816
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([worksheetNode])}`;
817
+ }
818
+
819
+ private _buildSheetDataNode(): XmlNode {
820
+ const rowMap = new Map<number, Cell[]>();
821
+ for (const cell of this._cells.values()) {
822
+ const row = cell.row;
823
+ if (!rowMap.has(row)) {
824
+ rowMap.set(row, []);
825
+ }
826
+ rowMap.get(row)!.push(cell);
827
+ }
828
+
829
+ for (const rowIdx of this._rowHeights.keys()) {
830
+ if (!rowMap.has(rowIdx)) {
831
+ rowMap.set(rowIdx, []);
832
+ }
833
+ }
834
+
835
+ const sortedRows = Array.from(rowMap.entries()).sort((a, b) => a[0] - b[0]);
836
+ const rowNodes: XmlNode[] = [];
837
+ for (const [rowIdx, cells] of sortedRows) {
838
+ cells.sort((a, b) => a.col - b.col);
839
+
840
+ const cellNodes: XmlNode[] = [];
841
+ for (const cell of cells) {
842
+ const cellNode = this._buildCellNode(cell);
843
+ cellNodes.push(cellNode);
844
+ }
845
+
846
+ const rowAttrs: Record<string, string> = { r: String(rowIdx + 1) };
847
+ const rowHeight = this._rowHeights.get(rowIdx);
848
+ if (rowHeight !== undefined) {
849
+ rowAttrs.ht = String(rowHeight);
850
+ rowAttrs.customHeight = '1';
851
+ }
852
+ const rowNode = createElement('row', rowAttrs, cellNodes);
853
+ rowNodes.push(rowNode);
854
+ }
855
+
856
+ return createElement('sheetData', {}, rowNodes);
857
+ }
858
+
859
+ private _buildSheetViewsNode(): XmlNode | null {
860
+ if (!this._frozenPane) return null;
861
+ const paneAttrs: Record<string, string> = { state: 'frozen' };
862
+ const topLeftCell = toAddress(this._frozenPane.row, this._frozenPane.col);
863
+ paneAttrs.topLeftCell = topLeftCell;
864
+ if (this._frozenPane.col > 0) {
865
+ paneAttrs.xSplit = String(this._frozenPane.col);
866
+ }
867
+ if (this._frozenPane.row > 0) {
868
+ paneAttrs.ySplit = String(this._frozenPane.row);
869
+ }
870
+
871
+ let activePane = 'bottomRight';
872
+ if (this._frozenPane.row > 0 && this._frozenPane.col === 0) {
873
+ activePane = 'bottomLeft';
874
+ } else if (this._frozenPane.row === 0 && this._frozenPane.col > 0) {
875
+ activePane = 'topRight';
876
+ }
877
+
878
+ paneAttrs.activePane = activePane;
879
+ const paneNode = createElement('pane', paneAttrs, []);
880
+ const selectionNode = createElement(
881
+ 'selection',
882
+ { pane: activePane, activeCell: topLeftCell, sqref: topLeftCell },
883
+ [],
884
+ );
885
+
886
+ const sheetViewNode = createElement('sheetView', { workbookViewId: '0' }, [paneNode, selectionNode]);
887
+ return createElement('sheetViews', {}, [sheetViewNode]);
888
+ }
889
+
890
+ private _buildColsNode(): XmlNode | null {
891
+ if (this._columnWidths.size === 0) return null;
892
+ const colNodes: XmlNode[] = [];
893
+ const entries = Array.from(this._columnWidths.entries()).sort((a, b) => a[0] - b[0]);
894
+ for (const [colIndex, width] of entries) {
895
+ colNodes.push(
896
+ createElement(
897
+ 'col',
898
+ {
899
+ min: String(colIndex + 1),
900
+ max: String(colIndex + 1),
901
+ width: String(width),
902
+ customWidth: '1',
903
+ },
904
+ [],
905
+ ),
906
+ );
907
+ }
908
+ return createElement('cols', {}, colNodes);
909
+ }
910
+
911
+ private _buildMergeCellsNode(): XmlNode | null {
912
+ if (this._mergedCells.size === 0) return null;
913
+ const mergeCellNodes: XmlNode[] = [];
914
+ for (const ref of this._mergedCells) {
915
+ mergeCellNodes.push(createElement('mergeCell', { ref }, []));
916
+ }
917
+ return createElement('mergeCells', { count: String(this._mergedCells.size) }, mergeCellNodes);
918
+ }
919
+
920
+ private _buildTablePartsNode(): XmlNode | null {
921
+ if (this._tables.length === 0) return null;
922
+ const tablePartNodes: XmlNode[] = [];
923
+ for (let i = 0; i < this._tables.length; i++) {
924
+ const relId =
925
+ this._tableRelIds && this._tableRelIds.length === this._tables.length ? this._tableRelIds[i] : `rId${i + 1}`;
926
+ tablePartNodes.push(createElement('tablePart', { 'r:id': relId }, []));
927
+ }
928
+ return createElement('tableParts', { count: String(this._tables.length) }, tablePartNodes);
929
+ }
930
+
931
+ private _buildPivotTablePartsNode(): XmlNode | null {
932
+ if (!this._pivotTableRelIds || this._pivotTableRelIds.length === 0) return null;
933
+ const pivotPartNodes: XmlNode[] = this._pivotTableRelIds.map((relId) =>
934
+ createElement('pivotTablePart', { 'r:id': relId }, []),
935
+ );
936
+ return createElement('pivotTableParts', { count: String(pivotPartNodes.length) }, pivotPartNodes);
937
+ }
938
+
939
+ private _buildPreservedWorksheet(): XmlNode | null {
940
+ if (!this._xmlNodes) return null;
941
+ const worksheet = findElement(this._xmlNodes, 'worksheet');
942
+ if (!worksheet) return null;
943
+
944
+ const children = getChildren(worksheet, 'worksheet');
945
+
946
+ const upsertChild = (tag: string, node: XmlNode | null) => {
947
+ const existingIndex = children.findIndex((child) => tag in child);
948
+ if (node) {
949
+ if (existingIndex >= 0) {
950
+ children[existingIndex] = node;
951
+ } else {
952
+ children.push(node);
953
+ }
954
+ } else if (existingIndex >= 0) {
955
+ children.splice(existingIndex, 1);
956
+ }
957
+ };
958
+
959
+ if (this._sheetViewsDirty) {
960
+ const sheetViewsNode = this._buildSheetViewsNode();
961
+ upsertChild('sheetViews', sheetViewsNode);
962
+ }
963
+
964
+ if (this._colsDirty) {
965
+ const colsNode = this._buildColsNode();
966
+ upsertChild('cols', colsNode);
967
+ }
968
+
969
+ const sheetDataNode = this._buildSheetDataNode();
970
+ upsertChild('sheetData', sheetDataNode);
971
+
972
+ const mergeCellsNode = this._buildMergeCellsNode();
973
+ upsertChild('mergeCells', mergeCellsNode);
974
+
975
+ if (this._tablePartsDirty) {
976
+ const tablePartsNode = this._buildTablePartsNode();
977
+ upsertChild('tableParts', tablePartsNode);
978
+ }
979
+
980
+ if (this._pivotTablePartsDirty) {
981
+ const pivotTablePartsNode = this._buildPivotTablePartsNode();
982
+ upsertChild('pivotTableParts', pivotTablePartsNode);
983
+ }
984
+
985
+ return worksheet;
986
+ }
987
+
988
+ /**
989
+ * Build a cell XML node from a Cell object
990
+ */
991
+ private _buildCellNode(cell: Cell): XmlNode {
992
+ const data = cell.data;
993
+ const attrs: Record<string, string> = { r: cell.address };
994
+
995
+ if (data.t && data.t !== 'n') {
996
+ attrs.t = data.t;
997
+ }
998
+ if (data.s !== undefined) {
999
+ attrs.s = String(data.s);
1000
+ }
1001
+
1002
+ const children: XmlNode[] = [];
1003
+
1004
+ // Formula
1005
+ if (data.f) {
1006
+ const fAttrs: Record<string, string> = {};
1007
+ if (data.F) fAttrs.ref = data.F;
1008
+ if (data.si !== undefined) fAttrs.si = String(data.si);
1009
+ children.push(createElement('f', fAttrs, [createText(data.f)]));
1010
+ }
1011
+
1012
+ // Value
1013
+ if (data.v !== undefined) {
1014
+ children.push(createElement('v', {}, [createText(String(data.v))]));
1015
+ }
1016
+
1017
+ return createElement('c', attrs, children);
1018
+ }
1019
+ }