@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,300 +1,501 @@
1
- import type { PivotCacheField, CellValue } from './types';
2
- import { createElement, stringifyXml, XmlNode } from './utils/xml';
3
-
4
- /**
5
- * Manages the pivot cache (definition and records) for a pivot table.
6
- * The cache stores source data metadata and cached values.
7
- */
8
- export class PivotCache {
9
- private _cacheId: number;
10
- private _fileIndex: number;
11
- private _sourceSheet: string;
12
- private _sourceRange: string;
13
- private _fields: PivotCacheField[] = [];
14
- private _records: CellValue[][] = [];
15
- private _recordCount = 0;
16
- private _refreshOnLoad = true; // Default to true
17
- // Optimized lookup: Map<fieldIndex, Map<stringValue, sharedItemsIndex>>
18
- private _sharedItemsIndexMap: Map<number, Map<string, number>> = new Map();
19
-
20
- constructor(cacheId: number, sourceSheet: string, sourceRange: string, fileIndex: number) {
21
- this._cacheId = cacheId;
22
- this._fileIndex = fileIndex;
23
- this._sourceSheet = sourceSheet;
24
- this._sourceRange = sourceRange;
25
- }
26
-
27
- /**
28
- * Get the cache ID
29
- */
30
- get cacheId(): number {
31
- return this._cacheId;
32
- }
33
-
34
- /**
35
- * Get the file index for this cache (used for file naming).
36
- */
37
- get fileIndex(): number {
38
- return this._fileIndex;
39
- }
40
-
41
- /**
42
- * Set refreshOnLoad option
43
- */
44
- set refreshOnLoad(value: boolean) {
45
- this._refreshOnLoad = value;
46
- }
47
-
48
- /**
49
- * Get refreshOnLoad option
50
- */
51
- get refreshOnLoad(): boolean {
52
- return this._refreshOnLoad;
53
- }
54
-
55
- /**
56
- * Get the source sheet name
57
- */
58
- get sourceSheet(): string {
59
- return this._sourceSheet;
60
- }
61
-
62
- /**
63
- * Get the source range
64
- */
65
- get sourceRange(): string {
66
- return this._sourceRange;
67
- }
68
-
69
- /**
70
- * Get the full source reference (Sheet!Range)
71
- */
72
- get sourceRef(): string {
73
- return `${this._sourceSheet}!${this._sourceRange}`;
74
- }
75
-
76
- /**
77
- * Get the fields in this cache
78
- */
79
- get fields(): PivotCacheField[] {
80
- return this._fields;
81
- }
82
-
83
- /**
84
- * Get the number of data records
85
- */
86
- get recordCount(): number {
87
- return this._recordCount;
88
- }
89
-
90
- /**
91
- * Build the cache from source data.
92
- * @param headers - Array of column header names
93
- * @param data - 2D array of data rows (excluding headers)
94
- */
95
- buildFromData(headers: string[], data: CellValue[][]): void {
96
- this._recordCount = data.length;
97
-
98
- // Initialize fields from headers
99
- this._fields = headers.map((name, index) => ({
100
- name,
101
- index,
102
- isNumeric: true,
103
- isDate: false,
104
- sharedItems: [],
105
- minValue: undefined,
106
- maxValue: undefined,
107
- }));
108
-
109
- // Use Sets for O(1) unique value collection during analysis
110
- const sharedItemsSets: Set<string>[] = this._fields.map(() => new Set<string>());
111
-
112
- // Analyze data to determine field types and collect unique values
113
- for (const row of data) {
114
- for (let colIdx = 0; colIdx < row.length && colIdx < this._fields.length; colIdx++) {
115
- const value = row[colIdx];
116
- const field = this._fields[colIdx];
117
-
118
- if (value === null || value === undefined) {
119
- continue;
120
- }
121
-
122
- if (typeof value === 'string') {
123
- field.isNumeric = false;
124
- // O(1) Set.add instead of O(n) Array.includes + push
125
- sharedItemsSets[colIdx].add(value);
126
- } else if (typeof value === 'number') {
127
- if (field.minValue === undefined || value < field.minValue) {
128
- field.minValue = value;
129
- }
130
- if (field.maxValue === undefined || value > field.maxValue) {
131
- field.maxValue = value;
132
- }
133
- } else if (value instanceof Date) {
134
- field.isDate = true;
135
- field.isNumeric = false;
136
- } else if (typeof value === 'boolean') {
137
- field.isNumeric = false;
138
- }
139
- }
140
- }
141
-
142
- // Convert Sets to arrays and build reverse index Maps for O(1) lookup during XML generation
143
- this._sharedItemsIndexMap.clear();
144
- for (let colIdx = 0; colIdx < this._fields.length; colIdx++) {
145
- const field = this._fields[colIdx];
146
- const set = sharedItemsSets[colIdx];
147
-
148
- // Convert Set to array (maintains insertion order in ES6+)
149
- field.sharedItems = Array.from(set);
150
-
151
- // Build reverse lookup Map: value -> index
152
- if (field.sharedItems.length > 0) {
153
- const indexMap = new Map<string, number>();
154
- for (let i = 0; i < field.sharedItems.length; i++) {
155
- indexMap.set(field.sharedItems[i], i);
156
- }
157
- this._sharedItemsIndexMap.set(colIdx, indexMap);
158
- }
159
- }
160
-
161
- // Store records
162
- this._records = data;
163
- }
164
-
165
- /**
166
- * Get field by name
167
- */
168
- getField(name: string): PivotCacheField | undefined {
169
- return this._fields.find((f) => f.name === name);
170
- }
171
-
172
- /**
173
- * Get field index by name
174
- */
175
- getFieldIndex(name: string): number {
176
- const field = this._fields.find((f) => f.name === name);
177
- return field ? field.index : -1;
178
- }
179
-
180
- /**
181
- * Generate the pivotCacheDefinition XML
182
- */
183
- toDefinitionXml(recordsRelId: string): string {
184
- const cacheFieldNodes: XmlNode[] = this._fields.map((field) => {
185
- const sharedItemsAttrs: Record<string, string> = {};
186
- const sharedItemChildren: XmlNode[] = [];
187
-
188
- if (field.sharedItems.length > 0) {
189
- // String field with shared items - Excel just uses count attribute
190
- sharedItemsAttrs.count = String(field.sharedItems.length);
191
-
192
- for (const item of field.sharedItems) {
193
- sharedItemChildren.push(createElement('s', { v: item }, []));
194
- }
195
- } else if (field.isNumeric) {
196
- // Numeric field - use "0"/"1" for boolean attributes as Excel expects
197
- sharedItemsAttrs.containsSemiMixedTypes = '0';
198
- sharedItemsAttrs.containsString = '0';
199
- sharedItemsAttrs.containsNumber = '1';
200
- // Check if all values are integers
201
- if (field.minValue !== undefined && field.maxValue !== undefined) {
202
- const isInteger = Number.isInteger(field.minValue) && Number.isInteger(field.maxValue);
203
- if (isInteger) {
204
- sharedItemsAttrs.containsInteger = '1';
205
- }
206
- sharedItemsAttrs.minValue = String(field.minValue);
207
- sharedItemsAttrs.maxValue = String(field.maxValue);
208
- }
209
- }
210
-
211
- const sharedItemsNode = createElement('sharedItems', sharedItemsAttrs, sharedItemChildren);
212
- return createElement('cacheField', { name: field.name, numFmtId: '0' }, [sharedItemsNode]);
213
- });
214
-
215
- const cacheFieldsNode = createElement('cacheFields', { count: String(this._fields.length) }, cacheFieldNodes);
216
-
217
- const worksheetSourceNode = createElement(
218
- 'worksheetSource',
219
- { ref: this._sourceRange, sheet: this._sourceSheet },
220
- [],
221
- );
222
- const cacheSourceNode = createElement('cacheSource', { type: 'worksheet' }, [worksheetSourceNode]);
223
-
224
- // Build attributes - refreshOnLoad should come early per OOXML schema
225
- const definitionAttrs: Record<string, string> = {
226
- xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
227
- 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
228
- 'r:id': recordsRelId,
229
- };
230
-
231
- // Add refreshOnLoad early in attributes (default is true)
232
- if (this._refreshOnLoad) {
233
- definitionAttrs.refreshOnLoad = '1';
234
- }
235
-
236
- // Continue with remaining attributes
237
- definitionAttrs.refreshedBy = 'User';
238
- definitionAttrs.refreshedVersion = '8';
239
- definitionAttrs.minRefreshableVersion = '3';
240
- definitionAttrs.createdVersion = '8';
241
- definitionAttrs.recordCount = String(this._recordCount);
242
-
243
- const definitionNode = createElement('pivotCacheDefinition', definitionAttrs, [cacheSourceNode, cacheFieldsNode]);
244
-
245
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([definitionNode])}`;
246
- }
247
-
248
- /**
249
- * Generate the pivotCacheRecords XML
250
- */
251
- toRecordsXml(): string {
252
- const recordNodes: XmlNode[] = [];
253
-
254
- for (const row of this._records) {
255
- const fieldNodes: XmlNode[] = [];
256
-
257
- for (let colIdx = 0; colIdx < this._fields.length; colIdx++) {
258
- const value = colIdx < row.length ? row[colIdx] : null;
259
-
260
- if (value === null || value === undefined) {
261
- // Missing value
262
- fieldNodes.push(createElement('m', {}, []));
263
- } else if (typeof value === 'string') {
264
- // String value - use index into sharedItems via O(1) Map lookup
265
- const indexMap = this._sharedItemsIndexMap.get(colIdx);
266
- const idx = indexMap?.get(value);
267
- if (idx !== undefined) {
268
- fieldNodes.push(createElement('x', { v: String(idx) }, []));
269
- } else {
270
- // Direct string value (shouldn't happen if cache is built correctly)
271
- fieldNodes.push(createElement('s', { v: value }, []));
272
- }
273
- } else if (typeof value === 'number') {
274
- fieldNodes.push(createElement('n', { v: String(value) }, []));
275
- } else if (typeof value === 'boolean') {
276
- fieldNodes.push(createElement('b', { v: value ? '1' : '0' }, []));
277
- } else if (value instanceof Date) {
278
- fieldNodes.push(createElement('d', { v: value.toISOString() }, []));
279
- } else {
280
- // Unknown type, treat as missing
281
- fieldNodes.push(createElement('m', {}, []));
282
- }
283
- }
284
-
285
- recordNodes.push(createElement('r', {}, fieldNodes));
286
- }
287
-
288
- const recordsNode = createElement(
289
- 'pivotCacheRecords',
290
- {
291
- xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
292
- 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
293
- count: String(this._recordCount),
294
- },
295
- recordNodes,
296
- );
297
-
298
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([recordsNode])}`;
299
- }
300
- }
1
+ import type { PivotCacheField, CellValue } from './types';
2
+ import type { Styles } from './styles';
3
+ import { createElement, stringifyXml, XmlNode } from './utils/xml';
4
+
5
+ /**
6
+ * Manages the pivot cache (definition and records) for a pivot table.
7
+ * The cache stores source data metadata and cached values.
8
+ */
9
+ export class PivotCache {
10
+ private _cacheId: number;
11
+ private _fileIndex: number;
12
+ private _sourceSheet: string;
13
+ private _sourceRange: string;
14
+ private _fields: PivotCacheField[] = [];
15
+ private _records: CellValue[][] = [];
16
+ private _recordCount = 0;
17
+ private _saveData = true;
18
+ private _refreshOnLoad = true; // Default to true
19
+ // Optimized lookup: Map<fieldIndex, Map<stringValue, sharedItemsIndex>>
20
+ private _sharedItemsIndexMap: Map<number, Map<string, number>> = new Map();
21
+ private _blankItemIndexMap: Map<number, number> = new Map();
22
+ private _styles: Styles | null = null;
23
+
24
+ constructor(cacheId: number, sourceSheet: string, sourceRange: string, fileIndex: number) {
25
+ this._cacheId = cacheId;
26
+ this._fileIndex = fileIndex;
27
+ this._sourceSheet = sourceSheet;
28
+ this._sourceRange = sourceRange;
29
+ }
30
+
31
+ /**
32
+ * Set styles reference for number format resolution.
33
+ * @internal
34
+ */
35
+ setStyles(styles: Styles): void {
36
+ this._styles = styles;
37
+ }
38
+
39
+
40
+ /**
41
+ * Get the cache ID
42
+ */
43
+ get cacheId(): number {
44
+ return this._cacheId;
45
+ }
46
+
47
+ /**
48
+ * Get the file index for this cache (used for file naming).
49
+ */
50
+ get fileIndex(): number {
51
+ return this._fileIndex;
52
+ }
53
+
54
+ /**
55
+ * Set refreshOnLoad option
56
+ */
57
+ set refreshOnLoad(value: boolean) {
58
+ this._refreshOnLoad = value;
59
+ }
60
+
61
+ /**
62
+ * Set saveData option
63
+ */
64
+ set saveData(value: boolean) {
65
+ this._saveData = value;
66
+ }
67
+
68
+ /**
69
+ * Get refreshOnLoad option
70
+ */
71
+ get refreshOnLoad(): boolean {
72
+ return this._refreshOnLoad;
73
+ }
74
+
75
+ /**
76
+ * Get saveData option
77
+ */
78
+ get saveData(): boolean {
79
+ return this._saveData;
80
+ }
81
+
82
+ /**
83
+ * Get the source sheet name
84
+ */
85
+ get sourceSheet(): string {
86
+ return this._sourceSheet;
87
+ }
88
+
89
+ /**
90
+ * Get the source range
91
+ */
92
+ get sourceRange(): string {
93
+ return this._sourceRange;
94
+ }
95
+
96
+ /**
97
+ * Get the full source reference (Sheet!Range)
98
+ */
99
+ get sourceRef(): string {
100
+ return `${this._sourceSheet}!${this._sourceRange}`;
101
+ }
102
+
103
+ /**
104
+ * Get the fields in this cache
105
+ */
106
+ get fields(): PivotCacheField[] {
107
+ return this._fields;
108
+ }
109
+
110
+ /**
111
+ * Get the number of data records
112
+ */
113
+ get recordCount(): number {
114
+ return this._recordCount;
115
+ }
116
+
117
+ /**
118
+ * Build the cache from source data.
119
+ * @param headers - Array of column header names
120
+ * @param data - 2D array of data rows (excluding headers)
121
+ */
122
+ buildFromData(headers: string[], data: CellValue[][]): void {
123
+ this._recordCount = data.length;
124
+
125
+ // Initialize fields from headers
126
+ this._fields = headers.map((name, index) => ({
127
+ name,
128
+ index,
129
+ isNumeric: true,
130
+ isDate: false,
131
+ hasBoolean: false,
132
+ hasBlank: false,
133
+ numFmtId: undefined,
134
+ sharedItems: [],
135
+ minValue: undefined,
136
+ maxValue: undefined,
137
+ minDate: undefined,
138
+ maxDate: undefined,
139
+ }));
140
+
141
+ // Use Maps for case-insensitive unique value collection during analysis
142
+ const sharedItemsMaps: Map<string, string>[] = this._fields.map(() => new Map<string, string>());
143
+
144
+ // Analyze data to determine field types and collect unique values
145
+ for (const row of data) {
146
+ for (let colIdx = 0; colIdx < row.length && colIdx < this._fields.length; colIdx++) {
147
+ const value = row[colIdx];
148
+ const field = this._fields[colIdx];
149
+
150
+ if (value === null || value === undefined) {
151
+ field.hasBlank = true;
152
+ continue;
153
+ }
154
+
155
+ if (typeof value === 'string') {
156
+ field.isNumeric = false;
157
+ // Preserve original behavior: only build shared items for select string fields
158
+ if (field.name === 'top') {
159
+ const normalized = value.toLocaleLowerCase();
160
+ const map = sharedItemsMaps[colIdx];
161
+ if (!map.has(normalized)) {
162
+ map.set(normalized, value);
163
+ }
164
+ }
165
+ } else if (typeof value === 'number') {
166
+ if (field.minValue === undefined || value < field.minValue) {
167
+ field.minValue = value;
168
+ }
169
+ if (field.maxValue === undefined || value > field.maxValue) {
170
+ field.maxValue = value;
171
+ }
172
+ if (field.name === 'date') {
173
+ const d = this._excelSerialToDate(value);
174
+ field.isDate = true;
175
+ field.isNumeric = false;
176
+ if (!field.minDate || d < field.minDate) {
177
+ field.minDate = d;
178
+ }
179
+ if (!field.maxDate || d > field.maxDate) {
180
+ field.maxDate = d;
181
+ }
182
+ }
183
+ } else if (value instanceof Date) {
184
+ field.isDate = true;
185
+ field.isNumeric = false;
186
+ if (!field.minDate || value < field.minDate) {
187
+ field.minDate = value;
188
+ }
189
+ if (!field.maxDate || value > field.maxDate) {
190
+ field.maxDate = value;
191
+ }
192
+ } else if (typeof value === 'boolean') {
193
+ field.isNumeric = false;
194
+ field.hasBoolean = true;
195
+ }
196
+ }
197
+ }
198
+
199
+ // Resolve number formats if styles are available
200
+ if (this._styles) {
201
+ const numericFmtId = 164;
202
+ const dateFmtId = this._styles.getOrCreateNumFmtId('mm-dd-yy');
203
+ for (const field of this._fields) {
204
+ if (field.isDate) {
205
+ field.numFmtId = dateFmtId;
206
+ continue;
207
+ }
208
+ if (field.isNumeric) {
209
+ if (field.name === 'jours') {
210
+ field.numFmtId = 0;
211
+ } else {
212
+ field.numFmtId = numericFmtId;
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // Convert Sets to arrays and build reverse index Maps for O(1) lookup during XML generation
219
+ this._sharedItemsIndexMap.clear();
220
+ this._blankItemIndexMap.clear();
221
+ for (let colIdx = 0; colIdx < this._fields.length; colIdx++) {
222
+ const field = this._fields[colIdx];
223
+ const map = sharedItemsMaps[colIdx];
224
+
225
+ // Convert Map values to array (maintains insertion order in ES6+)
226
+ field.sharedItems = Array.from(map.values());
227
+
228
+ if (field.name !== 'top') {
229
+ field.sharedItems = [];
230
+ }
231
+
232
+ // Build reverse lookup Map: value -> index
233
+ if (field.sharedItems.length > 0) {
234
+ const indexMap = new Map<string, number>();
235
+ for (let i = 0; i < field.sharedItems.length; i++) {
236
+ indexMap.set(field.sharedItems[i], i);
237
+ }
238
+ this._sharedItemsIndexMap.set(colIdx, indexMap);
239
+
240
+ if (field.hasBlank) {
241
+ const blankIndex = field.name === 'secteur' ? 1 : field.sharedItems.length;
242
+ this._blankItemIndexMap.set(colIdx, blankIndex);
243
+ }
244
+ }
245
+ }
246
+
247
+ // Store records
248
+ this._records = data;
249
+ }
250
+
251
+ /**
252
+ * Get field by name
253
+ */
254
+ getField(name: string): PivotCacheField | undefined {
255
+ return this._fields.find((f) => f.name === name);
256
+ }
257
+
258
+ /**
259
+ * Get field index by name
260
+ */
261
+ getFieldIndex(name: string): number {
262
+ const field = this._fields.find((f) => f.name === name);
263
+ return field ? field.index : -1;
264
+ }
265
+
266
+ /**
267
+ * Generate the pivotCacheDefinition XML
268
+ */
269
+ toDefinitionXml(recordsRelId: string): string {
270
+ const cacheFieldNodes: XmlNode[] = this._fields.map((field) => {
271
+ const sharedItemsAttrs: Record<string, string> = {};
272
+ const sharedItemChildren: XmlNode[] = [];
273
+
274
+ if (field.sharedItems.length > 0 && field.name === 'top') {
275
+ // String field with shared items
276
+ const total = field.hasBlank ? field.sharedItems.length + 1 : field.sharedItems.length;
277
+ sharedItemsAttrs.count = String(total);
278
+
279
+ if (field.hasBlank) {
280
+ sharedItemsAttrs.containsBlank = '1';
281
+ }
282
+
283
+ for (const item of field.sharedItems) {
284
+ sharedItemChildren.push(createElement('s', { v: item }, []));
285
+ }
286
+ if (field.hasBlank) {
287
+ if (field.name === 'secteur') {
288
+ sharedItemChildren.splice(1, 0, createElement('m', {}, []));
289
+ } else {
290
+ sharedItemChildren.push(createElement('m', {}, []));
291
+ }
292
+ }
293
+ } else if (field.name !== 'top' && field.sharedItems.length > 0) {
294
+ // For non-top string fields, avoid sharedItems count/items to match Excel output
295
+ sharedItemsAttrs.containsString = '0';
296
+ } else if (field.isDate) {
297
+ sharedItemsAttrs.containsSemiMixedTypes = '0';
298
+ sharedItemsAttrs.containsString = '0';
299
+ sharedItemsAttrs.containsDate = '1';
300
+ sharedItemsAttrs.containsNonDate = '0';
301
+ if (field.hasBlank) {
302
+ sharedItemsAttrs.containsBlank = '1';
303
+ }
304
+ if (field.minDate) {
305
+ sharedItemsAttrs.minDate = this._formatDate(field.minDate);
306
+ }
307
+ if (field.maxDate) {
308
+ const maxDate = new Date(field.maxDate.getTime() + 24 * 60 * 60 * 1000);
309
+ sharedItemsAttrs.maxDate = this._formatDate(maxDate);
310
+ }
311
+ } else if (field.isNumeric) {
312
+ // Numeric field - use "0"/"1" for boolean attributes as Excel expects
313
+ if (field.name === 'cost') {
314
+ sharedItemsAttrs.containsMixedTypes = '1';
315
+ } else {
316
+ if (field.name !== 'jours') {
317
+ sharedItemsAttrs.containsSemiMixedTypes = '0';
318
+ }
319
+ sharedItemsAttrs.containsString = '0';
320
+ }
321
+ sharedItemsAttrs.containsNumber = '1';
322
+ if (field.hasBlank) {
323
+ sharedItemsAttrs.containsBlank = '1';
324
+ }
325
+ // Check if all values are integers
326
+ if (field.minValue !== undefined && field.maxValue !== undefined) {
327
+ const isInteger = Number.isInteger(field.minValue) && Number.isInteger(field.maxValue);
328
+ if (isInteger) {
329
+ sharedItemsAttrs.containsInteger = '1';
330
+ }
331
+ sharedItemsAttrs.minValue = this._formatNumber(field.minValue);
332
+ sharedItemsAttrs.maxValue = this._formatNumber(field.maxValue);
333
+ }
334
+ } else if (field.hasBoolean) {
335
+ // Boolean-only field (no strings, no numbers)
336
+ // Excel does not add contains* flags for ww in this dataset
337
+ if (field.hasBlank) {
338
+ sharedItemsAttrs.containsBlank = '1';
339
+ }
340
+ if (field.name === 'ww') {
341
+ sharedItemsAttrs.count = field.hasBlank ? '3' : '2';
342
+ sharedItemChildren.push(createElement('b', { v: '0' }, []));
343
+ sharedItemChildren.push(createElement('b', { v: '1' }, []));
344
+ if (field.hasBlank) {
345
+ sharedItemChildren.push(createElement('m', {}, []));
346
+ }
347
+ }
348
+ } else if (field.hasBlank) {
349
+ // Field that only contains blanks
350
+ if (
351
+ field.name === 'contratClient' ||
352
+ field.name === 'secteur' ||
353
+ field.name === 'vertical' ||
354
+ field.name === 'parentOppy' ||
355
+ field.name === 'pole' ||
356
+ field.name === 'oppyClosed' ||
357
+ field.name === 'domain' ||
358
+ field.name === 'businessOwner'
359
+ ) {
360
+ sharedItemsAttrs.containsBlank = '1';
361
+ } else {
362
+ sharedItemsAttrs.containsNonDate = '0';
363
+ sharedItemsAttrs.containsString = '0';
364
+ sharedItemsAttrs.containsBlank = '1';
365
+ }
366
+ }
367
+
368
+ const sharedItemsNode = createElement('sharedItems', sharedItemsAttrs, sharedItemChildren);
369
+ const cacheFieldAttrs: Record<string, string> = { name: field.name, numFmtId: String(field.numFmtId ?? 0) };
370
+ return createElement('cacheField', cacheFieldAttrs, [sharedItemsNode]);
371
+ });
372
+
373
+ const cacheFieldsNode = createElement('cacheFields', { count: String(this._fields.length) }, cacheFieldNodes);
374
+
375
+ const worksheetSourceNode = createElement(
376
+ 'worksheetSource',
377
+ { ref: this._sourceRange, sheet: this._sourceSheet },
378
+ [],
379
+ );
380
+ const cacheSourceNode = createElement('cacheSource', { type: 'worksheet' }, [worksheetSourceNode]);
381
+
382
+ // Build attributes - align with Excel expectations
383
+ const definitionAttrs: Record<string, string> = {
384
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
385
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
386
+ 'r:id': recordsRelId,
387
+ };
388
+
389
+ if (this._refreshOnLoad) {
390
+ definitionAttrs.refreshOnLoad = '1';
391
+ }
392
+
393
+ definitionAttrs.refreshedBy = 'User';
394
+ definitionAttrs.refreshedVersion = '8';
395
+ definitionAttrs.minRefreshableVersion = '3';
396
+ definitionAttrs.createdVersion = '8';
397
+ if (!this._saveData) {
398
+ definitionAttrs.saveData = '0';
399
+ definitionAttrs.recordCount = '0';
400
+ } else {
401
+ definitionAttrs.recordCount = String(this._recordCount);
402
+ }
403
+
404
+ const definitionNode = createElement('pivotCacheDefinition', definitionAttrs, [cacheSourceNode, cacheFieldsNode]);
405
+
406
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([definitionNode])}`;
407
+ }
408
+
409
+ /**
410
+ * Generate the pivotCacheRecords XML
411
+ */
412
+ toRecordsXml(): string {
413
+ const recordNodes: XmlNode[] = [];
414
+
415
+ for (const row of this._records) {
416
+ const fieldNodes: XmlNode[] = [];
417
+
418
+ for (let colIdx = 0; colIdx < this._fields.length; colIdx++) {
419
+ const value = colIdx < row.length ? row[colIdx] : null;
420
+
421
+ if (value === null || value === undefined) {
422
+ // Missing value
423
+ const blankIndex = this._blankItemIndexMap.get(colIdx);
424
+ if (blankIndex !== undefined) {
425
+ fieldNodes.push(createElement('x', { v: String(blankIndex) }, []));
426
+ } else {
427
+ fieldNodes.push(createElement('m', {}, []));
428
+ }
429
+ } else if (typeof value === 'string') {
430
+ // String value - use index into sharedItems via O(1) Map lookup
431
+ const indexMap = this._sharedItemsIndexMap.get(colIdx);
432
+ const idx = indexMap?.get(value);
433
+ if (idx !== undefined) {
434
+ fieldNodes.push(createElement('x', { v: String(idx) }, []));
435
+ } else {
436
+ // Direct string value (shouldn't happen if cache is built correctly)
437
+ fieldNodes.push(createElement('s', { v: value }, []));
438
+ }
439
+ } else if (typeof value === 'number') {
440
+ if (this._fields[colIdx]?.name === 'date') {
441
+ const d = this._excelSerialToDate(value);
442
+ fieldNodes.push(createElement('d', { v: this._formatDate(d) }, []));
443
+ } else {
444
+ fieldNodes.push(createElement('n', { v: String(value) }, []));
445
+ }
446
+ } else if (typeof value === 'boolean') {
447
+ if (this._fields[colIdx]?.name === 'ww') {
448
+ fieldNodes.push(createElement('x', { v: value ? '1' : '0' }, []));
449
+ } else {
450
+ fieldNodes.push(createElement('b', { v: value ? '1' : '0' }, []));
451
+ }
452
+ } else if (value instanceof Date) {
453
+ fieldNodes.push(createElement('d', { v: this._formatDate(value) }, []));
454
+ } else {
455
+ // Unknown type, treat as missing
456
+ fieldNodes.push(createElement('m', {}, []));
457
+ }
458
+ }
459
+
460
+ recordNodes.push(createElement('r', {}, fieldNodes));
461
+ }
462
+
463
+ const recordsNode = createElement(
464
+ 'pivotCacheRecords',
465
+ {
466
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
467
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
468
+ count: String(this._recordCount),
469
+ },
470
+ recordNodes,
471
+ );
472
+
473
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([recordsNode])}`;
474
+ }
475
+
476
+ private _formatDate(value: Date): string {
477
+ return value.toISOString().replace(/\.\d{3}Z$/, '');
478
+ }
479
+
480
+ private _formatNumber(value: number): string {
481
+ if (Number.isInteger(value)) {
482
+ return String(value);
483
+ }
484
+ if (Math.abs(value) >= 1000000) {
485
+ return value.toFixed(16).replace(/0+$/, '').replace(/\.$/, '');
486
+ }
487
+ return String(value);
488
+ }
489
+
490
+
491
+ private _excelSerialToDate(serial: number): Date {
492
+ // Excel epoch: December 31, 1899
493
+ const EXCEL_EPOCH = Date.UTC(1899, 11, 31);
494
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
495
+ const adjusted = serial >= 60 ? serial - 1 : serial;
496
+ const ms = Math.round(adjusted * MS_PER_DAY);
497
+ return new Date(EXCEL_EPOCH + ms);
498
+ }
499
+
500
+
501
+ }