@rowsncolumns/toolkit 24.5.3 → 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 (79) 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 +409 -160
  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/helpers.d.ts +2 -0
  37. package/dist/cjs/helpers.d.ts.map +1 -1
  38. package/dist/cjs/helpers.js +21 -12
  39. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  40. package/dist/esm/autofilter-condition.js +6 -2
  41. package/dist/esm/excel-parser/helpers.d.ts +9 -1
  42. package/dist/esm/excel-parser/helpers.d.ts.map +1 -1
  43. package/dist/esm/excel-parser/helpers.js +65 -3
  44. package/dist/esm/excel-parser/interface.d.ts +9 -1
  45. package/dist/esm/excel-parser/interface.d.ts.map +1 -1
  46. package/dist/esm/excel-parser/plugins/xlsx/conditional-formatting.d.ts.map +1 -1
  47. package/dist/esm/excel-parser/plugins/xlsx/conditional-formatting.js +42 -1
  48. package/dist/esm/excel-parser/plugins/xlsx/data-validations.d.ts.map +1 -1
  49. package/dist/esm/excel-parser/plugins/xlsx/data-validations.js +11 -2
  50. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts +8 -1
  51. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts.map +1 -1
  52. package/dist/esm/excel-parser/plugins/xlsx/index.js +109 -4
  53. package/dist/esm/excel-parser/plugins/xlsx/styles.d.ts +17 -2
  54. package/dist/esm/excel-parser/plugins/xlsx/styles.d.ts.map +1 -1
  55. package/dist/esm/excel-parser/plugins/xlsx/styles.js +20 -8
  56. package/dist/esm/excel-parser/types.d.ts +5 -1
  57. package/dist/esm/excel-parser/types.d.ts.map +1 -1
  58. package/dist/esm/excel-parser/workbook.d.ts +1 -0
  59. package/dist/esm/excel-parser/workbook.d.ts.map +1 -1
  60. package/dist/esm/excel-parser/workbook.js +3 -0
  61. package/dist/esm/excel-parser/worker.js +3 -0
  62. package/dist/esm/exporter/xlsx/chart-builder.d.ts.map +1 -1
  63. package/dist/esm/exporter/xlsx/chart-builder.js +80 -0
  64. package/dist/esm/exporter/xlsx/future-functions.d.ts +6 -7
  65. package/dist/esm/exporter/xlsx/future-functions.d.ts.map +1 -1
  66. package/dist/esm/exporter/xlsx/future-functions.js +5 -261
  67. package/dist/esm/exporter/xlsx/style-builder.d.ts.map +1 -1
  68. package/dist/esm/exporter/xlsx/style-builder.js +56 -18
  69. package/dist/esm/exporter/xlsx/xlsx-export.d.ts +10 -2
  70. package/dist/esm/exporter/xlsx/xlsx-export.d.ts.map +1 -1
  71. package/dist/esm/exporter/xlsx/xlsx-export.js +343 -108
  72. package/dist/esm/helpers/excel-formula-prefixes.d.ts +51 -0
  73. package/dist/esm/helpers/excel-formula-prefixes.d.ts.map +1 -0
  74. package/dist/esm/helpers/excel-formula-prefixes.js +531 -0
  75. package/dist/esm/helpers.d.ts +2 -0
  76. package/dist/esm/helpers.d.ts.map +1 -1
  77. package/dist/esm/helpers.js +23 -12
  78. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  79. 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()
@@ -967,7 +985,7 @@ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, ce
967
985
  const sheetFormatPrAttrs = [`defaultRowHeight="${defaultRowHeightPt}"`];
968
986
  if (typeof explicitDefaultColumnWidthPx === "number" &&
969
987
  explicitDefaultColumnWidthPx > 0) {
970
- sheetFormatPrAttrs.push(`defaultColWidth="${columnPixelToChars(explicitDefaultColumnWidthPx).toFixed(2)}"`);
988
+ sheetFormatPrAttrs.push(`defaultColWidth="${columnPixelToChars(explicitDefaultColumnWidthPx)}"`);
971
989
  }
972
990
  const worksheetXml = `${XML_DECLARATION}<worksheet xmlns="${WORKSHEET_NS}" xmlns:r="${REL_NS}">${sheetPrXml}<dimension ref="${dimension}"/>${sheetViews}<sheetFormatPr ${sheetFormatPrAttrs.join(" ")}/>${columnXml}${sheetDataXml}${protectionXml}${basicFilterXml}${mergeXml}${conditionalFormattingXml}${dataValidationsXml}${hyperlinksXml}${printSettingsXml}${drawingXml}${legacyDrawingXml}${controlsXml}${tablePartsXml}${extLstXml}</worksheet>`;
