@cj-tech-master/excelts 1.5.0 → 1.6.1

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.
Files changed (45) hide show
  1. package/dist/browser/excelts.iife.js +1057 -201
  2. package/dist/browser/excelts.iife.js.map +1 -1
  3. package/dist/browser/excelts.iife.min.js +63 -33
  4. package/dist/cjs/doc/column.js +7 -3
  5. package/dist/cjs/doc/pivot-table.js +149 -61
  6. package/dist/cjs/doc/workbook.js +3 -1
  7. package/dist/cjs/doc/worksheet.js +0 -2
  8. package/dist/cjs/stream/xlsx/worksheet-writer.js +1 -1
  9. package/dist/cjs/utils/unzip/zip-parser.js +2 -5
  10. package/dist/cjs/xlsx/xform/book/workbook-xform.js +3 -0
  11. package/dist/cjs/xlsx/xform/core/content-types-xform.js +19 -14
  12. package/dist/cjs/xlsx/xform/pivot-table/cache-field-xform.js +135 -0
  13. package/dist/cjs/xlsx/xform/pivot-table/cache-field.js +7 -4
  14. package/dist/cjs/xlsx/xform/pivot-table/pivot-cache-definition-xform.js +135 -13
  15. package/dist/cjs/xlsx/xform/pivot-table/pivot-cache-records-xform.js +193 -45
  16. package/dist/cjs/xlsx/xform/pivot-table/pivot-table-xform.js +390 -39
  17. package/dist/cjs/xlsx/xform/sheet/cell-xform.js +6 -0
  18. package/dist/cjs/xlsx/xform/sheet/worksheet-xform.js +14 -3
  19. package/dist/cjs/xlsx/xlsx.js +261 -38
  20. package/dist/esm/doc/column.js +7 -3
  21. package/dist/esm/doc/pivot-table.js +150 -62
  22. package/dist/esm/doc/workbook.js +3 -1
  23. package/dist/esm/doc/worksheet.js +0 -2
  24. package/dist/esm/stream/xlsx/worksheet-writer.js +1 -1
  25. package/dist/esm/utils/unzip/zip-parser.js +2 -5
  26. package/dist/esm/xlsx/xform/book/workbook-xform.js +3 -0
  27. package/dist/esm/xlsx/xform/core/content-types-xform.js +19 -14
  28. package/dist/esm/xlsx/xform/pivot-table/cache-field-xform.js +132 -0
  29. package/dist/esm/xlsx/xform/pivot-table/cache-field.js +7 -4
  30. package/dist/esm/xlsx/xform/pivot-table/pivot-cache-definition-xform.js +135 -13
  31. package/dist/esm/xlsx/xform/pivot-table/pivot-cache-records-xform.js +193 -45
  32. package/dist/esm/xlsx/xform/pivot-table/pivot-table-xform.js +390 -39
  33. package/dist/esm/xlsx/xform/sheet/cell-xform.js +6 -0
  34. package/dist/esm/xlsx/xform/sheet/worksheet-xform.js +14 -3
  35. package/dist/esm/xlsx/xlsx.js +261 -38
  36. package/dist/types/doc/column.d.ts +13 -6
  37. package/dist/types/doc/pivot-table.d.ts +135 -9
  38. package/dist/types/doc/workbook.d.ts +2 -0
  39. package/dist/types/index.d.ts +1 -0
  40. package/dist/types/xlsx/xform/pivot-table/cache-field-xform.d.ts +42 -0
  41. package/dist/types/xlsx/xform/pivot-table/pivot-cache-definition-xform.d.ts +45 -6
  42. package/dist/types/xlsx/xform/pivot-table/pivot-cache-records-xform.d.ts +52 -5
  43. package/dist/types/xlsx/xform/pivot-table/pivot-table-xform.d.ts +98 -5
  44. package/dist/types/xlsx/xlsx.d.ts +27 -0
  45. package/package.json +17 -17
@@ -1,23 +1,114 @@
1
- import { objectFromProps, range, toSortedArray } from "../utils/utils.js";
1
+ import { range, toSortedArray } from "../utils/utils.js";
2
+ import { colCache } from "../utils/col-cache.js";
2
3
  // TK(2023-10-10): turn this into a class constructor.
