@niicojs/excel 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niicojs/excel",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
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
@@ -7,6 +7,8 @@ export { SharedStrings } from './shared-strings';
7
7
  export { Styles } from './styles';
8
8
  export { PivotTable } from './pivot-table';
9
9
  export { PivotCache } from './pivot-cache';
10
+ export { Table } from './table';
11
+ export { parseAddress, toAddress, parseRange, toRange } from './utils/address';
10
12
 
11
13
  // Type exports
12
14
  export type {
@@ -20,11 +22,18 @@ export type {
20
22
  BorderStyle,
21
23
  BorderType,
22
24
  Alignment,
25
+ DateHandling,
23
26
  // Pivot table types
24
27
  PivotTableConfig,
25
28
  PivotValueConfig,
26
29
  AggregationType,
27
30
  PivotFieldAxis,
31
+ PivotSortOrder,
32
+ PivotFieldFilter,
33
+ // Table types
34
+ TableConfig,
35
+ TableStyleConfig,
36
+ TableTotalFunction,
28
37
  // Sheet from data types
29
38
  SheetFromDataConfig,
30
39
  ColumnConfig,
@@ -34,4 +43,3 @@ export type {
34
43
  } from './types';
35
44
 
36
45
  // Utility exports
37
- export { parseAddress, toAddress, parseRange, toRange } from './utils/address';
@@ -7,6 +7,7 @@ import { createElement, stringifyXml, XmlNode } from './utils/xml';
7
7
  */
8
8
  export class PivotCache {
9
9
  private _cacheId: number;
10
+ private _fileIndex: number;
10
11
  private _sourceSheet: string;
11
12
  private _sourceRange: string;
12
13
  private _fields: PivotCacheField[] = [];
@@ -16,8 +17,9 @@ export class PivotCache {
16
17
  // Optimized lookup: Map<fieldIndex, Map<stringValue, sharedItemsIndex>>
17
18
  private _sharedItemsIndexMap: Map<number, Map<string, number>> = new Map();
18
19
 
19
- constructor(cacheId: number, sourceSheet: string, sourceRange: string) {
20
+ constructor(cacheId: number, sourceSheet: string, sourceRange: string, fileIndex: number) {
20
21
  this._cacheId = cacheId;
22
+ this._fileIndex = fileIndex;
21
23
  this._sourceSheet = sourceSheet;
22
24
  this._sourceRange = sourceRange;
23
25
  }
@@ -29,6 +31,13 @@ export class PivotCache {
29
31
  return this._cacheId;
30
32
  }
31
33
 
34
+ /**
35
+ * Get the file index for this cache (used for file naming).
36
+ */
37
+ get fileIndex(): number {
38
+ return this._fileIndex;
39
+ }
40
+
32
41
  /**
33
42
  * Set refreshOnLoad option
34
43
  */
@@ -1,4 +1,4 @@
1
- import type { AggregationType, PivotFieldAxis } from './types';
1
+ import type { AggregationType, PivotFieldAxis, PivotFieldFilter, PivotSortOrder, PivotValueConfig } from './types';
2
2
  import type { Styles } from './styles';
3
3
  import { PivotCache } from './pivot-cache';
4
4
  import { createElement, stringifyXml, XmlNode } from './utils/xml';
@@ -12,7 +12,9 @@ interface FieldAssignment {
12
12
  axis: PivotFieldAxis;
13
13
  aggregation?: AggregationType;
14
14
  displayName?: string;
15
- numberFormat?: string;
15
+ numFmtId?: number;
16
+ sortOrder?: PivotSortOrder;
17
+ filter?: PivotFieldFilter;
16
18
  }
17
19
 
18
20
  /**
@@ -30,8 +32,10 @@ export class PivotTable {
30
32
  private _columnFields: FieldAssignment[] = [];
31
33
  private _valueFields: FieldAssignment[] = [];
32
34
  private _filterFields: FieldAssignment[] = [];
35
+ private _fieldAssignments: Map<number, FieldAssignment> = new Map();
33
36
 
34
37
  private _pivotTableIndex: number;
38
+ private _cacheFileIndex: number;
35
39
  private _styles: Styles | null = null;
36
40
 
37
41
  constructor(
@@ -42,6 +46,7 @@ export class PivotTable {
42
46
  targetRow: number,
43
47
  targetCol: number,
44
48
  pivotTableIndex: number,
49
+ cacheFileIndex: number,
45
50
  ) {
46
51
  this._name = name;
47
52
  this._cache = cache;
@@ -50,6 +55,7 @@ export class PivotTable {
50
55
  this._targetRow = targetRow;
51
56
  this._targetCol = targetCol;
52
57
  this._pivotTableIndex = pivotTableIndex;
58
+ this._cacheFileIndex = cacheFileIndex;
53
59
  }
54
60
 
55
61
  /**
@@ -87,6 +93,14 @@ export class PivotTable {
87
93
  return this._pivotTableIndex;
88
94
  }
89
95
 
96
+ /**
97
+ * Get the pivot cache file index used for rels.
98
+ * @internal
99
+ */
100
+ get cacheFileIndex(): number {
101
+ return this._cacheFileIndex;
102
+ }
103
+
90
104
  /**
91
105
  * Set the styles reference for number format resolution
92
106
  * @internal
@@ -106,11 +120,13 @@ export class PivotTable {
106
120
  throw new Error(`Field not found in source data: ${fieldName}`);
107
121
  }
108
122
 
109
- this._rowFields.push({
123
+ const assignment: FieldAssignment = {
110
124
  fieldName,
111
125
  fieldIndex,
112
126
  axis: 'row',
113
- });
127
+ };
128
+ this._rowFields.push(assignment);
129
+ this._fieldAssignments.set(fieldIndex, assignment);
114
130
 
115
131
  return this;
116
132
  }
@@ -125,43 +141,80 @@ export class PivotTable {
125
141
  throw new Error(`Field not found in source data: ${fieldName}`);
126
142
  }
127
143
 
128
- this._columnFields.push({
144
+ const assignment: FieldAssignment = {
129
145
  fieldName,
130
146
  fieldIndex,
131
147
  axis: 'column',
132
- });
148
+ };
149
+ this._columnFields.push(assignment);
150
+ this._fieldAssignments.set(fieldIndex, assignment);
133
151
 
134
152
  return this;
135
153
  }
136
154
 
137
155
  /**
138
- * Add a field to the values area with aggregation
139
- * @param fieldName - Name of the source field (column header)
140
- * @param aggregation - Aggregation function (sum, count, average, min, max)
141
- * @param displayName - Optional display name (defaults to "Sum of FieldName")
142
- * @param numberFormat - Optional number format (e.g., '$#,##0.00', '0.00%')
156
+ * Add a field to the values area with aggregation.
157
+ *
158
+ * Supports two call signatures:
159
+ * - Positional: `addValueField(fieldName, aggregation?, displayName?, numberFormat?)`
160
+ * - Object: `addValueField({ field, aggregation?, name?, numberFormat? })`
161
+ *
162
+ * @example
163
+ * // Positional arguments
164
+ * pivot.addValueField('Sales', 'sum', 'Total Sales', '$#,##0.00');
165
+ *
166
+ * // Object form
167
+ * pivot.addValueField({ field: 'Sales', aggregation: 'sum', name: 'Total Sales', numberFormat: '$#,##0.00' });
143
168
  */
169
+ addValueField(config: PivotValueConfig): this;
170
+ addValueField(fieldName: string, aggregation?: AggregationType, displayName?: string, numberFormat?: string): this;
144
171
  addValueField(
145
- fieldName: string,
172
+ fieldNameOrConfig: string | PivotValueConfig,
146
173
  aggregation: AggregationType = 'sum',
147
174
  displayName?: string,
148
175
  numberFormat?: string,
149
176
  ): this {
177
+ // Normalize arguments to a common form
178
+ let fieldName: string;
179
+ let agg: AggregationType;
180
+ let name: string | undefined;
181
+ let format: string | undefined;
182
+
183
+ if (typeof fieldNameOrConfig === 'object') {
184
+ fieldName = fieldNameOrConfig.field;
185
+ agg = fieldNameOrConfig.aggregation ?? 'sum';
186
+ name = fieldNameOrConfig.name;
187
+ format = fieldNameOrConfig.numberFormat;
188
+ } else {
189
+ fieldName = fieldNameOrConfig;
190
+ agg = aggregation;
191
+ name = displayName;
192
+ format = numberFormat;
193
+ }
194
+
150
195
  const fieldIndex = this._cache.getFieldIndex(fieldName);
151
196
  if (fieldIndex < 0) {
152
197
  throw new Error(`Field not found in source data: ${fieldName}`);
153
198
  }
154
199
 
155
- const defaultName = `${aggregation.charAt(0).toUpperCase() + aggregation.slice(1)} of ${fieldName}`;
200
+ const defaultName = `${agg.charAt(0).toUpperCase() + agg.slice(1)} of ${fieldName}`;
201
+
202
+ // Resolve numFmtId immediately if format is provided and styles are available
203
+ let numFmtId: number | undefined;
204
+ if (format && this._styles) {
205
+ numFmtId = this._styles.getOrCreateNumFmtId(format);
206
+ }
156
207
 
157
- this._valueFields.push({
208
+ const assignment: FieldAssignment = {
158
209
  fieldName,
159
210
  fieldIndex,
160
211
  axis: 'value',
161
- aggregation,
162
- displayName: displayName || defaultName,
163
- numberFormat,
164
- });
212
+ aggregation: agg,
213
+ displayName: name || defaultName,
214
+ numFmtId,
215
+ };
216
+ this._valueFields.push(assignment);
217
+ this._fieldAssignments.set(fieldIndex, assignment);
165
218
 
166
219
  return this;
167
220
  }
@@ -176,12 +229,61 @@ export class PivotTable {
176
229
  throw new Error(`Field not found in source data: ${fieldName}`);
177
230
  }
178
231
 
179
- this._filterFields.push({
232
+ const assignment: FieldAssignment = {
180
233
  fieldName,
181
234
  fieldIndex,
182
235
  axis: 'filter',
183
- });
236
+ };
237
+ this._filterFields.push(assignment);
238
+ this._fieldAssignments.set(fieldIndex, assignment);
239
+
240
+ return this;
241
+ }
242
+
243
+ /**
244
+ * Set a sort order for a row or column field
245
+ * @param fieldName - Name of the field to sort
246
+ * @param order - Sort order ('asc' or 'desc')
247
+ */
248
+ sortField(fieldName: string, order: PivotSortOrder): this {
249
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
250
+ if (fieldIndex < 0) {
251
+ throw new Error(`Field not found in source data: ${fieldName}`);
252
+ }
253
+
254
+ const assignment = this._fieldAssignments.get(fieldIndex);
255
+ if (!assignment) {
256
+ throw new Error(`Field is not assigned to pivot table: ${fieldName}`);
257
+ }
258
+ if (assignment.axis !== 'row' && assignment.axis !== 'column') {
259
+ throw new Error(`Sort is only supported for row or column fields: ${fieldName}`);
260
+ }
261
+
262
+ assignment.sortOrder = order;
263
+ return this;
264
+ }
265
+
266
+ /**
267
+ * Filter items for a row, column, or filter field
268
+ * @param fieldName - Name of the field to filter
269
+ * @param filter - Filter configuration with include or exclude list
270
+ */
271
+ filterField(fieldName: string, filter: PivotFieldFilter): this {
272
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
273
+ if (fieldIndex < 0) {
274
+ throw new Error(`Field not found in source data: ${fieldName}`);
275
+ }
276
+
277
+ const assignment = this._fieldAssignments.get(fieldIndex);
278
+ if (!assignment) {
279
+ throw new Error(`Field is not assigned to pivot table: ${fieldName}`);
280
+ }
281
+
282
+ if (filter.include && filter.exclude) {
283
+ throw new Error('Cannot use both include and exclude in the same filter');
284
+ }
184
285
 
286
+ assignment.filter = filter;
185
287
  return this;
186
288
  }
187
289
 
@@ -279,9 +381,9 @@ export class PivotTable {
279
381
  subtotal: f.aggregation || 'sum',
280
382
  };
281
383
 
282
- // Add numFmtId if format specified and styles available
283
- if (f.numberFormat && this._styles) {
284
- attrs.numFmtId = String(this._styles.getOrCreateNumFmtId(f.numberFormat));
384
+ // Add numFmtId if it was resolved during addValueField
385
+ if (f.numFmtId !== undefined) {
386
+ attrs.numFmtId = String(f.numFmtId);
285
387
  }
286
388
 
287
389
  return createElement('dataField', attrs, []);
@@ -290,7 +392,7 @@ export class PivotTable {
290
392
  }
291
393
 
292
394
  // Check if any value field has a number format
293
- const hasNumberFormats = this._valueFields.some((f) => f.numberFormat);
395
+ const hasNumberFormats = this._valueFields.some((f) => f.numFmtId !== undefined);
294
396
 
295
397
  // Pivot table style
296
398
  children.push(
@@ -353,30 +455,36 @@ export class PivotTable {
353
455
  const filterField = this._filterFields.find((f) => f.fieldIndex === fieldIndex);
354
456
  const valueField = this._valueFields.find((f) => f.fieldIndex === fieldIndex);
355
457
 
458
+ // Get the assignment to check for sort/filter options
459
+ const assignment = rowField || colField || filterField;
460
+
356
461
  if (rowField) {
357
462
  attrs.axis = 'axisRow';
358
463
  attrs.showAll = '0';
464
+
465
+ // Add sort order if specified
466
+ if (rowField.sortOrder) {
467
+ attrs.sortType = rowField.sortOrder === 'asc' ? 'ascending' : 'descending';
468
+ }
469
+
359
470
  // Add items for shared values
360
471
  const cacheField = this._cache.fields[fieldIndex];
361
472
  if (cacheField && cacheField.sharedItems.length > 0) {
362
- const itemNodes: XmlNode[] = [];
363
- for (let i = 0; i < cacheField.sharedItems.length; i++) {
364
- itemNodes.push(createElement('item', { x: String(i) }, []));
365
- }
366
- // Add default subtotal item
367
- itemNodes.push(createElement('item', { t: 'default' }, []));
473
+ const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
368
474
  children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
369
475
  }
370
476
  } else if (colField) {
371
477
  attrs.axis = 'axisCol';
372
478
  attrs.showAll = '0';
479
+
480
+ // Add sort order if specified
481
+ if (colField.sortOrder) {
482
+ attrs.sortType = colField.sortOrder === 'asc' ? 'ascending' : 'descending';
483
+ }
484
+
373
485
  const cacheField = this._cache.fields[fieldIndex];
374
486
  if (cacheField && cacheField.sharedItems.length > 0) {
375
- const itemNodes: XmlNode[] = [];
376
- for (let i = 0; i < cacheField.sharedItems.length; i++) {
377
- itemNodes.push(createElement('item', { x: String(i) }, []));
378
- }
379
- itemNodes.push(createElement('item', { t: 'default' }, []));
487
+ const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
380
488
  children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
381
489
  }
382
490
  } else if (filterField) {
@@ -384,11 +492,7 @@ export class PivotTable {
384
492
  attrs.showAll = '0';
385
493
  const cacheField = this._cache.fields[fieldIndex];
386
494
  if (cacheField && cacheField.sharedItems.length > 0) {
387
- const itemNodes: XmlNode[] = [];
388
- for (let i = 0; i < cacheField.sharedItems.length; i++) {
389
- itemNodes.push(createElement('item', { x: String(i) }, []));
390
- }
391
- itemNodes.push(createElement('item', { t: 'default' }, []));
495
+ const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
392
496
  children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
393
497
  }
394
498
  } else if (valueField) {
@@ -401,6 +505,38 @@ export class PivotTable {
401
505
  return createElement('pivotField', attrs, children);
402
506
  }
403
507
 
508
+ /**
509
+ * Build item nodes for a pivot field, with optional filtering
510
+ */
511
+ private _buildItemNodes(sharedItems: string[], filter?: PivotFieldFilter): XmlNode[] {
512
+ const itemNodes: XmlNode[] = [];
513
+
514
+ for (let i = 0; i < sharedItems.length; i++) {
515
+ const itemValue = sharedItems[i];
516
+ const itemAttrs: Record<string, string> = { x: String(i) };
517
+
518
+ // Check if this item should be hidden
519
+ if (filter) {
520
+ let hidden = false;
521
+ if (filter.exclude && filter.exclude.includes(itemValue)) {
522
+ hidden = true;
523
+ } else if (filter.include && !filter.include.includes(itemValue)) {
524
+ hidden = true;
525
+ }
526
+ if (hidden) {
527
+ itemAttrs.h = '1';
528
+ }
529
+ }
530
+
531
+ itemNodes.push(createElement('item', itemAttrs, []));
532
+ }
533
+
534
+ // Add default subtotal item
535
+ itemNodes.push(createElement('item', { t: 'default' }, []));
536
+
537
+ return itemNodes;
538
+ }
539
+
404
540
  /**
405
541
  * Build row items based on unique values in row fields
406
542
  */
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
  }
@@ -1,13 +1,23 @@
1
- import { parseXml, findElement, getChildren, XmlNode, stringifyXml, createElement, createText } from './utils/xml';
1
+ import {
2
+ parseXml,
3
+ findElement,
4
+ getChildren,
5
+ getAttr,
6
+ XmlNode,
7
+ stringifyXml,
8
+ createElement,
9
+ createText,
10
+ } from './utils/xml';
2
11
 
3
12
  /**
4
13
  * Manages the shared strings table from xl/sharedStrings.xml
5
14
  * Excel stores strings in a shared table to reduce file size
6
15
  */
7
16
  export class SharedStrings {
8
- private strings: string[] = [];
17
+ private entries: SharedStringEntry[] = [];
9
18
  private stringToIndex: Map<string, number> = new Map();
10
19
  private _dirty = false;
20
+ private _totalCount = 0;
11
21
 
12
22
  /**
13
23
  * Parse shared strings from XML content
@@ -18,16 +28,28 @@ export class SharedStrings {
18
28
  const sst = findElement(parsed, 'sst');
19
29
  if (!sst) return ss;
20
30
 
31
+ const countAttr = getAttr(sst, 'count');
32
+ if (countAttr) {
33
+ const total = parseInt(countAttr, 10);
34
+ if (Number.isFinite(total) && total >= 0) {
35
+ ss._totalCount = total;
36
+ }
37
+ }
38
+
21
39
  const children = getChildren(sst, 'sst');
22
40
  for (const child of children) {
23
41
  if ('si' in child) {
24
42
  const siChildren = getChildren(child, 'si');
25
43
  const text = ss.extractText(siChildren);
26
- ss.strings.push(text);
27
- ss.stringToIndex.set(text, ss.strings.length - 1);
44
+ ss.entries.push({ text, node: child });
45
+ ss.stringToIndex.set(text, ss.entries.length - 1);
28
46
  }
29
47
  }
30
48
 
49
+ if (ss._totalCount === 0 && ss.entries.length > 0) {
50
+ ss._totalCount = ss.entries.length;
51
+ }
52
+
31
53
  return ss;
32
54
  }
33
55
 
@@ -68,7 +90,7 @@ export class SharedStrings {
68
90
  * Get a string by index
69
91
  */
70
92
  getString(index: number): string | undefined {
71
- return this.strings[index];
93
+ return this.entries[index]?.text;
72
94
  }
73
95
 
74
96
  /**
@@ -78,11 +100,18 @@ export class SharedStrings {
78
100
  addString(str: string): number {
79
101
  const existing = this.stringToIndex.get(str);
80
102
  if (existing !== undefined) {
103
+ this._totalCount++;
104
+ this._dirty = true;
81
105
  return existing;
82
106
  }
83
- const index = this.strings.length;
84
- this.strings.push(str);
107
+ const index = this.entries.length;
108
+ const tElement = createElement('t', str.startsWith(' ') || str.endsWith(' ') ? { 'xml:space': 'preserve' } : {}, [
109
+ createText(str),
110
+ ]);
111
+ const siElement = createElement('si', {}, [tElement]);
112
+ this.entries.push({ text: str, node: siElement });
85
113
  this.stringToIndex.set(str, index);
114
+ this._totalCount++;
86
115
  this._dirty = true;
87
116
  return index;
88
117
  }
@@ -98,7 +127,14 @@ export class SharedStrings {
98
127
  * Get the count of strings
99
128
  */
100
129
  get count(): number {
101
- return this.strings.length;
130
+ return this.entries.length;
131
+ }
132
+
133
+ /**
134
+ * Get total usage count of shared strings
135
+ */
136
+ get totalCount(): number {
137
+ return Math.max(this._totalCount, this.entries.length);
102
138
  }
103
139
 
104
140
  /**
@@ -106,20 +142,28 @@ export class SharedStrings {
106
142
  */
107
143
  toXml(): string {
108
144
  const siElements: XmlNode[] = [];
109
- for (const str of this.strings) {
110
- const tElement = createElement('t', str.startsWith(' ') || str.endsWith(' ') ? { 'xml:space': 'preserve' } : {}, [
111
- createText(str),
112
- ]);
113
- const siElement = createElement('si', {}, [tElement]);
114
- siElements.push(siElement);
145
+ for (const entry of this.entries) {
146
+ if (entry.node) {
147
+ siElements.push(entry.node);
148
+ } else {
149
+ const str = entry.text;
150
+ const tElement = createElement(
151
+ 't',
152
+ str.startsWith(' ') || str.endsWith(' ') ? { 'xml:space': 'preserve' } : {},
153
+ [createText(str)],
154
+ );
155
+ const siElement = createElement('si', {}, [tElement]);
156
+ siElements.push(siElement);
157
+ }
115
158
  }
116
159
 
160
+ const totalCount = Math.max(this._totalCount, this.entries.length);
117
161
  const sst = createElement(
118
162
  'sst',
119
163
  {
120
164
  xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
121
- count: String(this.strings.length),
122
- uniqueCount: String(this.strings.length),
165
+ count: String(totalCount),
166
+ uniqueCount: String(this.entries.length),
123
167
  },
124
168
  siElements,
125
169
  );
@@ -127,3 +171,8 @@ export class SharedStrings {
127
171
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([sst])}`;
128
172
  }
129
173
  }
174
+
175
+ interface SharedStringEntry {
176
+ text: string;
177
+ node?: XmlNode;
178
+ }