@niicojs/excel 0.3.4 → 0.3.6

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.
@@ -1,396 +1,480 @@
1
- import type { AggregationType, PivotFieldAxis, PivotFieldFilter, PivotSortOrder, PivotValueConfig } from './types';
2
- import type { Styles } from './styles';
3
- import { PivotCache } from './pivot-cache';
1
+ import type {
2
+ PivotAggregationType,
3
+ PivotFieldFilter,
4
+ PivotSortOrder,
5
+ PivotTableConfig,
6
+ PivotValueConfig,
7
+ RangeAddress,
8
+ CellValue,
9
+ } from './types';
10
+ import type { Workbook } from './workbook';
11
+ import type { Worksheet } from './worksheet';
4
12
  import { createElement, stringifyXml, XmlNode } from './utils/xml';
13
+ import { toAddress, toRange } from './utils/address';
14
+
15
+ const AGGREGATION_TO_XML: Record<PivotAggregationType, string> = {
16
+ sum: 'sum',
17
+ count: 'count',
18
+ average: 'average',
19
+ min: 'min',
20
+ max: 'max',
21
+ };
22
+
23
+ const SORT_TO_XML: Record<PivotSortOrder, 'ascending' | 'descending'> = {
24
+ asc: 'ascending',
25
+ desc: 'descending',
26
+ };
27
+
28
+ interface PivotValueField {
29
+ field: string;
30
+ aggregation: PivotAggregationType;
31
+ name: string;
32
+ numberFormat?: string;
33
+ }
5
34
 
