@rowsncolumns/toolkit 24.4.5 → 24.5.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 (113) hide show
  1. package/dist/cjs/autofilter-condition.d.ts +45 -1
  2. package/dist/cjs/autofilter-condition.d.ts.map +1 -1
  3. package/dist/cjs/autofilter-condition.js +135 -12
  4. package/dist/cjs/excel-parser/helpers.d.ts +15 -1
  5. package/dist/cjs/excel-parser/helpers.d.ts.map +1 -1
  6. package/dist/cjs/excel-parser/helpers.js +119 -5
  7. package/dist/cjs/excel-parser/interface.d.ts +3 -1
  8. package/dist/cjs/excel-parser/interface.d.ts.map +1 -1
  9. package/dist/cjs/excel-parser/plugins/ods/index.d.ts.map +1 -1
  10. package/dist/cjs/excel-parser/plugins/ods/index.js +186 -97
  11. package/dist/cjs/excel-parser/plugins/ods/styles.d.ts +15 -0
  12. package/dist/cjs/excel-parser/plugins/ods/styles.d.ts.map +1 -1
  13. package/dist/cjs/excel-parser/plugins/ods/styles.js +80 -6
  14. package/dist/cjs/excel-parser/plugins/xlsb/index.d.ts.map +1 -1
  15. package/dist/cjs/excel-parser/plugins/xlsb/index.js +107 -103
  16. package/dist/cjs/excel-parser/plugins/xlsx/charts.d.ts +77 -1
  17. package/dist/cjs/excel-parser/plugins/xlsx/charts.d.ts.map +1 -1
  18. package/dist/cjs/excel-parser/plugins/xlsx/charts.js +920 -122
  19. package/dist/cjs/excel-parser/plugins/xlsx/drawing.d.ts +7 -1
  20. package/dist/cjs/excel-parser/plugins/xlsx/drawing.d.ts.map +1 -1
  21. package/dist/cjs/excel-parser/plugins/xlsx/drawing.js +24 -9
  22. package/dist/cjs/excel-parser/plugins/xlsx/form-controls.d.ts +24 -3
  23. package/dist/cjs/excel-parser/plugins/xlsx/form-controls.d.ts.map +1 -1
  24. package/dist/cjs/excel-parser/plugins/xlsx/form-controls.js +25 -3
  25. package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts +24 -1
  26. package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts.map +1 -1
  27. package/dist/cjs/excel-parser/plugins/xlsx/index.js +561 -394
  28. package/dist/cjs/excel-parser/plugins/xlsx/pivots.d.ts +8 -2
  29. package/dist/cjs/excel-parser/plugins/xlsx/pivots.d.ts.map +1 -1
  30. package/dist/cjs/excel-parser/plugins/xlsx/pivots.js +312 -21
  31. package/dist/cjs/excel-parser/plugins/xlsx/relationships.d.ts +6 -0
  32. package/dist/cjs/excel-parser/plugins/xlsx/relationships.d.ts.map +1 -1
  33. package/dist/cjs/excel-parser/plugins/xlsx/relationships.js +9 -0
  34. package/dist/cjs/excel-parser/plugins/xlsx/theme.d.ts +1 -1
  35. package/dist/cjs/excel-parser/types.d.ts +9 -1
  36. package/dist/cjs/excel-parser/types.d.ts.map +1 -1
  37. package/dist/cjs/excel-parser/workbook.d.ts +1 -0
  38. package/dist/cjs/excel-parser/workbook.d.ts.map +1 -1
  39. package/dist/cjs/excel-parser/workbook.js +5 -0
  40. package/dist/cjs/excel-parser/worker.js +2 -0
  41. package/dist/cjs/exporter/ods/ods-export.d.ts.map +1 -1
  42. package/dist/cjs/exporter/ods/ods-export.js +255 -80
  43. package/dist/cjs/exporter/xlsx/chart-builder.d.ts +7 -1
  44. package/dist/cjs/exporter/xlsx/chart-builder.d.ts.map +1 -1
  45. package/dist/cjs/exporter/xlsx/chart-builder.js +902 -119
  46. package/dist/cjs/exporter/xlsx/form-controls-builder.d.ts +16 -3
  47. package/dist/cjs/exporter/xlsx/form-controls-builder.d.ts.map +1 -1
  48. package/dist/cjs/exporter/xlsx/form-controls-builder.js +30 -8
  49. package/dist/cjs/exporter/xlsx/pivot-builder.d.ts.map +1 -1
  50. package/dist/cjs/exporter/xlsx/pivot-builder.js +206 -162
  51. package/dist/cjs/exporter/xlsx/style-builder.d.ts +8 -2
  52. package/dist/cjs/exporter/xlsx/style-builder.d.ts.map +1 -1
  53. package/dist/cjs/exporter/xlsx/style-builder.js +25 -0
  54. package/dist/cjs/exporter/xlsx/xlsx-export.d.ts +2 -2
  55. package/dist/cjs/exporter/xlsx/xlsx-export.d.ts.map +1 -1
  56. package/dist/cjs/exporter/xlsx/xlsx-export.js +884 -470
  57. package/dist/esm/autofilter-condition.d.ts +45 -1
  58. package/dist/esm/autofilter-condition.d.ts.map +1 -1
  59. package/dist/esm/autofilter-condition.js +131 -11
  60. package/dist/esm/excel-parser/helpers.d.ts +15 -1
  61. package/dist/esm/excel-parser/helpers.d.ts.map +1 -1
  62. package/dist/esm/excel-parser/helpers.js +94 -4
  63. package/dist/esm/excel-parser/interface.d.ts +3 -1
  64. package/dist/esm/excel-parser/interface.d.ts.map +1 -1
  65. package/dist/esm/excel-parser/plugins/ods/index.d.ts.map +1 -1
  66. package/dist/esm/excel-parser/plugins/ods/index.js +105 -22
  67. package/dist/esm/excel-parser/plugins/ods/styles.d.ts +15 -0
  68. package/dist/esm/excel-parser/plugins/ods/styles.d.ts.map +1 -1
  69. package/dist/esm/excel-parser/plugins/ods/styles.js +79 -5
  70. package/dist/esm/excel-parser/plugins/xlsb/index.d.ts.map +1 -1
  71. package/dist/esm/excel-parser/plugins/xlsb/index.js +15 -7
  72. package/dist/esm/excel-parser/plugins/xlsx/charts.d.ts +77 -1
  73. package/dist/esm/excel-parser/plugins/xlsx/charts.d.ts.map +1 -1
  74. package/dist/esm/excel-parser/plugins/xlsx/charts.js +708 -16
  75. package/dist/esm/excel-parser/plugins/xlsx/drawing.d.ts +7 -1
  76. package/dist/esm/excel-parser/plugins/xlsx/drawing.d.ts.map +1 -1
  77. package/dist/esm/excel-parser/plugins/xlsx/drawing.js +23 -0
  78. package/dist/esm/excel-parser/plugins/xlsx/form-controls.d.ts +24 -3
  79. package/dist/esm/excel-parser/plugins/xlsx/form-controls.d.ts.map +1 -1
  80. package/dist/esm/excel-parser/plugins/xlsx/form-controls.js +21 -3
  81. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts +24 -1
  82. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts.map +1 -1
  83. package/dist/esm/excel-parser/plugins/xlsx/index.js +192 -54
  84. package/dist/esm/excel-parser/plugins/xlsx/pivots.d.ts +8 -2
  85. package/dist/esm/excel-parser/plugins/xlsx/pivots.d.ts.map +1 -1
  86. package/dist/esm/excel-parser/plugins/xlsx/pivots.js +220 -4
  87. package/dist/esm/excel-parser/plugins/xlsx/relationships.d.ts +6 -0
  88. package/dist/esm/excel-parser/plugins/xlsx/relationships.d.ts.map +1 -1
  89. package/dist/esm/excel-parser/plugins/xlsx/relationships.js +9 -0
  90. package/dist/esm/excel-parser/plugins/xlsx/theme.d.ts +1 -1
  91. package/dist/esm/excel-parser/types.d.ts +9 -1
  92. package/dist/esm/excel-parser/types.d.ts.map +1 -1
  93. package/dist/esm/excel-parser/workbook.d.ts +1 -0
  94. package/dist/esm/excel-parser/workbook.d.ts.map +1 -1
  95. package/dist/esm/excel-parser/workbook.js +4 -0
  96. package/dist/esm/excel-parser/worker.js +2 -0
  97. package/dist/esm/exporter/ods/ods-export.d.ts.map +1 -1
  98. package/dist/esm/exporter/ods/ods-export.js +238 -72
  99. package/dist/esm/exporter/xlsx/chart-builder.d.ts +7 -1
  100. package/dist/esm/exporter/xlsx/chart-builder.d.ts.map +1 -1
  101. package/dist/esm/exporter/xlsx/chart-builder.js +674 -58
  102. package/dist/esm/exporter/xlsx/form-controls-builder.d.ts +16 -3
  103. package/dist/esm/exporter/xlsx/form-controls-builder.d.ts.map +1 -1
  104. package/dist/esm/exporter/xlsx/form-controls-builder.js +29 -8
  105. package/dist/esm/exporter/xlsx/pivot-builder.d.ts.map +1 -1
  106. package/dist/esm/exporter/xlsx/pivot-builder.js +187 -139
  107. package/dist/esm/exporter/xlsx/style-builder.d.ts +8 -2
  108. package/dist/esm/exporter/xlsx/style-builder.d.ts.map +1 -1
  109. package/dist/esm/exporter/xlsx/style-builder.js +28 -0
  110. package/dist/esm/exporter/xlsx/xlsx-export.d.ts +2 -2
  111. package/dist/esm/exporter/xlsx/xlsx-export.d.ts.map +1 -1
  112. package/dist/esm/exporter/xlsx/xlsx-export.js +600 -206
  113. package/package.json +10 -5
@@ -1,8 +1,8 @@
1
1
  import { cellRangeToAddress, cellToAddress, escapeCharacters, getCellEffectiveFormat, getCellEffectiveValue, getCellFormattedValue, getCellUserEnteredFormat, getCellUserEnteredValue, getExtendedValueBool, getExtendedValueError, getExtendedValueFormula, getExtendedValueNumber, getExtendedValueString, isNil, sortSheetsByIndex, } from "@rowsncolumns/utils";
2
2
  import { zipSync } from "fflate";
3
3
  import { omitInvalidDataValidationError } from "../invalid-data-validation";
4
- import { buildAutoFilterConditionXml } from "../../autofilter-condition";
5
- import { buildControlElementXml, buildControlsXml, buildCtrlPropXml, buildFormControlVmlShape, buildVmlDrawingXml, CTRL_PROP_CONTENT_TYPE, CTRL_PROP_REL_TYPE, FORM_CONTROL_VML_SHAPETYPE, } from "./form-controls-builder";
4
+ import { buildAutoFilterCriteriaXml } from "../../autofilter-condition";
5
+ import { buildControlElementXml, buildControlsXml, buildCtrlPropXml, buildFormControlVmlShape, controlAnchorFlags, buildVmlDrawingXml, CTRL_PROP_CONTENT_TYPE, CTRL_PROP_REL_TYPE, FORM_CONTROL_VML_SHAPETYPE, } from "./form-controls-builder";
6
6
  import { buildChartExGraphicFrameXml, buildChartExXml, CHART_EX_CONTENT_TYPE, CHART_EX_REL_TYPE, isChartExType, } from "./chartex-builder";
