@rowsncolumns/toolkit 24.5.4 → 24.5.5

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 (73) hide show
  1. package/dist/cjs/autofilter-condition.js +6 -2
  2. package/dist/cjs/excel-parser/helpers.d.ts +9 -1
  3. package/dist/cjs/excel-parser/helpers.d.ts.map +1 -1
  4. package/dist/cjs/excel-parser/helpers.js +70 -16
  5. package/dist/cjs/excel-parser/interface.d.ts +9 -1
  6. package/dist/cjs/excel-parser/interface.d.ts.map +1 -1
  7. package/dist/cjs/excel-parser/plugins/xlsx/conditional-formatting.d.ts.map +1 -1
  8. package/dist/cjs/excel-parser/plugins/xlsx/conditional-formatting.js +61 -6
  9. package/dist/cjs/excel-parser/plugins/xlsx/data-validations.d.ts.map +1 -1
  10. package/dist/cjs/excel-parser/plugins/xlsx/data-validations.js +13 -2
  11. package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts +8 -1
  12. package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts.map +1 -1
  13. package/dist/cjs/excel-parser/plugins/xlsx/index.js +159 -26
  14. package/dist/cjs/excel-parser/plugins/xlsx/styles.d.ts +17 -2
  15. package/dist/cjs/excel-parser/plugins/xlsx/styles.d.ts.map +1 -1
  16. package/dist/cjs/excel-parser/plugins/xlsx/styles.js +25 -14
  17. package/dist/cjs/excel-parser/types.d.ts +5 -1
  18. package/dist/cjs/excel-parser/types.d.ts.map +1 -1
  19. package/dist/cjs/excel-parser/workbook.d.ts +1 -0
  20. package/dist/cjs/excel-parser/workbook.d.ts.map +1 -1
  21. package/dist/cjs/excel-parser/workbook.js +4 -0
  22. package/dist/cjs/excel-parser/worker.js +3 -0
  23. package/dist/cjs/exporter/xlsx/chart-builder.d.ts.map +1 -1
  24. package/dist/cjs/exporter/xlsx/chart-builder.js +167 -53
  25. package/dist/cjs/exporter/xlsx/future-functions.d.ts +6 -7
  26. package/dist/cjs/exporter/xlsx/future-functions.d.ts.map +1 -1
  27. package/dist/cjs/exporter/xlsx/future-functions.js +5 -261
  28. package/dist/cjs/exporter/xlsx/style-builder.d.ts.map +1 -1
  29. package/dist/cjs/exporter/xlsx/style-builder.js +62 -19
  30. package/dist/cjs/exporter/xlsx/xlsx-export.d.ts +10 -2
  31. package/dist/cjs/exporter/xlsx/xlsx-export.d.ts.map +1 -1
  32. package/dist/cjs/exporter/xlsx/xlsx-export.js +404 -158
  33. package/dist/cjs/helpers/excel-formula-prefixes.d.ts +51 -0
  34. package/dist/cjs/helpers/excel-formula-prefixes.d.ts.map +1 -0
  35. package/dist/cjs/helpers/excel-formula-prefixes.js +548 -0
  36. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  37. package/dist/esm/autofilter-condition.js +6 -2
  38. package/dist/esm/excel-parser/helpers.d.ts +9 -1
  39. package/dist/esm/excel-parser/helpers.d.ts.map +1 -1
  40. package/dist/esm/excel-parser/helpers.js +65 -3
  41. package/dist/esm/excel-parser/interface.d.ts +9 -1
  42. package/dist/esm/excel-parser/interface.d.ts.map +1 -1
  43. package/dist/esm/excel-parser/plugins/xlsx/conditional-formatting.d.ts.map +1 -1
  44. package/dist/esm/excel-parser/plugins/xlsx/conditional-formatting.js +42 -1
  45. package/dist/esm/excel-parser/plugins/xlsx/data-validations.d.ts.map +1 -1
  46. package/dist/esm/excel-parser/plugins/xlsx/data-validations.js +11 -2
  47. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts +8 -1
  48. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts.map +1 -1
  49. package/dist/esm/excel-parser/plugins/xlsx/index.js +109 -4
  50. package/dist/esm/excel-parser/plugins/xlsx/styles.d.ts +17 -2
  51. package/dist/esm/excel-parser/plugins/xlsx/styles.d.ts.map +1 -1
  52. package/dist/esm/excel-parser/plugins/xlsx/styles.js +20 -8
  53. package/dist/esm/excel-parser/types.d.ts +5 -1
  54. package/dist/esm/excel-parser/types.d.ts.map +1 -1
  55. package/dist/esm/excel-parser/workbook.d.ts +1 -0
  56. package/dist/esm/excel-parser/workbook.d.ts.map +1 -1
  57. package/dist/esm/excel-parser/workbook.js +3 -0
  58. package/dist/esm/excel-parser/worker.js +3 -0
  59. package/dist/esm/exporter/xlsx/chart-builder.d.ts.map +1 -1
  60. package/dist/esm/exporter/xlsx/chart-builder.js +80 -0
  61. package/dist/esm/exporter/xlsx/future-functions.d.ts +6 -7
  62. package/dist/esm/exporter/xlsx/future-functions.d.ts.map +1 -1
  63. package/dist/esm/exporter/xlsx/future-functions.js +5 -261
  64. package/dist/esm/exporter/xlsx/style-builder.d.ts.map +1 -1
  65. package/dist/esm/exporter/xlsx/style-builder.js +56 -18
  66. package/dist/esm/exporter/xlsx/xlsx-export.d.ts +10 -2
  67. package/dist/esm/exporter/xlsx/xlsx-export.d.ts.map +1 -1
  68. package/dist/esm/exporter/xlsx/xlsx-export.js +338 -106
  69. package/dist/esm/helpers/excel-formula-prefixes.d.ts +51 -0
  70. package/dist/esm/helpers/excel-formula-prefixes.d.ts.map +1 -0
  71. package/dist/esm/helpers/excel-formula-prefixes.js +531 -0
  72. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  73. package/package.json +4 -4
@@ -1,4 +1,4 @@
1
- import { cellRangeToAddress, cellToAddress, escapeCharacters, getCellEffectiveFormat, getCellEffectiveValue, getCellFormattedValue, getCellUserEnteredFormat, getCellUserEnteredValue, getExtendedValueBool, getExtendedValueError, getExtendedValueFormula, getExtendedValueNumber, getExtendedValueString, isNil, sortSheetsByIndex, } from "@rowsncolumns/utils";
1
+ import { cellRangeToAddress, cellToAddress, escapeCharacters, getCellEffectiveFormat, getCellEffectiveValue, getCellFormattedValue, getCellUserEnteredFormat, getCellUserEnteredValue, getExtendedValueBool, getExtendedValueError, getExtendedValueFormula, getExtendedValueNumber, getExtendedValueString, isNil, sortSheetsByIndex, conditionOperandToFormulaText, } from "@rowsncolumns/utils";
2
2
  import { zipSync } from "fflate";
3
3
  import { omitInvalidDataValidationError } from "../invalid-data-validation";
4
4
  import { buildAutoFilterCriteriaXml } from "../../autofilter-condition";
@@ -25,7 +25,7 @@ const isStyleReference = (value) => !!value && typeof value === "object" && "sid
25
25
  const DEFAULT_COLUMN_WIDTH_PX = 100;
26
26
  const DEFAULT_ROW_HEIGHT_PX = 21;