6
- /**
7
- * Internal structure for tracking field assignments
8
- */
9
- interface FieldAssignment {
10
- fieldName: string;
11
- fieldIndex: number;
12
- axis: PivotFieldAxis;
13
- aggregation?: AggregationType;
14
- displayName?: string;
15
- numFmtId?: number;
16
- sortOrder?: PivotSortOrder;
17
- filter?: PivotFieldFilter;
35
+ interface PivotFieldMeta {
36
+ name: string;
37
+ sourceCol: number;
38
+ }
39
+
40
+ interface PivotNumericInfo {
41
+ nonNullCount: number;
42
+ numericCount: number;
43
+ min: number;
44
+ max: number;
45
+ hasNumeric: boolean;
46
+ allIntegers: boolean;
47
+ }
48
+
49
+ interface PivotCacheData {
50
+ rowCount: number;
51
+ recordNodes: XmlNode[];
52
+ sharedItemIndexByField: Array<Map<string, number> | null>;
53
+ sharedItemsByField: Array<XmlNode[] | null>;
54
+ distinctItemsByField: Array<Exclude<CellValue, null>[] | null>;
55
+ numericInfoByField: PivotNumericInfo[];
56
+ isAxisFieldByIndex: boolean[];
57
+ isValueFieldByIndex: boolean[];
18
58
  }
19
59
 
20
60
  /**
21
- * Represents an Excel pivot table with a fluent API for configuration.
61
+ * Represents an Excel PivotTable with a fluent configuration API.
22
62
  */
23
63
  export class PivotTable {
64
+ private _workbook: Workbook;
24
65
  private _name: string;
25
- private _cache: PivotCache;
26
- private _targetSheet: string;
27
- private _targetCell: string;
28
- private _targetRow: number;
29
- private _targetCol: number;
30
-
31
- private _rowFields: FieldAssignment[] = [];
32
- private _columnFields: FieldAssignment[] = [];
33
- private _valueFields: FieldAssignment[] = [];
34
- private _filterFields: FieldAssignment[] = [];
35
- private _fieldAssignments: Map<number, FieldAssignment> = new Map();
36
-
37
- private _pivotTableIndex: number;
38
- private _cacheFileIndex: number;
39
- private _styles: Styles | null = null;
66
+ private _sourceSheetName: string;
67
+ private _sourceSheet: Worksheet;
68
+ private _sourceRange: RangeAddress;
69
+ private _targetSheetName: string;
70
+ private _targetCell: { row: number; col: number };
71
+ private _refreshOnLoad: boolean;
72
+ private _cacheId: number;
73
+ private _pivotId: number;
74
+ private _cachePartIndex: number;
75
+ private _fields: PivotFieldMeta[];
76
+
77
+ private _rowFields: string[] = [];
78
+ private _columnFields: string[] = [];
79
+ private _filterFields: string[] = [];
80
+ private _valueFields: PivotValueField[] = [];
81
+ private _sortOrders: Map<string, PivotSortOrder> = new Map();
82
+ private _filters: Map<string, PivotFieldFilter> = new Map();
40
83
 
41
84
  constructor(
42
- name: string,
43
- cache: PivotCache,
44
- targetSheet: string,
45
- targetCell: string,
46
- targetRow: number,
47
- targetCol: number,
48
- pivotTableIndex: number,
49
- cacheFileIndex: number,
85
+ workbook: Workbook,
86
+ config: PivotTableConfig,
87
+ sourceSheetName: string,
88
+ sourceSheet: Worksheet,
89
+ sourceRange: RangeAddress,
90
+ targetSheetName: string,
91
+ targetCell: { row: number; col: number },
92
+ cacheId: number,
93
+ pivotId: number,
94
+ cachePartIndex: number,
95
+ fields: PivotFieldMeta[],
50
96
  ) {
51
- this._name = name;
52
- this._cache = cache;
53
- this._targetSheet = targetSheet;
97
+ this._workbook = workbook;
98
+ this._name = config.name;
99
+ this._sourceSheetName = sourceSheetName;
100
+ this._sourceSheet = sourceSheet;
101
+ this._sourceRange = sourceRange;
102
+ this._targetSheetName = targetSheetName;
54
103
  this._targetCell = targetCell;
55
- this._targetRow = targetRow;
56
- this._targetCol = targetCol;
57
- this._pivotTableIndex = pivotTableIndex;
58
- this._cacheFileIndex = cacheFileIndex;
104
+ this._refreshOnLoad = config.refreshOnLoad !== false;
105
+ this._cacheId = cacheId;
106
+ this._pivotId = pivotId;
107
+ this._cachePartIndex = cachePartIndex;
108
+ this._fields = fields;
59
109
  }
60
110
 
61
- /**
62
- * Get the pivot table name
63
- */
64
111
  get name(): string {
65
112
  return this._name;
66
113
  }
67
114
 
68
- /**
69
- * Get the target sheet name
70
- */
71
- get targetSheet(): string {
72
- return this._targetSheet;
115
+ get sourceSheetName(): string {
116
+ return this._sourceSheetName;
73
117
  }
74
118
 
75
- /**
76
- * Get the target cell address
77
- */
78
- get targetCell(): string {
79
- return this._targetCell;
119
+ get sourceRange(): RangeAddress {
120
+ return { start: { ...this._sourceRange.start }, end: { ...this._sourceRange.end } };
80
121
  }
81
122
 
82
- /**
83
- * Get the pivot cache
84
- */
85
- get cache(): PivotCache {
86
- return this._cache;
123
+ get targetSheetName(): string {
124
+ return this._targetSheetName;
87
125
  }
88
126
 
89
- /**
90
- * Get the pivot table index (for file naming)
91
- */
92
- get index(): number {
93
- return this._pivotTableIndex;
127
+ get targetCell(): { row: number; col: number } {
128
+ return { ...this._targetCell };
94
129
  }
95
130
 
96
- /**
97
- * Get the pivot cache file index used for rels.
98
- * @internal
99
- */
100
- get cacheFileIndex(): number {
101
- return this._cacheFileIndex;
131
+ get refreshOnLoad(): boolean {
132
+ return this._refreshOnLoad;
102
133
  }
103
134
 
104
- /**
105
- * Set the styles reference for number format resolution
106
- * @internal
107
- */
108
- setStyles(styles: Styles): this {
109
- this._styles = styles;
110
- return this;
135
+ get cacheId(): number {
136
+ return this._cacheId;
111
137
  }
112
138
 
113
- /**
114
- * Add a field to the row area
115
- * @param fieldName - Name of the source field (column header)
116
- */
117
- addRowField(fieldName: string): this {
118
- const fieldIndex = this._cache.getFieldIndex(fieldName);
119
- if (fieldIndex < 0) {
120
- throw new Error(`Field not found in source data: ${fieldName}`);
121
- }
139
+ get pivotId(): number {
140
+ return this._pivotId;
141
+ }
122
142
 
123
- const assignment: FieldAssignment = {
124
- fieldName,
125
- fieldIndex,
126
- axis: 'row',
127
- };
128
- this._rowFields.push(assignment);
129
- this._fieldAssignments.set(fieldIndex, assignment);
143
+ get cachePartIndex(): number {
144
+ return this._cachePartIndex;
145
+ }
130
146
 
147
+ addRowField(fieldName: string): this {
148
+ this._assertFieldExists(fieldName);
149
+ if (!this._rowFields.includes(fieldName)) {
150
+ this._rowFields.push(fieldName);
151
+ }
131
152
  return this;
132
153
  }
133
154
 
134
- /**
135
- * Add a field to the column area
136
- * @param fieldName - Name of the source field (column header)
137
- */
138
155
  addColumnField(fieldName: string): this {
139
- const fieldIndex = this._cache.getFieldIndex(fieldName);
140
- if (fieldIndex < 0) {
141
- throw new Error(`Field not found in source data: ${fieldName}`);
156
+ this._assertFieldExists(fieldName);
157
+ if (!this._columnFields.includes(fieldName)) {
158
+ this._columnFields.push(fieldName);
142
159
  }
160
+ return this;
161
+ }
143
162
 
144
- const assignment: FieldAssignment = {
145
- fieldName,
146
- fieldIndex,
147
- axis: 'column',
148
- };
149
- this._columnFields.push(assignment);
150
- this._fieldAssignments.set(fieldIndex, assignment);
151
-
163
+ addFilterField(fieldName: string): this {
164
+ this._assertFieldExists(fieldName);
165
+ if (!this._filterFields.includes(fieldName)) {
166
+ this._filterFields.push(fieldName);
167
+ }
152
168
  return this;
153
169
  }
154
170
 
155
- /**
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' });
168
- */
171
+ addValueField(
172
+ fieldName: string,
173
+ aggregation?: PivotAggregationType,
174
+ displayName?: string,
175
+ numberFormat?: string,
176
+ ): this;
169
177
  addValueField(config: PivotValueConfig): this;
170
- addValueField(fieldName: string, aggregation?: AggregationType, displayName?: string, numberFormat?: string): this;
171
178
  addValueField(
172
179
  fieldNameOrConfig: string | PivotValueConfig,
173
- aggregation: AggregationType = 'sum',
180
+ aggregation: PivotAggregationType = 'sum',
174
181
  displayName?: string,
175
182
  numberFormat?: string,
176
183
  ): 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;
184
+ let config: PivotValueConfig;
185
+
186
+ if (typeof fieldNameOrConfig === 'string') {
187
+ config = {
188
+ field: fieldNameOrConfig,
189
+ aggregation,
190
+ name: displayName,
191
+ numberFormat,
192
+ };
188
193
  } else {
189
- fieldName = fieldNameOrConfig;
190
- agg = aggregation;
191
- name = displayName;
192
- format = numberFormat;
193
- }
194
-
195
- const fieldIndex = this._cache.getFieldIndex(fieldName);
196
- if (fieldIndex < 0) {
197
- throw new Error(`Field not found in source data: ${fieldName}`);
194
+ config = fieldNameOrConfig;
198
195
  }
199
196
 
200
- const defaultName = `${agg.charAt(0).toUpperCase() + agg.slice(1)} of ${fieldName}`;
197
+ this._assertFieldExists(config.field);
201
198
 
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
- }
199
+ const resolvedAggregation = config.aggregation ?? 'sum';
200
+ const resolvedName = config.name ?? `${this._aggregationLabel(resolvedAggregation)} of ${config.field}`;
207
201
 
208
- const assignment: FieldAssignment = {
209
- fieldName,
210
- fieldIndex,
211
- axis: 'value',
212
- aggregation: agg,
213
- displayName: name || defaultName,
214
- numFmtId,
215
- };
216
- this._valueFields.push(assignment);
217
- this._fieldAssignments.set(fieldIndex, assignment);
202
+ this._valueFields.push({
203
+ field: config.field,
204
+ aggregation: resolvedAggregation,
205
+ name: resolvedName,
206
+ numberFormat: config.numberFormat,
207
+ });
218
208
 
219
209
  return this;
220
210
  }
221
211
 
222
- /**
223
- * Add a field to the filter (page) area
224
- * @param fieldName - Name of the source field (column header)
225
- */
226
- addFilterField(fieldName: string): this {
227
- const fieldIndex = this._cache.getFieldIndex(fieldName);
228
- if (fieldIndex < 0) {
229
- throw new Error(`Field not found in source data: ${fieldName}`);
212
+ sortField(fieldName: string, order: PivotSortOrder): this {
213
+ this._assertFieldExists(fieldName);
214
+ if (!this._rowFields.includes(fieldName) && !this._columnFields.includes(fieldName)) {
215
+ throw new Error(`Cannot sort field "${fieldName}": only row or column fields can be sorted`);
230
216
  }
231
-
232
- const assignment: FieldAssignment = {
233
- fieldName,
234
- fieldIndex,
235
- axis: 'filter',
236
- };
237
- this._filterFields.push(assignment);
238
- this._fieldAssignments.set(fieldIndex, assignment);
239
-
217
+ this._sortOrders.set(fieldName, order);
240
218
  return this;
241
219
  }
242
220
 
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
- }
221
+ filterField(fieldName: string, filter: PivotFieldFilter): this {
222
+ this._assertFieldExists(fieldName);
253
223
 
254
- const assignment = this._fieldAssignments.get(fieldIndex);
255
- if (!assignment) {
256
- throw new Error(`Field is not assigned to pivot table: ${fieldName}`);
224
+ const hasInclude = 'include' in filter;
225
+ const hasExclude = 'exclude' in filter;
226
+ if ((hasInclude && hasExclude) || (!hasInclude && !hasExclude)) {
227
+ throw new Error('Pivot filter must contain either include or exclude');
257
228
  }
258
- if (assignment.axis !== 'row' && assignment.axis !== 'column') {
259
- throw new Error(`Sort is only supported for row or column fields: ${fieldName}`);
229
+
230
+ const values = hasInclude ? filter.include : filter.exclude;
231
+ if (!values || values.length === 0) {
232
+ throw new Error('Pivot filter values cannot be empty');
260
233
  }
261
234
 
262
- assignment.sortOrder = order;
235
+ this._filters.set(fieldName, filter);
263
236
  return this;
264
237
  }
265
238
 
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
- }
239
+ toPivotCacheDefinitionXml(): string {
240
+ const cacheData = this._buildPivotCacheData();
241
+ return this._buildPivotCacheDefinitionXml(cacheData);
242
+ }
276
243
 
277
- const assignment = this._fieldAssignments.get(fieldIndex);
278
- if (!assignment) {
279
- throw new Error(`Field is not assigned to pivot table: ${fieldName}`);
280
- }
244
+ toPivotCacheRecordsXml(): string {
245
+ const cacheData = this._buildPivotCacheData();
246
+ return this._buildPivotCacheRecordsXml(cacheData);
247
+ }
281
248
 
282
- if (filter.include && filter.exclude) {
283
- throw new Error('Cannot use both include and exclude in the same filter');
284
- }
249
+ toPivotCacheDefinitionRelsXml(): string {
250
+ const relsRoot = createElement(
251
+ 'Relationships',
252
+ { xmlns: 'http://schemas.openxmlformats.org/package/2006/relationships' },
253
+ [
254
+ createElement(
255
+ 'Relationship',
256
+ {
257
+ Id: 'rId1',
258
+ Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords',
259
+ Target: `pivotCacheRecords${this._cachePartIndex}.xml`,
260
+ },
261
+ [],
262
+ ),
263
+ ],
264
+ );
285
265
 
286
- assignment.filter = filter;
287
- return this;
266
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([relsRoot])}`;
288
267
  }
289
268
 
290
269
  /**
291
- * Generate the pivotTableDefinition XML
270
+ * @internal
292
271
  */
293
- toXml(): string {
294
- const children: XmlNode[] = [];
272
+ buildPivotPartsXml(): {
273
+ cacheDefinitionXml: string;
274
+ cacheRecordsXml: string;
275
+ cacheRelsXml: string;
276
+ pivotTableXml: string;
277
+ } {
278
+ const cacheData = this._buildPivotCacheData();
279
+ return {
280
+ cacheDefinitionXml: this._buildPivotCacheDefinitionXml(cacheData),
281
+ cacheRecordsXml: this._buildPivotCacheRecordsXml(cacheData),
282
+ cacheRelsXml: this.toPivotCacheDefinitionRelsXml(),
283
+ pivotTableXml: this._buildPivotTableDefinitionXml(cacheData),
284
+ };
285
+ }
286
+
287
+ toPivotTableDefinitionXml(): string {
288
+ const cacheData = this._buildPivotCacheData();
289
+ return this._buildPivotTableDefinitionXml(cacheData);
290
+ }
291
+
292
+ private _buildPivotCacheDefinitionXml(cacheData: PivotCacheData): string {
293
+ const cacheFieldNodes = this._fields.map((field, index) => this._buildCacheFieldNode(field, index, cacheData));
294
+
295
+ const attrs: Record<string, string> = {
296
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
297
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
298
+ 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
299
+ 'mc:Ignorable': 'xr',
300
+ 'xmlns:xr': 'http://schemas.microsoft.com/office/spreadsheetml/2014/revision',
301
+ 'r:id': 'rId1',
302
+ createdVersion: '8',
303
+ minRefreshableVersion: '3',
304
+ refreshedVersion: '8',
305
+ refreshOnLoad: this._refreshOnLoad ? '1' : '0',
306
+ recordCount: String(cacheData.rowCount),
307
+ };
308
+
309
+ const cacheSourceNode = createElement('cacheSource', { type: 'worksheet' }, [
310
+ createElement('worksheetSource', { sheet: this._sourceSheetName, ref: toRange(this._sourceRange) }, []),
311
+ ]);
295
312
 
296
- // Calculate location (estimate based on fields)
297
- const locationRef = this._calculateLocationRef();
313
+ const cacheFieldsNode = createElement('cacheFields', { count: String(cacheFieldNodes.length) }, cacheFieldNodes);
298
314
 
299
- // Calculate first data row/col offsets (1-based, relative to pivot table)
300
- // firstHeaderRow: row offset of column headers (usually 1)
301
- // firstDataRow: row offset where data starts (after filters and column headers)
302
- // firstDataCol: column offset where data starts (after row labels)
303
- const filterRowCount = this._filterFields.length > 0 ? this._filterFields.length + 1 : 0;
304
- const headerRows = this._columnFields.length > 0 ? 1 : 0;
305
- const firstDataRow = filterRowCount + headerRows + 1;
306
- const firstDataCol = this._rowFields.length > 0 ? this._rowFields.length : 1;
315
+ const extLstNode = createElement('extLst', {}, [
316
+ createElement(
317
+ 'ext',
318
+ {
319
+ uri: '{725AE2AE-9491-48be-B2B4-4EB974FC3084}',
320
+ 'xmlns:x14': 'http://schemas.microsoft.com/office/spreadsheetml/2009/9/main',
321
+ },
322
+ [createElement('x14:pivotCacheDefinition', {}, [])],
323
+ ),
324
+ ]);
325
+
326
+ const root = createElement('pivotCacheDefinition', attrs, [cacheSourceNode, cacheFieldsNode, extLstNode]);
327
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([root])}`;
328
+ }
307
329
 
308
- const locationNode = createElement(
309
- 'location',
330
+ private _buildPivotCacheRecordsXml(cacheData: PivotCacheData): string {
331
+ const recordNodes = cacheData.recordNodes;
332
+ const root = createElement(
333
+ 'pivotCacheRecords',
310
334
  {
311
- ref: locationRef,
312
- firstHeaderRow: String(filterRowCount + 1),
313
- firstDataRow: String(firstDataRow),
314
- firstDataCol: String(firstDataCol),
335
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
336
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
337
+ 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006',
338
+ 'mc:Ignorable': 'xr',
339
+ 'xmlns:xr': 'http://schemas.microsoft.com/office/spreadsheetml/2014/revision',
340
+ count: String(recordNodes.length),
315
341
  },
316
- [],
342
+ recordNodes,
343
+ );
344
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([root])}`;
345
+ }
346
+
347
+ private _buildPivotTableDefinitionXml(cacheData: PivotCacheData): string {
348
+ const effectiveValueFields = this._valueFields.length > 0 ? [this._valueFields[0]] : [];
349
+ const sourceFieldCount = this._fields.length;
350
+ const pivotFields: XmlNode[] = [];
351
+ const effectiveRowFieldName = this._rowFields[0];
352
+ const rowFieldIndexes = effectiveRowFieldName ? [this._fieldIndex(effectiveRowFieldName)] : [];
353
+ const colFieldIndexes = this._columnFields.length > 0 ? [this._fieldIndex(this._columnFields[0])] : [];
354
+ const valueFieldIndexes = new Set<number>(
355
+ effectiveValueFields.map((valueField) => this._fieldIndex(valueField.field)),
317
356
  );
318
- children.push(locationNode);
319
357
 
320
- // Build pivotFields (one per source field)
321
- const pivotFieldNodes: XmlNode[] = [];
322
- for (const cacheField of this._cache.fields) {
323
- const fieldNode = this._buildPivotFieldNode(cacheField.index);
324
- pivotFieldNodes.push(fieldNode);
358
+ for (let index = 0; index < this._fields.length; index++) {
359
+ const field = this._fields[index];
360
+ const attrs: Record<string, string> = { showAll: '0' };
361
+
362
+ if (rowFieldIndexes.includes(index)) {
363
+ attrs.axis = 'axisRow';
364
+ } else if (colFieldIndexes.includes(index)) {
365
+ attrs.axis = 'axisCol';
366
+ }
367
+
368
+ if (valueFieldIndexes.has(index)) {
369
+ attrs.dataField = '1';
370
+ }
371
+
372
+ const sortOrder = this._sortOrders.get(field.name);
373
+ if (sortOrder) {
374
+ attrs.sortType = SORT_TO_XML[sortOrder];
375
+ }
376
+
377
+ const children: XmlNode[] = [];
378
+ if (rowFieldIndexes.includes(index) || colFieldIndexes.includes(index)) {
379
+ const distinctItems = cacheData.distinctItemsByField[index] ?? [];
380
+ const itemNodes: XmlNode[] = distinctItems.map((_item, itemIndex) =>
381
+ createElement('item', { x: String(itemIndex) }, []),
382
+ );
383
+ itemNodes.push(createElement('item', { t: 'default' }, []));
384
+ children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
385
+ }
386
+
387
+ pivotFields.push(createElement('pivotField', attrs, children));
325
388
  }
326
- children.push(createElement('pivotFields', { count: String(pivotFieldNodes.length) }, pivotFieldNodes));
327
389
 
328
- // Row fields
329
- if (this._rowFields.length > 0) {
330
- const rowFieldNodes = this._rowFields.map((f) => createElement('field', { x: String(f.fieldIndex) }, []));
331
- children.push(createElement('rowFields', { count: String(rowFieldNodes.length) }, rowFieldNodes));
390
+ const children: XmlNode[] = [];
391
+
392
+ const locationRef = this._buildTargetAreaRef(cacheData);
393
+ children.push(
394
+ createElement(
395
+ 'location',
396
+ {
397
+ ref: locationRef,
398
+ firstHeaderRow: '1',
399
+ firstDataRow: '1',
400
+ firstDataCol: String(Math.max(1, this._rowFields.length + 1)),
401
+ },
402
+ [],
403
+ ),
404
+ );
405
+
406
+ children.push(createElement('pivotFields', { count: String(sourceFieldCount) }, pivotFields));
332
407
 
333
- // Row items
334
- const rowItemNodes = this._buildRowItems();
408
+ if (rowFieldIndexes.length > 0) {
409
+ children.push(
410
+ createElement(
411
+ 'rowFields',
412
+ { count: String(rowFieldIndexes.length) },
413
+ rowFieldIndexes.map((fieldIndex) => createElement('field', { x: String(fieldIndex) }, [])),
414
+ ),
415
+ );
416
+
417
+ const distinctRowItems = cacheData.distinctItemsByField[rowFieldIndexes[0]] ?? [];
418
+ const rowItemNodes: XmlNode[] = [];
419
+ if (distinctRowItems.length > 0) {
420
+ rowItemNodes.push(createElement('i', {}, [createElement('x', {}, [])]));
421
+ for (let itemIndex = 1; itemIndex < distinctRowItems.length; itemIndex++) {
422
+ rowItemNodes.push(createElement('i', {}, [createElement('x', { v: String(itemIndex) }, [])]));
423
+ }
424
+ }
425
+ rowItemNodes.push(createElement('i', { t: 'grand' }, [createElement('x', {}, [])]));
335
426
  children.push(createElement('rowItems', { count: String(rowItemNodes.length) }, rowItemNodes));
336
427
  }
337
428
 
338
- // Column fields
339
- if (this._columnFields.length > 0) {
340
- const colFieldNodes = this._columnFields.map((f) => createElement('field', { x: String(f.fieldIndex) }, []));
341
- // If we have multiple value fields, add -2 to indicate where "Values" header goes
342
- if (this._valueFields.length > 1) {
343
- colFieldNodes.push(createElement('field', { x: '-2' }, []));
344
- }
345
- children.push(createElement('colFields', { count: String(colFieldNodes.length) }, colFieldNodes));
346
-
347
- // Column items - need to account for multiple value fields
348
- const colItemNodes = this._buildColItems();
349
- children.push(createElement('colItems', { count: String(colItemNodes.length) }, colItemNodes));
350
- } else if (this._valueFields.length > 1) {
351
- // If no column fields but we have multiple values, need colFields with -2 (data field indicator)
352
- children.push(createElement('colFields', { count: '1' }, [createElement('field', { x: '-2' }, [])]));
353
-
354
- // Column items for each value field
355
- const colItemNodes: XmlNode[] = [];
356
- for (let i = 0; i < this._valueFields.length; i++) {
357
- colItemNodes.push(createElement('i', {}, [createElement('x', i === 0 ? {} : { v: String(i) }, [])]));
358
- }
359
- children.push(createElement('colItems', { count: String(colItemNodes.length) }, colItemNodes));
360
- } else if (this._valueFields.length === 1) {
361
- // Single value field - just add a single column item
362
- children.push(createElement('colItems', { count: '1' }, [createElement('i', {}, [])]));
429
+ if (colFieldIndexes.length > 0) {
430
+ children.push(
431
+ createElement(
432
+ 'colFields',
433
+ { count: String(colFieldIndexes.length) },
434
+ colFieldIndexes.map((fieldIndex) => createElement('field', { x: String(fieldIndex) }, [])),
435
+ ),
436
+ );
363
437
  }
364
438
 
365
- // Page (filter) fields
439
+ // Excel expects colItems even when no explicit column fields are configured.
440
+ children.push(createElement('colItems', { count: '1' }, [createElement('i', {}, [])]));
441
+
366
442
  if (this._filterFields.length > 0) {
367
- const pageFieldNodes = this._filterFields.map((f) =>
368
- createElement('pageField', { fld: String(f.fieldIndex), hier: '-1' }, []),
443
+ children.push(
444
+ createElement(
445
+ 'pageFields',
446
+ { count: String(this._filterFields.length) },
447
+ this._filterFields.map((field, index) =>
448
+ createElement('pageField', { fld: String(this._fieldIndex(field)), hier: '-1', item: String(index) }, []),
449
+ ),
450
+ ),
369
451
  );
370
- children.push(createElement('pageFields', { count: String(pageFieldNodes.length) }, pageFieldNodes));
371
- }
372
-
373
- // Data fields (values)
374
- if (this._valueFields.length > 0) {
375
- const dataFieldNodes = this._valueFields.map((f) => {
376
- const attrs: Record<string, string> = {
377
- name: f.displayName || f.fieldName,
378
- fld: String(f.fieldIndex),
379
- baseField: '0',
380
- baseItem: '0',
381
- subtotal: f.aggregation || 'sum',
382
- };
383
-
384
- if (f.numFmtId !== undefined) {
385
- attrs.numFmtId = String(f.numFmtId);
386
- }
452
+ }
387
453
 
388
- return createElement('dataField', attrs, []);
389
- });
390
- children.push(createElement('dataFields', { count: String(dataFieldNodes.length) }, dataFieldNodes));
454
+ if (effectiveValueFields.length > 0) {
455
+ children.push(
456
+ createElement(
457
+ 'dataFields',
458
+ { count: String(effectiveValueFields.length) },
459
+ effectiveValueFields.map((valueField) => {
460
+ const attrs: Record<string, string> = {
461
+ name: valueField.name,
462
+ fld: String(this._fieldIndex(valueField.field)),
463
+ baseField: '0',
464
+ baseItem: '0',
465
+ subtotal: AGGREGATION_TO_XML[valueField.aggregation],
466
+ };
467
+
468
+ if (valueField.numberFormat) {
469
+ attrs.numFmtId = String(this._workbook.styles.getOrCreateNumFmtId(valueField.numberFormat));
470
+ }
471
+
472
+ return createElement('dataField', attrs, []);
473
+ }),
474
+ ),
475
+ );
391
476
  }
392
477
 
393
- // Pivot table style
394
478
  children.push(
395
479
  createElement(
396
480
  'pivotTableStyleInfo',
@@ -406,275 +490,286 @@ export class PivotTable {
406
490
  ),
407
491
  );
408
492
 
409
- const pivotTableNode = createElement(
410
- 'pivotTableDefinition',
411
- {
412
- xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
413
- 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
414
- name: this._name,
415
- cacheId: String(this._cache.cacheId),
416
- applyNumberFormats: '1',
417
- applyBorderFormats: '0',
418
- applyFontFormats: '0',
419
- applyPatternFormats: '0',
420
- applyAlignmentFormats: '0',
421
- applyWidthHeightFormats: '1',
422
- dataCaption: 'Values',
423
- updatedVersion: '8',
424
- minRefreshableVersion: '3',
425
- useAutoFormatting: '1',
426
- rowGrandTotals: '1',
427
- colGrandTotals: '1',
428
- itemPrintTitles: '1',
429
- createdVersion: '8',
430
- indent: '0',
431
- outline: '1',
432
- outlineData: '1',
433
- multipleFieldFilters: '0',
434
- },
435
- children,
436
- );
493
+ const attrs: Record<string, string> = {
494
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
495
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
496
+ name: this._name,
497
+ cacheId: String(this._cacheId),
498
+ dataCaption: 'Values',
499
+ applyNumberFormats: '1',
500
+ applyBorderFormats: '0',
501
+ applyFontFormats: '0',
502
+ applyPatternFormats: '0',
503
+ applyAlignmentFormats: '0',
504
+ applyWidthHeightFormats: '1',
505
+ updatedVersion: '8',
506
+ minRefreshableVersion: '3',
507
+ createdVersion: '8',
508
+ useAutoFormatting: '1',
509
+ rowGrandTotals: '1',
510
+ colGrandTotals: '1',
511
+ itemPrintTitles: '1',
512
+ indent: '0',
513
+ multipleFieldFilters: this._filters.size > 0 ? '1' : '0',
514
+ outline: '1',
515
+ outlineData: '1',
516
+ };
437
517
 
438
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([pivotTableNode])}`;
518
+ const root = createElement('pivotTableDefinition', attrs, children);
519
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([root])}`;
439
520
  }
440
521
 
441
- /**
442
- * Build a pivotField node for a given field index
443
- */
444
- private _buildPivotFieldNode(fieldIndex: number): XmlNode {
445
- const attrs: Record<string, string> = {};
446
- const children: XmlNode[] = [];
522
+ private _buildCacheFieldNode(field: PivotFieldMeta, fieldIndex: number, cacheData: PivotCacheData): XmlNode {
523
+ const info = cacheData.numericInfoByField[fieldIndex];
524
+ const isAxisField = cacheData.isAxisFieldByIndex[fieldIndex];
525
+ const isValueField = cacheData.isValueFieldByIndex[fieldIndex];
526
+ const allNonNullAreNumbers = info.nonNullCount > 0 && info.numericCount === info.nonNullCount;
527
+
528
+ if (isValueField || (!isAxisField && allNonNullAreNumbers)) {
529
+ const minValue = info.hasNumeric ? info.min : 0;
530
+ const maxValue = info.hasNumeric ? info.max : 0;
531
+ const hasInteger = info.hasNumeric ? info.allIntegers : true;
532
+
533
+ const attrs: Record<string, string> = {
534
+ containsSemiMixedTypes: '0',
535
+ containsString: '0',
536
+ containsNumber: '1',
537
+ minValue: String(minValue),
538
+ maxValue: String(maxValue),
539
+ };
540
+ if (hasInteger) {
541
+ attrs.containsInteger = '1';
542
+ }
447
543
 
448
- // Check if this field is assigned to an axis
449
- const rowField = this._rowFields.find((f) => f.fieldIndex === fieldIndex);
450
- const colField = this._columnFields.find((f) => f.fieldIndex === fieldIndex);
451
- const filterField = this._filterFields.find((f) => f.fieldIndex === fieldIndex);
452
- const valueField = this._valueFields.find((f) => f.fieldIndex === fieldIndex);
544
+ return createElement('cacheField', { name: field.name, numFmtId: '0' }, [
545
+ createElement('sharedItems', attrs, []),
546
+ ]);
547
+ }
453
548
 
454
- // Get the assignment to check for sort/filter options
455
- const assignment = rowField || colField || filterField;
549
+ if (!isAxisField) {
550
+ return createElement('cacheField', { name: field.name, numFmtId: '0' }, [createElement('sharedItems', {}, [])]);
551
+ }
456
552
 
457
- if (rowField) {
458
- attrs.axis = 'axisRow';
459
- attrs.showAll = '0';
553
+ const sharedItems = cacheData.sharedItemsByField[fieldIndex] ?? [];
554
+ return createElement('cacheField', { name: field.name, numFmtId: '0' }, [
555
+ createElement('sharedItems', { count: String(sharedItems.length) }, sharedItems),
556
+ ]);
557
+ }
460
558
 
461
- // Add sort order if specified
462
- if (rowField.sortOrder) {
463
- attrs.sortType = rowField.sortOrder === 'asc' ? 'ascending' : 'descending';
464
- }
559
+ private _buildTargetAreaRef(cacheData: PivotCacheData): string {
560
+ const start = this._targetCell;
561
+ const estimatedRows = Math.max(3, this._estimateOutputRows(cacheData));
562
+ const estimatedCols = Math.max(1, this._rowFields.length + Math.max(1, this._valueFields.length));
465
563
 
466
- // Add items for shared values
467
- const cacheField = this._cache.fields[fieldIndex];
468
- if (cacheField && cacheField.sharedItems.length > 0) {
469
- const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
470
- children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
471
- }
472
- } else if (colField) {
473
- attrs.axis = 'axisCol';
474
- attrs.showAll = '0';
564
+ const endRow = start.row + estimatedRows - 1;
565
+ const endCol = start.col + estimatedCols - 1;
475
566
 
476
- // Add sort order if specified
477
- if (colField.sortOrder) {
478
- attrs.sortType = colField.sortOrder === 'asc' ? 'ascending' : 'descending';
479
- }
567
+ return `${toAddress(start.row, start.col)}:${toAddress(endRow, endCol)}`;
568
+ }
480
569
 
481
- const cacheField = this._cache.fields[fieldIndex];
482
- if (cacheField && cacheField.sharedItems.length > 0) {
483
- const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
484
- children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
485
- }
486
- } else if (filterField) {
487
- attrs.axis = 'axisPage';
488
- attrs.showAll = '0';
489
- const cacheField = this._cache.fields[fieldIndex];
490
- if (cacheField && cacheField.sharedItems.length > 0) {
491
- const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
492
- children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
493
- }
494
- } else if (valueField) {
495
- attrs.dataField = '1';
496
- attrs.showAll = '0';
497
- } else {
498
- attrs.showAll = '0';
570
+ private _estimateOutputRows(cacheData: PivotCacheData): number {
571
+ if (this._rowFields.length === 0) {
572
+ return 3;
499
573
  }
500
574
 
501
- return createElement('pivotField', attrs, children);
575
+ const rowFieldIndex = this._fieldIndex(this._rowFields[0]);
576
+ const distinctItems = cacheData.distinctItemsByField[rowFieldIndex] ?? [];
577
+ return Math.max(3, distinctItems.length + 2);
502
578
  }
503
579
 
504
- /**
505
- * Build item nodes for a pivot field, with optional filtering
506
- */
507
- private _buildItemNodes(sharedItems: string[], filter?: PivotFieldFilter): XmlNode[] {
508
- const itemNodes: XmlNode[] = [];
509
-
510
- for (let i = 0; i < sharedItems.length; i++) {
511
- const itemValue = sharedItems[i];
512
- const itemAttrs: Record<string, string> = { x: String(i) };
513
-
514
- // Check if this item should be hidden
515
- if (filter) {
516
- let hidden = false;
517
- if (filter.exclude && filter.exclude.includes(itemValue)) {
518
- hidden = true;
519
- } else if (filter.include && !filter.include.includes(itemValue)) {
520
- hidden = true;
521
- }
522
- if (hidden) {
523
- itemAttrs.h = '1';
524
- }
580
+ private _buildPivotCacheData(): PivotCacheData {
581
+ const rowCount = Math.max(0, this._sourceRange.end.row - this._sourceRange.start.row);
582
+ const fieldCount = this._fields.length;
583
+ const recordNodes: XmlNode[] = new Array(rowCount);
584
+ const sharedItemIndexByField: Array<Map<string, number> | null> = new Array(fieldCount).fill(null);
585
+ const sharedItemsByField: Array<XmlNode[] | null> = new Array(fieldCount).fill(null);
586
+ const distinctItemsByField: Array<Exclude<CellValue, null>[] | null> = new Array(fieldCount).fill(null);
587
+ const numericInfoByField: PivotNumericInfo[] = new Array(fieldCount);
588
+ const isAxisFieldByIndex: boolean[] = new Array(fieldCount);
589
+ const isValueFieldByIndex: boolean[] = new Array(fieldCount);
590
+
591
+ const effectiveRowField = this._rowFields[0] ?? null;
592
+ const effectiveColumnField = this._columnFields[0] ?? null;
593
+ const filterFields = new Set(this._filterFields);
594
+ const valueFields = new Set(this._valueFields.map((valueField) => valueField.field));
595
+
596
+ for (let fieldIndex = 0; fieldIndex < fieldCount; fieldIndex++) {
597
+ const fieldName = this._fields[fieldIndex].name;
598
+ const isAxisField =
599
+ fieldName === effectiveRowField || fieldName === effectiveColumnField || filterFields.has(fieldName);
600
+ const isValueField = valueFields.has(fieldName);
601
+
602
+ isAxisFieldByIndex[fieldIndex] = isAxisField;
603
+ isValueFieldByIndex[fieldIndex] = isValueField;
604
+
605
+ if (isAxisField) {
606
+ sharedItemIndexByField[fieldIndex] = new Map<string, number>();
607
+ sharedItemsByField[fieldIndex] = [];
608
+ distinctItemsByField[fieldIndex] = [];
525
609
  }
526
610
 
527
- itemNodes.push(createElement('item', itemAttrs, []));
611
+ numericInfoByField[fieldIndex] = {
612
+ nonNullCount: 0,
613
+ numericCount: 0,
614
+ min: 0,
615
+ max: 0,
616
+ hasNumeric: false,
617
+ allIntegers: true,
618
+ };
528
619
  }
529
620
 
530
- // Add default subtotal item
531
- itemNodes.push(createElement('item', { t: 'default' }, []));
621
+ for (let rowOffset = 0; rowOffset < rowCount; rowOffset++) {
622
+ const row = this._sourceRange.start.row + 1 + rowOffset;
623
+ const valueNodes: XmlNode[] = [];
624
+
625
+ for (let fieldIndex = 0; fieldIndex < fieldCount; fieldIndex++) {
626
+ const field = this._fields[fieldIndex];
627
+ const cellValue = this._sourceSheet.getCellIfExists(row, field.sourceCol)?.value ?? null;
628
+
629
+ if (cellValue !== null) {
630
+ const numericInfo = numericInfoByField[fieldIndex];
631
+ numericInfo.nonNullCount++;
632
+
633
+ if (typeof cellValue === 'number' && Number.isFinite(cellValue)) {
634
+ numericInfo.numericCount++;
635
+ if (!numericInfo.hasNumeric) {
636
+ numericInfo.min = cellValue;
637
+ numericInfo.max = cellValue;
638
+ numericInfo.hasNumeric = true;
639
+ } else {
640
+ if (cellValue < numericInfo.min) numericInfo.min = cellValue;
641
+ if (cellValue > numericInfo.max) numericInfo.max = cellValue;
642
+ }
643
+ if (!Number.isInteger(cellValue)) {
644
+ numericInfo.allIntegers = false;
645
+ }
646
+ }
532
647
 
533
- return itemNodes;
534
- }
648
+ if (isAxisFieldByIndex[fieldIndex]) {
649
+ const distinctMap = sharedItemIndexByField[fieldIndex]!;
650
+ const key = this._distinctKey(cellValue as Exclude<CellValue, null>);
651
+ let index = distinctMap.get(key);
652
+ if (index === undefined) {
653
+ index = distinctMap.size;
654
+ distinctMap.set(key, index);
655
+ distinctItemsByField[fieldIndex]!.push(cellValue as Exclude<CellValue, null>);
656
+ const sharedNode = this._buildSharedItemNode(cellValue as Exclude<CellValue, null>);
657
+ if (sharedNode) {
658
+ sharedItemsByField[fieldIndex]!.push(sharedNode);
659
+ }
660
+ }
661
+ valueNodes.push(createElement('x', { v: String(index) }, []));
662
+ continue;
663
+ }
664
+ }
535
665
 
536
- /**
537
- * Build row items based on unique values in row fields
538
- */
539
- private _buildRowItems(): XmlNode[] {
540
- const items: XmlNode[] = [];
666
+ valueNodes.push(this._buildRawCacheValueNode(cellValue));
667
+ }
541
668
 
542
- if (this._rowFields.length === 0) return items;
669
+ recordNodes[rowOffset] = createElement('r', {}, valueNodes);
670
+ }
543
671
 
544
- // Get unique values from first row field
545
- const firstRowField = this._rowFields[0];
546
- const cacheField = this._cache.fields[firstRowField.fieldIndex];
672
+ return {
673
+ rowCount,
674
+ recordNodes,
675
+ sharedItemIndexByField,
676
+ sharedItemsByField,
677
+ distinctItemsByField,
678
+ numericInfoByField,
679
+ isAxisFieldByIndex,
680
+ isValueFieldByIndex,
681
+ };
682
+ }
547
683
 
548
- if (cacheField && cacheField.sharedItems.length > 0) {
549
- for (let i = 0; i < cacheField.sharedItems.length; i++) {
550
- items.push(createElement('i', {}, [createElement('x', i === 0 ? {} : { v: String(i) }, [])]));
551
- }
684
+ private _buildSharedItemNode(value: Exclude<CellValue, null>): XmlNode | null {
685
+ if (typeof value === 'string') {
686
+ return { s: [], ':@': { '@_v': value } } as XmlNode;
552
687
  }
553
688
 
554
- // Add grand total row
555
- items.push(createElement('i', { t: 'grand' }, [createElement('x', {}, [])]));
556
-
557
- return items;
558
- }
689
+ if (typeof value === 'number') {
690
+ return createElement('n', { v: String(value) }, []);
691
+ }
559
692
 
560
- /**
561
- * Build column items based on unique values in column fields
562
- */
563
- private _buildColItems(): XmlNode[] {
564
- const items: XmlNode[] = [];
565
-
566
- if (this._columnFields.length === 0) return items;
567
-
568
- // Get unique values from first column field
569
- const firstColField = this._columnFields[0];
570
- const cacheField = this._cache.fields[firstColField.fieldIndex];
571
-
572
- if (cacheField && cacheField.sharedItems.length > 0) {
573
- if (this._valueFields.length > 1) {
574
- // Multiple value fields - need nested items for each column value + value field combination
575
- for (let colIdx = 0; colIdx < cacheField.sharedItems.length; colIdx++) {
576
- for (let valIdx = 0; valIdx < this._valueFields.length; valIdx++) {
577
- const xNodes: XmlNode[] = [
578
- createElement('x', colIdx === 0 ? {} : { v: String(colIdx) }, []),
579
- createElement('x', valIdx === 0 ? {} : { v: String(valIdx) }, []),
580
- ];
581
- items.push(createElement('i', {}, xNodes));
582
- }
583
- }
584
- } else {
585
- // Single value field - simple column items
586
- for (let i = 0; i < cacheField.sharedItems.length; i++) {
587
- items.push(createElement('i', {}, [createElement('x', i === 0 ? {} : { v: String(i) }, [])]));
588
- }
589
- }
693
+ if (typeof value === 'boolean') {
694
+ return createElement('b', { v: value ? '1' : '0' }, []);
590
695
  }
591
696
 
592
- // Add grand total column(s)
593
- if (this._valueFields.length > 1) {
594
- // Grand total for each value field
595
- for (let valIdx = 0; valIdx < this._valueFields.length; valIdx++) {
596
- const xNodes: XmlNode[] = [
597
- createElement('x', {}, []),
598
- createElement('x', valIdx === 0 ? {} : { v: String(valIdx) }, []),
599
- ];
600
- items.push(createElement('i', { t: 'grand' }, xNodes));
601
- }
602
- } else {
603
- items.push(createElement('i', { t: 'grand' }, [createElement('x', {}, [])]));
697
+ if (value instanceof Date) {
698
+ return createElement('d', { v: value.toISOString() }, []);
604
699
  }
605
700
 
606
- return items;
701
+ return null;
607
702
  }
608
703
 
609
- /**
610
- * Calculate the location reference for the pivot table output
611
- */
612
- private _calculateLocationRef(): string {
613
- // Estimate output size based on fields
614
- const numRows = this._estimateRowCount();
615
- const numCols = this._estimateColCount();
616
-
617
- const startRow = this._targetRow;
618
- const startCol = this._targetCol;
619
- const endRow = startRow + numRows - 1;
620
- const endCol = startCol + numCols - 1;
704
+ private _buildRawCacheValueNode(value: CellValue): XmlNode {
705
+ if (value === null) {
706
+ return createElement('m', {}, []);
707
+ }
621
708
 
622
- return `${this._colToLetter(startCol)}${startRow}:${this._colToLetter(endCol)}${endRow}`;
623
- }
709
+ if (typeof value === 'string') {
710
+ return { s: [], ':@': { '@_v': value } } as XmlNode;
711
+ }
624
712
 
625
- /**
626
- * Estimate number of rows in pivot table output
627
- */
628
- private _estimateRowCount(): number {
629
- let count = 1; // Header row
713
+ if (typeof value === 'number') {
714
+ return createElement('n', { v: String(value) }, []);
715
+ }
630
716
 
631
- // Add filter area rows
632
- count += this._filterFields.length;
717
+ if (typeof value === 'boolean') {
718
+ return createElement('b', { v: value ? '1' : '0' }, []);
719
+ }
633
720
 
634
- // Add row labels (unique values in row fields)
635
- if (this._rowFields.length > 0) {
636
- const firstRowField = this._rowFields[0];
637
- const cacheField = this._cache.fields[firstRowField.fieldIndex];
638
- count += (cacheField?.sharedItems.length || 1) + 1; // +1 for grand total
639
- } else {
640
- count += 1; // At least one data row
721
+ if (value instanceof Date) {
722
+ return createElement('d', { v: value.toISOString() }, []);
641
723
  }
642
724
 
643
- return Math.max(count, 3);
725
+ return createElement('m', {}, []);
644
726
  }
645
727
 
646
- /**
647
- * Estimate number of columns in pivot table output
648
- */
649
- private _estimateColCount(): number {
650
- let count = 0;
651
-
652
- // Row label columns
653
- count += Math.max(this._rowFields.length, 1);
728
+ private _assertFieldExists(fieldName: string): void {
729
+ if (!this._fields.some((field) => field.name === fieldName)) {
730
+ throw new Error(`Pivot field not found: ${fieldName}`);
731
+ }
732
+ }
654
733
 
655
- // Column labels (unique values in column fields)
656
- if (this._columnFields.length > 0) {
657
- const firstColField = this._columnFields[0];
658
- const cacheField = this._cache.fields[firstColField.fieldIndex];
659
- count += (cacheField?.sharedItems.length || 1) + 1; // +1 for grand total
660
- } else {
661
- // Value columns
662
- count += Math.max(this._valueFields.length, 1);
734
+ private _fieldIndex(fieldName: string): number {
735
+ const index = this._fields.findIndex((field) => field.name === fieldName);
736
+ if (index < 0) {
737
+ throw new Error(`Pivot field not found: ${fieldName}`);
663
738
  }
739
+ return index;
740
+ }
664
741
 
665
- return Math.max(count, 2);
742
+ private _aggregationLabel(aggregation: PivotAggregationType): string {
743
+ switch (aggregation) {
744
+ case 'sum':
745
+ return 'Sum';
746
+ case 'count':
747
+ return 'Count';
748
+ case 'average':
749
+ return 'Average';
750
+ case 'min':
751
+ return 'Min';
752
+ case 'max':
753
+ return 'Max';
754
+ }
666
755
  }
667
756
 
668
- /**
669
- * Convert 0-based column index to letter (A, B, ..., Z, AA, etc.)
670
- */
671
- private _colToLetter(col: number): string {
672
- let result = '';
673
- let n = col;
674
- while (n >= 0) {
675
- result = String.fromCharCode((n % 26) + 65) + result;
676
- n = Math.floor(n / 26) - 1;
677
- }
678
- return result;
757
+ private _distinctKey(value: Exclude<CellValue, null>): string {
758
+ if (value instanceof Date) {
759
+ return `d:${value.toISOString()}`;
760
+ }
761
+ if (typeof value === 'string') {
762
+ return `s:${value}`;
763
+ }
764
+ if (typeof value === 'number') {
765
+ return `n:${value}`;
766
+ }
767
+ if (typeof value === 'boolean') {
768
+ return `b:${value ? 1 : 0}`;
769
+ }
770
+ if (typeof value === 'object' && value && 'error' in value) {
771
+ return `e:${value.error}`;
772
+ }
773
+ return 'u:';
679
774
  }
680
775
  }