7
7
  import { applyPowerQueryParts } from "./power-query-builder";
8
8
  import { encodeImplicitIntersection } from "../../helpers/implicit-intersection";
@@ -19,7 +19,7 @@ import { buildChartXml } from "./chart-builder";
19
19
  import { buildPivotCacheDefinitionXml, buildPivotTableRelsXml, buildPivotTableXml, calculatedFieldReferences, } from "./pivot-builder";
20
20
  import { buildSlicerCacheXml, buildSlicerXml } from "./slicer-builder";
21
21
  import { buildSparklineGroupsXml } from "./sparkline-builder";
22
- import { StyleBuilder } from "./style-builder";
22
+ import { StyleBuilder, tableStyleElementsFromBanding, } from "./style-builder";
23
23
  const BLANK_LABEL = "(Blanks)";
24
24
  const isStyleReference = (value) => !!value && typeof value === "object" && "sid" in value;
25
25
  const DEFAULT_COLUMN_WIDTH_PX = 100;
@@ -38,9 +38,15 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
38
38
  if (namedCellStyles && namedCellStyles.length > 0) {
39
39
  styleBuilder.setNamedCellStyles(namedCellStyles);
40
40
  }
41
- if (customTableStyles && customTableStyles.length > 0) {
42
- styleBuilder.setCustomTableStyles(customTableStyles);
41
+ // The document facet carries `{ name, bandedRange }` (#668 T-103); a host may still pass the
42
+ // parser's element records. Both become `<tableStyle>` elements.
43
+ const customTableStyleRecords = (customTableStyles ?? []).map((style) => "bandedRange" in style
44
+ ? { name: style.name, elements: tableStyleElementsFromBanding(style.bandedRange) }
45
+ : style);
46
+ if (customTableStyleRecords.length > 0) {
47
+ styleBuilder.setCustomTableStyles(customTableStyleRecords);
43
48
  }
49
+ const customTableStyleNames = new Set(customTableStyleRecords.map((s) => s.name));
44
50
  // Drop tables with invalid coords or overlapping ranges — Excel rejects them
45
51
  // and would trigger a recovery prompt. Filter once so the id remap and the
46
52
  // prepared output stay in lockstep.
@@ -52,10 +58,13 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
52
58
  tableIdMap.set(String(table.id), index + 1);
53
59
  });
54
60
  const preparedTables = prepareTables(exportableTables);
55
- const embedsBySheet = groupEmbedsBySheet(embeds ?? []);
56
- const textBoxesBySheet = groupTextBoxesBySheet(embeds ?? []);
57
- const shapesBySheet = groupShapesBySheet(embeds ?? []);
58
- const formControlsBySheet = groupFormControlsBySheet(embeds ?? []);
61
+ // Charts and embeds share one z-order per sheet (`position.zOrder`, else
62
+ // creation order: charts below embeds) — the drawing anchors follow it.
63
+ const chartCount = charts?.length ?? 0;
64
+ const embedsBySheet = groupEmbedsBySheet(embeds ?? [], chartCount);
65
+ const textBoxesBySheet = groupTextBoxesBySheet(embeds ?? [], chartCount);
66
+ const shapesBySheet = groupShapesBySheet(embeds ?? [], chartCount);
67
+ const formControlsBySheet = groupFormControlsBySheet(embeds ?? [], chartCount);
59
68
  const ctrlProps = { parts: [] };
60
69
  const chartsBySheet = groupChartsBySheet(charts ?? []);
61
70
  // Remap slicer tableIds to match prepared table IDs
@@ -100,6 +109,22 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
100
109
  const pivotCacheRelsEntries = [];
101
110
  const pivotTableEntries = [];
102
111
  const pivotTableRelsEntries = [];
112
+ // `<c:pivotSource><c:name>` for PivotCharts: the exported pivot's name on
113
+ // its target sheet (`pivot-builder` names an unnamed pivot the same way).
114
+ // Only pivots the pivot writer emits (source + pivotRange) are named, so
115
+ // a chart never points Excel at a pivot part that is not in the file.
116
+ const pivotSourceNameById = new Map();
117
+ for (const p of pivotTables) {
118
+ const targetSheet = sortedSheets.find((s) => s.sheetId === p.targetPosition?.sheetId);
119
+ if (!targetSheet || !p.source || !p.pivotRange)
120
+ continue;
121
+ const sheetTitle = targetSheet.title ?? `Sheet${targetSheet.sheetId}`;
122
+ const quoted = /^[A-Za-z_][A-Za-z0-9_.]*$/.test(sheetTitle)
123
+ ? sheetTitle
124
+ : `'${sheetTitle.replace(/'/g, "''")}'`;
125
+ pivotSourceNameById.set(String(p.pivotId), `[Workbook]${quoted}!${p.name ?? `PivotTable${p.pivotId}`}`);
126
+ }
127
+ const resolvePivotSourceName = (pivotTableId) => pivotSourceNameById.get(String(pivotTableId));
103
128
  // Group pivots by the sheet they render on (targetPosition.sheetId).
104
129
  const pivotsByTargetSheet = new Map();
105
130
  for (const p of pivotTables) {
@@ -115,7 +140,15 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
115
140
  // Records pivot caches that need a workbook-level entry (added
116
141
  // after the sheet loop closes). Each entry: { cacheIndex, rIdOnWorkbook }
117
142
  const workbookPivotCacheRefs = [];
143
+ // Worksheet and chartsheet parts are numbered 1..N within their own folder
144
+ // (`xl/worksheets/sheetN.xml` / `xl/chartsheets/sheetN.xml`), as Excel does;
145
+ // drawings stay keyed by the sheet's position in `sortedSheets`.
146
+ let worksheetCount = 0;
147
+ let chartsheetCount = 0;
118
148
  const worksheetEntries = sortedSheets.map((sheet, index) => {
149
+ const isChartsheet = sheet.type === "chartsheet";
150
+ const partIndex = isChartsheet ? ++chartsheetCount : ++worksheetCount;
151
+ const partFolder = isChartsheet ? "chartsheets" : "worksheets";
119
152
  const rows = getRowsForSheet(sheetData, sheet.sheetId);
120
153
  const sheetRelationships = new RelationshipsBuilder();
121
154
  const sheetTables = preparedTables.filter((table) => table.sheetId === sheet.sheetId);
@@ -165,6 +198,7 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
165
198
  offsetY: placement.offsetY,
166
199
  width: placement.width,
167
200
  height: placement.height,
201
+ overlay: slicer.position?.overlayPosition,
168
202
  });
169
203
  globalSlicerIndex += 1;
170
204
  }
@@ -233,35 +267,42 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
233
267
  workbookPivotCacheRefs.push({ cacheIndex });
234
268
  globalPivotIndex += 1;
235
269
  }
236
- const result = buildWorksheetXml({
237
- sheet,
238
- rows,
239
- sharedStrings,
240
- sourceSharedStrings,
241
- cellXfs,
242
- styleBuilder,
243
- tables: sheetTables,
244
- relationships: sheetRelationships,
245
- drawingContext,
246
- conditionalFormats: sheetConditionalFormats,
247
- dataValidationsById,
248
- dataValidations: _dataValidations,
249
- ignoreFormulaPatterns,
250
- citations: sheetCitations,
251
- comments,
252
- personRegistry,
253
- slicerRelIds: worksheetSlicerRelIds,
254
- sheetData,
255
- sheets: sortedSheets,
256
- locale,
257
- protectedRanges,
258
- formControls: sheetFormControls,
259
- ctrlProps,
260
- });
270
+ const result = isChartsheet
271
+ ? buildChartsheetXml({
272
+ sheet,
273
+ relationships: sheetRelationships,
274
+ drawingContext,
275
+ })
276
+ : buildWorksheetXml({
277
+ sheet,
278
+ rows,
279
+ sharedStrings,
280
+ sourceSharedStrings,
281
+ cellXfs,
282
+ styleBuilder,
283
+ tables: sheetTables,
284
+ relationships: sheetRelationships,
285
+ drawingContext,
286
+ conditionalFormats: sheetConditionalFormats,
287
+ dataValidationsById,
288
+ dataValidations: _dataValidations,
289
+ ignoreFormulaPatterns,
290
+ citations: sheetCitations,
291
+ comments,
292
+ personRegistry,
293
+ slicerRelIds: worksheetSlicerRelIds,
294
+ sheetData,
295
+ sheets: sortedSheets,
296
+ locale,
297
+ protectedRanges,
298
+ formControls: sheetFormControls,
299
+ ctrlProps,
300
+ });
261
301
  return {
262
302
  index,
263
- path: `xl/worksheets/sheet${index + 1}.xml`,
264
- relationshipsPath: `xl/worksheets/_rels/sheet${index + 1}.xml.rels`,
303
+ isChartsheet,
304
+ path: `xl/${partFolder}/sheet${partIndex}.xml`,
305
+ relationshipsPath: `xl/${partFolder}/_rels/sheet${partIndex}.xml.rels`,
265
306
  content: result.xml,
266
307
  relationshipsXml: result.relationshipsXml,
267
308
  drawingPath: `xl/drawings/drawing${index + 1}.xml`,
@@ -278,12 +319,25 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
278
319
  threadedCommentsXml: result.threadedCommentsXml,
279
320
  };
280
321
  });
281
- const { xml: workbookRelationshipsXml, sheetRelationshipIds, slicerCacheRelationshipIds, pivotCacheRelationshipIds, externalLinkRelationshipIds, } = buildWorkbookRelationships(worksheetEntries.length, slicerCacheEntries.length, externalLinks.length, pivotCacheEntries.length, !personRegistry.isEmpty());
322
+ const { xml: workbookRelationshipsXml, sheetRelationshipIds, slicerCacheRelationshipIds, pivotCacheRelationshipIds, externalLinkRelationshipIds, } = buildWorkbookRelationships(worksheetEntries.map((entry) => ({
323
+ chartsheet: entry.isChartsheet,
324
+ target: entry.path.replace(/^xl\//, ""),
325
+ })), slicerCacheEntries.length, externalLinks.length, pivotCacheEntries.length, !personRegistry.isEmpty());
282
326
  const workbookXml = buildWorkbookXml(sortedSheets, sheetRelationshipIds, workbookSheetNames, namedRanges, iterativeCalculationOptions, slicerCacheRelationshipIds, use1904, externalLinkRelationshipIds, pivotCacheRelationshipIds);
283
327
  const sharedStringsXml = buildSharedStringsXml(sharedStrings);
284
328
  const rootRelationshipsXml = buildRootRelationshipsXml();
285
- const appXml = buildAppXml(workbookSheetNames, properties);
329
+ const appXml = buildAppXml(workbookSheetNames, sortedSheets.map((sheet) => sheet.type === "chartsheet"), properties);
286
330
  const coreXml = buildCoreXml(properties);
331
+ // Table parts register dxfs (colour filters, colour sort levels), so they
332
+ // are built before styles.xml is serialized.
333
+ const tablePartFiles = {};
334
+ for (const table of preparedTables) {
335
+ const rows = getRowsForSheet(sheetData, table.sheetId);
336
+ const tableXml = buildTableXml(table, rows, sourceSharedStrings, styleBuilder, customTableStyleNames);
337
+ if (tableXml) {
338
+ tablePartFiles[table.path] = encodeXml(tableXml);
339
+ }
340
+ }
287
341
  const stylesXml = styleBuilder.toXml();
288
342
  const themeXml = theme ? generateThemeXml(theme) : theme1Xml;
289
343
  const files = {
@@ -317,13 +371,7 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
317
371
  if (personsXml) {
318
372
  files["xl/persons/person.xml"] = encodeXml(personsXml);
319
373
  }
320
- for (const table of preparedTables) {
321
- const rows = getRowsForSheet(sheetData, table.sheetId);
322
- const tableXml = buildTableXml(table, rows, sourceSharedStrings, styleBuilder);
323
- if (tableXml) {
324
- files[table.path] = encodeXml(tableXml);
325
- }
326
- }
374
+ Object.assign(files, tablePartFiles);
327
375
  let globalImageIndex = 1;
328
376
  let globalChartIndex = 1;
329
377
  const drawingSheets = [];
@@ -356,6 +404,7 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
356
404
  charts: chartsForSheet,
357
405
  slicers: slicersForSheet,
358
406
  sheetNameById,
407
+ resolvePivotSourceName,
359
408
  });