27
27
  const pixelToPoints = (px) => Math.max(1, Number(((px * 72) / 96).toFixed(2)));
28
- export const createExcelFile = async ({ conditionalFormats: _conditionalFormats, dataValidations: _dataValidations, sheets, sheetData, sharedStrings: sourceSharedStrings, tables: _tables = [], cellXfs, theme, defaultFont, embeds = [], charts = [], slicers = [], pivotTables = [], externalLinks = [], namedRanges = [], protectedRanges = [], namedCellStyles, customTableStyles, iterativeCalculationOptions, ignoreFormulaPatterns, citations, comments, password, locale, use1904 = false, properties, powerQuery, }) => {
28
+ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats, dataValidations: _dataValidations, sheets, sheetData, sharedStrings: sourceSharedStrings, tables: _tables = [], cellXfs, theme, defaultFont, embeds = [], charts = [], slicers = [], pivotTables = [], externalLinks = [], namedRanges = [], protectedRanges = [], namedCellStyles, customTableStyles, iterativeCalculationOptions, workbookProtection, ignoreFormulaPatterns, citations, comments, password, locale, use1904 = false, properties, powerQuery, }) => {
29
29
  const sortedSheets = sortSheetsByIndex(sheets, true);
30
30
  const sharedStrings = new SharedStrings();
31
31
  // Workbook-scoped author registry shared across every sheet's threaded
@@ -317,13 +317,17 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
317
317
  commentsXml: result.commentsXml,
318
318
  vmlDrawingXml: result.vmlDrawingXml,
319
319
  threadedCommentsXml: result.threadedCommentsXml,
320
+ hasDynamicArrays: result.hasDynamicArrays,
320
321
  };
321
322
  });
323
+ // Dynamic-array anchors (`cm="1"`) index the XLDAPR record of one
324
+ // workbook-level xl/metadata.xml part; without it Excel repairs the file.
325
+ const hasDynamicArrays = worksheetEntries.some((entry) => entry.hasDynamicArrays);
322
326
  const { xml: workbookRelationshipsXml, sheetRelationshipIds, slicerCacheRelationshipIds, pivotCacheRelationshipIds, externalLinkRelationshipIds, } = buildWorkbookRelationships(worksheetEntries.map((entry) => ({
323
327
  chartsheet: entry.isChartsheet,
324
328
  target: entry.path.replace(/^xl\//, ""),
325
- })), slicerCacheEntries.length, externalLinks.length, pivotCacheEntries.length, !personRegistry.isEmpty());
326
- const workbookXml = buildWorkbookXml(sortedSheets, sheetRelationshipIds, workbookSheetNames, namedRanges, iterativeCalculationOptions, slicerCacheRelationshipIds, use1904, externalLinkRelationshipIds, pivotCacheRelationshipIds);
329
+ })), slicerCacheEntries.length, externalLinks.length, pivotCacheEntries.length, !personRegistry.isEmpty(), hasDynamicArrays);
330
+ const workbookXml = buildWorkbookXml(sortedSheets, sheetRelationshipIds, workbookSheetNames, namedRanges, iterativeCalculationOptions, slicerCacheRelationshipIds, use1904, externalLinkRelationshipIds, pivotCacheRelationshipIds, workbookProtection);
327
331
  const sharedStringsXml = buildSharedStringsXml(sharedStrings);
328
332
  const rootRelationshipsXml = buildRootRelationshipsXml();
329
333
  const appXml = buildAppXml(workbookSheetNames, sortedSheets.map((sheet) => sheet.type === "chartsheet"), properties);
@@ -350,6 +354,9 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
350
354
  "xl/styles.xml": encodeXml(stylesXml),
351
355
  "xl/theme/theme1.xml": encodeXml(themeXml),
352
356
  };
357
+ if (hasDynamicArrays) {
358
+ files["xl/metadata.xml"] = encodeXml(DYNAMIC_ARRAY_METADATA_XML);
359
+ }
353
360
  for (const entry of worksheetEntries) {
354
361
  files[entry.path] = encodeXml(entry.content);
355
362
  if (entry.relationshipsXml) {
@@ -491,6 +498,7 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
491
498
  externalLinkCount: externalLinks.length,
492
499
  hasVmlDrawings,
493
500
  ctrlPropCount: ctrlProps.parts.length,
501
+ hasSheetMetadata: hasDynamicArrays,
494
502
  });
495
503
  files["[Content_Types].xml"] = encodeXml(contentTypesXml);
496
504
  // Preserved Power Query machinery (DataMashup customXml, connections.xml, queryTable parts) is
@@ -615,9 +623,13 @@ export const normalizeSqrefMembers = (refs) => {
615
623
  return out;
616
624
  };
617
625
  class DataValidationCollector {
626
+ locale;
618
627
  entries = new Map();
628
+ constructor(locale) {
629
+ this.locale = locale;
630
+ }
619
631
  add(address, rule) {
620
- const config = convertDataValidationRule(rule);
632
+ const config = convertDataValidationRule(rule, this.locale);
621
633
  if (!config)
622
634
  return;
623
635
  const key = getDataValidationKey(config);
@@ -756,7 +768,7 @@ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, ce
756
768
  const defaultRowHeightPx = getDefaultRowHeightPx(sheet);
757
769
  const rowXmlChunks = [];
758
770
  const collectedImages = [];
759
- const dataValidationCollector = new DataValidationCollector();
771
+ const dataValidationCollector = new DataValidationCollector(locale);
760
772
  const hyperlinkEntries = [];
761
773
  const commentsCollector = new CommentsCollector();
762
774
  const threadedCommentsCollector = new ThreadedCommentsCollector(comments, personRegistry ?? new PersonRegistry());
@@ -805,6 +817,8 @@ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, ce
805
817
  // see `<f t="shared" si="N"/>` with no corresponding leader and read the
806
818
  // cell as having no formula at all.
807
819
  const validSharedFormulaLeaders = new Set();
820
+ // Any `cm="1"` anchor on this sheet makes the workbook carry xl/metadata.xml.
821
+ let hasDynamicArrays = false;
808
822
  for (let rowIndex = 1; rowIndex < maxRows; rowIndex++) {
809
823
  const row = rows[rowIndex];
810
824
  const rowValues = row?.values;
@@ -839,6 +853,9 @@ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, ce
839
853
  sheets,
840
854
  locale,
841
855
  validSharedFormulaLeaders,
856
+ onDynamicArray: () => {
857
+ hasDynamicArrays = true;
858
+ },
842
859
  onImage: (image) => {
843
860
  collectedImages.push(image);
844
861
  drawingContext.ensureRel();
@@ -888,6 +905,7 @@ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, ce
888
905
  sheetId: sheet.sheetId,
889
906
  conditionalFormats,
890
907
  styleBuilder,
908
+ locale,
891
909
  });
892
910
  const tablePartsXml = buildTablePartsXml(tables, relationships);
893
911
  const drawingXml = drawingContext.hasDrawing()
@@ -977,6 +995,7 @@ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, ce
977
995
  commentsXml,
978
996
  vmlDrawingXml,
979
997
  threadedCommentsXml,
998
+ hasDynamicArrays,
980
999
  };
981
1000
  };