973
991
  return {
@@ -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
@@ -2208,8 +2309,11 @@ const buildColumnsXml = (sheet, bounds) => {
2208
2309
  continue;
2209
2310
  const attributes = [`min="${columnIndex}"`, `max="${columnIndex}"`];
2210
2311
  if (hasExplicitSize) {
2312
+ // Full precision on purpose: the stored value is a multiple of 1/256
2313
+ // (Excel writes e.g. 9.140625), and rounding to two decimals would
2314
+ // break the exact export → import pixel fixed point.
2211
2315
  const width = columnPixelToChars(getColumnWidthPx(sheet, columnIndex - 1));
2212
- attributes.push(`width="${width.toFixed(2)}"`);
2316
+ attributes.push(`width="${width}"`);
2213
2317
  attributes.push(`customWidth="1"`);
2214
2318
  }
2215
2319
  if (hasHiddenFlag) {
@@ -2657,9 +2761,18 @@ const buildTableXml = (table, rows, sourceSharedStrings, styleBuilder, customTab
2657
2761
  const autoFilter = headerRowCount > 0 && table.filterButton !== false
2658
2762
  ? (() => {
2659
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;
2660
2773
  return filterColumns
2661
- ? `<autoFilter ref="${rangeRef}">${filterColumns}</autoFilter>`
2662
- : `<autoFilter ref="${rangeRef}"/>`;
2774
+ ? `<autoFilter ref="${filterRef}">${filterColumns}</autoFilter>`
2775
+ : `<autoFilter ref="${filterRef}"/>`;
2663
2776
  })()
2664
2777
  : "";
2665
2778
  const sortState = buildTableSortStateXml(table, headerRowCount, totalsRowCount, styleBuilder);
@@ -2982,53 +3095,86 @@ hiddenButtonColumns) => {
2982
3095
  .map(([, xml]) => xml)
2983
3096
  .join("");
2984
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
+ */
2985
3108
  const buildFilterColumnXml = (spec, range, rows, headerRow = true, totalRow = false, styleBuilder) => {
2986
- const parts = [];
2987
- // <colorFilter dxfId="N" cellColor="1|0"/> — Excel matches against the
2988
- // dxf table. We register the color as a dxf and emit the resulting id.
2989
- const colorFilter = spec.filterCriteria?.colorFilter;
2990
- if (colorFilter && styleBuilder) {
2991
- const dxfId = colorFilter.mode === "cellColor"
2992
- ? styleBuilder.registerDifferentialFormat({
2993
- backgroundColor: colorFilter.color,
2994
- })
2995
- : styleBuilder.registerDifferentialFormat({
2996
- textFormat: { color: colorFilter.color },
2997
- });
2998
- if (typeof dxfId === "number") {
2999
- const cellColorAttr = colorFilter.mode === "cellColor" ? "1" : "0";
3000
- parts.push(`<colorFilter dxfId="${dxfId}" cellColor="${cellColorAttr}"/>`);
3001
- }
3002
- }
3003
- // <iconFilter iconSet="…" iconId="…"/>
3004
- const iconFilter = spec.filterCriteria?.iconFilter;
3005
- if (iconFilter?.iconSet) {
3006
- parts.push(`<iconFilter iconSet="${escapeXmlAttr(iconFilter.iconSet)}" iconId="${iconFilter.iconId ?? 0}"/>`);
3007
- }
3008
- // A condition filter (greater-than / contains / between / relative-date /
3009
- // above-average …) takes precedence over a value-list and is emitted as
3010
- // <customFilters> or <dynamicFilter>. Falls back to the value list when the
3011
- // condition has no AutoFilter representation.
3012
- const conditionXml = spec.filterCriteria
3013
- ? buildAutoFilterCriteriaXml(spec.filterCriteria)
3014
- : null;
3015
- if (conditionXml) {
3016
- parts.push(conditionXml);
3017
- }
3018
- else {
3019
- // <filters><filter val=…/></filters>
3020
- const values = spec.filterCriteria?.visibleValues ??
3021
- getFilterVisibleValues(spec, range, rows, headerRow, totalRow);
3022
- if (values?.length) {
3023
- const filters = values
3024
- .map((value) => `<filter val="${escapeXmlAttr(value)}"/>`)
3025
- .join("");
3026
- parts.push(`<filters>${filters}</filters>`);
3027
- }
3028
- }
3029
- 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)
3030
3116
  return "";
3031
- 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>`;
3032
3178
  };
3033
3179
  const gridRangeToRef = (range) => {
3034
3180
  if (!range)
@@ -3309,7 +3455,7 @@ const groupSlicersBySheet = (slicers) => {
3309
3455
  }
3310
3456
  return result;
3311
3457
  };
3312
- const buildConditionalFormattingXml = ({ conditionalFormats, sheetId, styleBuilder, }) => {
3458
+ const buildConditionalFormattingXml = ({ conditionalFormats, sheetId, styleBuilder, locale, }) => {
3313
3459
  if (!conditionalFormats?.length) {
3314
3460
  return "";
3315
3461
  }
@@ -3356,6 +3502,7 @@ const buildConditionalFormattingXml = ({ conditionalFormats, sheetId, styleBuild
3356
3502
  styleBuilder,
3357
3503
  priority: basePriority,
3358
3504
  baseCellAddress,
3505
+ locale,
3359
3506
  });
3360
3507
  if (!cfRulesXml?.length)
3361
3508
  continue;
@@ -3464,7 +3611,7 @@ const buildXlsxSourceCfRuleXml = (rule, styleBuilder, priority) => {
3464
3611
  }
3465
3612
  return `<cfRule ${attrs.join(" ")}${stopIfTrueAttr}>${formulasXml}${childXmlParts.join("")}</cfRule>`;
3466
3613
  };
3467
- const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseCellAddress, }) => {
3614
+ const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseCellAddress, locale, }) => {
3468
3615
  // Round-trip via xlsxSource when present (imported rules). Falls through
3469
3616
  // to synthesizer for rules without source metadata (UI-created).
3470
3617
  if (rule.xlsxSource?.type) {
@@ -3599,8 +3746,11 @@ const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseC
3599
3746
  NUMBER_LESS_THAN_EQ: "lessThanOrEqual",
3600
3747
  };
3601
3748
  const operator = operatorMap[condition.type];
3602
- const first = stripFormula(firstRaw);
3603
- 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);
3604
3754
  if ((operator === "between" || operator === "notBetween") && !secondRaw) {
3605
3755
  return undefined;
3606
3756
  }
@@ -3630,18 +3780,11 @@ const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseC
3630
3780
  DATE_BETWEEN: "between",
3631
3781
  DATE_NOT_BETWEEN: "notBetween",
3632
3782
  };
3633
- const normalizeDateValue = (raw) => {
3634
- const trimmed = raw.trim();
3635
- if (!trimmed)
3636
- return "";
3637
- if (trimmed.startsWith("=")) {
3638
- return stripFormula(trimmed);
3639
- }
3640
- if (!isNaN(Number(trimmed))) {
3641
- return trimmed;
3642
- }
3643
- return `DATEVALUE("${escapeFormulaText(trimmed)}")`;
3644
- };
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);
3645
3788
  const first = normalizeDateValue(firstRaw);
3646
3789
  const second = normalizeDateValue(secondRaw);
3647
3790
  const operator = operatorMap[condition.type];
@@ -4775,7 +4918,7 @@ const buildCalcPrXml = (iterativeCalculationOptions) => {
4775
4918
  }
4776
4919
  return `<calcPr ${attrs.join(" ")}/>`;
4777
4920
  };
4778
- 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) => {
4779
4922
  const sheetEntries = sheets
4780
4923
  .map((sheet, index) => {
4781
4924
  const relId = sheetRelIds[index];
@@ -4833,7 +4976,54 @@ const buildWorkbookXml = (sheets, sheetRelIds, sheetNames, namedRanges, iterativ
4833
4976
  const workbookPrXml = use1904
4834
4977
  ? `<workbookPr date1904="1"/>`
4835
4978
  : `<workbookPr/>`;
4836
- 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(" ")}/>` : "";
4837
5027
  };
4838
5028
  const buildDefinedNamesXml = (namedRanges, sheetNameById, printScopes = []) => {
4839
5029
  const entries = [];
@@ -4875,10 +5065,33 @@ const buildDefinedNamesXml = (namedRanges, sheetNameById, printScopes = []) => {
4875
5065
  entries.push(`<definedName name="${PRINT_TITLES_NAME}" localSheetId="${index}">${escapeXmlText(titles)}</definedName>`);
4876
5066
  }
4877
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
+ };
4878
5090
  for (const namedRange of namedRanges ?? []) {
4879
5091
  if (!namedRange?.name || isBuiltinPrintName(namedRange.name))
4880
5092
  continue;
4881
5093
  const hiddenAttr = namedRange.hidden ? ` hidden="1"` : "";
5094
+ const scopeAttr = localSheetIdAttr(namedRange);
4882
5095
  if (namedRange.range?.sheetId) {
4883
5096
  const sheetName = sheetNameById.get(namedRange.range.sheetId);
4884
5097
  if (!sheetName)
@@ -4886,18 +5099,34 @@ const buildDefinedNamesXml = (namedRanges, sheetNameById, printScopes = []) => {
4886
5099
  const ref = cellRangeToAddress(undefined, namedRange.range);
4887
5100
  if (!ref)
4888
5101
  continue;
5102
+ if (!claimNameScope(namedRange.name, scopeAttr))
5103
+ continue;
4889
5104
  const address = `${escapeSheetName(sheetName)}!${toAbsoluteRef(ref)}`;
4890
- entries.push(`<definedName name="${escapeXmlAttr(namedRange.name)}"${hiddenAttr}>${escapeXmlText(address)}</definedName>`);
5105
+ entries.push(`<definedName name="${escapeXmlAttr(namedRange.name)}"${scopeAttr}${hiddenAttr}>${escapeXmlText(address)}</definedName>`);
4891
5106
  continue;
4892
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.
4893
5111
  const rawValue = namedRange.value;
4894
- 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)
4895
5118
  continue;
4896
- const trimmed = rawValue.trim();
5119
+ const trimmed = valueText.trim();
4897
5120
  if (!trimmed)
4898
5121
  continue;
4899
5122
  const expression = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed;
4900
- 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>`);
4901
5130
  }
4902
5131
  if (!entries.length)
4903
5132
  return "";
@@ -4928,7 +5157,7 @@ const buildWorkbookSlicerCachesXml = (slicerCacheRelIds) => {
4928
5157
  };
4929
5158
  const buildWorkbookRelationships = (
4930
5159
  /** One entry per sheet in `<sheets>` order; `target` is relative to `xl/`. */
4931
- sheetParts, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, hasPersons = false) => {
5160
+ sheetParts, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, hasPersons = false, hasSheetMetadata = false) => {
4932
5161
  const relationships = [];
4933
5162
  const sheetRelIds = [];
4934
5163
  const slicerCacheRelIds = [];
@@ -4945,6 +5174,9 @@ sheetParts, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, ha
4945
5174
  relationships.push(`<Relationship Id="${themeId}" Type="${REL_NS}/theme" Target="theme/theme1.xml"/>`);
4946
5175
  relationships.push(`<Relationship Id="${sharedStringsId}" Type="${REL_NS}/sharedStrings" Target="sharedStrings.xml"/>`);
4947
5176
  let nextRelId = sheetCount + 4;
5177
+ if (hasSheetMetadata) {
5178
+ relationships.push(`<Relationship Id="rId${nextRelId++}" Type="${REL_NS}/sheetMetadata" Target="metadata.xml"/>`);
5179
+ }
4948
5180
  for (let i = 0; i < slicerCacheCount; i++) {
4949
5181
  const relId = `rId${nextRelId++}`;
4950
5182
  slicerCacheRelIds.push(relId);
@@ -4981,7 +5213,10 @@ sheetParts, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, ha
4981
5213
  slicerCacheRelationshipIds: slicerCacheRelIds,
4982
5214
  };
4983
5215
  };
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, }) => {
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
+ : "";
4985
5220
  const sheetOverrides = Array.from({ length: worksheetCount }, (_, index) => {
4986
5221
  return `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
4987
5222
  }).join("");
@@ -5026,7 +5261,7 @@ const buildContentTypesXml = ({ worksheetCount, chartsheetCount = 0, tableCount,
5026
5261
  ? `<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>`
5027
5262
  : "";
5028
5263
  const ctrlPropOverrides = Array.from({ length: ctrlPropCount }, (_, index) => `<Override PartName="/xl/ctrlProps/ctrlProp${index + 1}.xml" ContentType="${CTRL_PROP_CONTENT_TYPE}"/>`).join("");
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>`;
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>`;
5030
5265
  };
5031
5266
  const buildRootRelationshipsXml = () => {
5032
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>`;