@niicojs/excel 0.2.2 → 0.2.4

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,8 +1,8 @@
1
- import type { CellData, RangeAddress } from './types';
1
+ import type { CellData, RangeAddress, SheetToJsonConfig, CellValue, DateHandling } from './types';
2
2
  import type { Workbook } from './workbook';
3
3
  import { Cell, parseCellRef } from './cell';
4
4
  import { Range } from './range';
5
- import { parseRange, toAddress, parseAddress } from './utils/address';
5
+ import { parseRange, toAddress, parseAddress, letterToCol } from './utils/address';
6
6
  import {
7
7
  parseXml,
8
8
  findElement,
@@ -25,6 +25,11 @@ export class Worksheet {
25
25
  private _dirty = false;
26
26
  private _mergedCells: Set<string> = new Set();
27
27
  private _sheetData: XmlNode[] = [];
28
+ private _columnWidths: Map<number, number> = new Map();
29
+ private _rowHeights: Map<number, number> = new Map();
30
+ private _frozenPane: { row: number; col: number } | null = null;
31
+ private _dataBoundsCache: { minRow: number; maxRow: number; minCol: number; maxCol: number } | null = null;
32
+ private _boundsDirty = true;
28
33
 
29
34
  constructor(workbook: Workbook, name: string) {
30
35
  this._workbook = workbook;
@@ -63,6 +68,24 @@ export class Worksheet {
63
68
 
64
69
  const worksheetChildren = getChildren(worksheet, 'worksheet');
65
70
 
71
+ // Parse sheet views (freeze panes)
72
+ const sheetViews = findElement(worksheetChildren, 'sheetViews');
73
+ if (sheetViews) {
74
+ const viewChildren = getChildren(sheetViews, 'sheetViews');
75
+ const sheetView = findElement(viewChildren, 'sheetView');
76
+ if (sheetView) {
77
+ const sheetViewChildren = getChildren(sheetView, 'sheetView');
78
+ const pane = findElement(sheetViewChildren, 'pane');
79
+ if (pane && getAttr(pane, 'state') === 'frozen') {
80
+ const xSplit = parseInt(getAttr(pane, 'xSplit') || '0', 10);
81
+ const ySplit = parseInt(getAttr(pane, 'ySplit') || '0', 10);
82
+ if (xSplit > 0 || ySplit > 0) {
83
+ this._frozenPane = { row: ySplit, col: xSplit };
84
+ }
85
+ }
86
+ }
87
+ }
88
+
66
89
  // Parse sheet data (cells)
67
90
  const sheetData = findElement(worksheetChildren, 'sheetData');
68
91
  if (sheetData) {
@@ -70,6 +93,24 @@ export class Worksheet {
70
93
  this._parseSheetData(this._sheetData);
71
94
  }
72
95
 
96
+ // Parse column widths
97
+ const cols = findElement(worksheetChildren, 'cols');
98
+ if (cols) {
99
+ const colChildren = getChildren(cols, 'cols');
100
+ for (const col of colChildren) {
101
+ if (!('col' in col)) continue;
102
+ const min = parseInt(getAttr(col, 'min') || '0', 10);
103
+ const max = parseInt(getAttr(col, 'max') || '0', 10);
104
+ const width = parseFloat(getAttr(col, 'width') || '0');
105
+ if (!Number.isFinite(width) || width <= 0) continue;
106
+ if (min > 0 && max > 0) {
107
+ for (let idx = min; idx <= max; idx++) {
108
+ this._columnWidths.set(idx - 1, width);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
73
114
  // Parse merged cells
74
115
  const mergeCells = findElement(worksheetChildren, 'mergeCells');
75
116
  if (mergeCells) {
@@ -92,6 +133,12 @@ export class Worksheet {
92
133
  for (const rowNode of rows) {
93
134
  if (!('row' in rowNode)) continue;
94
135
 
136
+ const rowIndex = parseInt(getAttr(rowNode, 'r') || '0', 10) - 1;
137
+ const rowHeight = parseFloat(getAttr(rowNode, 'ht') || '0');
138
+ if (rowIndex >= 0 && Number.isFinite(rowHeight) && rowHeight > 0) {
139
+ this._rowHeights.set(rowIndex, rowHeight);
140
+ }
141
+
95
142
  const rowChildren = getChildren(rowNode, 'row');
96
143
  for (const cellNode of rowChildren) {
97
144
  if (!('c' in cellNode)) continue;
@@ -105,6 +152,8 @@ export class Worksheet {
105
152
  this._cells.set(ref, cell);
106
153
  }
107
154
  }
155
+
156
+ this._boundsDirty = true;
108
157
  }
109
158
 
110
159
  /**
@@ -205,11 +254,21 @@ export class Worksheet {
205
254
  if (!cell) {
206
255
  cell = new Cell(this, row, c);
207
256
  this._cells.set(address, cell);
257
+ this._boundsDirty = true;
208
258
  }
209
259
 
210
260
  return cell;
211
261
  }
212
262
 
263
+ /**
264
+ * Get an existing cell without creating it.
265
+ */
266
+ getCellIfExists(rowOrAddress: number | string, col?: number): Cell | undefined {
267
+ const { row, col: c } = parseCellRef(rowOrAddress, col);
268
+ const address = toAddress(row, c);
269
+ return this._cells.get(address);
270
+ }
271
+
213
272
  /**
214
273
  * Get a range of cells
215
274
  */
@@ -280,6 +339,220 @@ export class Worksheet {
280
339
  return this._cells;
281
340
  }
282
341
 
342
+ /**
343
+ * Set a column width (0-based index or column letter)
344
+ */
345
+ setColumnWidth(col: number | string, width: number): void {
346
+ if (!Number.isFinite(width) || width <= 0) {
347
+ throw new Error('Column width must be a positive number');
348
+ }
349
+
350
+ const colIndex = typeof col === 'number' ? col : letterToCol(col);
351
+ if (colIndex < 0) {
352
+ throw new Error(`Invalid column: ${col}`);
353
+ }
354
+
355
+ this._columnWidths.set(colIndex, width);
356
+ this._dirty = true;
357
+ }
358
+
359
+ /**
360
+ * Get a column width if set
361
+ */
362
+ getColumnWidth(col: number | string): number | undefined {
363
+ const colIndex = typeof col === 'number' ? col : letterToCol(col);
364
+ return this._columnWidths.get(colIndex);
365
+ }
366
+
367
+ /**
368
+ * Set a row height (0-based index)
369
+ */
370
+ setRowHeight(row: number, height: number): void {
371
+ if (!Number.isFinite(height) || height <= 0) {
372
+ throw new Error('Row height must be a positive number');
373
+ }
374
+ if (row < 0) {
375
+ throw new Error('Row index must be >= 0');
376
+ }
377
+
378
+ this._rowHeights.set(row, height);
379
+ this._dirty = true;
380
+ }
381
+
382
+ /**
383
+ * Get a row height if set
384
+ */
385
+ getRowHeight(row: number): number | undefined {
386
+ return this._rowHeights.get(row);
387
+ }
388
+
389
+ /**
390
+ * Freeze panes at a given row/column split (counts from top-left)
391
+ */
392
+ freezePane(rowSplit: number, colSplit: number): void {
393
+ if (rowSplit < 0 || colSplit < 0) {
394
+ throw new Error('Freeze pane splits must be >= 0');
395
+ }
396
+ if (rowSplit === 0 && colSplit === 0) {
397
+ this._frozenPane = null;
398
+ } else {
399
+ this._frozenPane = { row: rowSplit, col: colSplit };
400
+ }
401
+ this._dirty = true;
402
+ }
403
+
404
+ /**
405
+ * Get current frozen pane configuration
406
+ */
407
+ getFrozenPane(): { row: number; col: number } | null {
408
+ return this._frozenPane ? { ...this._frozenPane } : null;
409
+ }
410
+
411
+ /**
412
+ * Convert sheet data to an array of JSON objects.
413
+ *
414
+ * @param config - Configuration options
415
+ * @returns Array of objects where keys are field names and values are cell values
416
+ *
417
+ * @example
418
+ * ```typescript
419
+ * // Using first row as headers
420
+ * const data = sheet.toJson();
421
+ *
422
+ * // Using custom field names
423
+ * const data = sheet.toJson({ fields: ['name', 'age', 'city'] });
424
+ *
425
+ * // Starting from a specific row/column
426
+ * const data = sheet.toJson({ startRow: 2, startCol: 1 });
427
+ * ```
428
+ */
429
+ toJson<T = Record<string, CellValue>>(config: SheetToJsonConfig = {}): T[] {
430
+ const {
431
+ fields,
432
+ startRow = 0,
433
+ startCol = 0,
434
+ endRow,
435
+ endCol,
436
+ stopOnEmptyRow = true,
437
+ dateHandling = this._workbook.dateHandling,
438
+ } = config;
439
+
440
+ // Get the bounds of data in the sheet
441
+ const bounds = this._getDataBounds();
442
+ if (!bounds) {
443
+ return [];
444
+ }
445
+
446
+ const effectiveEndRow = endRow ?? bounds.maxRow;
447
+ const effectiveEndCol = endCol ?? bounds.maxCol;
448
+
449
+ // Determine field names
450
+ let fieldNames: string[];
451
+ let dataStartRow: number;
452
+
453
+ if (fields) {
454
+ // Use provided field names, data starts at startRow
455
+ fieldNames = fields;
456
+ dataStartRow = startRow;
457
+ } else {
458
+ // Use first row as headers
459
+ fieldNames = [];
460
+ for (let col = startCol; col <= effectiveEndCol; col++) {
461
+ const cell = this._cells.get(toAddress(startRow, col));
462
+ const value = cell?.value;
463
+ fieldNames.push(value != null ? String(value) : `column${col}`);
464
+ }
465
+ dataStartRow = startRow + 1;
466
+ }
467
+
468
+ // Read data rows
469
+ const result: T[] = [];
470
+
471
+ for (let row = dataStartRow; row <= effectiveEndRow; row++) {
472
+ const obj: Record<string, CellValue> = {};
473
+ let hasData = false;
474
+
475
+ for (let colOffset = 0; colOffset < fieldNames.length; colOffset++) {
476
+ const col = startCol + colOffset;
477
+ const cell = this._cells.get(toAddress(row, col));
478
+ let value = cell?.value ?? null;
479
+
480
+ if (value instanceof Date) {
481
+ value = this._serializeDate(value, dateHandling, cell);
482
+ }
483
+
484
+ if (value !== null) {
485
+ hasData = true;
486
+ }
487
+
488
+ const fieldName = fieldNames[colOffset];
489
+ if (fieldName) {
490
+ obj[fieldName] = value;
491
+ }
492
+ }
493
+
494
+ // Stop on empty row if configured
495
+ if (stopOnEmptyRow && !hasData) {
496
+ break;
497
+ }
498
+
499
+ result.push(obj as T);
500
+ }
501
+
502
+ return result;
503
+ }
504
+
505
+ private _serializeDate(value: Date, dateHandling: DateHandling, cell?: Cell | null): CellValue | number | string {
506
+ if (dateHandling === 'excelSerial') {
507
+ return cell?._jsDateToExcel(value) ?? value;
508
+ }
509
+
510
+ if (dateHandling === 'isoString') {
511
+ return value.toISOString();
512
+ }
513
+
514
+ return value;
515
+ }
516
+
517
+ /**
518
+ * Get the bounds of data in the sheet (min/max row and column with data)
519
+ */
520
+ private _getDataBounds(): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null {
521
+ if (!this._boundsDirty && this._dataBoundsCache) {
522
+ return this._dataBoundsCache;
523
+ }
524
+
525
+ if (this._cells.size === 0) {
526
+ this._dataBoundsCache = null;
527
+ this._boundsDirty = false;
528
+ return null;
529
+ }
530
+
531
+ let minRow = Infinity;
532
+ let maxRow = -Infinity;
533
+ let minCol = Infinity;
534
+ let maxCol = -Infinity;
535
+
536
+ for (const cell of this._cells.values()) {
537
+ if (cell.value !== null) {
538
+ minRow = Math.min(minRow, cell.row);
539
+ maxRow = Math.max(maxRow, cell.row);
540
+ minCol = Math.min(minCol, cell.col);
541
+ maxCol = Math.max(maxCol, cell.col);
542
+ }
543
+ }
544
+
545
+ if (minRow === Infinity) {
546
+ this._dataBoundsCache = null;
547
+ this._boundsDirty = false;
548
+ return null;
549
+ }
550
+
551
+ this._dataBoundsCache = { minRow, maxRow, minCol, maxCol };
552
+ this._boundsDirty = false;
553
+ return this._dataBoundsCache;
554
+ }
555
+
283
556
  /**
284
557
  * Generate XML for this worksheet
285
558
  */
@@ -294,6 +567,12 @@ export class Worksheet {
294
567
  rowMap.get(row)!.push(cell);
295
568
  }
296
569
 
570
+ for (const rowIdx of this._rowHeights.keys()) {
571
+ if (!rowMap.has(rowIdx)) {
572
+ rowMap.set(rowIdx, []);
573
+ }
574
+ }
575
+
297
576
  // Sort rows and cells
298
577
  const sortedRows = Array.from(rowMap.entries()).sort((a, b) => a[0] - b[0]);
299
578
 
@@ -307,14 +586,74 @@ export class Worksheet {
307
586
  cellNodes.push(cellNode);
308
587
  }
309
588
 
310
- const rowNode = createElement('row', { r: String(rowIdx + 1) }, cellNodes);
589
+ const rowAttrs: Record<string, string> = { r: String(rowIdx + 1) };
590
+ const rowHeight = this._rowHeights.get(rowIdx);
591
+ if (rowHeight !== undefined) {
592
+ rowAttrs.ht = String(rowHeight);
593
+ rowAttrs.customHeight = '1';
594
+ }
595
+ const rowNode = createElement('row', rowAttrs, cellNodes);
311
596
  rowNodes.push(rowNode);
312
597
  }
313
598
 
314
599
  const sheetDataNode = createElement('sheetData', {}, rowNodes);
315
600
 
316
601
  // Build worksheet structure
317
- const worksheetChildren: XmlNode[] = [sheetDataNode];
602
+ const worksheetChildren: XmlNode[] = [];
603
+
604
+ // Sheet views (freeze panes)
605
+ if (this._frozenPane) {
606
+ const paneAttrs: Record<string, string> = { state: 'frozen' };
607
+ const topLeftCell = toAddress(this._frozenPane.row, this._frozenPane.col);
608
+ paneAttrs.topLeftCell = topLeftCell;
609
+ if (this._frozenPane.col > 0) {
610
+ paneAttrs.xSplit = String(this._frozenPane.col);
611
+ }
612
+ if (this._frozenPane.row > 0) {
613
+ paneAttrs.ySplit = String(this._frozenPane.row);
614
+ }
615
+
616
+ let activePane = 'bottomRight';
617
+ if (this._frozenPane.row > 0 && this._frozenPane.col === 0) {
618
+ activePane = 'bottomLeft';
619
+ } else if (this._frozenPane.row === 0 && this._frozenPane.col > 0) {
620
+ activePane = 'topRight';
621
+ }
622
+
623
+ paneAttrs.activePane = activePane;
624
+ const paneNode = createElement('pane', paneAttrs, []);
625
+ const selectionNode = createElement(
626
+ 'selection',
627
+ { pane: activePane, activeCell: topLeftCell, sqref: topLeftCell },
628
+ [],
629
+ );
630
+
631
+ const sheetViewNode = createElement('sheetView', { workbookViewId: '0' }, [paneNode, selectionNode]);
632
+ worksheetChildren.push(createElement('sheetViews', {}, [sheetViewNode]));
633
+ }
634
+
635
+ // Column widths
636
+ if (this._columnWidths.size > 0) {
637
+ const colNodes: XmlNode[] = [];
638
+ const entries = Array.from(this._columnWidths.entries()).sort((a, b) => a[0] - b[0]);
639
+ for (const [colIndex, width] of entries) {
640
+ colNodes.push(
641
+ createElement(
642
+ 'col',
643
+ {
644
+ min: String(colIndex + 1),
645
+ max: String(colIndex + 1),
646
+ width: String(width),
647
+ customWidth: '1',
648
+ },
649
+ [],
650
+ ),
651
+ );
652
+ }
653
+ worksheetChildren.push(createElement('cols', {}, colNodes));
654
+ }
655
+
656
+ worksheetChildren.push(sheetDataNode);
318
657
 
319
658
  // Add merged cells if any
320
659
  if (this._mergedCells.size > 0) {