360
409
  if (!drawingParts)
361
410
  continue;
@@ -425,7 +474,8 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
425
474
  .filter((entry) => entry.threadedCommentsXml)
426
475
  .map((entry) => entry.sheet.sheetId ?? entry.index + 1);
427
476
  const contentTypesXml = buildContentTypesXml({
428
- sheetCount: worksheetEntries.length,
477
+ worksheetCount,
478
+ chartsheetCount,
429
479
  tableCount: preparedTables.length,
430
480
  drawingSheets,
431
481
  mediaExtensions: Array.from(imageExtensions),
@@ -929,6 +979,27 @@ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, ce
929
979
  threadedCommentsXml,
930
980
  };
931
981
  };
982
+ /**
983
+ * `CT_Chartsheet` — a sheet tab whose whole body is one chart and that has no
984
+ * cells (#668 C-17). Children in schema order: `sheetPr` (tab colour only),
985
+ * `sheetViews`, `pageMargins`, `drawing`. The chart itself rides the sheet's
986
+ * drawing part as an `absoluteAnchor`, like every other chart.
987
+ */
988
+ const buildChartsheetXml = ({ sheet, relationships, drawingContext, }) => {
989
+ const tabColor = serializeColorXml(sheet.tabColor ?? undefined);
990
+ const sheetPrXml = tabColor ? `<sheetPr><tabColor ${tabColor}/></sheetPr>` : "";
991
+ const drawingXml = drawingContext.hasDrawing()
992
+ ? `<drawing r:id="${drawingContext.relId}"/>`
993
+ : "";
994
+ return {
995
+ xml: `${XML_DECLARATION}<chartsheet xmlns="${WORKSHEET_NS}" xmlns:r="${REL_NS}">${sheetPrXml}<sheetViews><sheetView workbookViewId="0" zoomToFit="1"/></sheetViews>${buildPageMarginsXml(sheet.printSettings)}${drawingXml}</chartsheet>`,
996
+ relationshipsXml: relationships.toXml(),
997
+ images: [],
998
+ commentsXml: undefined,
999
+ vmlDrawingXml: undefined,
1000
+ threadedCommentsXml: undefined,
1001
+ };
1002
+ };
932
1003
  /**
933
1004
  * Build combined extLst XML for worksheet (sparklines + slicers)
934
1005
  */
@@ -1015,6 +1086,109 @@ const normalizeMergeRanges = (ranges) => {
1015
1086
  }
1016
1087
  return valid;
1017
1088
  };