982
1001
  /**
@@ -998,6 +1017,7 @@ const buildChartsheetXml = ({ sheet, relationships, drawingContext, }) => {
998
1017
  commentsXml: undefined,
999
1018
  vmlDrawingXml: undefined,
1000
1019
  threadedCommentsXml: undefined,
1020
+ hasDynamicArrays: false,
1001
1021
  };
1002
1022
  };
1003
1023
  /**
@@ -1189,7 +1209,7 @@ const normalizeXlsxSourceListFormula1 = (formula1) => {
1189
1209
  return trimmed;
1190
1210
  return `"${escapeFormulaText(trimmed)}"`;
1191
1211
  };
1192
- const convertDataValidationRule = (rule) => {
1212
+ const convertDataValidationRule = (rule, locale) => {
1193
1213
  if (!rule)
1194
1214
  return undefined;
1195
1215
  const allowBlank = rule.allowBlank !== false;
@@ -1238,7 +1258,7 @@ const convertDataValidationRule = (rule) => {
1238
1258
  rejectedListSource = normalizeXlsxSourceListFormula1(src.formula1);
1239
1259
  }
1240
1260
  }
1241
- const synthesized = convertDataValidationCondition(rule, baseConfig);
1261
+ const synthesized = convertDataValidationCondition(rule, baseConfig, locale);
1242
1262
  if (synthesized)
1243
1263
  return synthesized;
1244
1264
  // A rejected `list` source with no usable condition is still kept — its
@@ -1257,7 +1277,7 @@ const convertDataValidationRule = (rule) => {
1257
1277
  // The condition-based synthesizer: the Excel config for a rule that has no
1258
1278
  // replayable `xlsxSource` (UI-authored, edited after import, or rejected by
1259
1279
  // the round-trip guard above).
1260
- const convertDataValidationCondition = (rule, baseConfig) => {
1280
+ const convertDataValidationCondition = (rule, baseConfig, locale) => {
1261
1281
  if (!rule.condition?.type) {
1262
1282
  // Prompt-only DV (no validation rule, just an input-message tooltip
1263
1283
  // and/or an error alert). Emit it with no type/formula so Excel still
@@ -1269,8 +1289,9 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1269
1289
  }
1270
1290
  const { condition } = rule;
1271
1291
  const values = condition.values ?? [];
1292
+ // OOXML `<formula1>` carries the formula text WITHOUT a leading `=` — Excel
1293
+ // never writes one (the xlsxSource replay path strips it the same way).
1272
1294
  const currentCell = 'INDIRECT("RC",FALSE)';
1273
- const ensureFormula = (value) => value.startsWith("=") ? value : `=${value}`;
1274
1295
  const ifErrorWrap = (formula) => `IFERROR(${formula},TRUE)`;
1275
1296
  if (condition.type === "ONE_OF_LIST") {
1276
1297
  const entries = values
@@ -1292,10 +1313,9 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1292
1313
  .filter((value) => typeof value === "string");
1293
1314
  if (!references.length)
1294
1315
  return undefined;
1295
- const formula1 = references.join(",");
1296
1316
  return {
1297
1317
  type: "list",
1298
- formula1: formula1.startsWith("=") ? formula1 : `=${formula1}`,
1318
+ formula1: stripLeadingEquals(references.join(",")),
1299
1319
  showDropDown: rule.displayStyle === "plain" ? true : undefined,
1300
1320
  ...baseConfig,
1301
1321
  };
@@ -1306,28 +1326,28 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1306
1326
  return undefined;
1307
1327
  return {
1308
1328
  type: "custom",
1309
- formula1: ensureFormula(raw),
1329
+ formula1: stripLeadingEquals(raw),
1310
1330
  ...baseConfig,
1311
1331
  };
1312
1332
  }
1313
1333
  if (condition.type === "BOOLEAN") {
1314
1334
  return {
1315
1335
  type: "custom",
1316
- formula1: `=OR(${currentCell}=TRUE,${currentCell}=FALSE)`,
1336
+ formula1: `OR(${currentCell}=TRUE,${currentCell}=FALSE)`,
1317
1337
  ...baseConfig,
1318
1338
  };
1319
1339
  }
1320
1340
  if (condition.type === "BLANK") {
1321
1341
  return {
1322
1342
  type: "custom",
1323
- formula1: `=${currentCell}=""`,
1343
+ formula1: `${currentCell}=""`,
1324
1344
  ...baseConfig,
1325
1345
  };
1326
1346
  }
1327
1347
  if (condition.type === "NOT_BLANK") {
1328
1348
  return {
1329
1349
  type: "custom",
1330
- formula1: `=${currentCell}<>""`,
1350
+ formula1: `${currentCell}<>""`,
1331
1351
  ...baseConfig,
1332
1352
  };
1333
1353
  }
@@ -1338,7 +1358,7 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1338
1358
  const op = condition.type === "TEXT_EQ" ? "=" : "<>";
1339
1359
  return {
1340
1360
  type: "custom",
1341
- formula1: `=${currentCell}${op}"${escapeFormulaText(raw)}"`,
1361
+ formula1: `${currentCell}${op}"${escapeFormulaText(raw)}"`,
1342
1362
  ...baseConfig,
1343
1363
  };
1344
1364
  }
@@ -1348,7 +1368,7 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1348
1368
  return undefined;
1349
1369
  return {
1350
1370
  type: "custom",
1351
- formula1: `=ISNUMBER(SEARCH("${escapeFormulaText(raw)}",${currentCell}))`,
1371
+ formula1: `ISNUMBER(SEARCH("${escapeFormulaText(raw)}",${currentCell}))`,
1352
1372
  ...baseConfig,
1353
1373
  };
1354
1374
  }
@@ -1358,7 +1378,7 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1358
1378
  return undefined;
1359
1379
  return {
1360
1380
  type: "custom",
1361
- formula1: `=ISERROR(SEARCH("${escapeFormulaText(raw)}",${currentCell}))`,
1381
+ formula1: `ISERROR(SEARCH("${escapeFormulaText(raw)}",${currentCell}))`,
1362
1382
  ...baseConfig,
1363
1383
  };
1364
1384
  }
@@ -1369,7 +1389,7 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1369
1389
  const text = escapeFormulaText(raw);
1370
1390
  return {
1371
1391
  type: "custom",
1372
- formula1: `=LEFT(${currentCell},LEN("${text}"))="${text}"`,
1392
+ formula1: `LEFT(${currentCell},LEN("${text}"))="${text}"`,
1373
1393
  ...baseConfig,
1374
1394
  };
1375
1395
  }
@@ -1380,21 +1400,21 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1380
1400
  const text = escapeFormulaText(raw);
1381
1401
  return {
1382
1402
  type: "custom",
1383
- formula1: `=RIGHT(${currentCell},LEN("${text}"))="${text}"`,
1403
+ formula1: `RIGHT(${currentCell},LEN("${text}"))="${text}"`,
1384
1404
  ...baseConfig,
1385
1405
  };
1386
1406
  }
1387
1407
  if (condition.type === "TEXT_IS_EMAIL") {
1388
1408
  return {
1389
1409
  type: "custom",
1390
- formula1: `=AND(ISNUMBER(SEARCH("@",${currentCell})),ISNUMBER(SEARCH(".",${currentCell})))`,
1410
+ formula1: `AND(ISNUMBER(SEARCH("@",${currentCell})),ISNUMBER(SEARCH(".",${currentCell})))`,
1391
1411
  ...baseConfig,
1392
1412
  };
1393
1413
  }
1394
1414
  if (condition.type === "TEXT_IS_URL") {
1395
1415
  return {
1396
1416
  type: "custom",
1397
- formula1: `=OR(LEFT(${currentCell},7)="http://",LEFT(${currentCell},8)="https://")`,
1417
+ formula1: `OR(LEFT(${currentCell},7)="http://",LEFT(${currentCell},8)="https://")`,
1398
1418
  ...baseConfig,
1399
1419
  };
1400
1420
  }
@@ -1420,13 +1440,16 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1420
1440
  NUMBER_LESS: "lessThan",
1421
1441
  NUMBER_LESS_THAN_EQ: "lessThanOrEqual",
1422
1442
  };
1443
+ // Bounds are written the way Excel stores them: typed literals normalized
1444
+ // (`50%` → `0.5`, a typed date → its serial), formulas without their `=`,
1445
+ // references / names / function calls verbatim.
1423
1446
  return {
1424
1447
  type: "decimal",
1425
1448
  operator: operatorMap[condition.type],
1426
- formula1: first,
1449
+ formula1: conditionOperandToFormulaText(first, locale),
1427
1450
  formula2: condition.type === "NUMBER_BETWEEN" ||
1428
1451
  condition.type === "NUMBER_NOT_BETWEEN"
1429
- ? second
1452
+ ? conditionOperandToFormulaText(second, locale)
1430
1453
  : undefined,
1431
1454
  ...baseConfig,
1432
1455
  };
@@ -1444,7 +1467,7 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1444
1467
  const op = opMap[condition.type];
1445
1468
  return {
1446
1469
  type: "custom",
1447
- formula1: `=${ifErrorWrap(`${currentCell}${op}INDIRECT("R[-1]C",FALSE)`)}`,
1470
+ formula1: `${ifErrorWrap(`${currentCell}${op}INDIRECT("R[-1]C",FALSE)`)}`,
1448
1471
  ...baseConfig,
1449
1472
  };
1450
1473
  }
@@ -1473,10 +1496,10 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1473
1496
  return {
1474
1497
  type: "date",
1475
1498
  operator: operatorMap[condition.type],
1476
- formula1: first,
1499
+ formula1: conditionOperandToFormulaText(first, locale),
1477
1500
  formula2: condition.type === "DATE_BETWEEN" ||
1478
1501
  condition.type === "DATE_NOT_BETWEEN"
1479
- ? second
1502
+ ? conditionOperandToFormulaText(second, locale)
1480
1503
  : undefined,
1481
1504
  ...baseConfig,
1482
1505
  };
@@ -1484,7 +1507,7 @@ const convertDataValidationCondition = (rule, baseConfig) => {
1484
1507
  if (condition.type === "DATE_IS_VALID") {
1485
1508
  return {
1486
1509
  type: "custom",
1487
- formula1: `=ISNUMBER(${currentCell})`,
1510
+ formula1: `ISNUMBER(${currentCell})`,
1488
1511
  ...baseConfig,
1489
1512
  };
1490
1513
  }
@@ -1563,7 +1586,7 @@ const resolveCellDataValidation = (cell, map) => {
1563
1586
  }
1564
1587
  return raw;
1565
1588
  };
1566
- const buildWorksheetRow = ({ rowNumber, row, rowMetadata, defaultRowHeightPx, sharedStrings, sourceSharedStrings, cellXfs, styleBuilder, onImage, onHyperlink, dataValidationsById, onDataValidation, tableNameMap, tables, ignoreFormulaPatterns, commentsCollector, onCommentThread, sheetId, sheetData, sheets, locale, validSharedFormulaLeaders, }) => {
1589
+ const buildWorksheetRow = ({ rowNumber, row, rowMetadata, defaultRowHeightPx, sharedStrings, sourceSharedStrings, cellXfs, styleBuilder, onImage, onHyperlink, dataValidationsById, onDataValidation, tableNameMap, tables, ignoreFormulaPatterns, commentsCollector, onCommentThread, sheetId, sheetData, sheets, locale, validSharedFormulaLeaders, onDynamicArray, }) => {
1567
1590
  const values = row?.values ?? [];
1568
1591
  const cellXmlChunks = [];
1569
1592
  let minColumn = Number.POSITIVE_INFINITY;
@@ -1592,6 +1615,7 @@ const buildWorksheetRow = ({ rowNumber, row, rowMetadata, defaultRowHeightPx, sh
1592
1615
  sheets,
1593
1616
  locale,
1594
1617
  validSharedFormulaLeaders,
1618
+ onDynamicArray,
1595
1619
  });
1596
1620
  if (!cellXml)
1597
1621
  continue;
@@ -1662,7 +1686,7 @@ const buildWorksheetRow = ({ rowNumber, row, rowMetadata, defaultRowHeightPx, sh
1662
1686
  maxColumn: maxColumn || undefined,
1663
1687
  };
1664
1688
  };
1665
- const buildCellXml = ({ rowNumber, columnIndex, cell, sharedStrings, sourceSharedStrings, cellXfs, styleBuilder, onImage, onHyperlink, dataValidationsById, onDataValidation, tableNameMap, tables, ignoreFormulaPatterns, commentsCollector, onCommentThread, sheetId, sheetData, sheets, locale, validSharedFormulaLeaders, }) => {
1689
+ const buildCellXml = ({ rowNumber, columnIndex, cell, sharedStrings, sourceSharedStrings, cellXfs, styleBuilder, onImage, onHyperlink, dataValidationsById, onDataValidation, tableNameMap, tables, ignoreFormulaPatterns, commentsCollector, onCommentThread, sheetId, sheetData, sheets, locale, validSharedFormulaLeaders, onDynamicArray, }) => {
1666
1690
  const normalizedCell = cell;
1667
1691
  if (!normalizedCell)
1668
1692
  return undefined;
@@ -1798,12 +1822,26 @@ const buildCellXml = ({ rowNumber, columnIndex, cell, sharedStrings, sourceShare
1798
1822
  }
1799
1823
  const xmlParts = [];
1800
1824
  let typeAttr = "";
1825
+ let dynamicArrayAttr = "";
1801
1826
  if (exportedFormulaValue) {
1802
1827
  // Array formula anchor: emit <f t="array" ref="..."/>. Spilled cells in
1803
1828
  // the range carry no `ue` formula (they're populated by the calculator
1804
1829
  // as side-effects of the anchor's evaluation), so they never reach this
1805
- // branch and correctly emit only <v>.
1806
- const arrayRef = normalizedCell.af;
1830
+ // branch and correctly emit only <v>. `af` is stamped by the calculator
1831
+ // on every spilling anchor and captured from `<f t="array" ref>` on
1832
+ // import; a bare array constant (`={1,2;3,4}`) that never went through
1833
+ // the calculator still gets its range from the constant's own shape.
1834
+ const arrayRef = normalizedCell.af ??
1835
+ arrayConstantSpillRef(exportedFormulaValue, rowNumber, columnIndex);
1836
+ // Excel distinguishes the two kinds of `<f t="array">`: a dynamic array
1837
+ // carries `cm="1"` on the `<c>` (pointing at the XLDAPR record in
1838
+ // xl/metadata.xml) and re-opens as a spilling formula; without `cm` Excel
1839
+ // reads a legacy Ctrl+Shift+Enter array (`{=…}`). Only an anchor imported
1840
+ // as a CSE array (`cse`) is written back that way.
1841
+ if (arrayRef && !(normalizedCell.af && normalizedCell.cse)) {
1842
+ dynamicArrayAttr = ` cm="1"`;
1843
+ onDynamicArray?.();
1844
+ }
1807
1845
  // Shared formula: emit `<f t="shared" si="N" ref="A1:A10">FORMULA</f>`
1808
1846
  // on the leader cell (carries both `si` and `ref`) and
1809
1847
  // `<f t="shared" si="N"/>` on followers (carries only `si` — no formula
@@ -1888,7 +1926,7 @@ const buildCellXml = ({ rowNumber, columnIndex, cell, sharedStrings, sourceShare
1888
1926
  }
1889
1927
  return `<c r="${address}"${styleAttr}/>`;
1890
1928
  }
1891
- return `<c r="${address}"${typeAttr}${styleAttr}>${xmlParts.join("")}</c>`;
1929
+ return `<c r="${address}"${typeAttr}${styleAttr}${dynamicArrayAttr}>${xmlParts.join("")}</c>`;
1892
1930
  };
1893
1931
  const normalizeHyperlinkValue = (hyperlink) => {
1894
1932
  if (!hyperlink) {
@@ -1974,6 +2012,45 @@ function stripFormula(formula) {
1974
2012
  const trimmed = formula.startsWith("=") ? formula.slice(1) : formula;
1975
2013
  return sanitizeFormulaText(trimmed);
1976
2014
  }
2015
+ /**
2016
+ * The A1 range an array constant spills over from its anchor: `={1,2;3,4}` at
2017
+ * B3 → `B3:C4`. `undefined` for anything that is not exactly one array
2018
+ * constant (a constant nested in a larger expression, a malformed literal).
2019
+ * Separators inside string elements are skipped.
2020
+ */
2021
+ function arrayConstantSpillRef(formula, rowNumber, columnIndex) {
2022
+ const body = (formula.startsWith("=") ? formula.slice(1) : formula).trim();
2023
+ if (!body.startsWith("{") || !body.endsWith("}"))
2024
+ return undefined;
2025
+ let rows = 1;
2026
+ let columns = 1;
2027
+ let inString = false;
2028
+ for (const char of body.slice(1, -1)) {
2029
+ if (char === '"') {
2030
+ inString = !inString;
2031
+ }
2032
+ else if (inString) {
2033
+ continue;
2034
+ }
2035
+ else if (char === "{" || char === "}") {
2036
+ return undefined;
2037
+ }
2038
+ else if (char === "," && rows === 1) {
2039
+ columns++;
2040
+ }
2041
+ else if (char === ";") {
2042
+ rows++;
2043
+ }
2044
+ }
2045
+ if (inString)
2046
+ return undefined;
2047
+ return cellRangeToAddress(undefined, {
2048
+ startRowIndex: rowNumber,
2049
+ startColumnIndex: columnIndex,
2050
+ endRowIndex: rowNumber + rows - 1,
2051
+ endColumnIndex: columnIndex + columns - 1,
2052
+ });
2053
+ }
1977
2054
  function hasPropertyReference(formula) {
1978
2055
  if (!formula)
1979
2056
  return false;
@@ -2095,6 +2172,30 @@ function rewriteStructuredReferenceFormula(formula, tableNameMap, tables, rowNum
2095
2172
  const qualified = prefixUnqualifiedStructuredReferences(normalized, table.name);
2096
2173
  return normalizeThisRowStructuredReferences(qualified, table.name);
2097
2174
  }
2175
+ /**
2176
+ * `xl/metadata.xml` for a workbook with dynamic-array anchors — one XLDAPR
2177
+ * (dynamic array properties) record every `cm="1"` cell points at, exactly as
2178
+ * Excel 365 writes it. `fDynamic="1"` marks the anchor as a spilling formula;
2179
+ * `fCollapsed="0"` says the spill is shown expanded.
2180
+ */
2181
+ const DYNAMIC_ARRAY_METADATA_XML = `${XML_DECLARATION}<metadata xmlns="${WORKSHEET_NS}" xmlns:xda="http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray"><metadataTypes count="1"><metadataType name="XLDAPR" minSupportedVersion="120000" copy="1" pasteAll="1" pasteValues="1" merge="1" splitFirst="1" rowColShift="1" clearFormats="1" clearComments="1" assign="1" coerce="1" cellMeta="1"/></metadataTypes><futureMetadata name="XLDAPR" count="1"><bk><extLst><ext uri="{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}"><xda:dynamicArrayProperties fDynamic="1" fCollapsed="0"/></ext></extLst></bk></futureMetadata><cellMetadata count="1"><bk><rc t="1" v="0"/></bk></cellMetadata></metadata>`;
2182
+ /**
2183
+ * A defined-name expression that is a formula rather than a constant: a
2184
+ * function call, an operator expression, a reference. Constants (`123`,
2185
+ * `"text"`, `TRUE`) are written verbatim.
2186
+ */
2187
+ function isAFormulaExpression(expression) {
2188
+ const trimmed = expression.trim();
2189
+ if (!trimmed)
2190
+ return false;
2191
+ if (/^"(?:[^"]|"")*"$/.test(trimmed))
2192
+ return false;
2193
+ if (/^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?%?$/i.test(trimmed))
2194
+ return false;
2195
+ if (/^(?:TRUE|FALSE)$/i.test(trimmed))
2196
+ return false;
2197
+ return true;
2198
+ }
2098
2199
  function sanitizeFormulaText(formula) {
2099
2200
  const normalized = sanitizeXmlText(formula);
2100
2201
  // `@x` is persisted as `_xlfn.SINGLE(x)`; encoding first lets the
@@ -2660,9 +2761,18 @@ const buildTableXml = (table, rows, sourceSharedStrings, styleBuilder, customTab
2660
2761
  const autoFilter = headerRowCount > 0 && table.filterButton !== false
2661
2762
  ? (() => {
2662
2763
  const filterColumns = buildFilterColumnsXml(table.filterSpecs ?? [], table.range, rows, table.headerRow ?? true, table.totalRow ?? false, styleBuilder, hiddenButtonColumns);
2764
+ // Excel's table `<autoFilter ref>` spans the header and data rows
2765
+ // only — a ref that reaches into the totals row makes Excel repair
2766
+ // the table (the totals row is never filtered or sorted).
2767
+ const filterRef = totalsRowCount > 0
2768
+ ? (gridRangeToRef({
2769
+ ...table.range,
2770
+ endRowIndex: table.range.endRowIndex - totalsRowCount,
2771
+ }) ?? rangeRef)
2772
+ : rangeRef;
2663
2773
  return filterColumns
2664
- ? `<autoFilter ref="${rangeRef}">${filterColumns}</autoFilter>`
2665
- : `<autoFilter ref="${rangeRef}"/>`;
2774
+ ? `<autoFilter ref="${filterRef}">${filterColumns}</autoFilter>`
2775
+ : `<autoFilter ref="${filterRef}"/>`;
2666
2776
  })()
2667
2777
  : "";
2668
2778
  const sortState = buildTableSortStateXml(table, headerRowCount, totalsRowCount, styleBuilder);
@@ -2985,53 +3095,86 @@ hiddenButtonColumns) => {
2985
3095
  .map(([, xml]) => xml)
2986
3096
  .join("");
2987
3097
  };
3098
+ /**
3099
+ * One `<filterColumn colId=…>`, or `""` when the criteria have no autoFilter
3100
+ * representation. `CT_FilterColumn` is a CHOICE of exactly one of `filters` /
3101
+ * `top10` / `customFilters` / `dynamicFilter` / `colorFilter` / `iconFilter` —
3102
+ * a second child is a schema violation Excel repairs — so the first
3103
+ * representable criterion wins: colorFilter, iconFilter, condition(s), then
3104
+ * the value list (`<filters>`: the checked values, `blank="1"` for the
3105
+ * "(Blanks)" entry, and Excel's `<dateGroupItem>` date-tree entries). The
3106
+ * Rust exporter (`rnc-export` `filter_column_xml`) uses the same precedence.
3107
+ */
2988
3108
  const buildFilterColumnXml = (spec, range, rows, headerRow = true, totalRow = false, styleBuilder) => {
2989
- const parts = [];
2990
- // <colorFilter dxfId="N" cellColor="1|0"/> — Excel matches against the
2991
- // dxf table. We register the color as a dxf and emit the resulting id.
2992
- const colorFilter = spec.filterCriteria?.colorFilter;
2993
- if (colorFilter && styleBuilder) {
2994
- const dxfId = colorFilter.mode === "cellColor"
2995
- ? styleBuilder.registerDifferentialFormat({
2996
- backgroundColor: colorFilter.color,
2997
- })
2998
- : styleBuilder.registerDifferentialFormat({
2999
- textFormat: { color: colorFilter.color },
3000
- });
3001
- if (typeof dxfId === "number") {
3002
- const cellColorAttr = colorFilter.mode === "cellColor" ? "1" : "0";
3003
- parts.push(`<colorFilter dxfId="${dxfId}" cellColor="${cellColorAttr}"/>`);
3004
- }
3005
- }
3006
- // <iconFilter iconSet="…" iconId="…"/>
3007
- const iconFilter = spec.filterCriteria?.iconFilter;
3008
- if (iconFilter?.iconSet) {
3009
- parts.push(`<iconFilter iconSet="${escapeXmlAttr(iconFilter.iconSet)}" iconId="${iconFilter.iconId ?? 0}"/>`);
3010
- }
3011
- // A condition filter (greater-than / contains / between / relative-date /
3012
- // above-average …) takes precedence over a value-list and is emitted as
3013
- // <customFilters> or <dynamicFilter>. Falls back to the value list when the
3014
- // condition has no AutoFilter representation.
3015
- const conditionXml = spec.filterCriteria
3016
- ? buildAutoFilterCriteriaXml(spec.filterCriteria)
3017
- : null;
3018
- if (conditionXml) {
3019
- parts.push(conditionXml);
3020
- }
3021
- else {
3022
- // <filters><filter val=…/></filters>
3023
- const values = spec.filterCriteria?.visibleValues ??
3024
- getFilterVisibleValues(spec, range, rows, headerRow, totalRow);
3025
- if (values?.length) {
3026
- const filters = values
3027
- .map((value) => `<filter val="${escapeXmlAttr(value)}"/>`)
3028
- .join("");
3029
- parts.push(`<filters>${filters}</filters>`);
3030
- }
3031
- }
3032
- if (!parts.length)
3109
+ const child = buildColorFilterXml(spec.filterCriteria?.colorFilter, styleBuilder) ??
3110
+ buildIconFilterXml(spec.filterCriteria?.iconFilter) ??
3111
+ (spec.filterCriteria
3112
+ ? buildAutoFilterCriteriaXml(spec.filterCriteria)
3113
+ : null) ??
3114
+ buildFilterValueListXml(spec, range, rows, headerRow, totalRow);
3115
+ if (!child)
3033
3116
  return "";
3034
- return `<filterColumn colId="${spec.columnIndex}">${parts.join("")}</filterColumn>`;
3117
+ return `<filterColumn colId="${spec.columnIndex}">${child}</filterColumn>`;
3118
+ };
3119
+ /**
3120
+ * `<colorFilter dxfId="N" cellColor="1|0"/>` — Excel matches against the dxf
3121
+ * table; the color is registered as a dxf and referenced by id.
3122
+ */
3123
+ const buildColorFilterXml = (colorFilter, styleBuilder) => {
3124
+ if (!colorFilter || !styleBuilder)
3125
+ return null;
3126
+ const dxfId = colorFilter.mode === "cellColor"
3127
+ ? styleBuilder.registerDifferentialFormat({
3128
+ backgroundColor: colorFilter.color,
3129
+ })
3130
+ : styleBuilder.registerDifferentialFormat({
3131
+ textFormat: { color: colorFilter.color },
3132
+ });
3133
+ if (typeof dxfId !== "number")
3134
+ return null;
3135
+ const cellColorAttr = colorFilter.mode === "cellColor" ? "1" : "0";
3136
+ return `<colorFilter dxfId="${dxfId}" cellColor="${cellColorAttr}"/>`;
3137
+ };
3138
+ /** `<iconFilter iconSet="…" iconId="…"/>`. */
3139
+ const buildIconFilterXml = (iconFilter) => {
3140
+ if (!iconFilter?.iconSet)
3141
+ return null;
3142
+ return `<iconFilter iconSet="${escapeXmlAttr(iconFilter.iconSet)}" iconId="${iconFilter.iconId ?? 0}"/>`;
3143
+ };
3144
+ /**
3145
+ * `<filters>` — the checked values (`blank="1"` for the engine's "(Blanks)"
3146
+ * sentinel; Excel never writes it as a `<filter val>`) plus the date-grouped
3147
+ * `<dateGroupItem>` entries. Without explicit `visibleValues` the list is
3148
+ * derived from the column's cells (every value visible).
3149
+ */
3150
+ const buildFilterValueListXml = (spec, range, rows, headerRow, totalRow) => {
3151
+ const dateGroups = spec.filterCriteria?.dateGroups ?? [];
3152
+ const values = spec.filterCriteria?.visibleValues ??
3153
+ (dateGroups.length
3154
+ ? []
3155
+ : getFilterVisibleValues(spec, range, rows, headerRow, totalRow));
3156
+ if (!values.length && !dateGroups.length)
3157
+ return null;
3158
+ const showBlanks = values.includes(BLANK_LABEL);
3159
+ const filters = values
3160
+ .filter((value) => value !== BLANK_LABEL)
3161
+ .map((value) => `<filter val="${escapeXmlAttr(value)}"/>`)
3162
+ .join("");
3163
+ const groups = dateGroups
3164
+ .map((group) => {
3165
+ const attrs = [`year="${group.year}"`];
3166
+ for (const name of ["month", "day", "hour", "minute", "second"]) {
3167
+ const value = group[name];
3168
+ if (value !== undefined && value !== null) {
3169
+ attrs.push(`${name}="${value}"`);
3170
+ }
3171
+ }
3172
+ attrs.push(`dateTimeGrouping="${escapeXmlAttr(group.dateTimeGrouping ?? "day")}"`);
3173
+ return `<dateGroupItem ${attrs.join(" ")}/>`;
3174
+ })
3175
+ .join("");
3176
+ const blankAttr = showBlanks ? ` blank="1"` : "";
3177
+ return `<filters${blankAttr}>${filters}${groups}</filters>`;
3035
3178
  };
3036
3179
  const gridRangeToRef = (range) => {
3037
3180
  if (!range)
@@ -3312,7 +3455,7 @@ const groupSlicersBySheet = (slicers) => {
3312
3455
  }
3313
3456
  return result;
3314
3457
  };
3315
- const buildConditionalFormattingXml = ({ conditionalFormats, sheetId, styleBuilder, }) => {
3458
+ const buildConditionalFormattingXml = ({ conditionalFormats, sheetId, styleBuilder, locale, }) => {
3316
3459
  if (!conditionalFormats?.length) {
3317
3460
  return "";
3318
3461
  }
@@ -3359,6 +3502,7 @@ const buildConditionalFormattingXml = ({ conditionalFormats, sheetId, styleBuild
3359
3502
  styleBuilder,
3360
3503
  priority: basePriority,
3361
3504
  baseCellAddress,
3505
+ locale,
3362
3506
  });
3363
3507
  if (!cfRulesXml?.length)
3364
3508
  continue;
@@ -3467,7 +3611,7 @@ const buildXlsxSourceCfRuleXml = (rule, styleBuilder, priority) => {
3467
3611
  }
3468
3612
  return `<cfRule ${attrs.join(" ")}${stopIfTrueAttr}>${formulasXml}${childXmlParts.join("")}</cfRule>`;
3469
3613
  };
3470
- const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseCellAddress, }) => {
3614
+ const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseCellAddress, locale, }) => {
3471
3615
  // Round-trip via xlsxSource when present (imported rules). Falls through
3472
3616
  // to synthesizer for rules without source metadata (UI-created).
3473
3617
  if (rule.xlsxSource?.type) {
@@ -3602,8 +3746,11 @@ const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseC
3602
3746
  NUMBER_LESS_THAN_EQ: "lessThanOrEqual",
3603
3747
  };
3604
3748
  const operator = operatorMap[condition.type];
3605
- const first = stripFormula(firstRaw);
3606
- const second = stripFormula(secondRaw);
3749
+ // Typed literals are written the way Excel stores them (`50%` → `0.5`,
3750
+ // a typed date → its serial); formulas lose their `=`; references, names
3751
+ // and function calls are written verbatim.
3752
+ const first = conditionOperandToFormulaText(stripFormula(firstRaw), locale);
3753
+ const second = conditionOperandToFormulaText(stripFormula(secondRaw), locale);
3607
3754
  if ((operator === "between" || operator === "notBetween") && !secondRaw) {
3608
3755
  return undefined;
3609
3756
  }
@@ -3633,18 +3780,11 @@ const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseC
3633
3780
  DATE_BETWEEN: "between",
3634
3781
  DATE_NOT_BETWEEN: "notBetween",
3635
3782
  };
