@niicojs/excel 0.3.1 → 0.3.3

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,684 +1,680 @@
1
- import type { AggregationType, PivotFieldAxis, PivotFieldFilter, PivotSortOrder, PivotValueConfig } from './types';
2
- import type { Styles } from './styles';
3
- import { PivotCache } from './pivot-cache';
4
- import { createElement, stringifyXml, XmlNode } from './utils/xml';
5
-
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;
18
- }
19
-
20
- /**
21
- * Represents an Excel pivot table with a fluent API for configuration.
22
- */
23
- export class PivotTable {
24
- 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;
40
-
41
- 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,
50
- ) {
51
- this._name = name;
52
- this._cache = cache;
53
- this._targetSheet = targetSheet;
54
- this._targetCell = targetCell;
55
- this._targetRow = targetRow;
56
- this._targetCol = targetCol;
57
- this._pivotTableIndex = pivotTableIndex;
58
- this._cacheFileIndex = cacheFileIndex;
59
- }
60
-
61
- /**
62
- * Get the pivot table name
63
- */
64
- get name(): string {
65
- return this._name;
66
- }
67
-
68
- /**
69
- * Get the target sheet name
70
- */
71
- get targetSheet(): string {
72
- return this._targetSheet;
73
- }
74
-
75
- /**
76
- * Get the target cell address
77
- */
78
- get targetCell(): string {
79
- return this._targetCell;
80
- }
81
-
82
- /**
83
- * Get the pivot cache
84
- */
85
- get cache(): PivotCache {
86
- return this._cache;
87
- }
88
-
89
- /**
90
- * Get the pivot table index (for file naming)
91
- */
92
- get index(): number {
93
- return this._pivotTableIndex;
94
- }
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
-
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;
111
- }
112
-
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
- }
122
-
123
- const assignment: FieldAssignment = {
124
- fieldName,
125
- fieldIndex,
126
- axis: 'row',
127
- };
128
- this._rowFields.push(assignment);
129
- this._fieldAssignments.set(fieldIndex, assignment);
130
-
131
- return this;
132
- }
133
-
134
- /**
135
- * Add a field to the column area
136
- * @param fieldName - Name of the source field (column header)
137
- */
138
- 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}`);
142
- }
143
-
144
- const assignment: FieldAssignment = {
145
- fieldName,
146
- fieldIndex,
147
- axis: 'column',
148
- };
149
- this._columnFields.push(assignment);
150
- this._fieldAssignments.set(fieldIndex, assignment);
151
-
152
- return this;
153
- }
154
-
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
- */
169
- addValueField(config: PivotValueConfig): this;
170
- addValueField(fieldName: string, aggregation?: AggregationType, displayName?: string, numberFormat?: string): this;
171
- addValueField(
172
- fieldNameOrConfig: string | PivotValueConfig,
173
- aggregation: AggregationType = 'sum',
174
- displayName?: string,
175
- numberFormat?: string,
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
-
195
- const fieldIndex = this._cache.getFieldIndex(fieldName);
196
- if (fieldIndex < 0) {
197
- throw new Error(`Field not found in source data: ${fieldName}`);
198
- }
199
-
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
- }
207
-
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);
218
-
219
- return this;
220
- }
221
-
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}`);
230
- }
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
-
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
- }
285
-
286
- assignment.filter = filter;
287
- return this;
288
- }
289
-
290
- /**
291
- * Generate the pivotTableDefinition XML
292
- */
293
- toXml(): string {
294
- const children: XmlNode[] = [];
295
-
296
- // Calculate location (estimate based on fields)
297
- const locationRef = this._calculateLocationRef();
298
-
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;
307
-
308
- const locationNode = createElement(
309
- 'location',
310
- {
311
- ref: locationRef,
312
- firstHeaderRow: String(filterRowCount + 1),
313
- firstDataRow: String(firstDataRow),
314
- firstDataCol: String(firstDataCol),
315
- },
316
- [],
317
- );
318
- children.push(locationNode);
319
-
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);
325
- }
326
- children.push(createElement('pivotFields', { count: String(pivotFieldNodes.length) }, pivotFieldNodes));
327
-
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));
332
-
333
- // Row items
334
- const rowItemNodes = this._buildRowItems();
335
- children.push(createElement('rowItems', { count: String(rowItemNodes.length) }, rowItemNodes));
336
- }
337
-
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', {}, [])]));
363
- }
364
-
365
- // Page (filter) fields
366
- if (this._filterFields.length > 0) {
367
- const pageFieldNodes = this._filterFields.map((f) =>
368
- createElement('pageField', { fld: String(f.fieldIndex), hier: '-1' }, []),
369
- );
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
- // Add numFmtId if it was resolved during addValueField
385
- if (f.numFmtId !== undefined) {
386
- attrs.numFmtId = String(f.numFmtId);
387
- }
388
-
389
- return createElement('dataField', attrs, []);
390
- });
391
- children.push(createElement('dataFields', { count: String(dataFieldNodes.length) }, dataFieldNodes));
392
- }
393
-
394
- // Check if any value field has a number format
395
- const hasNumberFormats = this._valueFields.some((f) => f.numFmtId !== undefined);
396
-
397
- // Pivot table style
398
- children.push(
399
- createElement(
400
- 'pivotTableStyleInfo',
401
- {
402
- name: 'PivotStyleMedium9',
403
- showRowHeaders: '1',
404
- showColHeaders: '1',
405
- showRowStripes: '0',
406
- showColStripes: '0',
407
- showLastColumn: '1',
408
- },
409
- [],
410
- ),
411
- );
412
-
413
- const pivotTableNode = createElement(
414
- 'pivotTableDefinition',
415
- {
416
- xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
417
- 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
418
- name: this._name,
419
- cacheId: String(this._cache.cacheId),
420
- applyNumberFormats: hasNumberFormats ? '1' : '0',
421
- applyBorderFormats: '0',
422
- applyFontFormats: '0',
423
- applyPatternFormats: '0',
424
- applyAlignmentFormats: '0',
425
- applyWidthHeightFormats: '1',
426
- dataCaption: 'Values',
427
- updatedVersion: '8',
428
- minRefreshableVersion: '3',
429
- useAutoFormatting: '1',
430
- rowGrandTotals: '1',
431
- colGrandTotals: '1',
432
- itemPrintTitles: '1',
433
- createdVersion: '8',
434
- indent: '0',
435
- outline: '1',
436
- outlineData: '1',
437
- multipleFieldFilters: '0',
438
- },
439
- children,
440
- );
441
-
442
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([pivotTableNode])}`;
443
- }
444
-
445
- /**
446
- * Build a pivotField node for a given field index
447
- */
448
- private _buildPivotFieldNode(fieldIndex: number): XmlNode {
449
- const attrs: Record<string, string> = {};
450
- const children: XmlNode[] = [];
451
-
452
- // Check if this field is assigned to an axis
453
- const rowField = this._rowFields.find((f) => f.fieldIndex === fieldIndex);
454
- const colField = this._columnFields.find((f) => f.fieldIndex === fieldIndex);
455
- const filterField = this._filterFields.find((f) => f.fieldIndex === fieldIndex);
456
- const valueField = this._valueFields.find((f) => f.fieldIndex === fieldIndex);
457
-
458
- // Get the assignment to check for sort/filter options
459
- const assignment = rowField || colField || filterField;
460
-
461
- if (rowField) {
462
- attrs.axis = 'axisRow';
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
-
470
- // Add items for shared values
471
- const cacheField = this._cache.fields[fieldIndex];
472
- if (cacheField && cacheField.sharedItems.length > 0) {
473
- const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
474
- children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
475
- }
476
- } else if (colField) {
477
- attrs.axis = 'axisCol';
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
-
485
- const cacheField = this._cache.fields[fieldIndex];
486
- if (cacheField && cacheField.sharedItems.length > 0) {
487
- const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
488
- children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
489
- }
490
- } else if (filterField) {
491
- attrs.axis = 'axisPage';
492
- attrs.showAll = '0';
493
- const cacheField = this._cache.fields[fieldIndex];
494
- if (cacheField && cacheField.sharedItems.length > 0) {
495
- const itemNodes = this._buildItemNodes(cacheField.sharedItems, assignment?.filter);
496
- children.push(createElement('items', { count: String(itemNodes.length) }, itemNodes));
497
- }
498
- } else if (valueField) {
499
- attrs.dataField = '1';
500
- attrs.showAll = '0';
501
- } else {
502
- attrs.showAll = '0';
503
- }
504
-
505
- return createElement('pivotField', attrs, children);
506
- }
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
-
540
- /**
541
- * Build row items based on unique values in row fields
542
- */
543
- private _buildRowItems(): XmlNode[] {
544
- const items: XmlNode[] = [];
545
-
546
- if (this._rowFields.length === 0) return items;
547
-
548
- // Get unique values from first row field
549
- const firstRowField = this._rowFields[0];
550
- const cacheField = this._cache.fields[firstRowField.fieldIndex];
551
-
552
- if (cacheField && cacheField.sharedItems.length > 0) {
553
- for (let i = 0; i < cacheField.sharedItems.length; i++) {
554
- items.push(createElement('i', {}, [createElement('x', i === 0 ? {} : { v: String(i) }, [])]));
555
- }
556
- }
557
-
558
- // Add grand total row
559
- items.push(createElement('i', { t: 'grand' }, [createElement('x', {}, [])]));
560
-
561
- return items;
562
- }
563
-
564
- /**
565
- * Build column items based on unique values in column fields
566
- */
567
- private _buildColItems(): XmlNode[] {
568
- const items: XmlNode[] = [];
569
-
570
- if (this._columnFields.length === 0) return items;
571
-
572
- // Get unique values from first column field
573
- const firstColField = this._columnFields[0];
574
- const cacheField = this._cache.fields[firstColField.fieldIndex];
575
-
576
- if (cacheField && cacheField.sharedItems.length > 0) {
577
- if (this._valueFields.length > 1) {
578
- // Multiple value fields - need nested items for each column value + value field combination
579
- for (let colIdx = 0; colIdx < cacheField.sharedItems.length; colIdx++) {
580
- for (let valIdx = 0; valIdx < this._valueFields.length; valIdx++) {
581
- const xNodes: XmlNode[] = [
582
- createElement('x', colIdx === 0 ? {} : { v: String(colIdx) }, []),
583
- createElement('x', valIdx === 0 ? {} : { v: String(valIdx) }, []),
584
- ];
585
- items.push(createElement('i', {}, xNodes));
586
- }
587
- }
588
- } else {
589
- // Single value field - simple column items
590
- for (let i = 0; i < cacheField.sharedItems.length; i++) {
591
- items.push(createElement('i', {}, [createElement('x', i === 0 ? {} : { v: String(i) }, [])]));
592
- }
593
- }
594
- }
595
-
596
- // Add grand total column(s)
597
- if (this._valueFields.length > 1) {
598
- // Grand total for each value field
599
- for (let valIdx = 0; valIdx < this._valueFields.length; valIdx++) {
600
- const xNodes: XmlNode[] = [
601
- createElement('x', {}, []),
602
- createElement('x', valIdx === 0 ? {} : { v: String(valIdx) }, []),
603
- ];
604
- items.push(createElement('i', { t: 'grand' }, xNodes));
605
- }
606
- } else {
607
- items.push(createElement('i', { t: 'grand' }, [createElement('x', {}, [])]));
608
- }
609
-
610
- return items;
611
- }
612
-
613
- /**
614
- * Calculate the location reference for the pivot table output
615
- */
616
- private _calculateLocationRef(): string {
617
- // Estimate output size based on fields
618
- const numRows = this._estimateRowCount();
619
- const numCols = this._estimateColCount();
620
-
621
- const startRow = this._targetRow;
622
- const startCol = this._targetCol;
623
- const endRow = startRow + numRows - 1;
624
- const endCol = startCol + numCols - 1;
625
-
626
- return `${this._colToLetter(startCol)}${startRow}:${this._colToLetter(endCol)}${endRow}`;
627
- }
628
-
629
- /**
630
- * Estimate number of rows in pivot table output
631
- */
632
- private _estimateRowCount(): number {
633
- let count = 1; // Header row
634
-
635
- // Add filter area rows
636
- count += this._filterFields.length;
637
-
638
- // Add row labels (unique values in row fields)
639
- if (this._rowFields.length > 0) {
640
- const firstRowField = this._rowFields[0];
641
- const cacheField = this._cache.fields[firstRowField.fieldIndex];
642
- count += (cacheField?.sharedItems.length || 1) + 1; // +1 for grand total
643
- } else {
644
- count += 1; // At least one data row
645
- }
646
-
647
- return Math.max(count, 3);
648
- }
649
-
650
- /**
651
- * Estimate number of columns in pivot table output
652
- */
653
- private _estimateColCount(): number {
654
- let count = 0;
655
-
656
- // Row label columns
657
- count += Math.max(this._rowFields.length, 1);
658
-
659
- // Column labels (unique values in column fields)
660
- if (this._columnFields.length > 0) {
661
- const firstColField = this._columnFields[0];
662
- const cacheField = this._cache.fields[firstColField.fieldIndex];
663
- count += (cacheField?.sharedItems.length || 1) + 1; // +1 for grand total
664
- } else {
665
- // Value columns
666
- count += Math.max(this._valueFields.length, 1);
667
- }
668
-
669
- return Math.max(count, 2);
670
- }
671
-
672
- /**
673
- * Convert 0-based column index to letter (A, B, ..., Z, AA, etc.)
674
- */
675
- private _colToLetter(col: number): string {
676
- let result = '';
677
- let n = col;
678
- while (n >= 0) {
679
- result = String.fromCharCode((n % 26) + 65) + result;
680
- n = Math.floor(n / 26) - 1;
681
- }
682
- return result;
683
- }
684
- }
1
+ import type { AggregationType, PivotFieldAxis, PivotFieldFilter, PivotSortOrder, PivotValueConfig } from './types';
2
+ import type { Styles } from './styles';
3
+ import { PivotCache } from './pivot-cache';
4
+ import { createElement, stringifyXml, XmlNode } from './utils/xml';
5
+
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;
18
+ }
19
+
20
+ /**
21
+ * Represents an Excel pivot table with a fluent API for configuration.
22
+ */
23
+ export class PivotTable {
24
+ 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;
40
+
41
+ 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,
50
+ ) {
51
+ this._name = name;
52
+ this._cache = cache;
53
+ this._targetSheet = targetSheet;
54
+ this._targetCell = targetCell;
55
+ this._targetRow = targetRow;
56
+ this._targetCol = targetCol;
57
+ this._pivotTableIndex = pivotTableIndex;
58
+ this._cacheFileIndex = cacheFileIndex;
59
+ }
60
+
61
+ /**
62
+ * Get the pivot table name
63
+ */
64
+ get name(): string {
65
+ return this._name;
66
+ }
67
+
68
+ /**
69
+ * Get the target sheet name
70
+ */
71
+ get targetSheet(): string {
72
+ return this._targetSheet;
73
+ }
74
+
75
+ /**
76
+ * Get the target cell address
77
+ */
78
+ get targetCell(): string {
79
+ return this._targetCell;
80
+ }
81
+
82
+ /**
83
+ * Get the pivot cache
84
+ */
85
+ get cache(): PivotCache {
86
+ return this._cache;
87
+ }
88
+
89
+ /**
90
+ * Get the pivot table index (for file naming)
91
+ */
92
+ get index(): number {
93
+ return this._pivotTableIndex;
94
+ }
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
+
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;
111
+ }
112
+
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
+ }
122
+
123
+ const assignment: FieldAssignment = {
124
+ fieldName,
125
+ fieldIndex,
126
+ axis: 'row',
127
+ };
128
+ this._rowFields.push(assignment);
129
+ this._fieldAssignments.set(fieldIndex, assignment);
130
+
131
+ return this;
132
+ }
133
+
134
+ /**
135
+ * Add a field to the column area
136
+ * @param fieldName - Name of the source field (column header)
137
+ */
138
+ 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}`);
142
+ }
143
+
144
+ const assignment: FieldAssignment = {
145
+ fieldName,
146
+ fieldIndex,
147
+ axis: 'column',
148
+ };
149
+ this._columnFields.push(assignment);
150
+ this._fieldAssignments.set(fieldIndex, assignment);
151
+
152
+ return this;
153
+ }
154
+
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
+ */
169
+ addValueField(config: PivotValueConfig): this;
170
+ addValueField(fieldName: string, aggregation?: AggregationType, displayName?: string, numberFormat?: string): this;
171
+ addValueField(
172
+ fieldNameOrConfig: string | PivotValueConfig,
173
+ aggregation: AggregationType = 'sum',
174
+ displayName?: string,
175
+ numberFormat?: string,
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
+
195
+ const fieldIndex = this._cache.getFieldIndex(fieldName);
196
+ if (fieldIndex < 0) {
197
+ throw new Error(`Field not found in source data: ${fieldName}`);
198
+ }
199
+
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
+ }
207
+
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);
218
+
219
+ return this;
220
+ }
221
+
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}`);
230
+ }
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
+
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
+ }
285
+
286
+ assignment.filter = filter;
287
+ return this;
288
+ }
289
+
290
+ /**
291
+ * Generate the pivotTableDefinition XML
292
+ */
293
+ toXml(): string {
294
+ const children: XmlNode[] = [];
295
+
296
+ // Calculate location (estimate based on fields)
297
+ const locationRef = this._calculateLocationRef();
298
+
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;
307
+
308
+ const locationNode = createElement(
309
+ 'location',
310
+ {
311
+ ref: locationRef,
312
+ firstHeaderRow: String(filterRowCount + 1),
313
+ firstDataRow: String(firstDataRow),
314
+ firstDataCol: String(firstDataCol),
315
+ },
316
+ [],
317
+ );
318
+ children.push(locationNode);
319
+
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);
325
+ }
326
+ children.push(createElement('pivotFields', { count: String(pivotFieldNodes.length) }, pivotFieldNodes));
327
+
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));
332
+
333
+ // Row items
334
+ const rowItemNodes = this._buildRowItems();
335
+ children.push(createElement('rowItems', { count: String(rowItemNodes.length) }, rowItemNodes));
336
+ }
337
+
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', {}, [])]));
363
+ }
364
+
365
+ // Page (filter) fields
366
+ if (this._filterFields.length > 0) {
367
+ const pageFieldNodes = this._filterFields.map((f) =>
368
+ createElement('pageField', { fld: String(f.fieldIndex), hier: '-1' }, []),
369
+ );
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
+ }
387
+
388
+ return createElement('dataField', attrs, []);
389
+ });
390
+ children.push(createElement('dataFields', { count: String(dataFieldNodes.length) }, dataFieldNodes));
391
+ }
392
+
393
+ // Pivot table style
394
+ children.push(
395
+ createElement(
396
+ 'pivotTableStyleInfo',
397
+ {
398
+ name: 'PivotStyleMedium9',
399
+ showRowHeaders: '1',
400
+ showColHeaders: '1',
401
+ showRowStripes: '0',
402
+ showColStripes: '0',
403
+ showLastColumn: '1',
404
+ },
405
+ [],
406
+ ),
407
+ );
408
+
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
+ );
437
+
438
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([pivotTableNode])}`;
439
+ }
440
+
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[] = [];
447
+
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);
453
+
454
+ // Get the assignment to check for sort/filter options
455
+ const assignment = rowField || colField || filterField;
456
+
457
+ if (rowField) {
458
+ attrs.axis = 'axisRow';
459
+ attrs.showAll = '0';
460
+
461
+ // Add sort order if specified
462
+ if (rowField.sortOrder) {
463
+ attrs.sortType = rowField.sortOrder === 'asc' ? 'ascending' : 'descending';
464
+ }
465
+
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';
475
+
476
+ // Add sort order if specified
477
+ if (colField.sortOrder) {
478
+ attrs.sortType = colField.sortOrder === 'asc' ? 'ascending' : 'descending';
479
+ }
480
+
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';
499
+ }
500
+
501
+ return createElement('pivotField', attrs, children);
502
+ }
503
+
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
+ }
525
+ }
526
+
527
+ itemNodes.push(createElement('item', itemAttrs, []));
528
+ }
529
+
530
+ // Add default subtotal item
531
+ itemNodes.push(createElement('item', { t: 'default' }, []));
532
+
533
+ return itemNodes;
534
+ }
535
+
536
+ /**
537
+ * Build row items based on unique values in row fields
538
+ */
539
+ private _buildRowItems(): XmlNode[] {
540
+ const items: XmlNode[] = [];
541
+
542
+ if (this._rowFields.length === 0) return items;
543
+
544
+ // Get unique values from first row field
545
+ const firstRowField = this._rowFields[0];
546
+ const cacheField = this._cache.fields[firstRowField.fieldIndex];
547
+
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
+ }
552
+ }
553
+
554
+ // Add grand total row
555
+ items.push(createElement('i', { t: 'grand' }, [createElement('x', {}, [])]));
556
+
557
+ return items;
558
+ }
559
+
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
+ }
590
+ }
591
+
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', {}, [])]));
604
+ }
605
+
606
+ return items;
607
+ }
608
+
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;
621
+
622
+ return `${this._colToLetter(startCol)}${startRow}:${this._colToLetter(endCol)}${endRow}`;
623
+ }
624
+
625
+ /**
626
+ * Estimate number of rows in pivot table output
627
+ */
628
+ private _estimateRowCount(): number {
629
+ let count = 1; // Header row
630
+
631
+ // Add filter area rows
632
+ count += this._filterFields.length;
633
+
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
641
+ }
642
+
643
+ return Math.max(count, 3);
644
+ }
645
+
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);
654
+
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);
663
+ }
664
+
665
+ return Math.max(count, 2);
666
+ }
667
+
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;
679
+ }
680
+ }