@cj-tech-master/excelts 9.3.1 → 9.4.0

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 (109) hide show
  1. package/dist/browser/index.d.ts +1 -0
  2. package/dist/browser/index.js +2 -0
  3. package/dist/browser/modules/excel/cell.d.ts +18 -0
  4. package/dist/browser/modules/excel/cell.js +21 -0
  5. package/dist/browser/modules/excel/utils/cell-format.js +85 -13
  6. package/dist/browser/modules/excel/workbook.browser.d.ts +57 -0
  7. package/dist/browser/modules/excel/workbook.browser.js +49 -0
  8. package/dist/browser/modules/excel/xlsx/defaultnumformats.js +3 -3
  9. package/dist/browser/modules/formula/compile/binder.js +48 -6
  10. package/dist/browser/modules/formula/compile/bound-ast.d.ts +16 -2
  11. package/dist/browser/modules/formula/compile/bound-ast.js +1 -0
  12. package/dist/browser/modules/formula/compile/compiled-formula.js +41 -8
  13. package/dist/browser/modules/formula/functions/_shared.d.ts +19 -0
  14. package/dist/browser/modules/formula/functions/_shared.js +47 -0
  15. package/dist/browser/modules/formula/functions/conditional.js +103 -22
  16. package/dist/browser/modules/formula/functions/date.js +105 -23
  17. package/dist/browser/modules/formula/functions/dynamic-array.js +173 -69
  18. package/dist/browser/modules/formula/functions/engineering.d.ts +2 -2
  19. package/dist/browser/modules/formula/functions/engineering.js +103 -151
  20. package/dist/browser/modules/formula/functions/financial.js +210 -184
  21. package/dist/browser/modules/formula/functions/lookup.js +224 -157
  22. package/dist/browser/modules/formula/functions/math.d.ts +26 -0
  23. package/dist/browser/modules/formula/functions/math.js +249 -69
  24. package/dist/browser/modules/formula/functions/statistical.js +221 -171
  25. package/dist/browser/modules/formula/functions/text.js +112 -52
  26. package/dist/browser/modules/formula/integration/calculate-formulas-impl.js +20 -1
  27. package/dist/browser/modules/formula/materialize/build-writeback-plan.js +10 -6
  28. package/dist/browser/modules/formula/materialize/types.d.ts +15 -0
  29. package/dist/browser/modules/formula/runtime/evaluator.d.ts +8 -0
  30. package/dist/browser/modules/formula/runtime/evaluator.js +582 -162
  31. package/dist/browser/modules/formula/runtime/function-registry.d.ts +5 -0
  32. package/dist/browser/modules/formula/runtime/function-registry.js +59 -13
  33. package/dist/browser/modules/formula/runtime/values.d.ts +13 -0
  34. package/dist/browser/modules/formula/runtime/values.js +20 -2
  35. package/dist/browser/modules/formula/syntax/ast.d.ts +14 -2
  36. package/dist/browser/modules/formula/syntax/ast.js +1 -0
  37. package/dist/browser/modules/formula/syntax/parser.js +29 -7
  38. package/dist/browser/modules/formula/syntax/token-types.d.ts +4 -0
  39. package/dist/browser/modules/formula/syntax/token-types.js +9 -0
  40. package/dist/browser/modules/formula/syntax/tokenizer.js +76 -19
  41. package/dist/cjs/index.js +7 -2
  42. package/dist/cjs/modules/excel/cell.js +21 -0
  43. package/dist/cjs/modules/excel/utils/cell-format.js +85 -13
  44. package/dist/cjs/modules/excel/workbook.browser.js +49 -0
  45. package/dist/cjs/modules/excel/xlsx/defaultnumformats.js +3 -3
  46. package/dist/cjs/modules/formula/compile/binder.js +48 -6
  47. package/dist/cjs/modules/formula/compile/compiled-formula.js +41 -8
  48. package/dist/cjs/modules/formula/functions/_shared.js +48 -0
  49. package/dist/cjs/modules/formula/functions/conditional.js +103 -22
  50. package/dist/cjs/modules/formula/functions/date.js +104 -22
  51. package/dist/cjs/modules/formula/functions/dynamic-array.js +173 -69
  52. package/dist/cjs/modules/formula/functions/engineering.js +109 -157
  53. package/dist/cjs/modules/formula/functions/financial.js +209 -183
  54. package/dist/cjs/modules/formula/functions/lookup.js +224 -157
  55. package/dist/cjs/modules/formula/functions/math.js +254 -70
  56. package/dist/cjs/modules/formula/functions/statistical.js +222 -172
  57. package/dist/cjs/modules/formula/functions/text.js +112 -52
  58. package/dist/cjs/modules/formula/integration/calculate-formulas-impl.js +20 -1
  59. package/dist/cjs/modules/formula/materialize/build-writeback-plan.js +10 -6
  60. package/dist/cjs/modules/formula/runtime/evaluator.js +581 -161
  61. package/dist/cjs/modules/formula/runtime/function-registry.js +57 -11
  62. package/dist/cjs/modules/formula/runtime/values.js +21 -2
  63. package/dist/cjs/modules/formula/syntax/parser.js +29 -7
  64. package/dist/cjs/modules/formula/syntax/token-types.js +9 -0
  65. package/dist/cjs/modules/formula/syntax/tokenizer.js +76 -19
  66. package/dist/esm/index.js +2 -0
  67. package/dist/esm/modules/excel/cell.js +21 -0
  68. package/dist/esm/modules/excel/utils/cell-format.js +85 -13
  69. package/dist/esm/modules/excel/workbook.browser.js +49 -0
  70. package/dist/esm/modules/excel/xlsx/defaultnumformats.js +3 -3
  71. package/dist/esm/modules/formula/compile/binder.js +48 -6
  72. package/dist/esm/modules/formula/compile/bound-ast.js +1 -0
  73. package/dist/esm/modules/formula/compile/compiled-formula.js +41 -8
  74. package/dist/esm/modules/formula/functions/_shared.js +47 -0
  75. package/dist/esm/modules/formula/functions/conditional.js +103 -22
  76. package/dist/esm/modules/formula/functions/date.js +105 -23
  77. package/dist/esm/modules/formula/functions/dynamic-array.js +173 -69
  78. package/dist/esm/modules/formula/functions/engineering.js +103 -151
  79. package/dist/esm/modules/formula/functions/financial.js +210 -184
  80. package/dist/esm/modules/formula/functions/lookup.js +224 -157
  81. package/dist/esm/modules/formula/functions/math.js +249 -69
  82. package/dist/esm/modules/formula/functions/statistical.js +221 -171
  83. package/dist/esm/modules/formula/functions/text.js +112 -52
  84. package/dist/esm/modules/formula/integration/calculate-formulas-impl.js +20 -1
  85. package/dist/esm/modules/formula/materialize/build-writeback-plan.js +10 -6
  86. package/dist/esm/modules/formula/runtime/evaluator.js +582 -162
  87. package/dist/esm/modules/formula/runtime/function-registry.js +59 -13
  88. package/dist/esm/modules/formula/runtime/values.js +20 -2
  89. package/dist/esm/modules/formula/syntax/ast.js +1 -0
  90. package/dist/esm/modules/formula/syntax/parser.js +29 -7
  91. package/dist/esm/modules/formula/syntax/token-types.js +9 -0
  92. package/dist/esm/modules/formula/syntax/tokenizer.js +76 -19
  93. package/dist/iife/excelts.iife.js +1502 -1379
  94. package/dist/iife/excelts.iife.js.map +1 -1
  95. package/dist/iife/excelts.iife.min.js +26 -26
  96. package/dist/types/index.d.ts +1 -0
  97. package/dist/types/modules/excel/cell.d.ts +18 -0
  98. package/dist/types/modules/excel/workbook.browser.d.ts +57 -0
  99. package/dist/types/modules/formula/compile/bound-ast.d.ts +16 -2
  100. package/dist/types/modules/formula/functions/_shared.d.ts +19 -0
  101. package/dist/types/modules/formula/functions/engineering.d.ts +2 -2
  102. package/dist/types/modules/formula/functions/math.d.ts +26 -0
  103. package/dist/types/modules/formula/materialize/types.d.ts +15 -0
  104. package/dist/types/modules/formula/runtime/evaluator.d.ts +8 -0
  105. package/dist/types/modules/formula/runtime/function-registry.d.ts +5 -0
  106. package/dist/types/modules/formula/runtime/values.d.ts +13 -0
  107. package/dist/types/modules/formula/syntax/ast.d.ts +14 -2
  108. package/dist/types/modules/formula/syntax/token-types.d.ts +4 -0
  109. package/package.json +1 -1