3636
- const normalizeDateValue = (raw) => {
3637
- const trimmed = raw.trim();
3638
- if (!trimmed)
3639
- return "";
3640
- if (trimmed.startsWith("=")) {
3641
- return stripFormula(trimmed);
3642
- }
3643
- if (!isNaN(Number(trimmed))) {
3644
- return trimmed;
3645
- }
3646
- return `DATEVALUE("${escapeFormulaText(trimmed)}")`;
3647
- };
3783
+ // A typed date (`2024-01-05`, `1/5/2024` in the document locale) is
3784
+ // exported as its serial — what Excel itself stores; the bare text would
3785
+ // be the arithmetic 2024-01-05 = 2022. Formulas lose their `=`;
3786
+ // references and function calls are written verbatim.
3787
+ const normalizeDateValue = (raw) => conditionOperandToFormulaText(stripFormula(raw), locale);
3648
3788
  const first = normalizeDateValue(firstRaw);
3649
3789
  const second = normalizeDateValue(secondRaw);
3650
3790
  const operator = operatorMap[condition.type];
@@ -4778,7 +4918,7 @@ const buildCalcPrXml = (iterativeCalculationOptions) => {
4778
4918
  }
4779
4919
  return `<calcPr ${attrs.join(" ")}/>`;
4780
4920
  };