4
+ /**
5
+ * Creates a PivotTableSource adapter from a Table object.
6
+ * This allows Tables to be used as pivot table data sources with the same interface as Worksheets.
7
+ */
8
+ function createTableSourceAdapter(table) {
9
+ const tableModel = table.model;
10
+ // Validate that table has headerRow enabled (required for pivot table column names)
11
+ if (tableModel.headerRow === false) {
12
+ throw new Error("Cannot create pivot table from a table without headers. Set headerRow: true on the table.");
13
+ }
14
+ // Validate table has data rows
15
+ if (!tableModel.rows || tableModel.rows.length === 0) {
16
+ throw new Error("Cannot create pivot table from an empty table. Add data rows to the table.");
17
+ }
18
+ const columnNames = tableModel.columns.map(col => col.name);
19
+ // Check for duplicate column names
20
+ const nameSet = new Set();
21
+ for (const name of columnNames) {
22
+ if (nameSet.has(name)) {
23
+ throw new Error(`Duplicate column name "${name}" found in table. Pivot tables require unique column names.`);
24
+ }
25
+ nameSet.add(name);
26
+ }
27
+ // Build the full data array: headers + rows
28
+ const headerRow = [undefined, ...columnNames]; // sparse array starting at index 1
29
+ const dataRows = tableModel.rows.map(row => [undefined, ...row]); // sparse array starting at index 1
30
+ // Calculate the range reference for the table
31
+ const tl = tableModel.tl;
32
+ const startRow = tl.row;
33
+ const startCol = tl.col;
34
+ const endRow = startRow + tableModel.rows.length; // header row + data rows
35
+ const endCol = startCol + columnNames.length - 1;
36
+ const shortRange = colCache.encode(startRow, startCol, endRow, endCol);
37
+ return {
38
+ name: tableModel.name,
39
+ getRow(rowNumber) {
40
+ if (rowNumber === 1) {
41
+ return { values: headerRow };
42
+ }
43
+ const dataIndex = rowNumber - 2; // rowNumber 2 maps to index 0
44
+ if (dataIndex >= 0 && dataIndex < dataRows.length) {
45
+ return { values: dataRows[dataIndex] };
46
+ }
47
+ return { values: [] };
48
+ },
49
+ getColumn(columnNumber) {
50
+ // Validate column number is within bounds
51
+ if (columnNumber < 1 || columnNumber > columnNames.length) {
52
+ return { values: [] };
53
+ }
54
+ // Values should be sparse array with header at index 1, data starting at index 2
55
+ const values = [];
56
+ values[1] = columnNames[columnNumber - 1];
57
+ for (let i = 0; i < tableModel.rows.length; i++) {
58
+ values[i + 2] = tableModel.rows[i][columnNumber - 1];
59
+ }
60
+ return { values };
61
+ },
62
+ getSheetValues() {
63
+ // Return sparse array where index 1 is header row, and subsequent indices are data rows
64
+ const result = [];
65
+ result[1] = headerRow;
66
+ for (let i = 0; i < dataRows.length; i++) {
67
+ result[i + 2] = dataRows[i];
68
+ }
69
+ return result;
70
+ },
71
+ dimensions: { shortRange }
72
+ };
73
+ }
74
+ /**
75
+ * Resolves the data source from the model, supporting both sourceSheet and sourceTable.
76
+ */
77
+ function resolveSource(model) {
78
+ if (model.sourceTable) {
79
+ return createTableSourceAdapter(model.sourceTable);
80
+ }
81
+ // For sourceSheet, it already implements the required interface
82
+ return model.sourceSheet;
83
+ }
3
84
  function makePivotTable(worksheet, model) {
4
85
  // Example `model`:
5
86
  // {
6
- // // Source of data: the entire sheet range is taken,
7
- // // akin to `worksheet1.getSheetValues()`.
8
- // sourceSheet: worksheet1,
87
+ // // Source of data (either sourceSheet OR sourceTable):
88
+ // sourceSheet: worksheet1, // Use entire sheet range
89
+ // // OR
90
+ // sourceTable: table, // Use table data
9
91
  //
10
92
  // // Pivot table fields: values indicate field names;
11
- // // they come from the first row in `worksheet1`.
93
+ // // they come from the first row in `worksheet1` or table column names.
12
94
  // rows: ['A', 'B'],
13
95
  // columns: ['C'],
14
96
  // values: ['E'], // only 1 item possible for now
15
97
  // metric: 'sum', // only 'sum' possible for now
16
98
  // }
17
- validate(worksheet, model);
18
- const { sourceSheet } = model;
19
- const { rows, columns, values } = model;
20
- const cacheFields = makeCacheFields(sourceSheet, [...rows, ...columns]);
99
+ // Validate source exists before trying to resolve it
100
+ if (!model.sourceSheet && !model.sourceTable) {
101
+ throw new Error("Either sourceSheet or sourceTable must be provided.");
102
+ }
103
+ if (model.sourceSheet && model.sourceTable) {
104
+ throw new Error("Cannot specify both sourceSheet and sourceTable. Choose one.");
105
+ }
106
+ // Resolve source first to avoid creating adapter multiple times
107
+ const source = resolveSource(model);
108
+ validate(worksheet, model, source);
109
+ const { rows, values } = model;
110
+ const columns = model.columns ?? [];
111
+ const cacheFields = makeCacheFields(source, [...rows, ...columns]);
21
112
  const nameToIndex = cacheFields.reduce((result, cacheField, index) => {
22
113
  result[cacheField.name] = index;
23
114
  return result;
@@ -25,82 +116,79 @@ function makePivotTable(worksheet, model) {
25
116
  const rowIndices = rows.map(row => nameToIndex[row]);
26
117
  const columnIndices = columns.map(column => nameToIndex[column]);
27
118
  const valueIndices = values.map(value => nameToIndex[value]);
119
+ // Calculate tableNumber based on existing pivot tables (1-indexed)
120
+ const tableNumber = worksheet.workbook.pivotTables.length + 1;
121
+ // Base cache ID starts at 10 (Excel convention), each subsequent table increments
122
+ const BASE_CACHE_ID = 10;
28
123
  // form pivot table object
29
124
  return {
30
- sourceSheet,
125
+ source,
31
126
  rows: rowIndices,
32
127
  columns: columnIndices,
33
128
  values: valueIndices,
34
- metric: "sum",
129
+ metric: model.metric ?? "sum",
35
130
  cacheFields,
36
- // defined in <pivotTableDefinition> of xl/pivotTables/pivotTable1.xml;
37
- // also used in xl/workbook.xml
38
- cacheId: "10"
131
+ // Dynamic cacheId: 10 for first table, 11 for second, etc.
132
+ // Used in <pivotTableDefinition> and xl/workbook.xml
133
+ cacheId: String(BASE_CACHE_ID + tableNumber - 1),
134
+ // Control whether pivot table style overrides worksheet column widths
135
+ // '0' = preserve worksheet column widths (useful for custom sizing)
136
+ // '1' = apply pivot table style width/height (default Excel behavior)
137
+ applyWidthHeightFormats: model.applyWidthHeightFormats ?? "1",
138
+ // Table number for file naming (pivotTable1.xml, pivotTable2.xml, etc.)
139
+ tableNumber
39
140
  };
40
141
  }
41
- function validate(worksheet, model) {
42
- if (worksheet.workbook.pivotTables.length === 1) {
43
- throw new Error("A pivot table was already added. At this time, ExcelTS supports at most one pivot table per file.");
142
+ function validate(_worksheet, model, source) {
143
+ if (model.metric && model.metric !== "sum" && model.metric !== "count") {
144
+ throw new Error('Only the "sum" and "count" metrics are supported at this time.');
44
145
  }
45
- if (model.metric && model.metric !== "sum") {
46
- throw new Error('Only the "sum" metric is supported at this time.');
47
- }
48
- const headerNames = model.sourceSheet.getRow(1).values.slice(1);
49
- const isInHeaderNames = objectFromProps(headerNames, true);
50
- for (const name of [...model.rows, ...model.columns, ...model.values]) {
51
- if (!isInHeaderNames[name]) {
52
- throw new Error(`The header name "${name}" was not found in ${model.sourceSheet.name}.`);
146
+ const columns = model.columns ?? [];
147
+ // Get header names from source (already resolved)
148
+ const headerNames = source.getRow(1).values.slice(1);
149
+ // Use Set for O(1) lookup
150
+ const headerNameSet = new Set(headerNames);
151
+ for (const name of [...model.rows, ...columns, ...model.values]) {
152
+ if (!headerNameSet.has(name)) {
153
+ throw new Error(`The header name "${name}" was not found in ${source.name}.`);
53
154
  }
54
155
  }
55
156
  if (!model.rows.length) {
56
157
  throw new Error("No pivot table rows specified.");
57
158
  }
58
- if (!model.columns.length) {
59
- throw new Error("No pivot table columns specified.");
159
+ // Allow empty columns - Excel will use "Values" as column field
160
+ // But can't have multiple values with columns specified
161
+ if (model.values.length < 1) {
162
+ throw new Error("Must have at least one value.");
60
163
  }
61
- if (model.values.length !== 1) {
62
- throw new Error("Exactly 1 value needs to be specified at this time.");
164
+ if (model.values.length > 1 && columns.length > 0) {
165
+ throw new Error("It is currently not possible to have multiple values when columns are specified. Please either supply an empty array for columns or a single value.");
63
166
  }
64
167
  }
65
- function makeCacheFields(worksheet, fieldNamesWithSharedItems) {
168
+ function makeCacheFields(source, fieldNamesWithSharedItems) {
66
169
  // Cache fields are used in pivot tables to reference source data.
67
- //
68
- // Example
69
- // -------
70
- // Turn
71
- //
72
- // `worksheet` sheet values [
73
- // ['A', 'B', 'C', 'D', 'E'],
74
- // ['a1', 'b1', 'c1', 4, 5],
75
- // ['a1', 'b2', 'c1', 4, 5],
76
- // ['a2', 'b1', 'c2', 14, 24],
77
- // ['a2', 'b2', 'c2', 24, 35],
78
- // ['a3', 'b1', 'c3', 34, 45],
79
- // ['a3', 'b2', 'c3', 44, 45]
80
- // ];
81
- // fieldNamesWithSharedItems = ['A', 'B', 'C'];
82
- //
83
- // into
84
- //
85
- // [
86
- // { name: 'A', sharedItems: ['a1', 'a2', 'a3'] },
87
- // { name: 'B', sharedItems: ['b1', 'b2'] },
88
- // { name: 'C', sharedItems: ['c1', 'c2', 'c3'] },
89
- // { name: 'D', sharedItems: null },
90
- // { name: 'E', sharedItems: null }
91
- // ]
92
- const names = worksheet.getRow(1).values;
93
- const nameToHasSharedItems = objectFromProps(fieldNamesWithSharedItems, true);
170
+ // Fields in fieldNamesWithSharedItems get their unique values extracted as sharedItems.
171
+ // Other fields (typically numeric) have sharedItems = null.
172
+ const names = source.getRow(1).values;
173
+ // Use Set for O(1) lookup instead of object
174
+ const sharedItemsFields = new Set(fieldNamesWithSharedItems);
94
175
  const aggregate = (columnIndex) => {
95
- const columnValues = worksheet.getColumn(columnIndex).values.splice(2);
96
- const columnValuesAsSet = new Set(columnValues);
97
- return toSortedArray(columnValuesAsSet);
176
+ const columnValues = source.getColumn(columnIndex).values;
177
+ // Build unique values set directly, skipping header (index 0,1) and null/undefined
178
+ const uniqueValues = new Set();
179
+ for (let i = 2; i < columnValues.length; i++) {
180
+ const v = columnValues[i];
181
+ if (v !== null && v !== undefined) {
182
+ uniqueValues.add(v);
183
+ }
184
+ }
185
+ return toSortedArray(uniqueValues);
98
186
  };
99
- // make result
187
+ // Build result array
100
188
  const result = [];
101
189
  for (const columnIndex of range(1, names.length)) {
102
190
  const name = names[columnIndex];
103
- const sharedItems = nameToHasSharedItems[name] ? aggregate(columnIndex) : null;
191
+ const sharedItems = sharedItemsFields.has(name) ? aggregate(columnIndex) : null;
104
192
  result.push({ name, sharedItems });
105
193
  }
106
194
  return result;
@@ -196,7 +196,9 @@ class Workbook {
196
196
  this.views = value.views;
197
197
  this._themes = value.themes;
198
198
  this.media = value.media || [];
199
- this.pivotTables = value.pivotTables || [];
199
+ // Handle pivot tables - either newly created or loaded from file
200
+ // Loaded pivot tables come from loadedPivotTables after reconciliation
201
+ this.pivotTables = value.pivotTables || value.loadedPivotTables || [];
200
202
  }
201
203
  }
202
204
  export { Workbook };
@@ -830,8 +830,6 @@ class Worksheet {
830
830
  // =========================================================================
831
831
  // Pivot Tables
832
832
  addPivotTable(model) {
833
- console.warn(`Warning: Pivot Table support is experimental.
834
- Please leave feedback at https://github.com/excelts/excelts/discussions/2575`);
835
833
  const pivotTable = makePivotTable(this, model);
836
834
  this.pivotTables.push(pivotTable);
837
835
  this.workbook.pivotTables.push(pivotTable);
@@ -180,6 +180,7 @@ class WorksheetWriter {
180
180
  this._writeOpenSheetData();
181
181
  }
182
182
  this._writeCloseSheetData();
183
+ this._writeSheetProtection(); // Note: must be after sheetData and before autoFilter
183
184
  this._writeAutoFilter();
184
185
  this._writeMergeCells();
185
186
  // for some reason, Excel can't handle dimensions at the bottom of the file
@@ -187,7 +188,6 @@ class WorksheetWriter {
187
188
  this._writeHyperlinks();
188
189
  this._writeConditionalFormatting();
189
190
  this._writeDataValidations();
190
- this._writeSheetProtection();
191
191
  this._writePageMargins();
192
192
  this._writePageSetup();
193
193
  this._writeBackground();
@@ -175,7 +175,7 @@ export function parseZipEntries(data, options = {}) {
175
175
  reader.skip(2); // disk where central dir starts
176
176
  reader.skip(2); // entries on this disk
177
177
  let totalEntries = reader.readUint16(); // total entries
178
- let centralDirSize = reader.readUint32();
178
+ reader.skip(4); // central directory size (unused)
179
179
  let centralDirOffset = reader.readUint32();
180
180
  // Check for ZIP64
181
181
  const zip64LocatorOffset = findZip64EOCDLocator(data, eocdOffset);
@@ -194,15 +194,12 @@ export function parseZipEntries(data, options = {}) {
194
194
  zip64Reader.skip(4); // disk number
195
195
  zip64Reader.skip(4); // disk with central dir
196
196
  const zip64TotalEntries = Number(zip64Reader.readBigUint64());
197
- const zip64CentralDirSize = Number(zip64Reader.readBigUint64());
197
+ zip64Reader.skip(8); // central directory size (unused)
198
198
  const zip64CentralDirOffset = Number(zip64Reader.readBigUint64());
199
199
  // Use ZIP64 values if standard values are maxed out
200
200
  if (totalEntries === 0xffff) {
201
201
  totalEntries = zip64TotalEntries;
202
202
  }
203
- if (centralDirSize === 0xffffffff) {
204
- centralDirSize = zip64CentralDirSize;
205
- }
206
203
  if (centralDirOffset === 0xffffffff) {
207
204
  centralDirOffset = zip64CentralDirOffset;
208
205
  }
@@ -130,6 +130,9 @@ class WorkbookXform extends BaseXform {
130
130
  if (this.map.definedNames.model) {
131
131
  this.model.definedNames = this.map.definedNames.model;
132
132
  }
133
+ if (this.map.pivotCaches.model && this.map.pivotCaches.model.length > 0) {
134
+ this.model.pivotCaches = this.map.pivotCaches.model;
135
+ }
133
136
  return false;
134
137
  default:
135
138
  // not quite sure how we get here!
@@ -28,26 +28,31 @@ class ContentTypesXform extends BaseXform {
28
28
  PartName: "/xl/workbook.xml",
29
29
  ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
30
30
  });
31
- model.worksheets.forEach((worksheet) => {
32
- const name = `/xl/worksheets/sheet${worksheet.id}.xml`;
31
+ model.worksheets.forEach((worksheet, index) => {
32
+ // Use fileIndex if set, otherwise use sequential index (1-based)
33
+ const fileIndex = worksheet.fileIndex || index + 1;
34
+ const name = `/xl/worksheets/sheet${fileIndex}.xml`;
33
35
  xmlStream.leafNode("Override", {
34
36
  PartName: name,
35
37
  ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
36
38
  });
37
39
  });
38
40
  if ((model.pivotTables || []).length) {
39
- // Note(2023-10-06): assuming at most one pivot table for now.
40
- xmlStream.leafNode("Override", {
41
- PartName: "/xl/pivotCache/pivotCacheDefinition1.xml",
42
- ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"
43
- });
44
- xmlStream.leafNode("Override", {
45
- PartName: "/xl/pivotCache/pivotCacheRecords1.xml",
46
- ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"
47
- });
48
- xmlStream.leafNode("Override", {
49
- PartName: "/xl/pivotTables/pivotTable1.xml",
50
- ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"
41
+ // Add content types for each pivot table
42
+ (model.pivotTables || []).forEach((pivotTable) => {
43
+ const n = pivotTable.tableNumber;
44
+ xmlStream.leafNode("Override", {
45
+ PartName: `/xl/pivotCache/pivotCacheDefinition${n}.xml`,
46
+ ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"
47
+ });
48
+ xmlStream.leafNode("Override", {
49
+ PartName: `/xl/pivotCache/pivotCacheRecords${n}.xml`,
50
+ ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"
51
+ });
52
+ xmlStream.leafNode("Override", {
53
+ PartName: `/xl/pivotTables/pivotTable${n}.xml`,
54
+ ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"
55
+ });
51
56
  });
52
57
  }
53
58
  xmlStream.leafNode("Override", {
@@ -0,0 +1,132 @@
1
+ import { BaseXform } from "../base-xform.js";
2
+ import { xmlDecode } from "../../../utils/utils.js";
3
+ /**
4
+ * Xform for parsing individual <cacheField> elements within a pivot cache definition.
5
+ *
6
+ * Example XML:
7
+ * ```xml
8
+ * <cacheField name="Category" numFmtId="0">
9
+ * <sharedItems count="3">
10
+ * <s v="A" />
11
+ * <s v="B" />
12
+ * <s v="C" />
13
+ * </sharedItems>
14
+ * </cacheField>
15
+ *
16
+ * <cacheField name="Value" numFmtId="0">
17
+ * <sharedItems containsSemiMixedTypes="0" containsString="0"
18
+ * containsNumber="1" containsInteger="1" minValue="5" maxValue="45" />
19
+ * </cacheField>
20
+ * ```
21
+ */
22
+ class CacheFieldXform extends BaseXform {
23
+ constructor() {
24
+ super();
25
+ this.model = null;
26
+ this.inSharedItems = false;
27
+ }
28
+ get tag() {
29
+ return "cacheField";
30
+ }
31
+ reset() {
32
+ this.model = null;
33
+ this.inSharedItems = false;
34
+ }
35
+ parseOpen(node) {
36
+ const { name, attributes } = node;
37
+ switch (name) {
38
+ case "cacheField":
39
+ // Initialize the model with field name
40
+ this.model = {
41
+ name: xmlDecode(attributes.name || ""),
42
+ sharedItems: null
43
+ };
44
+ break;
45
+ case "sharedItems":
46
+ this.inSharedItems = true;
47
+ // Check if this is a numeric field (no string items)
48
+ if (attributes.containsNumber === "1" || attributes.containsInteger === "1") {
49
+ if (this.model) {
50
+ this.model.containsNumber = attributes.containsNumber === "1";
51
+ this.model.containsInteger = attributes.containsInteger === "1";
52
+ if (attributes.minValue !== undefined) {
53
+ this.model.minValue = parseFloat(attributes.minValue);
54
+ }
55
+ if (attributes.maxValue !== undefined) {
56
+ this.model.maxValue = parseFloat(attributes.maxValue);
57
+ }
58
+ // Numeric fields have sharedItems = null
59
+ this.model.sharedItems = null;
60
+ }
61
+ }
62
+ else {
63
+ // String field - initialize sharedItems array if count > 0
64
+ if (this.model) {
65
+ const count = parseInt(attributes.count || "0", 10);
66
+ if (count > 0) {
67
+ this.model.sharedItems = [];
68
+ }
69
+ }
70
+ }
71
+ break;
72
+ case "s":
73
+ // String value in sharedItems
74
+ if (this.inSharedItems && this.model?.sharedItems !== null) {
75
+ // Decode XML entities in the value
76
+ const value = xmlDecode(attributes.v || "");
77
+ this.model.sharedItems.push(value);
78
+ }
79
+ break;
80
+ case "n":
81
+ // Numeric value in sharedItems (less common, but possible)
82
+ if (this.inSharedItems && this.model?.sharedItems !== null) {
83
+ const value = parseFloat(attributes.v || "0");
84
+ this.model.sharedItems.push(value);
85
+ }
86
+ break;
87
+ case "b":
88
+ // Boolean value in sharedItems
89
+ if (this.inSharedItems && this.model?.sharedItems !== null) {
90
+ const value = attributes.v === "1";
91
+ this.model.sharedItems.push(value);
92
+ }
93
+ break;
94
+ case "e":
95
+ // Error value in sharedItems
96
+ if (this.inSharedItems && this.model?.sharedItems !== null) {
97
+ const value = `#${attributes.v || "ERROR!"}`;
98
+ this.model.sharedItems.push(value);
99
+ }
100
+ break;
101
+ case "m":
102
+ // Missing/null value in sharedItems
103
+ if (this.inSharedItems && this.model?.sharedItems !== null) {
104
+ this.model.sharedItems.push(null);
105
+ }
106
+ break;
107
+ case "d":
108
+ // Date value in sharedItems
109
+ if (this.inSharedItems && this.model?.sharedItems !== null) {
110
+ const value = new Date(attributes.v || "");
111
+ this.model.sharedItems.push(value);
112
+ }
113
+ break;
114
+ }
115
+ return true;
116
+ }
117
+ parseText(_text) {
118
+ // No text content in cacheField elements
119
+ }
120
+ parseClose(name) {
121
+ switch (name) {
122
+ case "cacheField":
123
+ // End of this cacheField element
124
+ return false;
125
+ case "sharedItems":
126
+ this.inSharedItems = false;
127
+ break;
128
+ }
129
+ return true;
130
+ }
131
+ }
132
+ export { CacheFieldXform };
@@ -1,3 +1,4 @@
1
+ import { xmlEncode } from "../../../utils/utils.js";
1
2
  class CacheField {
2
3
  constructor({ name, sharedItems }) {
3
4
  // string type
@@ -21,17 +22,19 @@ class CacheField {
21
22
  render() {
22
23
  // PivotCache Field: http://www.datypic.com/sc/ooxml/e-ssml_cacheField-1.html
23
24
  // Shared Items: http://www.datypic.com/sc/ooxml/e-ssml_sharedItems-1.html
25
+ // Escape XML special characters in name attribute
26
+ const escapedName = xmlEncode(this.name);
24
27
  // integer types
25
28
  if (this.sharedItems === null) {
26
29
  // TK(2023-07-18): left out attributes... minValue="5" maxValue="45"
27
- return `<cacheField name="${this.name}" numFmtId="0">
30
+ return `<cacheField name="${escapedName}" numFmtId="0">
28
31
  <sharedItems containsSemiMixedTypes="0" containsString="0" containsNumber="1" containsInteger="1" />
29
32
  </cacheField>`;
30
33
  }
31
- // string types
32
- return `<cacheField name="${this.name}" numFmtId="0">
34
+ // string types - escape XML special characters in each shared item value
35
+ return `<cacheField name="${escapedName}" numFmtId="0">
33
36
  <sharedItems count="${this.sharedItems.length}">
34
- ${this.sharedItems.map(item => `<s v="${item}" />`).join("")}
37
+ ${this.sharedItems.map(item => `<s v="${xmlEncode(String(item))}" />`).join("")}
35
38
  </sharedItems>
36
39
  </cacheField>`;
37
40
  }