@@ -55,8 +55,8 @@ const fnTEXTJOIN = args => {
55
55
  if (e0) {
56
56
  return e0;
57
57
  }
58
- const delimiter = (0, values_1.toStringRV)(args[0]);
59
- const ignoreEmptyRV = (0, values_1.toBooleanRV)(args[1]);
58
+ const delimiter = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
59
+ const ignoreEmptyRV = (0, values_1.toBooleanRV)((0, values_1.topLeft)(args[1]));
60
60
  if ((0, values_1.isError)(ignoreEmptyRV)) {
61
61
  return ignoreEmptyRV;
62
62
  }
@@ -102,7 +102,8 @@ const fnLEFT = args => {
102
102
  if (err) {
103
103
  return err;
104
104
  }
105
- const text = (0, values_1.toStringRV)(args[0]);
105
+ // Implicit intersection on the text arg (see MID for rationale).
106
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
106
107
  let n;
107
108
  if (args.length > 1) {
108
109
  // Use `argToNumber` so array arguments get implicit-intersection to
@@ -131,7 +132,7 @@ const fnRIGHT = args => {
131
132
  if (err) {
132
133
  return err;
133
134
  }
134
- const text = (0, values_1.toStringRV)(args[0]);
135
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
135
136
  let n;
136
137
  if (args.length > 1) {
137
138
  // Implicit intersection via `argToNumber` — see LEFT for rationale.
@@ -159,9 +160,11 @@ const fnMID = args => {
159
160
  if (err) {
160
161
  return err;
161
162
  }
162
- const text = (0, values_1.toStringRV)(args[0]);
163
- // Implicit intersection on both numeric arguments so array inputs
164
- // collapse to their top-left cells before coercion.
163
+ // Implicit intersection on the text arg — without topLeft, passing
164
+ // an array would route through `toStringRV`'s default branch and
165
+ // silently return the empty string, making `MID(A1:A2, 1, 3)` look
166
+ // like an empty cell instead of a 3-char prefix of the first cell.
167
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
165
168
  const startNumRV = (0, _shared_1.argToNumber)(args[1]);
166
169
  if ((0, values_1.isError)(startNumRV)) {
167
170
  return startNumRV;
@@ -258,7 +261,7 @@ const fnSUBSTITUTE = args => {
258
261
  return (0, values_1.rvString)(text);
259
262
  }
260
263
  if (args.length > 3) {
261
- const instanceNumRV = (0, values_1.toNumberRV)(args[3]);
264
+ const instanceNumRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[3]));
262
265
  if ((0, values_1.isError)(instanceNumRV)) {
263
266
  return instanceNumRV;
264
267
  }
@@ -283,7 +286,7 @@ const fnREPLACE = args => {
283
286
  if (err) {
284
287
  return err;
285
288
  }
286
- const text = (0, values_1.toStringRV)(args[0]);
289
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
287
290
  // Implicit intersection on the numeric arguments — see LEFT.
288
291
  const startNumRV = (0, _shared_1.argToNumber)(args[1]);
289
292
  if ((0, values_1.isError)(startNumRV)) {
@@ -305,7 +308,7 @@ const fnREPLACE = args => {
305
308
  if (e3) {
306
309
  return e3;
307
310
  }
308
- const newText = (0, values_1.toStringRV)(args[3]);
311
+ const newText = (0, values_1.toStringRV)((0, values_1.topLeft)(args[3]));
309
312
  return (0, values_1.rvString)(text.slice(0, startNum - 1) + newText + text.slice(startNum - 1 + numChars));
310
313
  };
311
314
  exports.fnREPLACE = fnREPLACE;
@@ -321,8 +324,9 @@ const fnFIND = args => {
321
324
  if (err1) {
322
325
  return err1;
323
326
  }
324
- const findText = (0, values_1.toStringRV)(args[0]);
325
- const withinText = (0, values_1.toStringRV)(args[1]);
327
+ // Implicit intersection on text args so arrays collapse to top-left.
328
+ const findText = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
329
+ const withinText = (0, values_1.toStringRV)((0, values_1.topLeft)(args[1]));
326
330
  let startNum;
327
331
  if (args.length > 2) {
328
332
  // Implicit intersection so an array supplied as start_num collapses
@@ -353,8 +357,9 @@ const fnSEARCH = args => {
353
357
  if (err1) {
354
358
  return err1;
355
359
  }
356
- let findText = (0, values_1.toStringRV)(args[0]);
357
- const withinText = (0, values_1.toStringRV)(args[1]);
360
+ // Implicit intersection on text args (see FIND for rationale).
361
+ let findText = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
362
+ const withinText = (0, values_1.toStringRV)((0, values_1.topLeft)(args[1]));
358
363
  let startNum;
359
364
  if (args.length > 2) {
360
365
  const startNumRV = (0, _shared_1.argToNumber)(args[2]);
@@ -395,7 +400,7 @@ const fnREPT = args => {
395
400
  return err;
396
401
  }
397
402
  const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
398
- const timesRV = (0, values_1.toNumberRV)(args[1]);
403
+ const timesRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[1]));
399
404
  if ((0, values_1.isError)(timesRV)) {
400
405
  return timesRV;
401
406
  }
@@ -425,7 +430,7 @@ const fnTEXT = args => {
425
430
  if (e1) {
426
431
  return e1;
427
432
  }
428
- const fmt = (0, values_1.toStringRV)(args[1]);
433
+ const fmt = (0, values_1.toStringRV)((0, values_1.topLeft)(args[1]));
429
434
  // "@" format = return text as-is
430
435
  if (fmt === "@") {
431
436
  return (0, values_1.rvString)((0, values_1.toStringRV)(rawVal));
@@ -603,10 +608,52 @@ function formatNumber(val, fmt) {
603
608
  tokens.push({ kind: "literal", text: ch });
604
609
  }
605
610
  // Count integer/fraction digit slots and decide whether to group.
611
+ // Trailing commas after the last integer digit token — before the
612
+ // decimal point or end of pattern — act as a "scale by 1/1000^k"
613
+ // multiplier (Excel's "thousands scaling"), not thousand separators.
614
+ // Example: `#,##0,` → 1,234,567 → "1,235" (divide by 1000).
615
+ // Example: `#,##0,,` → 1,234,567,890 → "1,234" (divide by 1,000,000).
606
616
  let sawDot = false;
607
617
  const intDigitSlots = [];
608
618
  const fracDigitSlots = [];
609
619
  let hasGrouping = false;
620
+ // Scan for trailing commas that sit strictly after the last integer
621
+ // digit slot but before any dot or fractional slot. Each such comma
622
+ // divides the value by 1000. Scan from end-of-pattern backward to
623
+ // locate them.
624
+ let scalingFactor = 1;
625
+ {
626
+ // Find the last integer digit-slot index.
627
+ let lastIntDigitIdx = -1;
628
+ let dotIdx = -1;
629
+ for (let i = 0; i < tokens.length; i++) {
630
+ const t = tokens[i];
631
+ if (t.kind === "dot") {
632
+ dotIdx = i;
633
+ break;
634
+ }
635
+ if (t.kind === "digit") {
636
+ lastIntDigitIdx = i;
637
+ }
638
+ }
639
+ // Trailing commas live after `lastIntDigitIdx` and strictly before
640
+ // `dotIdx` (if present). They must be adjacent — only run-of-commas
641
+ // directly abutting the last integer digit count as scaling.
642
+ if (lastIntDigitIdx !== -1) {
643
+ const stopBefore = dotIdx === -1 ? tokens.length : dotIdx;
644
+ let k = lastIntDigitIdx + 1;
645
+ while (k < stopBefore && tokens[k].kind === "comma") {
646
+ scalingFactor *= 1000;
647
+ // Remove this comma so later logic doesn't treat it as a
648
+ // thousand-separator or literal artefact. We mutate the token
649
+ // to a no-op literal with empty text.
650
+ tokens[k] = { kind: "literal", text: "" };
651
+ k++;
652
+ }
653
+ }
654
+ }
655
+ // Apply the scaling before any other formatting work.
656
+ const scaledVal = val / scalingFactor;
610
657
  for (const t of tokens) {
611
658
  if (t.kind === "dot") {
612
659
  sawDot = true;
@@ -630,7 +677,7 @@ function formatNumber(val, fmt) {
630
677
  return fmt; // Nothing to format; return the (raw) pattern.
631
678
  }
632
679
  // Round to the requested fractional precision.
633
- const rounded = roundHalfAwayFromZeroFmt(val, fracDigitSlots.length);
680
+ const rounded = roundHalfAwayFromZeroFmt(scaledVal, fracDigitSlots.length);
634
681
  const sign = rounded < 0 ? "-" : "";
635
682
  const absStr = Math.abs(rounded).toFixed(fracDigitSlots.length);
636
683
  const [intPartRaw, fracPart = ""] = absStr.split(".");
@@ -710,11 +757,19 @@ function formatNumber(val, fmt) {
710
757
  emittedOverflow = true;
711
758
  }
712
759
  const slotIdx = intCursor;
713
- // Integer slots map right-to-left onto `groupedInt`'s right-to-left
714
- // ordering. We'll compute the source character for this slot from
715
- // the right edge.
716
- const srcIdx = overflowDigits + slotIdx;
717
- if (srcIdx < groupedInt.length) {
760
+ // Right-align the integer into the digit slots: leading `#` slots
761
+ // emit nothing when the value is shorter than the pattern, and
762
+ // leading `0` slots pad with a zero. Previously the logic treated
763
+ // every slot as if the integer started at slot 0 (left-align),
764
+ // which made `TEXT(0, "#,##0")` produce "00" instead of "0".
765
+ //
766
+ // The number of "padding" slots that must appear before the first
767
+ // real digit equals `totalIntSlots − groupedInt.length` when that
768
+ // value is positive (overflow path zeroes it out since we already
769
+ // prepended the overflow digits before the first slot).
770
+ const paddingSlots = overflowDigits > 0 ? 0 : Math.max(0, totalIntSlots - groupedInt.length);
771
+ const srcIdx = overflowDigits + slotIdx - paddingSlots;
772
+ if (srcIdx >= 0 && srcIdx < groupedInt.length) {
718
773
  out += groupedInt[srcIdx];
719
774
  }
720
775
  else if (t.char === "0") {
@@ -1038,7 +1093,7 @@ const fnEXACT = args => {
1038
1093
  if (err1) {
1039
1094
  return err1;
1040
1095
  }
1041
- return (0, values_1.rvBoolean)((0, values_1.toStringRV)(args[0]) === (0, values_1.toStringRV)(args[1]));
1096
+ return (0, values_1.rvBoolean)((0, values_1.toStringRV)((0, values_1.topLeft)(args[0])) === (0, values_1.toStringRV)((0, values_1.topLeft)(args[1])));
1042
1097
  };
1043
1098
  exports.fnEXACT = fnEXACT;
1044
1099
  // ============================================================================
@@ -1104,7 +1159,7 @@ const fnUNICHAR = args => {
1104
1159
  if (err) {
1105
1160
  return err;
1106
1161
  }
1107
- const nRV = (0, values_1.toNumberRV)(args[0]);
1162
+ const nRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[0]));
1108
1163
  if ((0, values_1.isError)(nRV)) {
1109
1164
  return nRV;
1110
1165
  }
@@ -1125,7 +1180,7 @@ const fnUNICODE = args => {
1125
1180
  if (err) {
1126
1181
  return err;
1127
1182
  }
1128
- const text = (0, values_1.toStringRV)(args[0]);
1183
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
1129
1184
  if (text.length === 0) {
1130
1185
  return values_1.ERRORS.VALUE;
1131
1186
  }
@@ -1138,18 +1193,18 @@ const fnBAHTTEXT = args => {
1138
1193
  if (err) {
1139
1194
  return err;
1140
1195
  }
1141
- return (0, values_1.rvString)((0, values_1.toStringRV)(args[0]));
1196
+ return (0, values_1.rvString)((0, values_1.toStringRV)((0, values_1.topLeft)(args[0])));
1142
1197
  };
1143
1198
  exports.fnBAHTTEXT = fnBAHTTEXT;
1144
1199
  const fnDOLLAR = args => {
1145
- const numRV = (0, values_1.toNumberRV)(args[0]);
1200
+ const numRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[0]));
1146
1201
  if ((0, values_1.isError)(numRV)) {
1147
1202
  return numRV;
1148
1203
  }
1149
1204
  const num = numRV.value;
1150
1205
  let decimals;
1151
1206
  if (args.length > 1) {
1152
- const decRV = (0, values_1.toNumberRV)(args[1]);
1207
+ const decRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[1]));
1153
1208
  if ((0, values_1.isError)(decRV)) {
1154
1209
  return decRV;
1155
1210
  }
@@ -1175,14 +1230,14 @@ const fnDOLLAR = args => {
1175
1230
  };
1176
1231
  exports.fnDOLLAR = fnDOLLAR;
1177
1232
  const fnFIXED = args => {
1178
- const numRV = (0, values_1.toNumberRV)(args[0]);
1233
+ const numRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[0]));
1179
1234
  if ((0, values_1.isError)(numRV)) {
1180
1235
  return numRV;
1181
1236
  }
1182
1237
  const num = numRV.value;
1183
1238
  let decimals;
1184
1239
  if (args.length > 1) {
1185
- const decRV = (0, values_1.toNumberRV)(args[1]);
1240
+ const decRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[1]));
1186
1241
  if ((0, values_1.isError)(decRV)) {
1187
1242
  return decRV;
1188
1243
  }
@@ -1193,7 +1248,7 @@ const fnFIXED = args => {
1193
1248
  }
1194
1249
  let noCommas;
1195
1250
  if (args.length > 2) {
1196
- const ncRV = (0, values_1.toBooleanRV)(args[2]);
1251
+ const ncRV = (0, values_1.toBooleanRV)((0, values_1.topLeft)(args[2]));
1197
1252
  if ((0, values_1.isError)(ncRV)) {
1198
1253
  return ncRV;
1199
1254
  }
@@ -1225,7 +1280,7 @@ const fnASC = args => {
1225
1280
  if (err) {
1226
1281
  return err;
1227
1282
  }
1228
- const text = (0, values_1.toStringRV)(args[0]);
1283
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
1229
1284
  return (0, values_1.rvString)(text.replace(/[\uFF01-\uFF5E]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)));
1230
1285
  };
1231
1286
  exports.fnASC = fnASC;
@@ -1234,7 +1289,7 @@ const fnDBCS = args => {
1234
1289
  if (err) {
1235
1290
  return err;
1236
1291
  }
1237
- const text = (0, values_1.toStringRV)(args[0]);
1292
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
1238
1293
  return (0, values_1.rvString)(text.replace(/[!-~]/g, ch => String.fromCharCode(ch.charCodeAt(0) + 0xfee0)));
1239
1294
  };
1240
1295
  exports.fnDBCS = fnDBCS;
@@ -1255,14 +1310,15 @@ const fnNUMBERVALUE = args => {
1255
1310
  if (e0) {
1256
1311
  return e0;
1257
1312
  }
1258
- let text = (0, values_1.toStringRV)(args[0]);
1313
+ // Implicit intersection on every text arg.
1314
+ let text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
1259
1315
  let decSep = ".";
1260
1316
  if (args.length > 1) {
1261
1317
  const e1 = (0, _shared_1.checkError)(args[1]);
1262
1318
  if (e1) {
1263
1319
  return e1;
1264
1320
  }
1265
- decSep = (0, values_1.toStringRV)(args[1]);
1321
+ decSep = (0, values_1.toStringRV)((0, values_1.topLeft)(args[1]));
1266
1322
  }
1267
1323
  let grpSep = ",";
1268
1324
  if (args.length > 2) {
@@ -1270,15 +1326,19 @@ const fnNUMBERVALUE = args => {
1270
1326
  if (e2) {
1271
1327
  return e2;
1272
1328
  }
1273
- grpSep = (0, values_1.toStringRV)(args[2]);
1329
+ grpSep = (0, values_1.toStringRV)((0, values_1.topLeft)(args[2]));
1274
1330
  }
1275
1331
  text = text.split(grpSep).join("");
1276
1332
  if (decSep !== ".") {
1277
1333
  text = text.replace(decSep, ".");
1278
1334
  }
1279
- // Handle percentage
1280
- const isPct = text.endsWith("%");
1281
- if (isPct) {
1335
+ // Handle percentage. Excel divides by 100 for EACH trailing `%`, so
1336
+ // `NUMBERVALUE("50%%")` = 50 / 10000 = 0.005. Previously we only
1337
+ // recognised the first `%` and treated `50%%` as the literal string
1338
+ // "50%" → NaN → #VALUE!.
1339
+ let pctCount = 0;
1340
+ while (text.endsWith("%")) {
1341
+ pctCount++;
1282
1342
  text = text.slice(0, -1);
1283
1343
  }
1284
1344
  // `Number("")` is 0, not NaN — reject empty / whitespace-only inputs
@@ -1290,7 +1350,7 @@ const fnNUMBERVALUE = args => {
1290
1350
  if (isNaN(n)) {
1291
1351
  return values_1.ERRORS.VALUE;
1292
1352
  }
1293
- return (0, values_1.rvNumber)(isPct ? n / 100 : n);
1353
+ return (0, values_1.rvNumber)(pctCount > 0 ? n / Math.pow(100, pctCount) : n);
1294
1354
  };
1295
1355
  exports.fnNUMBERVALUE = fnNUMBERVALUE;
1296
1356
  // ============================================================================
@@ -1309,7 +1369,7 @@ exports.fnNUMBERVALUE = fnNUMBERVALUE;
1309
1369
  function parseTextBeforeAfterTail(args) {
1310
1370
  let inst = 1;
1311
1371
  if (args.length > 2) {
1312
- const instRV = (0, values_1.toNumberRV)(args[2]);
1372
+ const instRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[2]));
1313
1373
  if ((0, values_1.isError)(instRV)) {
1314
1374
  return instRV;
1315
1375
  }
@@ -1317,7 +1377,7 @@ function parseTextBeforeAfterTail(args) {
1317
1377
  }
1318
1378
  let matchMode = 0;
1319
1379
  if (args.length > 3) {
1320
- const mmRV = (0, values_1.toNumberRV)(args[3]);
1380
+ const mmRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[3]));
1321
1381
  if ((0, values_1.isError)(mmRV)) {
1322
1382
  return mmRV;
1323
1383
  }
@@ -1329,7 +1389,7 @@ function parseTextBeforeAfterTail(args) {
1329
1389
  }
1330
1390
  let matchEnd = 0;
1331
1391
  if (args.length > 4) {
1332
- const meRV = (0, values_1.toNumberRV)(args[4]);
1392
+ const meRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[4]));
1333
1393
  if ((0, values_1.isError)(meRV)) {
1334
1394
  return meRV;
1335
1395
  }
@@ -1351,8 +1411,8 @@ const fnTEXTBEFORE = args => {
1351
1411
  if (e1) {
1352
1412
  return e1;
1353
1413
  }
1354
- const text = (0, values_1.toStringRV)(args[0]);
1355
- const delimiter = (0, values_1.toStringRV)(args[1]);
1414
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
1415
+ const delimiter = (0, values_1.toStringRV)((0, values_1.topLeft)(args[1]));
1356
1416
  const tail = parseTextBeforeAfterTail(args);
1357
1417
  if ("kind" in tail && tail.kind === 4 /* RVKind.Error */) {
1358
1418
  return tail;
@@ -1406,8 +1466,8 @@ const fnTEXTAFTER = args => {
1406
1466
  if (e1) {
1407
1467
  return e1;
1408
1468
  }
1409
- const text = (0, values_1.toStringRV)(args[0]);
1410
- const delimiter = (0, values_1.toStringRV)(args[1]);
1469
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
1470
+ const delimiter = (0, values_1.toStringRV)((0, values_1.topLeft)(args[1]));
1411
1471
  const tail = parseTextBeforeAfterTail(args);
1412
1472
  if ("kind" in tail && tail.kind === 4 /* RVKind.Error */) {
1413
1473
  return tail;
@@ -1453,21 +1513,21 @@ const fnTEXTSPLIT = args => {
1453
1513
  if (e0) {
1454
1514
  return e0;
1455
1515
  }
1456
- const text = (0, values_1.toStringRV)(args[0]);
1516
+ const text = (0, values_1.toStringRV)((0, values_1.topLeft)(args[0]));
1457
1517
  let colDelimiter = "";
1458
1518
  if (args.length > 1) {
1459
1519
  const e1 = (0, _shared_1.checkError)(args[1]);
1460
1520
  if (e1) {
1461
1521
  return e1;
1462
1522
  }
1463
- colDelimiter = (0, values_1.toStringRV)(args[1]);
1523
+ colDelimiter = (0, values_1.toStringRV)((0, values_1.topLeft)(args[1]));
1464
1524
  }
1465
- const rowDelimiter = args.length > 2 && args[2].kind !== 0 /* RVKind.Blank */ ? (0, values_1.toStringRV)(args[2]) : "";
1525
+ const rowDelimiter = args.length > 2 && args[2].kind !== 0 /* RVKind.Blank */ ? (0, values_1.toStringRV)((0, values_1.topLeft)(args[2])) : "";
1466
1526
  // `ignore_empty` (4th arg, default FALSE) — when TRUE, suppress empty
1467
1527
  // fragments produced by consecutive delimiters.
1468
1528
  let ignoreEmpty = false;
1469
1529
  if (args.length > 3 && args[3].kind !== 0 /* RVKind.Blank */) {
1470
- const ieRV = (0, values_1.toBooleanRV)(args[3]);
1530
+ const ieRV = (0, values_1.toBooleanRV)((0, values_1.topLeft)(args[3]));
1471
1531
  if ((0, values_1.isError)(ieRV)) {
1472
1532
  return ieRV;
1473
1533
  }
@@ -1479,7 +1539,7 @@ const fnTEXTSPLIT = args => {
1479
1539
  // consistent with Excel's specification for TEXTSPLIT.
1480
1540
  let matchMode = 0;
1481
1541
  if (args.length > 4 && args[4].kind !== 0 /* RVKind.Blank */) {
1482
- const mmRV = (0, values_1.toNumberRV)(args[4]);
1542
+ const mmRV = (0, values_1.toNumberRV)((0, values_1.topLeft)(args[4]));
1483
1543
  if ((0, values_1.isError)(mmRV)) {
1484
1544
  return mmRV;
1485
1545
  }
@@ -171,10 +171,29 @@ function calculateFormulasImpl(workbook) {
171
171
  let evalOrder = (0, dependency_analysis_1.topologicalSort)(graph);
172
172
  // ── Step 6: Evaluate ──
173
173
  const session = new evaluator_1.EvalSession();
174
+ // Convert user-registered functions (keyed opaquely on WorkbookLike)
175
+ // into the evaluator's typed `FunctionDescriptor` shape. We take a
176
+ // snapshot up front so later mutations to the workbook's map during
177
+ // evaluation can't observe a half-built state.
178
+ let userFunctions;
179
+ if (workbook.userFunctions && workbook.userFunctions.size > 0) {
180
+ const adapted = new Map();
181
+ for (const [name, desc] of workbook.userFunctions) {
182
+ const upperName = name.toUpperCase();
183
+ adapted.set(upperName, {
184
+ name: upperName,
185
+ minArity: desc.minArity,
186
+ maxArity: desc.maxArity,
187
+ invoke: desc.invoke
188
+ });
189
+ }
190
+ userFunctions = adapted;
191
+ }
174
192
  const ctx = {
175
193
  snapshot,
176
194
  compiledFormulas: compiledMap,
177
- currentSheet: snapshot.worksheets[0]?.name ?? ""
195
+ currentSheet: snapshot.worksheets[0]?.name ?? "",
196
+ userFunctions
178
197
  };
179
198
  // Evaluate in topological order
180
199
  evaluateInOrder(evalOrder, compiledMap, results, ctx, session);
@@ -53,6 +53,13 @@ function buildWritebackPlan(snapshot, compiled, results, previousSpills, previou
53
53
  // Build ghost map from previous spills (validated against snapshot)
54
54
  const ghostMap = new Map();
55
55
  for (const [srcKey, region] of previousSpills) {
56
+ // Hoist the worksheet lookup once per region — the previous code did
57
+ // `snapshot.worksheetsById.get(…)` inside the inner cell loop,
58
+ // paying the Map lookup cost `region.rows × region.cols` times.
59
+ const ws = snapshot.worksheetsById.get(region.worksheetId);
60
+ if (!ws) {
61
+ continue;
62
+ }
56
63
  for (let r = 0; r < region.rows; r++) {
57
64
  for (let c = 0; c < region.cols; c++) {
58
65
  if (r === 0 && c === 0) {
@@ -62,12 +69,9 @@ function buildWritebackPlan(snapshot, compiled, results, previousSpills, previou
62
69
  const targetCol = region.sourceCol + c;
63
70
  const targetKey = (0, workbook_snapshot_1.spillCellKeyFromId)(region.worksheetId, targetRow, targetCol);
64
71
  // Validate ghost cell is still unmodified
65
- const ws = snapshot.worksheetsById.get(region.worksheetId);
66
- if (ws) {
67
- const cell = ws.cells.get((0, workbook_snapshot_1.snapshotCellKey)(targetRow, targetCol));
68
- if (isGhostUnmodified(cell, targetKey, previousGhosts)) {
69
- ghostMap.set(targetKey, srcKey);
70
- }
72
+ const cell = ws.cells.get((0, workbook_snapshot_1.snapshotCellKey)(targetRow, targetCol));
73
+ if (isGhostUnmodified(cell, targetKey, previousGhosts)) {
74
+ ghostMap.set(targetKey, srcKey);
71
75
  }
72
76
  }
73
77
  }