@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niicojs/excel",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "typescript library to manipulate excel files",
5
5
  "homepage": "https://github.com/niicojs/excel#readme",
6
6
  "bugs": {
package/src/index.ts CHANGED
@@ -8,6 +8,18 @@ export { Styles } from './styles';
8
8
  export { PivotTable } from './pivot-table';
9
9
  export { PivotCache } from './pivot-cache';
10
10
 
11
+ // Address utilities
12
+ export {
13
+ parseAddress,
14
+ toAddress,
15
+ parseRange,
16
+ toRange,
17
+ colToLetter,
18
+ letterToCol,
19
+ normalizeRange,
20
+ isInRange,
21
+ } from './utils/address';
22
+
11
23
  // Type exports
12
24
  export type {
13
25
  CellValue,
@@ -20,16 +32,20 @@ export type {
20
32
  BorderStyle,
21
33
  BorderType,
22
34
  Alignment,
35
+ DateHandling,
23
36
  // Pivot table types
24
37
  PivotTableConfig,
25
38
  PivotValueConfig,
26
39
  AggregationType,
27
40
  PivotFieldAxis,
41
+ PivotSortOrder,
42
+ PivotFieldFilter,
28
43
  // Sheet from data types
29
44
  SheetFromDataConfig,
30
45
  ColumnConfig,
31
46
  RichCellValue,
47
+ // Sheet to JSON types
48
+ SheetToJsonConfig,
32
49
  } from './types';
33
50
 
34
51
  // Utility exports
35
- export { parseAddress, toAddress, parseRange, toRange } from './utils/address';
@@ -13,6 +13,7 @@ export class PivotCache {
13
13
  private _records: CellValue[][] = [];
14
14
  private _recordCount = 0;
15
15
  private _refreshOnLoad = true; // Default to true
16
+ private _dateGrouping = false;
16
17
 
17
18
  constructor(cacheId: number, sourceSheet: string, sourceRange: string) {
18
19
  this._cacheId = cacheId;
@@ -126,6 +127,9 @@ export class PivotCache {
126
127
  }
127
128
  }
128
129
 
130
+ // Enable date grouping flag if any date field exists
131
+ this._dateGrouping = this._fields.some((field) => field.isDate);
132
+
129
133
  // Store records
130
134
  this._records = data;
131
135
  }
@@ -160,6 +164,8 @@ export class PivotCache {
160
164
  for (const item of field.sharedItems) {
161
165
  sharedItemChildren.push(createElement('s', { v: item }, []));
162
166
  }
167
+ } else if (field.isDate) {
168
+ sharedItemsAttrs.containsDate = '1';
163
169
  } else if (field.isNumeric) {
164
170
  // Numeric field - use "0"/"1" for boolean attributes as Excel expects
165
171
  sharedItemsAttrs.containsSemiMixedTypes = '0';
@@ -187,7 +193,11 @@ export class PivotCache {
187
193
  { ref: this._sourceRange, sheet: this._sourceSheet },
188
194
  [],
189
195
  );
190
- const cacheSourceNode = createElement('cacheSource', { type: 'worksheet' }, [worksheetSourceNode]);
196
+ const cacheSourceAttrs: Record<string, string> = { type: 'worksheet' };
197
+ if (this._dateGrouping) {
198
+ cacheSourceAttrs.grouping = '1';
199
+ }
200
+ const cacheSourceNode = createElement('cacheSource', cacheSourceAttrs, [worksheetSourceNode]);
191
201
 
192
202
  // Build attributes - refreshOnLoad should come early per OOXML schema
193
203
  const definitionAttrs: Record<string, string> = {
@@ -1,4 +1,4 @@
1
- import type { AggregationType, PivotFieldAxis } from './types';
1
+ import type { AggregationType, PivotFieldAxis, PivotFieldFilter, PivotSortOrder } from './types';
2
2
  import { PivotCache } from './pivot-cache';
3
3
  import { createElement, stringifyXml, XmlNode } from './utils/xml';
4
4
 
@@ -11,6 +11,8 @@ interface FieldAssignment {
11
11
  axis: PivotFieldAxis;
12
12
  aggregation?: AggregationType;
13
13
  displayName?: string;
14
+ sortOrder?: PivotSortOrder;
15
+ filter?: PivotFieldFilter;
14
16
  }
15
17
 
16
18
  /**
@@ -29,6 +31,8 @@ export class PivotTable {
29
31
  private _valueFields: FieldAssignment[] = [];
30
32
  private _filterFields: FieldAssignment[] = [];
31
33
 
34
+ private _fieldAssignments: Map<number, FieldAssignment> = new Map();
35
+
32
36
  private _pivotTableIndex: number;
33
37
 
34
38
  constructor(
@@ -94,11 +98,13 @@ export class PivotTable {
94
98
  throw new Error(`Field not found in source data: ${fieldName}`);
95
99
  }
96
100
 
97
- this._rowFields.push({
101
+ const assignment: FieldAssignment = {
98
102
  fieldName,
99
103
  fieldIndex,
100
104
  axis: 'row',
101
- });
105
+ };
106
+ this._rowFields.push(assignment);
107
+ this._fieldAssignments.set(fieldIndex, assignment);
102
108
 
103
109
  return this;
104
110
  }
@@ -113,11 +119,13 @@ export class PivotTable {
113
119
  throw new Error(`Field not found in source data: ${fieldName}`);
114
120
  }
115
121
 
116
- this._columnFields.push({
122
+ const assignment: FieldAssignment = {
117
123
  fieldName,
118
124
  fieldIndex,
119
125
  axis: 'column',
120
- });
126
+ };
127
+ this._columnFields.push(assignment);
128
+ this._fieldAssignments.set(fieldIndex, assignment);
121
129
 
122
130
  return this;
123
131
  }
@@ -136,13 +144,15 @@ export class PivotTable {
136
144
 
137
145
  const defaultName = `${aggregation.charAt(0).toUpperCase() + aggregation.slice(1)} of ${fieldName}`;
138
146
 
139
- this._valueFields.push({
147
+ const assignment: FieldAssignment = {
140
148
  fieldName,
141
149
  fieldIndex,
142
150
  axis: 'value',
143
151
  aggregation,
144
152
  displayName: displayName || defaultName,
145
- });
153
+ };
154
+ this._valueFields.push(assignment);
155
+ this._fieldAssignments.set(fieldIndex, assignment);
146
156
 
147
157
  return this;
148
158
  }
@@ -157,12 +167,54 @@ export class PivotTable {
157
167
  throw new Error(`Field not found in source data: ${fieldName}`);
158
168
  }
159
169
 
160
- this._filterFields.push({
170
+ const assignment: FieldAssignment = {
161
171
  fieldName,
162
172
  fieldIndex,
163
173
  axis: 'filter',
164
- });
174
+ };
175
+ this._filterFields.push(assignment);
176
+ this._fieldAssignments.set(fieldIndex, assignment);
177
+
178
+ return this;
179
+ }
180
+
181
+ /**
182
+ * Set a sort order for a row/column field
183
+ */
184
+ sortField(fieldName: string, order: PivotSortOrder): this {
185
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
186
+ if (fieldIndex < 0) {
187
+ throw new Error(`Field not found in source data: ${fieldName}`);
188
+ }
189
+
190
+ const assignment = this._fieldAssignments.get(fieldIndex);
191
+ if (!assignment || (assignment.axis !== 'row' && assignment.axis !== 'column')) {
192
+ throw new Error(`Field is not assigned to row or column axis: ${fieldName}`);
193
+ }
165
194
 
195
+ assignment.sortOrder = order;
196
+ return this;
197
+ }
198
+
199
+ /**
200
+ * Filter items for a field (include or exclude list)
201
+ */
202
+ filterField(fieldName: string, filter: PivotFieldFilter): this {
203
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
204
+ if (fieldIndex < 0) {
205
+ throw new Error(`Field not found in source data: ${fieldName}`);
206
+ }
207
+
208
+ const assignment = this._fieldAssignments.get(fieldIndex);
209
+ if (!assignment) {
210
+ throw new Error(`Field is not assigned to pivot table: ${fieldName}`);
211
+ }
212
+
213
+ if (filter.include && filter.exclude) {
214
+ throw new Error('Pivot field filter cannot use both include and exclude');
215
+ }
216
+
217
+ assignment.filter = filter;
166
218
  return this;
167
219
  }
168
220
 
@@ -328,15 +380,27 @@ export class PivotTable {
328
380
  const filterField = this._filterFields.find((f) => f.fieldIndex === fieldIndex);
329
381
  const valueField = this._valueFields.find((f) => f.fieldIndex === fieldIndex);
330
382
 
383
+ const assignment = this._fieldAssignments.get(fieldIndex);
384
+
331
385
  if (rowField) {
332
386
  attrs.axis = 'axisRow';
333
387
  attrs.showAll = '0';
388
+ if (assignment?.sortOrder) {
389
+ attrs.sortType = 'ascending';
390
+ attrs.sortOrder = assignment.sortOrder === 'asc' ? 'ascending' : 'descending';
391
+ }
334
392
  // Add items for shared values
335
393
  const cacheField = this._cache.fields[fieldIndex];
336
394
  if (cacheField && cacheField.sharedItems.length > 0) {
337
395
  const itemNodes: XmlNode[] = [];
396
+ const allowedIndexes = this._resolveItemFilter(cacheField.sharedItems, assignment?.filter);
338
397
  for (let i = 0; i < cacheField.sharedItems.length; i++) {
339
- itemNodes.push(createElement('item', { x: String(i) }, []));
398
+ const shouldInclude = allowedIndexes.has(i);
399
+ const itemAttrs: Record<string, string> = { x: String(i) };
400
+ if (!shouldInclude) {
401
+ itemAttrs.h = '1';
402
+ }
403
+ itemNodes.push(createElement('item', itemAttrs, []));
340
404
  }
341
405
  // Add default subtotal item
342
406
  itemNodes.push(createElement('item', { t: 'default' }, []));
@@ -345,11 +409,21 @@ export class PivotTable {
345
409
  } else if (colField) {
346
410
  attrs.axis = 'axisCol';
347
411
  attrs.showAll = '0';
412
+ if (assignment?.sortOrder) {
413
+ attrs.sortType = 'ascending';
414
+ attrs.sortOrder = assignment.sortOrder === 'asc' ? 'ascending' : 'descending';
415
+ }
348
416
  const cacheField = this._cache.fields[fieldIndex];
349
417
  if (cacheField && cacheField.sharedItems.length > 0) {
350
418
  const itemNodes: XmlNode[] = [];
419
+ const allowedIndexes = this._resolveItemFilter(cacheField.sharedItems, assignment?.filter);
351
420
  for (let i = 0; i < cacheField.sharedItems.length; i++) {
352
- itemNodes.push(createElement('item', { x: String(i) }, []));
421
+ const shouldInclude = allowedIndexes.has(i);
422
+ const itemAttrs: Record<string, string> = { x: String(i) };
423
+ if (!shouldInclude) {
424
+ itemAttrs.h = '1';
425
+ }
426
+ itemNodes.push(createElement('item', itemAttrs, []));
353
427
  }
354
428
  itemNodes.push(createElement('item', { t: 'default' }, []));
355
429
  children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
@@ -360,8 +434,14 @@ export class PivotTable {
360
434
  const cacheField = this._cache.fields[fieldIndex];
361
435
  if (cacheField && cacheField.sharedItems.length > 0) {
362
436
  const itemNodes: XmlNode[] = [];
437
+ const allowedIndexes = this._resolveItemFilter(cacheField.sharedItems, assignment?.filter);
363
438
  for (let i = 0; i < cacheField.sharedItems.length; i++) {
364
- itemNodes.push(createElement('item', { x: String(i) }, []));
439
+ const shouldInclude = allowedIndexes.has(i);
440
+ const itemAttrs: Record<string, string> = { x: String(i) };
441
+ if (!shouldInclude) {
442
+ itemAttrs.h = '1';
443
+ }
444
+ itemNodes.push(createElement('item', itemAttrs, []));
365
445
  }
366
446
  itemNodes.push(createElement('item', { t: 'default' }, []));
367
447
  children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
@@ -376,6 +456,36 @@ export class PivotTable {
376
456
  return createElement('pivotField', attrs, children);
377
457
  }
378
458
 
459
+ private _resolveItemFilter(items: string[], filter?: PivotFieldFilter): Set<number> {
460
+ const allowed = new Set<number>();
461
+
462
+ if (!filter || (!filter.include && !filter.exclude)) {
463
+ for (let i = 0; i < items.length; i++) {
464
+ allowed.add(i);
465
+ }
466
+ return allowed;
467
+ }
468
+
469
+ if (filter.include) {
470
+ for (let i = 0; i < items.length; i++) {
471
+ if (filter.include.includes(items[i])) {
472
+ allowed.add(i);
473
+ }
474
+ }
475
+ return allowed;
476
+ }
477
+
478
+ if (filter.exclude) {
479
+ for (let i = 0; i < items.length; i++) {
480
+ if (!filter.exclude.includes(items[i])) {
481
+ allowed.add(i);
482
+ }
483
+ }
484
+ }
485
+
486
+ return allowed;
487
+ }
488
+
379
489
  /**
380
490
  * Build row items based on unique values in row fields
381
491
  */
package/src/range.ts CHANGED
@@ -42,12 +42,25 @@ export class Range {
42
42
  * Get all values in the range as a 2D array
43
43
  */
44
44
  get values(): CellValue[][] {
45
+ return this.getValues();
46
+ }
47
+
48
+ /**
49
+ * Get all values in the range as a 2D array with options
50
+ */
51
+ getValues(options: { createMissing?: boolean } = {}): CellValue[][] {
52
+ const { createMissing = true } = options;
45
53
  const result: CellValue[][] = [];
46
54
  for (let r = this._range.start.row; r <= this._range.end.row; r++) {
47
55
  const row: CellValue[] = [];
48
56
  for (let c = this._range.start.col; c <= this._range.end.col; c++) {
49
- const cell = this._worksheet.cell(r, c);
50
- row.push(cell.value);
57
+ if (createMissing) {
58
+ const cell = this._worksheet.cell(r, c);
59
+ row.push(cell.value);
60
+ } else {
61
+ const cell = this._worksheet.getCellIfExists(r, c);
62
+ row.push(cell?.value ?? null);
63
+ }
51
64
  }
52
65
  result.push(row);
53
66
  }
package/src/styles.ts CHANGED
@@ -35,6 +35,52 @@ export class Styles {
35
35
 
36
36
  // Cache for style deduplication
37
37
  private _styleCache: Map<string, number> = new Map();
38
+ private _styleObjectCache: Map<number, CellStyle> = new Map();
39
+
40
+ /**
41
+ * Generate a deterministic cache key for a style object.
42
+ * More efficient than JSON.stringify as it avoids the overhead of
43
+ * full JSON serialization and produces a consistent key regardless
44
+ * of property order.
45
+ */
46
+ private _getStyleKey(style: CellStyle): string {
47
+ // Use a delimiter that won't appear in values
48
+ const SEP = '\x00';
49
+
50
+ // Build key from all style properties in a fixed order
51
+ const parts: string[] = [
52
+ style.bold ? '1' : '0',
53
+ style.italic ? '1' : '0',
54
+ style.underline === true ? '1' : style.underline === 'single' ? 's' : style.underline === 'double' ? 'd' : '0',
55
+ style.strike ? '1' : '0',
56
+ style.fontSize?.toString() ?? '',
57
+ style.fontName ?? '',
58
+ style.fontColor ?? '',
59
+ style.fill ?? '',
60
+ style.numberFormat ?? '',
61
+ ];
62
+
63
+ // Border properties
64
+ if (style.border) {
65
+ parts.push(style.border.top ?? '', style.border.bottom ?? '', style.border.left ?? '', style.border.right ?? '');
66
+ } else {
67
+ parts.push('', '', '', '');
68
+ }
69
+
70
+ // Alignment properties
71
+ if (style.alignment) {
72
+ parts.push(
73
+ style.alignment.horizontal ?? '',
74
+ style.alignment.vertical ?? '',
75
+ style.alignment.wrapText ? '1' : '0',
76
+ style.alignment.textRotation?.toString() ?? '',
77
+ );
78
+ } else {
79
+ parts.push('', '', '0', '');
80
+ }
81
+
82
+ return parts.join(SEP);
83
+ }
38
84
 
39
85
  /**
40
86
  * Parse styles from XML content
@@ -228,6 +274,9 @@ export class Styles {
228
274
  * Get a style by index
229
275
  */
230
276
  getStyle(index: number): CellStyle {
277
+ const cached = this._styleObjectCache.get(index);
278
+ if (cached) return { ...cached };
279
+
231
280
  const xf = this._cellXfs[index];
232
281
  if (!xf) return {};
233
282
 
@@ -276,6 +325,7 @@ export class Styles {
276
325
  };
277
326
  }
278
327
 
328
+ this._styleObjectCache.set(index, { ...style });
279
329
  return style;
280
330
  }
281
331
 
@@ -284,7 +334,7 @@ export class Styles {
284
334
  * Uses caching to deduplicate identical styles
285
335
  */
286
336
  createStyle(style: CellStyle): number {
287
- const key = JSON.stringify(style);
337
+ const key = this._getStyleKey(style);
288
338
  const cached = this._styleCache.get(key);
289
339
  if (cached !== undefined) {
290
340
  return cached;
@@ -324,10 +374,19 @@ export class Styles {
324
374
  const index = this._cellXfs.length;
325
375
  this._cellXfs.push(xf);
326
376
  this._styleCache.set(key, index);
377
+ this._styleObjectCache.set(index, { ...style });
327
378
 
328
379
  return index;
329
380
  }
330
381
 
382
+ /**
383
+ * Clone an existing style by index, optionally overriding fields.
384
+ */
385
+ cloneStyle(index: number, overrides: Partial<CellStyle> = {}): number {
386
+ const baseStyle = this.getStyle(index);
387
+ return this.createStyle({ ...baseStyle, ...overrides });
388
+ }
389
+
331
390
  private _findOrCreateFont(style: CellStyle): number {
332
391
  const font: StyleFont = {
333
392
  bold: style.bold || false,
package/src/types.ts CHANGED
@@ -17,6 +17,11 @@ export type ErrorType = '#NULL!' | '#DIV/0!' | '#VALUE!' | '#REF!' | '#NAME?' |
17
17
  */
18
18
  export type CellType = 'number' | 'string' | 'boolean' | 'date' | 'error' | 'empty';
19
19
 
20
+ /**
21
+ * Date handling strategy when serializing cell values.
22
+ */
23
+ export type DateHandling = 'jsDate' | 'excelSerial' | 'isoString';
24
+
20
25
  /**
21
26
  * Style definition for cells
22
27
  */
@@ -113,6 +118,19 @@ export interface Relationship {
113
118
  */
114
119
  export type AggregationType = 'sum' | 'count' | 'average' | 'min' | 'max';
115
120
 
121
+ /**
122
+ * Sort order for pivot fields.
123
+ */
124
+ export type PivotSortOrder = 'asc' | 'desc';
125
+
126
+ /**
127
+ * Filter configuration for pivot fields.
128
+ */
129
+ export interface PivotFieldFilter {
130
+ include?: string[];
131
+ exclude?: string[];
132
+ }
133
+
116
134
  /**
117
135
  * Configuration for a value field in a pivot table
118
136
  */
@@ -204,3 +222,47 @@ export interface RichCellValue {
204
222
  /** Cell style */
205
223
  style?: CellStyle;
206
224
  }
225
+
226
+ /**
227
+ * Configuration for converting a sheet to JSON objects.
228
+ */
229
+ export interface SheetToJsonConfig {
230
+ /**
231
+ * Field names to use for each column.
232
+ * If provided, the first row of data starts at row 1 (or startRow).
233
+ * If not provided, the first row is used as field names.
234
+ */
235
+ fields?: string[];
236
+
237
+ /**
238
+ * Starting row (0-based). Defaults to 0.
239
+ * If fields are not provided, this row contains the headers.
240
+ * If fields are provided, this is the first data row.
241
+ */
242
+ startRow?: number;
243
+
244
+ /**
245
+ * Starting column (0-based). Defaults to 0.
246
+ */
247
+ startCol?: number;
248
+
249
+ /**
250
+ * Ending row (0-based, inclusive). Defaults to the last row with data.
251
+ */
252
+ endRow?: number;
253
+
254
+ /**
255
+ * Ending column (0-based, inclusive). Defaults to the last column with data.
256
+ */
257
+ endCol?: number;
258
+
259
+ /**
260
+ * If true, stop reading when an empty row is encountered. Defaults to true.
261
+ */
262
+ stopOnEmptyRow?: boolean;
263
+
264
+ /**
265
+ * How to serialize Date values. Defaults to 'jsDate'.
266
+ */
267
+ dateHandling?: DateHandling;
268
+ }
@@ -41,8 +41,11 @@ export const parseAddress = (address: string): CellAddress => {
41
41
  if (!match) {
42
42
  throw new Error(`Invalid cell address: ${address}`);
43
43
  }
44
+ const rowNumber = +match[2];
45
+ if (rowNumber <= 0) throw new Error(`Invalid cell address: ${address}`);
46
+
44
47
  const col = letterToCol(match[1].toUpperCase());
45
- const row = parseInt(match[2], 10) - 1; // Convert to 0-based
48
+ const row = rowNumber - 1; // Convert to 0-based
46
49
  return { row, col };
47
50
  };
48
51
 
package/src/utils/xml.ts CHANGED
@@ -138,10 +138,3 @@ export const createElement = (tagName: string, attrs?: Record<string, string>, c
138
138
  export const createText = (text: string): XmlNode => {
139
139
  return { '#text': text } as unknown as XmlNode;
140
140
  };
141
-
142
- /**
143
- * Adds XML declaration to the start of an XML string
144
- */
145
- export const addXmlDeclaration = (xml: string): string => {
146
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${xml}`;
147
- };
package/src/workbook.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  SheetFromDataConfig,
8
8
  ColumnConfig,
9
9
  RichCellValue,
10
+ DateHandling,
10
11
  } from './types';
11
12
  import { Worksheet } from './worksheet';
12
13
  import { SharedStrings } from './shared-strings';
@@ -34,6 +35,9 @@ export class Workbook {
34
35
  private _pivotCaches: PivotCache[] = [];
35
36
  private _nextCacheId = 0;
36
37
 
38
+ // Date serialization handling
39
+ private _dateHandling: DateHandling = 'jsDate';
40
+
37
41
  private constructor() {
38
42
  this._sharedStrings = new SharedStrings();
39
43
  this._styles = Styles.createDefault();
@@ -119,6 +123,20 @@ export class Workbook {
119
123
  return this._styles;
120
124
  }
121
125
 
126
+ /**
127
+ * Get the workbook date handling strategy.
128
+ */
129
+ get dateHandling(): DateHandling {
130
+ return this._dateHandling;
131
+ }
132
+
133
+ /**
134
+ * Set the workbook date handling strategy.
135
+ */
136
+ set dateHandling(value: DateHandling) {
137
+ this._dateHandling = value;
138
+ }
139
+
122
140
  /**
123
141
  * Get a worksheet by name or index
124
142
  */