4781
- const buildWorkbookXml = (sheets, sheetRelIds, sheetNames, namedRanges, iterativeCalculationOptions, slicerCacheRelIds = [], use1904 = false, externalLinkRelIds = [], pivotCacheRelIds = []) => {
4921
+ const buildWorkbookXml = (sheets, sheetRelIds, sheetNames, namedRanges, iterativeCalculationOptions, slicerCacheRelIds = [], use1904 = false, externalLinkRelIds = [], pivotCacheRelIds = [], workbookProtection) => {
4782
4922
  const sheetEntries = sheets
4783
4923
  .map((sheet, index) => {
4784
4924
  const relId = sheetRelIds[index];
@@ -4836,7 +4976,54 @@ const buildWorkbookXml = (sheets, sheetRelIds, sheetNames, namedRanges, iterativ
4836
4976
  const workbookPrXml = use1904
4837
4977
  ? `<workbookPr date1904="1"/>`
4838
4978
  : `<workbookPr/>`;
4839
- return `${XML_DECLARATION}<workbook xmlns="${WORKSHEET_NS}" xmlns:r="${REL_NS}">${workbookPrXml}${calcPrXml}<bookViews><workbookView activeTab="0" firstSheet="0"/></bookViews><sheets>${sheetEntries}</sheets>${definedNamesXml}${externalReferencesXml}${pivotCachesXml}${slicerCachesXml}</workbook>`;
4979
+ // `CT_Workbook` puts `<workbookProtection>` right after `<workbookPr>`.
4980
+ const workbookProtectionXml = buildWorkbookProtectionXml(workbookProtection);
4981
+ return `${XML_DECLARATION}<workbook xmlns="${WORKSHEET_NS}" xmlns:r="${REL_NS}">${workbookPrXml}${workbookProtectionXml}${calcPrXml}<bookViews><workbookView activeTab="0" firstSheet="0"/></bookViews><sheets>${sheetEntries}</sheets>${definedNamesXml}${externalReferencesXml}${pivotCachesXml}${slicerCachesXml}</workbook>`;
4982
+ };
4983
+ /**
4984
+ * `<workbookProtection …/>` from the `workbookProtection` export prop: the
4985
+ * agile hash quartets first, then the legacy password hashes, then the lock
4986
+ * flags — Excel's own attribute order — writing only what the model carries
4987
+ * (flags as `"1"` / `"0"`), so an imported element round-trips verbatim. An
4988
+ * absent model or one with no attribute at all writes nothing. Twin of
4989
+ * `rnc-export`'s `workbook_protection_xml`.
4990
+ */
4991
+ const buildWorkbookProtectionXml = (protection) => {
4992
+ if (!protection)
4993
+ return "";
4994
+ const attrs = [];
4995
+ const text = (name) => {
4996
+ const value = protection[name];
4997
+ if (typeof value === "string" && value) {
4998
+ attrs.push(`${name}="${escapeXmlAttr(value)}"`);
4999
+ }
5000
+ };
5001
+ const count = (name) => {
5002
+ const value = protection[name];
5003
+ if (typeof value === "number" && Number.isFinite(value)) {
5004
+ attrs.push(`${name}="${Math.trunc(value)}"`);
5005
+ }
5006
+ };
5007
+ const flag = (name) => {
5008
+ const value = protection[name];
5009
+ if (typeof value === "boolean") {
5010
+ attrs.push(`${name}="${value ? 1 : 0}"`);
5011
+ }
5012
+ };
5013
+ text("workbookAlgorithmName");
5014
+ text("workbookHashValue");
5015
+ text("workbookSaltValue");
5016
+ count("workbookSpinCount");
5017
+ text("revisionsAlgorithmName");
5018
+ text("revisionsHashValue");
5019
+ text("revisionsSaltValue");
5020
+ count("revisionsSpinCount");
5021
+ text("workbookPassword");
5022
+ text("revisionsPassword");
5023
+ flag("lockStructure");
5024
+ flag("lockWindows");
5025
+ flag("lockRevision");
5026
+ return attrs.length ? `<workbookProtection ${attrs.join(" ")}/>` : "";
4840
5027
  };
4841
5028
  const buildDefinedNamesXml = (namedRanges, sheetNameById, printScopes = []) => {
4842
5029
  const entries = [];
@@ -4878,10 +5065,33 @@ const buildDefinedNamesXml = (namedRanges, sheetNameById, printScopes = []) => {
4878
5065
  entries.push(`<definedName name="${PRINT_TITLES_NAME}" localSheetId="${index}">${escapeXmlText(titles)}</definedName>`);
4879
5066
  }
4880
5067
  });
5068
+ // A sheet-scoped name (`scopeSheetId`) is written with `localSheetId` = the
5069
+ // sheet's position in `<sheets>`; a scope that names no exported sheet
5070
+ // falls back to workbook scope. Excel allows one entry per (name, scope)
5071
+ // pair and repairs the workbook when a pair repeats, so a duplicate (two
5072
+ // scoped `LocalRange`s that both lost their scope, or a multi-area name
5073
+ // imported as one range per area) is written once.
5074
+ const localSheetIdAttr = (namedRange) => {
5075
+ const scope = namedRange.scopeSheetId;
5076
+ if (scope === undefined || scope === null)
5077
+ return "";
5078
+ const sheetName = sheetNameById.get(scope);
5079
+ const index = sheetName ? sheetIndexByName.get(sheetName) : undefined;
5080
+ return index == null ? "" : ` localSheetId="${index}"`;
5081
+ };
5082
+ const seenNameScopes = new Set();
5083
+ const claimNameScope = (name, localSheetId) => {
5084
+ const key = `${name.toLowerCase()}|${localSheetId}`;
5085
+ if (seenNameScopes.has(key))
5086
+ return false;
5087
+ seenNameScopes.add(key);
5088
+ return true;
5089
+ };
4881
5090
  for (const namedRange of namedRanges ?? []) {
4882
5091
  if (!namedRange?.name || isBuiltinPrintName(namedRange.name))
4883
5092
  continue;
4884
5093
  const hiddenAttr = namedRange.hidden ? ` hidden="1"` : "";
5094
+ const scopeAttr = localSheetIdAttr(namedRange);
4885
5095
  if (namedRange.range?.sheetId) {
4886
5096
  const sheetName = sheetNameById.get(namedRange.range.sheetId);
4887
5097
  if (!sheetName)
@@ -4889,18 +5099,34 @@ const buildDefinedNamesXml = (namedRanges, sheetNameById, printScopes = []) => {
4889
5099
  const ref = cellRangeToAddress(undefined, namedRange.range);
4890
5100
  if (!ref)
4891
5101
  continue;
5102
+ if (!claimNameScope(namedRange.name, scopeAttr))
5103
+ continue;
4892
5104
  const address = `${escapeSheetName(sheetName)}!${toAbsoluteRef(ref)}`;
4893
- entries.push(`<definedName name="${escapeXmlAttr(namedRange.name)}"${hiddenAttr}>${escapeXmlText(address)}</definedName>`);
5105
+ entries.push(`<definedName name="${escapeXmlAttr(namedRange.name)}"${scopeAttr}${hiddenAttr}>${escapeXmlText(address)}</definedName>`);
4894
5106
  continue;
4895
5107
  }
5108
+ // A constant name reaches us as text (`"Hello"`, `0.2`) or, when the
5109
+ // XML layer coerced it, as a number / boolean — both are the same
5110
+ // `<definedName>` body in Excel.
4896
5111
  const rawValue = namedRange.value;
4897
- if (typeof rawValue !== "string")
5112
+ const valueText = typeof rawValue === "string"
5113
+ ? rawValue
5114
+ : typeof rawValue === "number" || typeof rawValue === "boolean"
5115
+ ? String(rawValue)
5116
+ : undefined;
5117
+ if (valueText === undefined)
4898
5118
  continue;
4899
- const trimmed = rawValue.trim();
5119
+ const trimmed = valueText.trim();
4900
5120
  if (!trimmed)
4901
5121
  continue;
4902
5122
  const expression = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed;
4903
- entries.push(`<definedName name="${escapeXmlAttr(namedRange.name)}"${hiddenAttr}>${escapeXmlText(expression)}</definedName>`);
5123
+ if (!claimNameScope(namedRange.name, scopeAttr))
5124
+ continue;
5125
+ // A formula-valued name (`Dbl` = `LAMBDA(x,x*2)`) is stored with the same
5126
+ // markers as a cell formula: `_xlfn.LAMBDA(_xlpm.x,_xlpm.x*2)`.
5127
+ entries.push(`<definedName name="${escapeXmlAttr(namedRange.name)}"${scopeAttr}${hiddenAttr}>${escapeXmlText(isAFormulaExpression(expression)
5128
+ ? sanitizeFormulaText(expression)
5129
+ : expression)}</definedName>`);
4904
5130
  }
4905
5131
  if (!entries.length)
4906
5132
  return "";
@@ -4931,7 +5157,7 @@ const buildWorkbookSlicerCachesXml = (slicerCacheRelIds) => {
4931
5157
  };
4932
5158
  const buildWorkbookRelationships = (
4933
5159
  /** One entry per sheet in `<sheets>` order; `target` is relative to `xl/`. */
4934
- sheetParts, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, hasPersons = false) => {
5160
+ sheetParts, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, hasPersons = false, hasSheetMetadata = false) => {
4935
5161
  const relationships = [];
4936
5162
  const sheetRelIds = [];
4937
5163
  const slicerCacheRelIds = [];
@@ -4948,6 +5174,9 @@ sheetParts, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, ha
4948
5174
  relationships.push(`<Relationship Id="${themeId}" Type="${REL_NS}/theme" Target="theme/theme1.xml"/>`);
4949
5175
  relationships.push(`<Relationship Id="${sharedStringsId}" Type="${REL_NS}/sharedStrings" Target="sharedStrings.xml"/>`);
4950
5176
  let nextRelId = sheetCount + 4;
5177
+ if (hasSheetMetadata) {
5178
+ relationships.push(`<Relationship Id="rId${nextRelId++}" Type="${REL_NS}/sheetMetadata" Target="metadata.xml"/>`);
5179
+ }
4951
5180
  for (let i = 0; i < slicerCacheCount; i++) {
4952
5181
  const relId = `rId${nextRelId++}`;
4953
5182
  slicerCacheRelIds.push(relId);
@@ -4984,7 +5213,10 @@ sheetParts, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, ha
4984
5213
  slicerCacheRelationshipIds: slicerCacheRelIds,
4985
5214
  };
4986
5215
  };
4987
- 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, }) => {
5216
+ 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, hasSheetMetadata = false, }) => {
5217
+ const metadataOverride = hasSheetMetadata
5218
+ ? `<Override PartName="/xl/metadata.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml"/>`
5219
+ : "";
4988
5220
  const sheetOverrides = Array.from({ length: worksheetCount }, (_, index) => {
4989
5221
  return `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
4990
5222
  }).join("");
@@ -5029,7 +5261,7 @@ const buildContentTypesXml = ({ worksheetCount, chartsheetCount = 0, tableCount,
5029
5261
  ? `<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>`
5030
5262
  : "";
5031
5263
  const ctrlPropOverrides = Array.from({ length: ctrlPropCount }, (_, index) => `<Override PartName="/xl/ctrlProps/ctrlProp${index + 1}.xml" ContentType="${CTRL_PROP_CONTENT_TYPE}"/>`).join("");
5032
- 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>`;
5264
+ 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}${metadataOverride}<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>`;
5033
5265
  };
5034
5266
  const buildRootRelationshipsXml = () => {
5035
5267
  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>`;