1089
+ /** Escape `"` as `""` inside a formula string literal. */
1090
+ const escapeFormulaText = (value) => value.replace(/"/g, '""');
1091
+ // `xlsxSource` is meant to hold the verbatim OOXML representation, but
1092
+ // writers other than the importer have filled it from Excel's JavaScript-API
1093
+ // vocabulary: a bare `list.source` literal (`On track,Watch,Off track`),
1094
+ // PascalCase operators (`Between`), PascalCase types (`WholeNumber`) and
1095
+ // `=`-prefixed formulas. Excel opens a file carrying any of those in repair
1096
+ // mode and drops the rule, so the exporter normalizes every field before
1097
+ // replaying it, and a type / operator that is not an OOXML token even after
1098
+ // normalization (`undefined` below) is never replayed — the rule falls
1099
+ // through to the condition synthesizer. Twin of `normalize_source_*` in
1100
+ // `rust/crates/rnc-export/src/xlsx/datavalidation.rs` — keep the two in sync
1101
+ // so both engines emit identical XML.
1102
+ const normalizeXlsxSourceType = (type) => {
1103
+ switch (type.trim().toLowerCase()) {
1104
+ case "list":
1105
+ return "list";
1106
+ case "whole":
1107
+ case "wholenumber":
1108
+ return "whole";
1109
+ case "decimal":
1110
+ return "decimal";
1111
+ case "date":
1112
+ return "date";
1113
+ case "time":
1114
+ return "time";
1115
+ case "textlength":
1116
+ return "textLength";
1117
+ case "custom":
1118
+ return "custom";
1119
+ default:
1120
+ return undefined;
1121
+ }
1122
+ };
1123
+ const normalizeXlsxSourceOperator = (operator) => {
1124
+ switch (operator.trim().toLowerCase()) {
1125
+ case "between":
1126
+ return "between";
1127
+ case "notbetween":
1128
+ return "notBetween";
1129
+ case "equal":
1130
+ case "equalto":
1131
+ return "equal";
1132
+ case "notequal":
1133
+ case "notequalto":
1134
+ return "notEqual";
1135
+ case "greaterthan":
1136
+ return "greaterThan";
1137
+ case "lessthan":
1138
+ return "lessThan";
1139
+ case "greaterthanorequal":
1140
+ case "greaterthanorequalto":
1141
+ return "greaterThanOrEqual";
1142
+ case "lessthanorequal":
1143
+ case "lessthanorequalto":
1144
+ return "lessThanOrEqual";
1145
+ default:
1146
+ return undefined;
1147
+ }
1148
+ };
1149
+ // OOXML `<formula1>` text never carries the leading `=` a formula-style
1150
+ // source (`=Sheet1!$A$1:$A$5`) has.
1151
+ const stripLeadingEquals = (formula) => {
1152
+ const trimmed = formula.trim();
1153
+ return trimmed.startsWith("=") ? trimmed.slice(1) : trimmed;
1154
+ };
1155
+ // One identifier token — `[A-Za-z_\\][A-Za-z0-9_.]*` — which is what a
1156
+ // defined name (`StatusList`, `My_List.v2`) or a single A1 cell (`A1`) looks
1157
+ // like; a word list with commas or spaces is not one.
1158
+ const XLSX_IDENTIFIER_TOKEN = /^[A-Za-z_\\][A-Za-z0-9_.]*$/;
1159
+ // A single A1 cell token: 1–3 letters followed by digits.
1160
+ const XLSX_A1_CELL_TOKEN = /^[A-Za-z]{1,3}[0-9]+$/;
1161
+ // A function call — `OFFSET(…)`, `INDIRECT(…)` — which Excel writes without
1162
+ // a leading `=`.
1163
+ const XLSX_FUNCTION_CALL = /^[A-Za-z_\\][A-Za-z0-9_.]*\(.*\)$/;
1164
+ // `true` when a list source reads as a reference / formula rather than as
1165
+ // list text: it carries a reference marker (`!` sheet qualifier, `:` range,
1166
+ // `$` absolute anchor, `[` structured / external ref), is a single A1 cell
1167
+ // or defined-name token, or is a function call. Anything else — comma lists,
1168
+ // text with spaces, digit-only entries — is a bare literal that Excel only
1169
+ // accepts quoted.
1170
+ const looksLikeXlsxListReference = (text) => text.length > 0 &&
1171
+ (/[!:$[]/.test(text) ||
1172
+ XLSX_A1_CELL_TOKEN.test(text) ||
1173
+ XLSX_IDENTIFIER_TOKEN.test(text) ||
1174
+ XLSX_FUNCTION_CALL.test(text));
1175
+ // A `list` source is valid OOXML only as a quoted string literal (`"a,b"`)
1176
+ // or a reference (`$A$1:$A$5`, `Sheet1!$A$1:$A$5`, `StatusList`, `OFFSET(…)`).
1177
+ // Excel's API hands back a literal list as bare comma-separated text and a
1178
+ // reference with a leading `=`, so: quoted → verbatim, `=`-prefixed → the `=`
1179
+ // is stripped and the rest replayed verbatim, reference-like → verbatim,
1180
+ // anything else → a literal that must be quoted (with `"` doubled inside, as
1181
+ // the ONE_OF_LIST branch does).
1182
+ const normalizeXlsxSourceListFormula1 = (formula1) => {
1183
+ const trimmed = formula1.trim();
1184
+ if (trimmed.startsWith('"'))
1185
+ return trimmed;
1186
+ if (trimmed.startsWith("="))
1187
+ return trimmed.slice(1).trim();
1188
+ if (looksLikeXlsxListReference(trimmed))
1189
+ return trimmed;
1190
+ return `"${escapeFormulaText(trimmed)}"`;
1191
+ };
1018
1192
  const convertDataValidationRule = (rule) => {
1019
1193
  if (!rule)
1020
1194
  return undefined;
@@ -1032,20 +1206,58 @@ const convertDataValidationRule = (rule) => {
1032
1206
  prompt: inputMessage?.message ?? undefined,
1033
1207
  };
1034
1208
  // Round-trip path: when the rule was imported from xlsx, replay the
1035
- // verbatim source representation. Preserves type/operator/formulas
1036
- // exactly, including `whole`/`textLength`/`time` types and user-written
1037
- // custom formulas without `INDIRECT("RC",FALSE)` rewriting.
1209
+ // source representation after normalizing it to OOXML vocabulary (see
1210
+ // `normalizeXlsxSource*`). Preserves type/operator/formulas, including
1211
+ // `whole`/`textLength`/`time` types and user-written custom formulas
1212
+ // without `INDIRECT("RC",FALSE)` rewriting. A type / operator that is not
1213
+ // an OOXML token even after normalization (Excel's `Inconsistent`,
1214
+ // `MixedCriteria`) is never written: the rule falls through to the
1215
+ // condition synthesizer.
1038
1216
  const src = rule.xlsxSource;
1217
+ let rejectedListSource;
1039
1218
  if (src && src.type && typeof src.formula1 === "string" && src.formula1) {
1219
+ const type = normalizeXlsxSourceType(src.type);
1220
+ const operator = src.operator
1221
+ ? normalizeXlsxSourceOperator(src.operator)
1222
+ : undefined;
1223
+ const operatorValid = !src.operator || operator !== undefined;
1224
+ if (type !== undefined && operatorValid) {
1225
+ const formula1 = type === "list"
1226
+ ? normalizeXlsxSourceListFormula1(src.formula1)
1227
+ : stripLeadingEquals(src.formula1);
1228
+ return {
1229
+ type,
1230
+ operator,
1231
+ formula1,
1232
+ formula2: src.formula2 ? stripLeadingEquals(src.formula2) : undefined,
1233
+ showDropDown: type === "list" && rule.displayStyle === "plain" ? true : undefined,
1234
+ ...baseConfig,
1235
+ };
1236
+ }
1237
+ if (type === "list") {
1238
+ rejectedListSource = normalizeXlsxSourceListFormula1(src.formula1);
1239
+ }
1240
+ }
1241
+ const synthesized = convertDataValidationCondition(rule, baseConfig);
1242
+ if (synthesized)
1243
+ return synthesized;
1244
+ // A rejected `list` source with no usable condition is still kept — its
1245
+ // normalized source text (quoted literal or reference) is always valid
1246
+ // OOXML. Anything else rejected has nothing valid to write and is skipped.
1247
+ if (rejectedListSource !== undefined) {
1040
1248
  return {
1041
- type: src.type,
1042
- operator: src.operator,
1043
- formula1: src.formula1,
1044
- formula2: src.formula2,
1045
- showDropDown: src.type === "list" && rule.displayStyle === "plain" ? true : undefined,
1249
+ type: "list",
1250
+ formula1: rejectedListSource,
1251
+ showDropDown: rule.displayStyle === "plain" ? true : undefined,
1046
1252
  ...baseConfig,
1047
1253
  };
1048
1254
  }
1255
+ return undefined;
1256
+ };
1257
+ // The condition-based synthesizer: the Excel config for a rule that has no
1258
+ // replayable `xlsxSource` (UI-authored, edited after import, or rejected by
1259
+ // the round-trip guard above).
1260
+ const convertDataValidationCondition = (rule, baseConfig) => {
1049
1261
  if (!rule.condition?.type) {
1050
1262
  // Prompt-only DV (no validation rule, just an input-message tooltip
1051
1263
  // and/or an error alert). Emit it with no type/formula so Excel still
@@ -1058,7 +1270,6 @@ const convertDataValidationRule = (rule) => {
1058
1270
  const { condition } = rule;
1059
1271
  const values = condition.values ?? [];
1060
1272
  const currentCell = 'INDIRECT("RC",FALSE)';
1061
- const escapeFormulaText = (value) => value.replace(/"/g, '""');
1062
1273
  const ensureFormula = (value) => value.startsWith("=") ? value : `=${value}`;
1063
1274
  const ifErrorWrap = (formula) => `IFERROR(${formula},TRUE)`;
1064
1275
  if (condition.type === "ONE_OF_LIST") {
@@ -2360,7 +2571,7 @@ const resolveTableColumnTotals = (column, totalsCell) => {
2360
2571
  const label = getExtendedValueString(getCellUserEnteredValue(totalsCell));
2361
2572
  return label && label.trim() !== "" ? { totalsRowLabel: label } : undefined;
2362
2573
  };
2363
- const buildTableXml = (table, rows, sourceSharedStrings, styleBuilder) => {
2574
+ const buildTableXml = (table, rows, sourceSharedStrings, styleBuilder, customTableStyleNames) => {
2364
2575
  const rangeRef = table.ref;
2365
2576
  const columnCount = Math.max(table.columns?.length ?? 0, (table.range.endColumnIndex ?? table.range.startColumnIndex) -
2366
2577
  table.range.startColumnIndex +
@@ -2434,17 +2645,71 @@ const buildTableXml = (table, rows, sourceSharedStrings, styleBuilder) => {
2434
2645
  })
2435
2646
  .join("")}</tableColumns>`;
2436
2647
  const isBuiltInStyle = table.theme && /^TableStyle(Light|Medium|Dark)\d+$/.test(table.theme);
2437
- const tableStyleName = isBuiltInStyle ? table.theme : "TableStyleMedium2";
2648
+ // A custom style written to `<tableStyles>` keeps its name here (#668 T-103).
2649
+ const isCustomStyle = !!table.theme && (customTableStyleNames?.has(table.theme) ?? false);
2650
+ const tableStyleName = isBuiltInStyle || isCustomStyle ? table.theme : "TableStyleMedium2";
2438
2651
  const tableStyleInfo = `<tableStyleInfo name="${tableStyleName}" showFirstColumn="${table.showFirstColumn ? 1 : 0}" showLastColumn="${table.showLastColumn ? 1 : 0}" showRowStripes="${table.showRowStripes ? 1 : 0}" showColumnStripes="${table.showColumnStripes ? 1 : 0}"/>`;
2652
+ // Excel's per-column "filter button" toggle: `<filterColumn hiddenButton="1">`
2653
+ // (the table-level toggle is the presence of `<autoFilter>` itself).
2654
+ const hiddenButtonColumns = new Set(columnNames
2655
+ .map((_, idx) => idx)
2656
+ .filter((idx) => table.columns?.[idx]?.filterButton === false));
2439
2657
  const autoFilter = headerRowCount > 0 && table.filterButton !== false
2440
2658
  ? (() => {
2441
- const filterColumns = buildFilterColumnsXml(table.filterSpecs ?? [], table.range, rows, table.headerRow ?? true, table.totalRow ?? false, styleBuilder);
2659
+ const filterColumns = buildFilterColumnsXml(table.filterSpecs ?? [], table.range, rows, table.headerRow ?? true, table.totalRow ?? false, styleBuilder, hiddenButtonColumns);
2442
2660
  return filterColumns
2443
2661
  ? `<autoFilter ref="${rangeRef}">${filterColumns}</autoFilter>`
2444
2662
  : `<autoFilter ref="${rangeRef}"/>`;
2445
2663
  })()
2446
2664
  : "";
2447
- return `${XML_DECLARATION}<table xmlns="${WORKSHEET_NS}" id="${table.id}" name="${escapeXmlAttr(table.name)}" displayName="${escapeXmlAttr(table.displayName)}" ref="${rangeRef}" totalsRowCount="${totalsRowCount}" headerRowCount="${headerRowCount}">${autoFilter}${tableColumns}${tableStyleInfo}</table>`;
2665
+ const sortState = buildTableSortStateXml(table, headerRowCount, totalsRowCount, styleBuilder);
2666
+ return `${XML_DECLARATION}<table xmlns="${WORKSHEET_NS}" id="${table.id}" name="${escapeXmlAttr(table.name)}" displayName="${escapeXmlAttr(table.displayName)}" ref="${rangeRef}" totalsRowCount="${totalsRowCount}" headerRowCount="${headerRowCount}">${autoFilter}${sortState}${tableColumns}${tableStyleInfo}</table>`;
2667
+ };
2668
+ /**
2669
+ * A table's persisted sort levels as `<sortState ref="<body>">` with one
2670
+ * `<sortCondition>` per level (`CT_SortCondition`: `ref` is the level's
2671
+ * column over the body rows, `descending`, `sortBy` + `dxfId` for a colour
2672
+ * level, `sortBy="icon"` + `iconSet`/`iconId`, and Excel's comma-joined
2673
+ * `customList`). Sits between `<autoFilter>` and `<tableColumns>` per
2674
+ * `CT_Table`. The inverse of the parser's `createSortSpecsFromSortState`.
2675
+ */
2676
+ const buildTableSortStateXml = (table, headerRowCount, totalsRowCount, styleBuilder) => {
2677
+ const sortSpecs = table.sortSpecs ?? [];
2678
+ const bodyStartRow = table.range.startRowIndex + headerRowCount;
2679
+ const bodyEndRow = table.range.endRowIndex - totalsRowCount;
2680
+ if (!sortSpecs.length || bodyEndRow < bodyStartRow)
2681
+ return "";
2682
+ const bodyRef = (startColumnIndex, endColumnIndex) => cellRangeToAddress(undefined, {
2683
+ startRowIndex: bodyStartRow,
2684
+ endRowIndex: bodyEndRow,
2685
+ startColumnIndex,
2686
+ endColumnIndex,
2687
+ });
2688
+ const conditions = sortSpecs.map((spec) => {
2689
+ const columnIndex = table.range.startColumnIndex + spec.dimensionIndex;
2690
+ const attrs = [`ref="${bodyRef(columnIndex, columnIndex)}"`];
2691
+ if (spec.sortOrder === "DESCENDING")
2692
+ attrs.push(`descending="1"`);
2693
+ const sortBy = spec.sortBy ?? "value";
2694
+ if (sortBy === "cellColor" || sortBy === "fontColor") {
2695
+ attrs.push(`sortBy="${sortBy}"`);
2696
+ const dxfId = spec.sortColor && styleBuilder
2697
+ ? styleBuilder.registerDifferentialFormat(sortBy === "cellColor"
2698
+ ? { backgroundColor: spec.sortColor }
2699
+ : { textFormat: { color: spec.sortColor } })
2700
+ : undefined;
2701
+ if (typeof dxfId === "number")
2702
+ attrs.push(`dxfId="${dxfId}"`);
2703
+ }
2704
+ else if (sortBy === "icon" && spec.sortIcon) {
2705
+ attrs.push(`sortBy="icon"`, `iconSet="${escapeXmlAttr(spec.sortIcon.iconSet)}"`, `iconId="${spec.sortIcon.iconId ?? 0}"`);
2706
+ }
2707
+ if (spec.customList?.length) {
2708
+ attrs.push(`customList="${escapeXmlAttr(spec.customList.join(","))}"`);
2709
+ }
2710
+ return `<sortCondition ${attrs.join(" ")}/>`;
2711
+ });
2712
+ return `<sortState ref="${bodyRef(table.range.startColumnIndex, table.range.endColumnIndex)}">${conditions.join("")}</sortState>`;
2448
2713
  };
2449
2714
  const getFilterVisibleValues = (spec, range, rows, headerRow = true, totalRow = false) => {
2450
2715
  const values = new Set();
@@ -2615,6 +2880,12 @@ const xmlNumAttr = (name, value) => typeof value === "number" && Number.isFinite
2615
2880
  * mandatory in practice (Excel always writes it), so the default margins go
2616
2881
  * out when the sheet has none of its own.
2617
2882
  */
2883
+ const buildPageMarginsXml = (print) => {
2884
+ const margins = print?.pageMargins;
2885
+ return margins && Object.values(margins).some((v) => typeof v === "number")
2886
+ ? `<pageMargins left="${margins.left ?? 0.7}" right="${margins.right ?? 0.7}" top="${margins.top ?? 0.75}" bottom="${margins.bottom ?? 0.75}" header="${margins.header ?? 0.3}" footer="${margins.footer ?? 0.3}"/>`
2887
+ : DEFAULT_PAGE_MARGINS;
2888
+ };
2618
2889
  const buildPrintSettingsXml = (print) => {
2619
2890
  const parts = [];
2620
2891
  const options = print?.printOptions;
@@ -2624,11 +2895,7 @@ const buildPrintSettingsXml = (print) => {
2624
2895
  xmlBoolAttr("gridLines", options?.gridLines);
2625
2896
  if (optionAttrs)
2626
2897
  parts.push(`<printOptions${optionAttrs}/>`);
2627
- const margins = print?.pageMargins;
2628
- parts.push(margins &&
2629
- Object.values(margins).some((v) => typeof v === "number")
2630
- ? `<pageMargins left="${margins.left ?? 0.7}" right="${margins.right ?? 0.7}" top="${margins.top ?? 0.75}" bottom="${margins.bottom ?? 0.75}" header="${margins.header ?? 0.3}" footer="${margins.footer ?? 0.3}"/>`
2631
- : DEFAULT_PAGE_MARGINS);
2898
+ parts.push(buildPageMarginsXml(print));
2632
2899
  const setup = print?.pageSetup;
2633
2900
  if (setup) {
2634
2901
  // Excel keeps `firstPageNumber` as a dormant value until the user picks
@@ -2693,13 +2960,27 @@ const buildPrintSettingsXml = (print) => {
2693
2960
  }
2694
2961
  return parts.join("");
2695
2962
  };
2696
- const buildFilterColumnsXml = (filterSpecs, range, rows, headerRow = true, totalRow = false, styleBuilder) => {
2697
- if (!filterSpecs?.length)
2698
- return "";
2699
- const entries = filterSpecs
2700
- .map((spec) => buildFilterColumnXml(spec, range, rows, headerRow, totalRow, styleBuilder))
2701
- .filter((entry) => Boolean(entry));
2702
- return entries.join("");
2963
+ const buildFilterColumnsXml = (filterSpecs, range, rows, headerRow = true, totalRow = false, styleBuilder,
2964
+ /** Table-relative column ids whose header hides its filter button. */
2965
+ hiddenButtonColumns) => {
2966
+ const entries = new Map();
2967
+ for (const spec of filterSpecs ?? []) {
2968
+ const xml = buildFilterColumnXml(spec, range, rows, headerRow, totalRow, styleBuilder);
2969
+ if (xml)
2970
+ entries.set(spec.columnIndex, xml);
2971
+ }
2972
+ // `hiddenButton="1"` rides the column's criteria element when it has one,
2973
+ // else a bare `<filterColumn/>` carries it.
2974
+ hiddenButtonColumns?.forEach((colId) => {
2975
+ const existing = entries.get(colId);
2976
+ entries.set(colId, existing
2977
+ ? existing.replace(`<filterColumn colId="${colId}">`, `<filterColumn colId="${colId}" hiddenButton="1">`)
2978
+ : `<filterColumn colId="${colId}" hiddenButton="1"/>`);
2979
+ });
2980
+ return [...entries.entries()]
2981
+ .sort(([a], [b]) => a - b)
2982
+ .map(([, xml]) => xml)
2983
+ .join("");
2703
2984
  };
2704
2985
  const buildFilterColumnXml = (spec, range, rows, headerRow = true, totalRow = false, styleBuilder) => {
2705
2986
  const parts = [];
@@ -2728,7 +3009,9 @@ const buildFilterColumnXml = (spec, range, rows, headerRow = true, totalRow = fa
2728
3009
  // above-average …) takes precedence over a value-list and is emitted as
2729
3010
  // <customFilters> or <dynamicFilter>. Falls back to the value list when the
2730
3011
  // condition has no AutoFilter representation.
2731
- const conditionXml = buildAutoFilterConditionXml(spec.filterCriteria?.condition);
3012
+ const conditionXml = spec.filterCriteria
3013
+ ? buildAutoFilterCriteriaXml(spec.filterCriteria)
3014
+ : null;
2732
3015
  if (conditionXml) {
2733
3016
  parts.push(conditionXml);
2734
3017
  }
@@ -2758,9 +3041,9 @@ const gridRangeToRef = (range) => {
2758
3041
  return undefined;
2759
3042
  }
2760
3043
  };
2761
- const groupEmbedsBySheet = (embeds) => {
3044
+ const groupEmbedsBySheet = (embeds, chartCount) => {
2762
3045
  const result = new Map();
2763
- (embeds ?? []).forEach((embed, order) => {
3046
+ (embeds ?? []).forEach((embed, index) => {
2764
3047
  if (!embed.position?.overlayPosition)
2765
3048
  return;
2766
3049
  if (embed.spec?.type !== "image")
@@ -2779,13 +3062,14 @@ const groupEmbedsBySheet = (embeds) => {
2779
3062
  adjustments: Object.values(adjustments).some((v) => v !== undefined)
2780
3063
  ? adjustments
2781
3064
  : undefined,
2782
- order,
3065
+ order: embed.position.zOrder ?? chartCount + index,
2783
3066
  rowIndex: overlay.anchorCell.rowIndex,
2784
3067
  columnIndex: overlay.anchorCell.columnIndex,
2785
3068
  offsetX: overlay.offsetXPixels ?? 0,
2786
3069
  offsetY: overlay.offsetYPixels ?? 0,
2787
3070
  width: overlay.widthPixels ?? 120,
2788
3071
  height: overlay.heightPixels ?? 120,
3072
+ overlay,
2789
3073
  };
2790
3074
  const list = result.get(embed.position.sheetId) ?? [];
2791
3075
  list.push(placement);
@@ -2793,9 +3077,9 @@ const groupEmbedsBySheet = (embeds) => {
2793
3077
  });
2794
3078
  return result;
2795
3079
  };
2796
- const groupTextBoxesBySheet = (embeds) => {
3080
+ const groupTextBoxesBySheet = (embeds, chartCount) => {
2797
3081
  const result = new Map();
2798
- (embeds ?? []).forEach((embed, order) => {
3082
+ (embeds ?? []).forEach((embed, index) => {
2799
3083
  if (!embed.position?.overlayPosition)
2800
3084
  return;
2801
3085
  if (embed.spec?.type !== "textbox")
@@ -2803,7 +3087,7 @@ const groupTextBoxesBySheet = (embeds) => {
2803
3087
  const overlay = embed.position.overlayPosition;
2804
3088
  const placement = {
2805
3089
  textBox: embed,
2806
- order,
3090
+ order: embed.position.zOrder ?? chartCount + index,
2807
3091
  rowIndex: overlay.anchorCell.rowIndex,
2808
3092
  columnIndex: overlay.anchorCell.columnIndex,
2809
3093
  offsetX: overlay.offsetXPixels ?? 0,
@@ -2818,9 +3102,9 @@ const groupTextBoxesBySheet = (embeds) => {
2818
3102
  return result;
2819
3103
  };
2820
3104
  /** Form-control embeds (`spec.type === "formControl"`) by sheet, in host order. */
2821
- const groupFormControlsBySheet = (embeds) => {
3105
+ const groupFormControlsBySheet = (embeds, chartCount) => {
2822
3106
  const result = new Map();
2823
- (embeds ?? []).forEach((embed, order) => {
3107
+ (embeds ?? []).forEach((embed, index) => {
2824
3108
  if (!embed.position?.overlayPosition)
2825
3109
  return;
2826
3110
  if (embed.spec?.type !== "formControl")
@@ -2828,7 +3112,7 @@ const groupFormControlsBySheet = (embeds) => {
2828
3112
  const overlay = embed.position.overlayPosition;
2829
3113
  const placement = {
2830
3114
  shape: embed,
2831
- order,
3115
+ order: embed.position.zOrder ?? chartCount + index,
2832
3116
  rowIndex: overlay.anchorCell.rowIndex,
2833
3117
  columnIndex: overlay.anchorCell.columnIndex,
2834
3118
  offsetX: overlay.offsetXPixels ?? 0,
@@ -2883,18 +3167,33 @@ const buildSheetFormControls = ({ sheet, controls, relationships, ctrlProps, zIn
2883
3167
  const heightPx = Math.max(5, placement.height ?? 20);
2884
3168
  const columnSize = (index) => getColumnWidthPx(sheet, index, true);
2885
3169
  const rowSize = (index) => getRowHeightPx(sheet, index, true);
2886
- const toColumn = resolveAnchorEndpoint({
2887
- startIndex: fromColumn,
2888
- startOffset: offsetX,
2889
- span: widthPx,
2890
- getSize: columnSize,
2891
- });
2892
- const toRow = resolveAnchorEndpoint({
2893
- startIndex: fromRow,
2894
- startOffset: offsetY,
2895
- span: heightPx,
2896
- getSize: rowSize,
2897
- });
3170
+ // A "Move and size" control keeps its persisted second anchor (#668 C-46)
3171
+ const overlay = placement.shape.position?.overlayPosition;
3172
+ const persistedTo = controlAnchorFlags(spec, overlay).sizeWithCells && overlay?.toCell
3173
+ ? overlay.toCell
3174
+ : undefined;
3175
+ const toColumn = persistedTo
3176
+ ? {
3177
+ index: Math.max(0, persistedTo.columnIndex - 1),
3178
+ offset: Math.max(0, overlay?.toOffsetXPixels ?? 0),
3179
+ }
3180
+ : resolveAnchorEndpoint({
3181
+ startIndex: fromColumn,
3182
+ startOffset: offsetX,
3183
+ span: widthPx,
3184
+ getSize: columnSize,
3185
+ });
3186
+ const toRow = persistedTo
3187
+ ? {
3188
+ index: Math.max(0, persistedTo.rowIndex - 1),
3189
+ offset: Math.max(0, overlay?.toOffsetYPixels ?? 0),
3190
+ }
3191
+ : resolveAnchorEndpoint({
3192
+ startIndex: fromRow,
3193
+ startOffset: offsetY,
3194
+ span: heightPx,
3195
+ getSize: rowSize,
3196
+ });
2898
3197
  const partIndex = ctrlProps.parts.length + 1;
2899
3198
  const path = `xl/ctrlProps/ctrlProp${partIndex}.xml`;
2900
3199
  ctrlProps.parts.push({ path, xml: buildCtrlPropXml(spec) });
@@ -2916,11 +3215,13 @@ const buildSheetFormControls = ({ sheet, controls, relationships, ctrlProps, zIn
2916
3215
  row: toRow.index,
2917
3216
  rowOff: toRow.offset * EMUS_PER_PIXEL,
2918
3217
  },
3218
+ overlay,
2919
3219
  }));
2920
3220
  vmlShapes.push(buildFormControlVmlShape({
2921
3221
  spec,
2922
3222
  shapeId,
2923
3223
  zIndex: zIndexStart + position + 1,
3224
+ overlay,
2924
3225
  geometry: {
2925
3226
  leftPx: sumSizes(fromColumn, columnSize) + offsetX,
2926
3227
  topPx: sumSizes(fromRow, rowSize) + offsetY,
@@ -2941,9 +3242,9 @@ const buildSheetFormControls = ({ sheet, controls, relationships, ctrlProps, zIn
2941
3242
  });
2942
3243
  return { controlsXml: buildControlsXml(controlElements), vmlShapes };
2943
3244
  };
2944
- const groupShapesBySheet = (embeds) => {
3245
+ const groupShapesBySheet = (embeds, chartCount) => {
2945
3246
  const result = new Map();
2946
- (embeds ?? []).forEach((embed, order) => {
3247
+ (embeds ?? []).forEach((embed, index) => {
2947
3248
  if (!embed.position?.overlayPosition)
2948
3249
  return;
2949
3250
  if (embed.spec?.type !== "shape")
@@ -2951,7 +3252,7 @@ const groupShapesBySheet = (embeds) => {
2951
3252
  const overlay = embed.position.overlayPosition;
2952
3253
  const placement = {
2953
3254
  shape: embed,
2954
- order,
3255
+ order: embed.position.zOrder ?? chartCount + index,
2955
3256
  rowIndex: overlay.anchorCell.rowIndex,
2956
3257
  columnIndex: overlay.anchorCell.columnIndex,
2957
3258
  offsetX: overlay.offsetXPixels ?? 0,
@@ -2967,12 +3268,13 @@ const groupShapesBySheet = (embeds) => {
2967
3268
  };
2968
3269
  const groupChartsBySheet = (charts) => {
2969
3270
  const result = new Map();
2970
- for (const chart of charts ?? []) {
3271
+ (charts ?? []).forEach((chart, index) => {
2971
3272
  const overlay = chart.position?.overlayPosition;
2972
3273
  if (!overlay)
2973
- continue;
3274
+ return;
2974
3275
  const placement = {
2975
3276
  chart,
3277
+ order: chart.position.zOrder ?? index,
2976
3278
  rowIndex: overlay.anchorCell.rowIndex,
2977
3279
  columnIndex: overlay.anchorCell.columnIndex,
2978
3280
  offsetX: overlay.offsetXPixels ?? 0,
@@ -2983,7 +3285,7 @@ const groupChartsBySheet = (charts) => {
2983
3285
  const list = result.get(chart.position.sheetId) ?? [];
2984
3286
  list.push(placement);
2985
3287
  result.set(chart.position.sheetId, list);
2986
- }
3288
+ });
2987
3289
  return result;
2988
3290
  };
2989
3291
  const groupSlicersBySheet = (slicers) => {
@@ -3554,7 +3856,7 @@ const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseC
3554
3856
  rules.push(dataBarXml);
3555
3857
  return rules.length ? rules : undefined;
3556
3858
  };
3557
- const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImageIndex, startChartIndex, startChartExIndex = 1, charts, slicers, sheet, sheetNameById, }) => {
3859
+ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImageIndex, startChartIndex, startChartExIndex = 1, charts, slicers, sheet, sheetNameById, resolvePivotSourceName, }) => {
3558
3860
  if (!placements.length &&
3559
3861
  !textBoxes.length &&
3560
3862
  !shapes.length &&
@@ -3571,6 +3873,12 @@ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImag
3571
3873
  let frameId = 1;
3572
3874
  const chartEntries = [];
3573
3875
  const chartExEntries = [];
3876
+ // Charts and embeds (text boxes, shapes, images) are written back-to-front
3877
+ // by their shared z-order — anchor order IS Excel's z-order, so a picture
3878
+ // sent behind a chart must be emitted before it regardless of kind.
3879
+ // Placements without an order (in-cell images) go last, in their own order.
3880
+ const orderedAnchors = [];
3881
+ const orderOf = (order) => order ?? Number.MAX_SAFE_INTEGER;
3574
3882
  for (const placement of charts) {
3575
3883
  if (isChartExType(placement.chart.spec.chartType)) {
3576
3884
  // Treemap / sunburst live in a chartex part with an mc:AlternateContent anchor.
@@ -3578,7 +3886,16 @@ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImag
3578
3886
  if (!chartExXml)
3579
3887
  continue;
3580
3888
  const relId = relationships.add(CHART_EX_REL_TYPE, `../charts/chartEx${chartExIndex}.xml`);
3581
- anchors.push(createChartAnchor({ sheet, placement, relId, frameId, chartEx: true }));
3889
+ orderedAnchors.push({
3890
+ order: placement.order,
3891
+ xml: createChartAnchor({
3892
+ sheet,
3893
+ placement,
3894
+ relId,
3895
+ frameId,
3896
+ chartEx: true,
3897
+ }),
3898
+ });
3582
3899
  chartExEntries.push({
3583
3900
  path: `xl/charts/chartEx${chartExIndex}.xml`,
3584
3901
  xml: chartExXml,
@@ -3587,16 +3904,19 @@ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImag
3587
3904
  frameId += 1;
3588
3905
  continue;
3589
3906
  }
3590
- const chartXml = buildChartXml(placement.chart, sheetNameById);
3907
+ const chartXml = buildChartXml(placement.chart, sheetNameById, resolvePivotSourceName);
3591
3908
  if (!chartXml)
3592
3909
  continue;
3593
3910
  const relId = relationships.add(CHART_REL_TYPE, `../charts/chart${chartIndex}.xml`);
3594
- anchors.push(createChartAnchor({
3595
- sheet,
3596
- placement,
3597
- relId,
3598
- frameId,
3599
- }));
3911
+ orderedAnchors.push({
3912
+ order: placement.order,
3913
+ xml: createChartAnchor({
3914
+ sheet,
3915
+ placement,
3916
+ relId,
3917
+ frameId,
3918
+ }),
3919
+ });
3600
3920
  chartEntries.push({
3601
3921
  path: `xl/charts/chart${chartIndex}.xml`,
3602
3922
  xml: chartXml,
@@ -3612,14 +3932,8 @@ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImag
3612
3932
  }));
3613
3933
  frameId += 1;
3614
3934
  }
3615
- // Embeds (text boxes, shapes, images) keep the host's `embeds` order in the
3616
- // drawing part — anchor order IS Excel's z-order, so an image drawn over a
3617
- // shape must be emitted after it regardless of kind. Placements without an
3618
- // order (in-cell images) go last, in their own order.
3619
- const embedAnchors = [];
3620
- const orderOf = (order) => order ?? Number.MAX_SAFE_INTEGER;
3621
3935
  for (const placement of textBoxes) {
3622
- embedAnchors.push({
3936
+ orderedAnchors.push({
3623
3937
  order: orderOf(placement.order),
3624
3938
  xml: createTextBoxAnchor({
3625
3939
  sheet,
@@ -3630,7 +3944,7 @@ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImag
3630
3944
  frameId += 1;
3631
3945
  }
3632
3946
  for (const placement of shapes) {
3633
- embedAnchors.push({
3947
+ orderedAnchors.push({
3634
3948
  order: orderOf(placement.order),
3635
3949
  xml: createShapeAnchor({
3636
3950
  sheet,
@@ -3654,7 +3968,7 @@ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImag
3654
3968
  extension: buffer.extension,
3655
3969
  });
3656
3970
  const relId = relationships.add(IMAGE_REL_TYPE, `../media/image${imageIndex}.${buffer.extension}`);
3657
- embedAnchors.push({
3971
+ orderedAnchors.push({
3658
3972
  order: orderOf(placement.order),
3659
3973
  xml: createTwoCellAnchor({
3660
3974
  sheet,
@@ -3666,8 +3980,8 @@ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImag
3666
3980
  imageIndex += 1;
3667
3981
  pictureId += 1;
3668
3982
  }
3669
- embedAnchors.sort((a, b) => a.order - b.order);
3670
- for (const entry of embedAnchors)
3983
+ orderedAnchors.sort((a, b) => a.order - b.order);
3984
+ for (const entry of orderedAnchors)
3671
3985
  anchors.push(entry.xml);
3672
3986
  const drawingXml = `${XML_DECLARATION}<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${REL_NS}">${anchors.join("")}</xdr:wsDr>`;
3673
3987
  return {
@@ -3690,25 +4004,21 @@ const createTwoCellAnchor = ({ placement, relId, pictureId, sheet, }) => {
3690
4004
  const defaultHeight = placement.kind === "cell" ? getRowHeightPx(sheet, fromRow) : 120;
3691
4005
  const widthPx = Math.max(5, placement.width ?? defaultWidth);
3692
4006
  const heightPx = Math.max(5, placement.height ?? defaultHeight);
3693
- const toColumn = resolveAnchorEndpoint({
3694
- startIndex: fromColumn,
3695
- startOffset: offsetX,
3696
- span: widthPx,
3697
- getSize: (index) => getColumnWidthPx(sheet, index, true),
3698
- });
3699
- const toRow = resolveAnchorEndpoint({
3700
- startIndex: fromRow,
3701
- startOffset: offsetY,
3702
- span: heightPx,
3703
- getSize: (index) => getRowHeightPx(sheet, index, true),
3704
- });
3705
- const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
3706
- const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
3707
4007
  const { blipChildren, srcRectXml } = buildPictureAdjustmentsXml(placement.adjustments);
3708
4008
  const blipXml = blipChildren
3709
4009
  ? `<a:blip r:embed="${relId}">${blipChildren}</a:blip>`
3710
4010
  : `<a:blip r:embed="${relId}"/>`;
3711
- return `<xdr:twoCellAnchor editAs="oneCell">${fromXml}${toXml}<xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${pictureId}" name="Picture ${pictureId}"/><xdr:cNvPicPr/></xdr:nvPicPr><xdr:blipFill>${blipXml}${srcRectXml}<a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic><xdr:clientData/></xdr:twoCellAnchor>`;
4011
+ return buildDrawingAnchorXml({
4012
+ sheet,
4013
+ overlay: placement.overlay,
4014
+ fromRow,
4015
+ fromColumn,
4016
+ offsetX,
4017
+ offsetY,
4018
+ widthPx,
4019
+ heightPx,
4020
+ body: `<xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${pictureId}" name="Picture ${pictureId}"/><xdr:cNvPicPr/></xdr:nvPicPr><xdr:blipFill>${blipXml}${srcRectXml}<a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic>`,
4021
+ });
3712
4022
  };
3713
4023
  /** Fraction (0…1) → DrawingML per-mille percent (100000 = 100%). */
3714
4024
  const fractionToPct = (value) => Math.round(Math.max(-1, Math.min(1, value)) * 100000);
@@ -3759,29 +4069,30 @@ const createChartAnchor = ({ placement, relId, frameId, sheet, chartEx = false,
3759
4069
  const offsetY = Math.max(0, placement.offsetY ?? 0);
3760
4070
  const widthPx = Math.max(5, placement.width ?? 300);
3761
4071
  const heightPx = Math.max(5, placement.height ?? 200);
3762
- const toColumn = resolveAnchorEndpoint({
3763
- startIndex: fromColumn,
3764
- startOffset: offsetX,
3765
- span: widthPx,
3766
- getSize: (index) => getColumnWidthPx(sheet, index, true),
3767
- });
3768
- const toRow = resolveAnchorEndpoint({
3769
- startIndex: fromRow,
3770
- startOffset: offsetY,
3771
- span: heightPx,
3772
- getSize: (index) => getRowHeightPx(sheet, index, true),
3773
- });
3774
- const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
3775
- const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
3776
- if (chartEx) {
3777
- return `<xdr:twoCellAnchor>${fromXml}${toXml}${buildChartExGraphicFrameXml({
4072
+ const body = chartEx
4073
+ ? buildChartExGraphicFrameXml({
3778
4074
  relId,
3779
4075
  frameId,
3780
4076
  widthEmu: Math.round(widthPx * EMUS_PER_PIXEL),
3781
4077
  heightEmu: Math.round(heightPx * EMUS_PER_PIXEL),
3782
- })}<xdr:clientData/></xdr:twoCellAnchor>`;
3783
- }
3784
- return `<xdr:twoCellAnchor>${fromXml}${toXml}<xdr:graphicFrame><xdr:nvGraphicFramePr><xdr:cNvPr id="${frameId}" name="Chart ${frameId}"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="${REL_NS}" r:id="${relId}"/></a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>`;
4078
+ })
4079
+ : `<xdr:graphicFrame><xdr:nvGraphicFramePr><xdr:cNvPr id="${frameId}" name="Chart ${frameId}"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="${REL_NS}" r:id="${relId}"/></a:graphicData></a:graphic></xdr:graphicFrame>`;
4080
+ const overlay = placement.chart.position?.overlayPosition;
4081
+ return buildDrawingAnchorXml({
4082
+ sheet,
4083
+ // A chartsheet has no cells to anchor to: Excel only reads an
4084
+ // `absoluteAnchor` there, whatever placement the chart carries.
4085
+ overlay: sheet.type === "chartsheet" && overlay
4086
+ ? { ...overlay, placement: "absolute" }
4087
+ : overlay,
4088
+ fromRow,
4089
+ fromColumn,
4090
+ offsetX,
4091
+ offsetY,
4092
+ widthPx,
4093
+ heightPx,
4094
+ body,
4095
+ });
3785
4096
  };
3786
4097
  const createSlicerAnchor = ({ placement, frameId, sheet, }) => {
3787
4098
  const fromRow = Math.max(0, placement.rowIndex - 1);
@@ -3790,21 +4101,42 @@ const createSlicerAnchor = ({ placement, frameId, sheet, }) => {
3790
4101
  const offsetY = Math.max(0, placement.offsetY ?? 0);
3791
4102
  const widthPx = Math.max(50, placement.width ?? 200);
3792
4103
  const heightPx = Math.max(50, placement.height ?? 200);
3793
- const toColumn = resolveAnchorEndpoint({
3794
- startIndex: fromColumn,
3795
- startOffset: offsetX,
3796
- span: widthPx,
3797
- getSize: (index) => getColumnWidthPx(sheet, index, true),
3798
- });
3799
- const toRow = resolveAnchorEndpoint({
3800
- startIndex: fromRow,
3801
- startOffset: offsetY,
3802
- span: heightPx,
3803
- getSize: (index) => getRowHeightPx(sheet, index, true),
3804
- });
4104
+ // Excel keeps a slicer in a `twoCellAnchor` whatever its placement and
4105
+ // spells the other two modes as `editAs` (#668 C-46); a "Move and size"
4106
+ // slicer keeps its persisted second anchor.
4107
+ const overlay = placement.overlay;
4108
+ const mode = overlay?.placement ?? "moveAndSize";
4109
+ const persistedTo = mode === "moveAndSize" ? overlay?.toCell : undefined;
4110
+ const editAs = mode === "moveOnly"
4111
+ ? ' editAs="oneCell"'
4112
+ : mode === "absolute"
4113
+ ? ' editAs="absolute"'
4114
+ : "";
4115
+ const toColumn = persistedTo
4116
+ ? {
4117
+ index: Math.max(0, persistedTo.columnIndex - 1),
4118
+ offset: Math.max(0, overlay?.toOffsetXPixels ?? 0),
4119
+ }
4120
+ : resolveAnchorEndpoint({
4121
+ startIndex: fromColumn,
4122
+ startOffset: offsetX,
4123
+ span: widthPx,
4124
+ getSize: (index) => getColumnWidthPx(sheet, index, true),
4125
+ });
4126
+ const toRow = persistedTo
4127
+ ? {
4128
+ index: Math.max(0, persistedTo.rowIndex - 1),
4129
+ offset: Math.max(0, overlay?.toOffsetYPixels ?? 0),
4130
+ }
4131
+ : resolveAnchorEndpoint({
4132
+ startIndex: fromRow,
4133
+ startOffset: offsetY,
4134
+ span: heightPx,
4135
+ getSize: (index) => getRowHeightPx(sheet, index, true),
4136
+ });
3805
4137
  const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
3806
4138
  const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
3807
- return `<xdr:twoCellAnchor editAs="absolute">${fromXml}${toXml}<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:sle15="http://schemas.microsoft.com/office/drawing/2012/slicer" Requires="sle15"><xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="${frameId}" name="${escapeXmlAttr(placement.title)}"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/drawing/2010/slicer"><sle:slicer xmlns:sle="http://schemas.microsoft.com/office/drawing/2010/slicer" name="${escapeXmlAttr(placement.title)}"/></a:graphicData></a:graphic></xdr:graphicFrame></mc:Choice></mc:AlternateContent><xdr:clientData/></xdr:twoCellAnchor>`;
4139
+ return `<xdr:twoCellAnchor${editAs}>${fromXml}${toXml}<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:sle15="http://schemas.microsoft.com/office/drawing/2012/slicer" Requires="sle15"><xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="${frameId}" name="${escapeXmlAttr(placement.title)}"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/drawing/2010/slicer"><sle:slicer xmlns:sle="http://schemas.microsoft.com/office/drawing/2010/slicer" name="${escapeXmlAttr(placement.title)}"/></a:graphicData></a:graphic></xdr:graphicFrame></mc:Choice></mc:AlternateContent><xdr:clientData/></xdr:twoCellAnchor>`;
3808
4140
  };
3809
4141
  const createTextBoxAnchor = ({ placement, frameId, sheet, }) => {
3810
4142
  const fromRow = Math.max(0, placement.rowIndex - 1);
@@ -3813,24 +4145,20 @@ const createTextBoxAnchor = ({ placement, frameId, sheet, }) => {
3813
4145
  const offsetY = Math.max(0, placement.offsetY ?? 0);
3814
4146
  const widthPx = Math.max(50, placement.width ?? 200);
3815
4147
  const heightPx = Math.max(50, placement.height ?? 60);
3816
- const toColumn = resolveAnchorEndpoint({
3817
- startIndex: fromColumn,
3818
- startOffset: offsetX,
3819
- span: widthPx,
3820
- getSize: (index) => getColumnWidthPx(sheet, index, true),
3821
- });
3822
- const toRow = resolveAnchorEndpoint({
3823
- startIndex: fromRow,
3824
- startOffset: offsetY,
3825
- span: heightPx,
3826
- getSize: (index) => getRowHeightPx(sheet, index, true),
3827
- });
3828
- const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
3829
- const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
3830
4148
  const spec = placement.textBox.spec;
3831
4149
  const textBodyXml = buildTextBodyXml(spec);
3832
4150
  const shapePropertiesXml = buildShapePropertiesXml(spec, widthPx, heightPx);
3833
- return `<xdr:twoCellAnchor editAs="oneCell">${fromXml}${toXml}<xdr:sp><xdr:nvSpPr><xdr:cNvPr id="${frameId}" name="TextBox ${frameId}"/><xdr:cNvSpPr txBox="1"/></xdr:nvSpPr>${shapePropertiesXml}${textBodyXml}</xdr:sp><xdr:clientData/></xdr:twoCellAnchor>`;
4151
+ return buildDrawingAnchorXml({
4152
+ sheet,
4153
+ overlay: placement.textBox.position?.overlayPosition,
4154
+ fromRow,
4155
+ fromColumn,
4156
+ offsetX,
4157
+ offsetY,
4158
+ widthPx,
4159
+ heightPx,
4160
+ body: `<xdr:sp><xdr:nvSpPr><xdr:cNvPr id="${frameId}" name="TextBox ${frameId}"/><xdr:cNvSpPr txBox="1"/></xdr:nvSpPr>${shapePropertiesXml}${textBodyXml}</xdr:sp>`,
4161
+ });
3834
4162
  };
3835
4163
  /** ColorIndex slots → canonical DrawingML scheme names (inverse of the importer's map). */
3836
4164
  const THEME_INDEX_TO_SCHEME = [
@@ -3948,20 +4276,6 @@ const createShapeAnchor = ({ placement, frameId, sheet, }) => {
3948
4276
  const offsetY = Math.max(0, placement.offsetY ?? 0);
3949
4277
  const widthPx = Math.max(10, placement.width ?? 120);
3950
4278
  const heightPx = Math.max(10, placement.height ?? 60);
3951
- const toColumn = resolveAnchorEndpoint({
3952
- startIndex: fromColumn,
3953
- startOffset: offsetX,
3954
- span: widthPx,
3955
- getSize: (index) => getColumnWidthPx(sheet, index, true),
3956
- });
3957
- const toRow = resolveAnchorEndpoint({
3958
- startIndex: fromRow,
3959
- startOffset: offsetY,
3960
- span: heightPx,
3961
- getSize: (index) => getRowHeightPx(sheet, index, true),
3962
- });
3963
- const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
3964
- const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
3965
4279
  const xfrmAttrs = [
3966
4280
  placement.shape.rotation
3967
4281
  ? ` rot="${Math.round(placement.shape.rotation * 60000)}"`
@@ -3988,7 +4302,17 @@ const createShapeAnchor = ({ placement, frameId, sheet, }) => {
3988
4302
  ? buildTextBodyXml(spec.textBody)
3989
4303
  : `<xdr:txBody><a:bodyPr vertOverflow="clip" horzOverflow="clip" rtlCol="0" anchor="t"/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></xdr:txBody>`;
3990
4304
  const name = escapeXmlAttr(spec.name ?? `Shape ${frameId}`);
3991
- return `<xdr:twoCellAnchor editAs="oneCell">${fromXml}${toXml}<xdr:sp><xdr:nvSpPr><xdr:cNvPr id="${frameId}" name="${name}"/><xdr:cNvSpPr/></xdr:nvSpPr>${spPr}${styleXml}${textBodyXml}</xdr:sp><xdr:clientData/></xdr:twoCellAnchor>`;
4305
+ return buildDrawingAnchorXml({
4306
+ sheet,
4307
+ overlay: placement.shape.position?.overlayPosition,
4308
+ fromRow,
4309
+ fromColumn,
4310
+ offsetX,
4311
+ offsetY,
4312
+ widthPx,
4313
+ heightPx,
4314
+ body: `<xdr:sp><xdr:nvSpPr><xdr:cNvPr id="${frameId}" name="${name}"/><xdr:cNvSpPr/></xdr:nvSpPr>${spPr}${styleXml}${textBodyXml}</xdr:sp>`,
4315
+ });
3992
4316
  };
3993
4317
  const buildTextWithTabs = (text, rPrXml) => {
3994
4318
  const parts = text.split("\t");
@@ -4239,6 +4563,60 @@ const buildShapePropertiesXml = (spec, widthPx, heightPx) => {
4239
4563
  }
4240
4564
  return `<xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>${fillXml}${outlineXml}</xdr:spPr>`;
4241
4565
  };
4566
+ /**
4567
+ * Wrap a drawing object's body in the anchor its placement mode calls for
4568
+ * (#668 C-06). "Move and size with cells" (Excel's default) is a
4569
+ * `twoCellAnchor` whose `to` is the object's persisted second anchor — or,
4570
+ * for an object that has none yet, the cell its pixel extent reaches over
4571
+ * this sheet's row / column sizes. "Move but don't size" is a
4572
+ * `oneCellAnchor` (`from` + `ext`); "Don't move or size" is an
4573
+ * `absoluteAnchor` (`pos` + `ext`). The importers read all three back into
4574
+ * the same placement.
4575
+ */
4576
+ const buildDrawingAnchorXml = ({ sheet, overlay, fromRow, fromColumn, offsetX, offsetY, widthPx, heightPx, body, }) => {
4577
+ const emu = (px) => Math.round(px * EMUS_PER_PIXEL);
4578
+ const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${emu(offsetX)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${emu(offsetY)}</xdr:rowOff></xdr:from>`;
4579
+ const extXml = `<xdr:ext cx="${emu(widthPx)}" cy="${emu(heightPx)}"/>`;
4580
+ const placement = overlay?.placement ?? "moveAndSize";
4581
+ if (placement === "moveOnly") {
4582
+ return `<xdr:oneCellAnchor>${fromXml}${extXml}${body}<xdr:clientData/></xdr:oneCellAnchor>`;
4583
+ }
4584
+ if (placement === "absolute") {
4585
+ let x = offsetX;
4586
+ for (let index = 0; index < fromColumn; index++) {
4587
+ x += getColumnWidthPx(sheet, index, true);
4588
+ }
4589
+ let y = offsetY;
4590
+ for (let index = 0; index < fromRow; index++) {
4591
+ y += getRowHeightPx(sheet, index, true);
4592
+ }
4593
+ return `<xdr:absoluteAnchor><xdr:pos x="${emu(x)}" y="${emu(y)}"/>${extXml}${body}<xdr:clientData/></xdr:absoluteAnchor>`;
4594
+ }
4595
+ const toColumn = overlay?.toCell
4596
+ ? {
4597
+ index: Math.max(0, overlay.toCell.columnIndex - 1),
4598
+ offset: Math.max(0, overlay.toOffsetXPixels ?? 0),
4599
+ }
4600
+ : resolveAnchorEndpoint({
4601
+ startIndex: fromColumn,
4602
+ startOffset: offsetX,
4603
+ span: widthPx,
4604
+ getSize: (index) => getColumnWidthPx(sheet, index, true),
4605
+ });
4606
+ const toRow = overlay?.toCell
4607
+ ? {
4608
+ index: Math.max(0, overlay.toCell.rowIndex - 1),
4609
+ offset: Math.max(0, overlay.toOffsetYPixels ?? 0),
4610
+ }
4611
+ : resolveAnchorEndpoint({
4612
+ startIndex: fromRow,
4613
+ startOffset: offsetY,
4614
+ span: heightPx,
4615
+ getSize: (index) => getRowHeightPx(sheet, index, true),
4616
+ });
4617
+ const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${emu(toColumn.offset)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${emu(toRow.offset)}</xdr:rowOff></xdr:to>`;
4618
+ return `<xdr:twoCellAnchor>${fromXml}${toXml}${body}<xdr:clientData/></xdr:twoCellAnchor>`;
4619
+ };
4242
4620
  const resolveAnchorEndpoint = ({ startIndex, startOffset, span, getSize, }) => {
4243
4621
  let index = startIndex;
4244
4622
  let offset = Math.max(0, startOffset);
@@ -4548,15 +4926,18 @@ const buildWorkbookSlicerCachesXml = (slicerCacheRelIds) => {
4548
4926
  .join("");
4549
4927
  return `<extLst><ext uri="{46BE6895-7355-4a93-B00E-2C351335B9C9}" xmlns:x15="http://schemas.microsoft.com/office/spreadsheetml/2010/11/main"><x15:slicerCaches xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main">${cacheEntries}</x15:slicerCaches></ext></extLst>`;
4550
4928
  };
4551
- const buildWorkbookRelationships = (sheetCount, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, hasPersons = false) => {
4929
+ const buildWorkbookRelationships = (
4930
+ /** One entry per sheet in `<sheets>` order; `target` is relative to `xl/`. */
4931
+ sheetParts, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, hasPersons = false) => {
4552
4932
  const relationships = [];
4553
4933
  const sheetRelIds = [];
4554
4934
  const slicerCacheRelIds = [];
4555
- for (let i = 0; i < sheetCount; i++) {
4935
+ const sheetCount = sheetParts.length;
4936
+ sheetParts.forEach((part, i) => {
4556
4937
  const relId = `rId${i + 1}`;
4557
4938
  sheetRelIds.push(relId);
4558
- relationships.push(`<Relationship Id="${relId}" Type="${REL_NS}/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`);
4559
- }
4939
+ relationships.push(`<Relationship Id="${relId}" Type="${REL_NS}/${part.chartsheet ? "chartsheet" : "worksheet"}" Target="${part.target}"/>`);
4940
+ });
4560
4941
  const stylesId = `rId${sheetCount + 1}`;
4561
4942
  const themeId = `rId${sheetCount + 2}`;
4562
4943
  const sharedStringsId = `rId${sheetCount + 3}`;
@@ -4600,10 +4981,11 @@ const buildWorkbookRelationships = (sheetCount, slicerCacheCount = 0, externalLi
4600
4981
  slicerCacheRelationshipIds: slicerCacheRelIds,
4601
4982
  };
4602
4983
  };
4603
- const buildContentTypesXml = ({ sheetCount, tableCount, drawingSheets, mediaExtensions, chartCount, chartExCount = 0, slicerCount = 0, slicerCacheCount = 0, pivotTableCount = 0, pivotCacheCount = 0, commentsSheets = [], threadedCommentsSheets = [], hasPersons = false, externalLinkCount = 0, hasVmlDrawings = false, ctrlPropCount = 0, }) => {
4604
- const sheetOverrides = Array.from({ length: sheetCount }, (_, index) => {
4984
+ const buildContentTypesXml = ({ worksheetCount, chartsheetCount = 0, tableCount, drawingSheets, mediaExtensions, chartCount, chartExCount = 0, slicerCount = 0, slicerCacheCount = 0, pivotTableCount = 0, pivotCacheCount = 0, commentsSheets = [], threadedCommentsSheets = [], hasPersons = false, externalLinkCount = 0, hasVmlDrawings = false, ctrlPropCount = 0, }) => {
4985
+ const sheetOverrides = Array.from({ length: worksheetCount }, (_, index) => {
4605
4986
  return `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
4606
4987
  }).join("");
4988
+ const chartsheetOverrides = Array.from({ length: chartsheetCount }, (_, index) => `<Override PartName="/xl/chartsheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml"/>`).join("");
4607
4989
  const tableOverrides = Array.from({ length: tableCount }, (_, index) => {
4608
4990
  return `<Override PartName="/xl/tables/table${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>`;
4609
4991
  }).join("");
@@ -4644,13 +5026,25 @@ const buildContentTypesXml = ({ sheetCount, tableCount, drawingSheets, mediaExte
4644
5026
  ? `<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>`
4645
5027
  : "";
4646
5028
  const ctrlPropOverrides = Array.from({ length: ctrlPropCount }, (_, index) => `<Override PartName="/xl/ctrlProps/ctrlProp${index + 1}.xml" ContentType="${CTRL_PROP_CONTENT_TYPE}"/>`).join("");
4647
- return `${XML_DECLARATION}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${vmlDefault}${mediaDefaults}<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>${sheetOverrides}${tableOverrides}${drawingOverrides}${chartOverrides}${chartExOverrides}${slicerOverrides}${slicerCacheOverrides}${pivotTableOverrides}${pivotCacheOverrides}${externalLinkOverrides}${ctrlPropOverrides}${commentsOverrides}${threadedCommentsOverrides}${personOverride}<Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>`;
5029
+ return `${XML_DECLARATION}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${vmlDefault}${mediaDefaults}<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>${sheetOverrides}${chartsheetOverrides}${tableOverrides}${drawingOverrides}${chartOverrides}${chartExOverrides}${slicerOverrides}${slicerCacheOverrides}${pivotTableOverrides}${pivotCacheOverrides}${externalLinkOverrides}${ctrlPropOverrides}${commentsOverrides}${threadedCommentsOverrides}${personOverride}<Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>`;
4648
5030
  };
4649
5031
  const buildRootRelationshipsXml = () => {
4650
5032
  return `${XML_DECLARATION}<Relationships xmlns="${PACKAGE_REL_NS}"><Relationship Id="rId1" Type="${REL_NS}/officeDocument" Target="xl/workbook.xml"/><Relationship Id="rId2" Type="${REL_NS}/extended-properties" Target="docProps/app.xml"/><Relationship Id="rId3" Type="${REL_NS}/core-properties" Target="docProps/core.xml"/></Relationships>`;
4651
5033
  };
4652
- const buildAppXml = (sheetNames, properties) => {
4653
- const titles = sheetNames
5034
+ const buildAppXml = (sheetNames, isChartsheet, properties) => {
5035
+ // Excel groups the tabs by kind: `HeadingPairs` counts `Worksheets` then
5036
+ // `Charts` (a pair is only written for a non-empty kind) and `TitlesOfParts`
5037
+ // lists the worksheet names followed by the chartsheet names.
5038
+ const worksheetNames = sheetNames.filter((_, index) => !isChartsheet[index]);
5039
+ const chartsheetNames = sheetNames.filter((_, index) => isChartsheet[index]);
5040
+ const headingPairs = [
5041
+ ["Worksheets", worksheetNames.length],
5042
+ ["Charts", chartsheetNames.length],
5043
+ ].filter(([, count]) => count > 0);
5044
+ const headingPairsXml = headingPairs
5045
+ .map(([kind, count]) => `<vt:variant><vt:lpstr>${kind}</vt:lpstr></vt:variant><vt:variant><vt:i4>${count}</vt:i4></vt:variant>`)
5046
+ .join("");
5047
+ const titles = [...worksheetNames, ...chartsheetNames]
4654
5048
  .map((name) => `<vt:lpstr>${escapeXmlText(name)}</vt:lpstr>`)
4655
5049
  .join("");
4656
5050
  const application = properties?.application ?? CREATOR;
@@ -4660,7 +5054,7 @@ const buildAppXml = (sheetNames, properties) => {
4660
5054
  const managerTag = properties?.manager
4661
5055
  ? `<Manager>${escapeXmlText(properties.manager)}</Manager>`
4662
5056
  : "";
4663
- return `${XML_DECLARATION}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>${escapeXmlText(application)}</Application><DocSecurity>0</DocSecurity><ScaleCrop>false</ScaleCrop><HeadingPairs><vt:vector size="2" baseType="variant"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>${sheetNames.length}</vt:i4></vt:variant></vt:vector></HeadingPairs><TitlesOfParts><vt:vector size="${sheetNames.length}" baseType="lpstr">${titles}</vt:vector></TitlesOfParts>${companyTag}${managerTag}</Properties>`;
5057
+ return `${XML_DECLARATION}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>${escapeXmlText(application)}</Application><DocSecurity>0</DocSecurity><ScaleCrop>false</ScaleCrop><HeadingPairs><vt:vector size="${headingPairs.length * 2}" baseType="variant">${headingPairsXml}</vt:vector></HeadingPairs><TitlesOfParts><vt:vector size="${sheetNames.length}" baseType="lpstr">${titles}</vt:vector></TitlesOfParts>${companyTag}${managerTag}</Properties>`;
4664
5058
  };
4665
5059
  const buildCoreXml = (properties) => {
4666
5060
  const now = new Date().toISOString();