@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
@@ -51,8 +51,8 @@ export const fnTEXTJOIN = args => {
51
51
  if (e0) {
52
52
  return e0;
53
53
  }
54
- const delimiter = toStringRV(args[0]);
55
- const ignoreEmptyRV = toBooleanRV(args[1]);
54
+ const delimiter = toStringRV(topLeft(args[0]));
55
+ const ignoreEmptyRV = toBooleanRV(topLeft(args[1]));
56
56
  if (isError(ignoreEmptyRV)) {
57
57
  return ignoreEmptyRV;
58
58
  }
@@ -97,7 +97,8 @@ export const fnLEFT = args => {
97
97
  if (err) {
98
98
  return err;
99
99
  }
100
- const text = toStringRV(args[0]);
100
+ // Implicit intersection on the text arg (see MID for rationale).
101
+ const text = toStringRV(topLeft(args[0]));
101
102
  let n;
102
103
  if (args.length > 1) {
103
104
  // Use `argToNumber` so array arguments get implicit-intersection to
@@ -125,7 +126,7 @@ export const fnRIGHT = args => {
125
126
  if (err) {
126
127
  return err;
127
128
  }
128
- const text = toStringRV(args[0]);
129
+ const text = toStringRV(topLeft(args[0]));
129
130
  let n;
130
131
  if (args.length > 1) {
131
132
  // Implicit intersection via `argToNumber` — see LEFT for rationale.
@@ -152,9 +153,11 @@ export const fnMID = args => {
152
153
  if (err) {
153
154
  return err;
154
155
  }
155
- const text = toStringRV(args[0]);
156
- // Implicit intersection on both numeric arguments so array inputs
157
- // collapse to their top-left cells before coercion.
156
+ // Implicit intersection on the text arg — without topLeft, passing
157
+ // an array would route through `toStringRV`'s default branch and
158
+ // silently return the empty string, making `MID(A1:A2, 1, 3)` look
159
+ // like an empty cell instead of a 3-char prefix of the first cell.
160
+ const text = toStringRV(topLeft(args[0]));
158
161
  const startNumRV = argToNumber(args[1]);
159
162
  if (isError(startNumRV)) {
160
163
  return startNumRV;
@@ -245,7 +248,7 @@ export const fnSUBSTITUTE = args => {
245
248
  return rvString(text);
246
249
  }
247
250
  if (args.length > 3) {
248
- const instanceNumRV = toNumberRV(args[3]);
251
+ const instanceNumRV = toNumberRV(topLeft(args[3]));
249
252
  if (isError(instanceNumRV)) {
250
253
  return instanceNumRV;
251
254
  }
@@ -269,7 +272,7 @@ export const fnREPLACE = args => {
269
272
  if (err) {
270
273
  return err;
271
274
  }
272
- const text = toStringRV(args[0]);
275
+ const text = toStringRV(topLeft(args[0]));
273
276
  // Implicit intersection on the numeric arguments — see LEFT.
274
277
  const startNumRV = argToNumber(args[1]);
275
278
  if (isError(startNumRV)) {
@@ -291,7 +294,7 @@ export const fnREPLACE = args => {
291
294
  if (e3) {
292
295
  return e3;
293
296
  }
294
- const newText = toStringRV(args[3]);
297
+ const newText = toStringRV(topLeft(args[3]));
295
298
  return rvString(text.slice(0, startNum - 1) + newText + text.slice(startNum - 1 + numChars));
296
299
  };
297
300
  // ============================================================================
@@ -306,8 +309,9 @@ export const fnFIND = args => {
306
309
  if (err1) {
307
310
  return err1;
308
311
  }
309
- const findText = toStringRV(args[0]);
310
- const withinText = toStringRV(args[1]);
312
+ // Implicit intersection on text args so arrays collapse to top-left.
313
+ const findText = toStringRV(topLeft(args[0]));
314
+ const withinText = toStringRV(topLeft(args[1]));
311
315
  let startNum;
312
316
  if (args.length > 2) {
313
317
  // Implicit intersection so an array supplied as start_num collapses
@@ -337,8 +341,9 @@ export const fnSEARCH = args => {
337
341
  if (err1) {
338
342
  return err1;
339
343
  }
340
- let findText = toStringRV(args[0]);
341
- const withinText = toStringRV(args[1]);
344
+ // Implicit intersection on text args (see FIND for rationale).
345
+ let findText = toStringRV(topLeft(args[0]));
346
+ const withinText = toStringRV(topLeft(args[1]));
342
347
  let startNum;
343
348
  if (args.length > 2) {
344
349
  const startNumRV = argToNumber(args[2]);
@@ -378,7 +383,7 @@ export const fnREPT = args => {
378
383
  return err;
379
384
  }
380
385
  const text = toStringRV(topLeft(args[0]));
381
- const timesRV = toNumberRV(args[1]);
386
+ const timesRV = toNumberRV(topLeft(args[1]));
382
387
  if (isError(timesRV)) {
383
388
  return timesRV;
384
389
  }
@@ -407,7 +412,7 @@ export const fnTEXT = args => {
407
412
  if (e1) {
408
413
  return e1;
409
414
  }
410
- const fmt = toStringRV(args[1]);
415
+ const fmt = toStringRV(topLeft(args[1]));
411
416
  // "@" format = return text as-is
412
417
  if (fmt === "@") {
413
418
  return rvString(toStringRV(rawVal));
@@ -584,10 +589,52 @@ function formatNumber(val, fmt) {
584
589
  tokens.push({ kind: "literal", text: ch });
585
590
  }
586
591
  // Count integer/fraction digit slots and decide whether to group.
592
+ // Trailing commas after the last integer digit token — before the
593
+ // decimal point or end of pattern — act as a "scale by 1/1000^k"
594
+ // multiplier (Excel's "thousands scaling"), not thousand separators.
595
+ // Example: `#,##0,` → 1,234,567 → "1,235" (divide by 1000).
596
+ // Example: `#,##0,,` → 1,234,567,890 → "1,234" (divide by 1,000,000).
587
597
  let sawDot = false;
588
598
  const intDigitSlots = [];
589
599
  const fracDigitSlots = [];
590
600
  let hasGrouping = false;
601
+ // Scan for trailing commas that sit strictly after the last integer
602
+ // digit slot but before any dot or fractional slot. Each such comma
603
+ // divides the value by 1000. Scan from end-of-pattern backward to
604
+ // locate them.
605
+ let scalingFactor = 1;
606
+ {
607
+ // Find the last integer digit-slot index.
608
+ let lastIntDigitIdx = -1;
609
+ let dotIdx = -1;
610
+ for (let i = 0; i < tokens.length; i++) {
611
+ const t = tokens[i];
612
+ if (t.kind === "dot") {
613
+ dotIdx = i;
614
+ break;
615
+ }
616
+ if (t.kind === "digit") {
617
+ lastIntDigitIdx = i;
618
+ }
619
+ }
620
+ // Trailing commas live after `lastIntDigitIdx` and strictly before
621
+ // `dotIdx` (if present). They must be adjacent — only run-of-commas
622
+ // directly abutting the last integer digit count as scaling.
623
+ if (lastIntDigitIdx !== -1) {
624
+ const stopBefore = dotIdx === -1 ? tokens.length : dotIdx;
625
+ let k = lastIntDigitIdx + 1;
626
+ while (k < stopBefore && tokens[k].kind === "comma") {
627
+ scalingFactor *= 1000;
628
+ // Remove this comma so later logic doesn't treat it as a
629
+ // thousand-separator or literal artefact. We mutate the token
630
+ // to a no-op literal with empty text.
631
+ tokens[k] = { kind: "literal", text: "" };
632
+ k++;
633
+ }
634
+ }
635
+ }
636
+ // Apply the scaling before any other formatting work.
637
+ const scaledVal = val / scalingFactor;
591
638
  for (const t of tokens) {
592
639
  if (t.kind === "dot") {
593
640
  sawDot = true;
@@ -611,7 +658,7 @@ function formatNumber(val, fmt) {
611
658
  return fmt; // Nothing to format; return the (raw) pattern.
612
659
  }
613
660
  // Round to the requested fractional precision.
614
- const rounded = roundHalfAwayFromZeroFmt(val, fracDigitSlots.length);
661
+ const rounded = roundHalfAwayFromZeroFmt(scaledVal, fracDigitSlots.length);
615
662
  const sign = rounded < 0 ? "-" : "";
616
663
  const absStr = Math.abs(rounded).toFixed(fracDigitSlots.length);
617
664
  const [intPartRaw, fracPart = ""] = absStr.split(".");
@@ -691,11 +738,19 @@ function formatNumber(val, fmt) {
691
738
  emittedOverflow = true;
692
739
  }
693
740
  const slotIdx = intCursor;
694
- // Integer slots map right-to-left onto `groupedInt`'s right-to-left
695
- // ordering. We'll compute the source character for this slot from
696
- // the right edge.
697
- const srcIdx = overflowDigits + slotIdx;
698
- if (srcIdx < groupedInt.length) {
741
+ // Right-align the integer into the digit slots: leading `#` slots
742
+ // emit nothing when the value is shorter than the pattern, and
743
+ // leading `0` slots pad with a zero. Previously the logic treated
744
+ // every slot as if the integer started at slot 0 (left-align),
745
+ // which made `TEXT(0, "#,##0")` produce "00" instead of "0".
746
+ //
747
+ // The number of "padding" slots that must appear before the first
748
+ // real digit equals `totalIntSlots − groupedInt.length` when that
749
+ // value is positive (overflow path zeroes it out since we already
750
+ // prepended the overflow digits before the first slot).
751
+ const paddingSlots = overflowDigits > 0 ? 0 : Math.max(0, totalIntSlots - groupedInt.length);
752
+ const srcIdx = overflowDigits + slotIdx - paddingSlots;
753
+ if (srcIdx >= 0 && srcIdx < groupedInt.length) {
699
754
  out += groupedInt[srcIdx];
700
755
  }
701
756
  else if (t.char === "0") {
@@ -1018,7 +1073,7 @@ export const fnEXACT = args => {
1018
1073
  if (err1) {
1019
1074
  return err1;
1020
1075
  }
1021
- return rvBoolean(toStringRV(args[0]) === toStringRV(args[1]));
1076
+ return rvBoolean(toStringRV(topLeft(args[0])) === toStringRV(topLeft(args[1])));
1022
1077
  };
1023
1078
  // ============================================================================
1024
1079
  // Additional Text Functions
@@ -1079,7 +1134,7 @@ export const fnUNICHAR = args => {
1079
1134
  if (err) {
1080
1135
  return err;
1081
1136
  }
1082
- const nRV = toNumberRV(args[0]);
1137
+ const nRV = toNumberRV(topLeft(args[0]));
1083
1138
  if (isError(nRV)) {
1084
1139
  return nRV;
1085
1140
  }
@@ -1099,7 +1154,7 @@ export const fnUNICODE = args => {
1099
1154
  if (err) {
1100
1155
  return err;
1101
1156
  }
1102
- const text = toStringRV(args[0]);
1157
+ const text = toStringRV(topLeft(args[0]));
1103
1158
  if (text.length === 0) {
1104
1159
  return ERRORS.VALUE;
1105
1160
  }
@@ -1111,17 +1166,17 @@ export const fnBAHTTEXT = args => {
1111
1166
  if (err) {
1112
1167
  return err;
1113
1168
  }
1114
- return rvString(toStringRV(args[0]));
1169
+ return rvString(toStringRV(topLeft(args[0])));
1115
1170
  };
1116
1171
  export const fnDOLLAR = args => {
1117
- const numRV = toNumberRV(args[0]);
1172
+ const numRV = toNumberRV(topLeft(args[0]));
1118
1173
  if (isError(numRV)) {
1119
1174
  return numRV;
1120
1175
  }
1121
1176
  const num = numRV.value;
1122
1177
  let decimals;
1123
1178
  if (args.length > 1) {
1124
- const decRV = toNumberRV(args[1]);
1179
+ const decRV = toNumberRV(topLeft(args[1]));
1125
1180
  if (isError(decRV)) {
1126
1181
  return decRV;
1127
1182
  }
@@ -1146,14 +1201,14 @@ export const fnDOLLAR = args => {
1146
1201
  return rvString(num < 0 ? `($${result})` : `$${result}`);
1147
1202
  };
1148
1203
  export const fnFIXED = args => {
1149
- const numRV = toNumberRV(args[0]);
1204
+ const numRV = toNumberRV(topLeft(args[0]));
1150
1205
  if (isError(numRV)) {
1151
1206
  return numRV;
1152
1207
  }
1153
1208
  const num = numRV.value;
1154
1209
  let decimals;
1155
1210
  if (args.length > 1) {
1156
- const decRV = toNumberRV(args[1]);
1211
+ const decRV = toNumberRV(topLeft(args[1]));
1157
1212
  if (isError(decRV)) {
1158
1213
  return decRV;
1159
1214
  }
@@ -1164,7 +1219,7 @@ export const fnFIXED = args => {
1164
1219
  }
1165
1220
  let noCommas;
1166
1221
  if (args.length > 2) {
1167
- const ncRV = toBooleanRV(args[2]);
1222
+ const ncRV = toBooleanRV(topLeft(args[2]));
1168
1223
  if (isError(ncRV)) {
1169
1224
  return ncRV;
1170
1225
  }
@@ -1195,7 +1250,7 @@ export const fnASC = args => {
1195
1250
  if (err) {
1196
1251
  return err;
1197
1252
  }
1198
- const text = toStringRV(args[0]);
1253
+ const text = toStringRV(topLeft(args[0]));
1199
1254
  return rvString(text.replace(/[\uFF01-\uFF5E]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)));
1200
1255
  };
1201
1256
  export const fnDBCS = args => {
@@ -1203,7 +1258,7 @@ export const fnDBCS = args => {
1203
1258
  if (err) {
1204
1259
  return err;
1205
1260
  }
1206
- const text = toStringRV(args[0]);
1261
+ const text = toStringRV(topLeft(args[0]));
1207
1262
  return rvString(text.replace(/[!-~]/g, ch => String.fromCharCode(ch.charCodeAt(0) + 0xfee0)));
1208
1263
  };
1209
1264
  export const fnJIS = args => fnDBCS(args);
@@ -1221,14 +1276,15 @@ export const fnNUMBERVALUE = args => {
1221
1276
  if (e0) {
1222
1277
  return e0;
1223
1278
  }
1224
- let text = toStringRV(args[0]);
1279
+ // Implicit intersection on every text arg.
1280
+ let text = toStringRV(topLeft(args[0]));
1225
1281
  let decSep = ".";
1226
1282
  if (args.length > 1) {
1227
1283
  const e1 = checkError(args[1]);
1228
1284
  if (e1) {
1229
1285
  return e1;
1230
1286
  }
1231
- decSep = toStringRV(args[1]);
1287
+ decSep = toStringRV(topLeft(args[1]));
1232
1288
  }
1233
1289
  let grpSep = ",";
1234
1290
  if (args.length > 2) {
@@ -1236,15 +1292,19 @@ export const fnNUMBERVALUE = args => {
1236
1292
  if (e2) {
1237
1293
  return e2;
1238
1294
  }
1239
- grpSep = toStringRV(args[2]);
1295
+ grpSep = toStringRV(topLeft(args[2]));
1240
1296
  }
1241
1297
  text = text.split(grpSep).join("");
1242
1298
  if (decSep !== ".") {
1243
1299
  text = text.replace(decSep, ".");
1244
1300
  }
1245
- // Handle percentage
1246
- const isPct = text.endsWith("%");
1247
- if (isPct) {
1301
+ // Handle percentage. Excel divides by 100 for EACH trailing `%`, so
1302
+ // `NUMBERVALUE("50%%")` = 50 / 10000 = 0.005. Previously we only
1303
+ // recognised the first `%` and treated `50%%` as the literal string
1304
+ // "50%" → NaN → #VALUE!.
1305
+ let pctCount = 0;
1306
+ while (text.endsWith("%")) {
1307
+ pctCount++;
1248
1308
  text = text.slice(0, -1);
1249
1309
  }
1250
1310
  // `Number("")` is 0, not NaN — reject empty / whitespace-only inputs
@@ -1256,7 +1316,7 @@ export const fnNUMBERVALUE = args => {
1256
1316
  if (isNaN(n)) {
1257
1317
  return ERRORS.VALUE;
1258
1318
  }
1259
- return rvNumber(isPct ? n / 100 : n);
1319
+ return rvNumber(pctCount > 0 ? n / Math.pow(100, pctCount) : n);
1260
1320
  };
1261
1321
  // ============================================================================
1262
1322
  // Excel 365 Text Functions: TEXTBEFORE, TEXTAFTER, TEXTSPLIT
@@ -1274,7 +1334,7 @@ export const fnNUMBERVALUE = args => {
1274
1334
  function parseTextBeforeAfterTail(args) {
1275
1335
  let inst = 1;
1276
1336
  if (args.length > 2) {
1277
- const instRV = toNumberRV(args[2]);
1337
+ const instRV = toNumberRV(topLeft(args[2]));
1278
1338
  if (isError(instRV)) {
1279
1339
  return instRV;
1280
1340
  }
@@ -1282,7 +1342,7 @@ function parseTextBeforeAfterTail(args) {
1282
1342
  }
1283
1343
  let matchMode = 0;
1284
1344
  if (args.length > 3) {
1285
- const mmRV = toNumberRV(args[3]);
1345
+ const mmRV = toNumberRV(topLeft(args[3]));
1286
1346
  if (isError(mmRV)) {
1287
1347
  return mmRV;
1288
1348
  }
@@ -1294,7 +1354,7 @@ function parseTextBeforeAfterTail(args) {
1294
1354
  }
1295
1355
  let matchEnd = 0;
1296
1356
  if (args.length > 4) {
1297
- const meRV = toNumberRV(args[4]);
1357
+ const meRV = toNumberRV(topLeft(args[4]));
1298
1358
  if (isError(meRV)) {
1299
1359
  return meRV;
1300
1360
  }
@@ -1316,8 +1376,8 @@ export const fnTEXTBEFORE = args => {
1316
1376
  if (e1) {
1317
1377
  return e1;
1318
1378
  }
1319
- const text = toStringRV(args[0]);
1320
- const delimiter = toStringRV(args[1]);
1379
+ const text = toStringRV(topLeft(args[0]));
1380
+ const delimiter = toStringRV(topLeft(args[1]));
1321
1381
  const tail = parseTextBeforeAfterTail(args);
1322
1382
  if ("kind" in tail && tail.kind === RVKind.Error) {
1323
1383
  return tail;
@@ -1370,8 +1430,8 @@ export const fnTEXTAFTER = args => {
1370
1430
  if (e1) {
1371
1431
  return e1;
1372
1432
  }
1373
- const text = toStringRV(args[0]);
1374
- const delimiter = toStringRV(args[1]);
1433
+ const text = toStringRV(topLeft(args[0]));
1434
+ const delimiter = toStringRV(topLeft(args[1]));
1375
1435
  const tail = parseTextBeforeAfterTail(args);
1376
1436
  if ("kind" in tail && tail.kind === RVKind.Error) {
1377
1437
  return tail;
@@ -1416,21 +1476,21 @@ export const fnTEXTSPLIT = args => {
1416
1476
  if (e0) {
1417
1477
  return e0;
1418
1478
  }
1419
- const text = toStringRV(args[0]);
1479
+ const text = toStringRV(topLeft(args[0]));
1420
1480
  let colDelimiter = "";
1421
1481
  if (args.length > 1) {
1422
1482
  const e1 = checkError(args[1]);
1423
1483
  if (e1) {
1424
1484
  return e1;
1425
1485
  }
1426
- colDelimiter = toStringRV(args[1]);
1486
+ colDelimiter = toStringRV(topLeft(args[1]));
1427
1487
  }
1428
- const rowDelimiter = args.length > 2 && args[2].kind !== RVKind.Blank ? toStringRV(args[2]) : "";
1488
+ const rowDelimiter = args.length > 2 && args[2].kind !== RVKind.Blank ? toStringRV(topLeft(args[2])) : "";
1429
1489
  // `ignore_empty` (4th arg, default FALSE) — when TRUE, suppress empty
1430
1490
  // fragments produced by consecutive delimiters.
1431
1491
  let ignoreEmpty = false;
1432
1492
  if (args.length > 3 && args[3].kind !== RVKind.Blank) {
1433
- const ieRV = toBooleanRV(args[3]);
1493
+ const ieRV = toBooleanRV(topLeft(args[3]));
1434
1494
  if (isError(ieRV)) {
1435
1495
  return ieRV;
1436
1496
  }
@@ -1442,7 +1502,7 @@ export const fnTEXTSPLIT = args => {
1442
1502
  // consistent with Excel's specification for TEXTSPLIT.
1443
1503
  let matchMode = 0;
1444
1504
  if (args.length > 4 && args[4].kind !== RVKind.Blank) {
1445
- const mmRV = toNumberRV(args[4]);
1505
+ const mmRV = toNumberRV(topLeft(args[4]));
1446
1506
  if (isError(mmRV)) {
1447
1507
  return mmRV;
1448
1508
  }
@@ -168,10 +168,29 @@ export function calculateFormulasImpl(workbook) {
168
168
  let evalOrder = topologicalSort(graph);
169
169
  // ── Step 6: Evaluate ──
170
170
  const session = new EvalSession();
171
+ // Convert user-registered functions (keyed opaquely on WorkbookLike)
172
+ // into the evaluator's typed `FunctionDescriptor` shape. We take a
173
+ // snapshot up front so later mutations to the workbook's map during
174
+ // evaluation can't observe a half-built state.
175
+ let userFunctions;
176
+ if (workbook.userFunctions && workbook.userFunctions.size > 0) {
177
+ const adapted = new Map();
178
+ for (const [name, desc] of workbook.userFunctions) {
179
+ const upperName = name.toUpperCase();
180
+ adapted.set(upperName, {
181
+ name: upperName,
182
+ minArity: desc.minArity,
183
+ maxArity: desc.maxArity,
184
+ invoke: desc.invoke
185
+ });
186
+ }
187
+ userFunctions = adapted;
188
+ }
171
189
  const ctx = {
172
190
  snapshot,
173
191
  compiledFormulas: compiledMap,
174
- currentSheet: snapshot.worksheets[0]?.name ?? ""
192
+ currentSheet: snapshot.worksheets[0]?.name ?? "",
193
+ userFunctions
175
194
  };
176
195
  // Evaluate in topological order
177
196
  evaluateInOrder(evalOrder, compiledMap, results, ctx, session);
@@ -51,6 +51,13 @@ export function buildWritebackPlan(snapshot, compiled, results, previousSpills,
51
51
  // Build ghost map from previous spills (validated against snapshot)
52
52
  const ghostMap = new Map();
53
53
  for (const [srcKey, region] of previousSpills) {
54
+ // Hoist the worksheet lookup once per region — the previous code did
55
+ // `snapshot.worksheetsById.get(…)` inside the inner cell loop,
56
+ // paying the Map lookup cost `region.rows × region.cols` times.
57
+ const ws = snapshot.worksheetsById.get(region.worksheetId);
58
+ if (!ws) {
59
+ continue;
60
+ }
54
61
  for (let r = 0; r < region.rows; r++) {
55
62
  for (let c = 0; c < region.cols; c++) {
56
63
  if (r === 0 && c === 0) {
@@ -60,12 +67,9 @@ export function buildWritebackPlan(snapshot, compiled, results, previousSpills,
60
67
  const targetCol = region.sourceCol + c;
61
68
  const targetKey = spillCellKeyFromId(region.worksheetId, targetRow, targetCol);
62
69
  // Validate ghost cell is still unmodified
63
- const ws = snapshot.worksheetsById.get(region.worksheetId);
64
- if (ws) {
65
- const cell = ws.cells.get(snapshotCellKey(targetRow, targetCol));
66
- if (isGhostUnmodified(cell, targetKey, previousGhosts)) {
67
- ghostMap.set(targetKey, srcKey);
68
- }
70
+ const cell = ws.cells.get(snapshotCellKey(targetRow, targetCol));
71
+ if (isGhostUnmodified(cell, targetKey, previousGhosts)) {
72
+ ghostMap.set(targetKey, srcKey);
69
73
  }
70
74
  }
71
75
  }
@@ -159,6 +159,21 @@ export interface WorkbookLike {
159
159
  properties?: {
160
160
  date1904?: boolean;
161
161
  };
162
+ /**
163
+ * User-registered custom functions exposed to the formula engine.
164
+ * Keys are uppercase canonical names; values are arity + invoke
165
+ * descriptors. When the evaluator encounters a call it consults this
166
+ * map before the global built-in registry, so users can shadow a
167
+ * built-in (e.g. replace `IRR` with a domain-specific variant) or
168
+ * add entirely new names.
169
+ */
170
+ userFunctions?: ReadonlyMap<string, {
171
+ minArity: number;
172
+ maxArity: number;
173
+ invoke: (args: unknown[]) => unknown;
174
+ /** Reserved for future volatile-function wiring. */
175
+ volatile?: boolean;
176
+ }>;
162
177
  }
163
178
  /**
164
179
  * Tracks a spill region: the source formula cell and the range of cells it
@@ -8,6 +8,7 @@
8
8
  import type { BoundExpr } from "../compile/bound-ast.js";
9
9
  import type { CompiledFormula } from "../compile/compiled-formula.js";
10
10
  import type { WorkbookSnapshot } from "../integration/workbook-snapshot.js";
11
+ import type { FunctionDescriptor } from "./function-registry.js";
11
12
  import type { RuntimeValue } from "./values.js";
12
13
  /**
13
14
  * Cached formula evaluation result with both scalar and raw forms.
@@ -114,6 +115,13 @@ export interface EvalContext {
114
115
  };
115
116
  /** Local variable bindings from LET expressions. */
116
117
  localBindings?: Map<string, RuntimeValue>;
118
+ /**
119
+ * User-registered functions that take precedence over the built-in
120
+ * registry. Lookup happens in `evaluateCall` — a matching
121
+ * descriptor here shadows any built-in of the same name. Keys are
122
+ * canonical uppercase names (prefix-stripped on register).
123
+ */
124
+ readonly userFunctions?: ReadonlyMap<string, FunctionDescriptor>;
117
125
  }
118
126
  /**
119
127
  * Evaluate a BoundExpr to produce a RuntimeValue.