@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/README.md +373 -5
- package/dist/index.cjs +507 -25
- package/dist/index.d.cts +172 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +172 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +504 -26
- package/package.json +1 -1
- package/src/index.ts +17 -1
- package/src/pivot-cache.ts +11 -1
- package/src/pivot-table.ts +122 -12
- package/src/range.ts +15 -2
- package/src/styles.ts +60 -1
- package/src/types.ts +62 -0
- package/src/utils/address.ts +4 -1
- package/src/utils/xml.ts +0 -7
- package/src/workbook.ts +18 -0
- package/src/worksheet.ts +343 -4
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
|
|
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[] = [
|
|
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) {
|