@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
@@ -29,7 +29,7 @@ const TABLE_FMT = {
29
29
  11: "0.00E+00",
30
30
  12: "# ?/?",
31
31
  13: "# ??/??",
32
- 14: "m/d/yy",
32
+ 14: "mm-dd-yy",
33
33
  15: "d-mmm-yy",
34
34
  16: "d-mmm",
35
35
  17: "mmm-yy",
@@ -219,6 +219,56 @@ const MONTHS_LONG = [
219
219
  const MONTHS_LETTER = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
220
220
  const DAYS_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
221
221
  const DAYS_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
222
+ /**
223
+ * Disambiguate each `mm` occurrence in a format string that has already been
224
+ * placeholder-substituted for the other date/time tokens.
225
+ *
226
+ * Excel's rule: `mm` is minutes when it's adjacent to an hour or seconds
227
+ * token (with no intervening date tokens); otherwise it's a zero-padded
228
+ * month. This must be decided per occurrence — a single format string can
229
+ * contain both roles (e.g. `"yyyy-mm-dd hh:mm:ss"`).
230
+ *
231
+ * The caller has already replaced `yyyy`/`yy` → `Y4/Y2`, month-name tokens
232
+ * `mmmmm/mmmm/mmm` → `MN5/MN4/MN3`, `dd`/`d` → `D2/D1`, `hh`/`h` → `H2/H1`,
233
+ * `ss`/`s` → `S2/S1`. So any remaining literal `mm` substrings here are
234
+ * ambiguous between minute and month.
235
+ *
236
+ * Returns the input with each `mm` replaced by either `\x00MI2\x00` (minutes)
237
+ * or `\x00M2\x00` (month, zero-padded).
238
+ */
239
+ function resolveMonthOrMinute(s) {
240
+ // Tokens that, when present between an `mm` and a time anchor, break the
241
+ // "adjacent time context" chain and push the `mm` back into month-land.
242
+ const DATE_TOKEN = /\x00(?:Y[24]|D[12]|MN[345])\x00/;
243
+ const HOUR_TOKEN = /\x00H[12]\x00/g;
244
+ const SEC_TOKEN = /\x00S[12]\x00/g;
245
+ let out = "";
246
+ let work = s;
247
+ let idx = work.search(/mm/i);
248
+ while (idx !== -1) {
249
+ const before = work.slice(0, idx);
250
+ const after = work.slice(idx + 2);
251
+ // Find the *nearest* hour token preceding this `mm` (scan from the right).
252
+ let nearestHourIdx = -1;
253
+ let m;
254
+ HOUR_TOKEN.lastIndex = 0;
255
+ while ((m = HOUR_TOKEN.exec(before)) !== null) {
256
+ nearestHourIdx = m.index;
257
+ }
258
+ // Find the *nearest* seconds token following this `mm`.
259
+ SEC_TOKEN.lastIndex = 0;
260
+ const secMatch = SEC_TOKEN.exec(after);
261
+ const nearestSecIdx = secMatch ? secMatch.index : -1;
262
+ const hourInRange = nearestHourIdx !== -1 && !DATE_TOKEN.test(before.slice(nearestHourIdx));
263
+ const secInRange = nearestSecIdx !== -1 && !DATE_TOKEN.test(after.slice(0, nearestSecIdx));
264
+ const isMinutes = hourInRange || secInRange;
265
+ out += before + (isMinutes ? "\x00MI2\x00" : "\x00M2\x00");
266
+ work = after;
267
+ idx = work.search(/mm/i);
268
+ }
269
+ out += work;
270
+ return out;
271
+ }
222
272
  /**
223
273
  * Format a date value using Excel date format
224
274
  * @param serial Excel serial number (days since 1900-01-01)
@@ -279,16 +329,14 @@ function formatDate(serial, fmt) {
279
329
  // Seconds (before mm to avoid confusion)
280
330
  result = result.replace(/ss/gi, "\x00S2\x00");
281
331
  result = result.replace(/\bs\b/gi, "\x00S1\x00");
282
- // Minutes/Month mm - context dependent
283
- // If near h or s, it's minutes; otherwise month
284
- // For simplicity, check if we already have hour tokens nearby
285
- const hasTimeContext = /\x00H[12]\x00.*mm|mm.*\x00S[12]\x00/i.test(result);
286
- if (hasTimeContext) {
287
- result = result.replace(/mm/gi, "\x00MI2\x00");
288
- }
289
- else {
290
- result = result.replace(/mm/gi, "\x00M2\x00");
291
- }
332
+ // Minutes/Month `mm` — position-dependent. Excel treats `mm` as minutes
333
+ // when the nearest neighboring time-token is an hour (before) or a
334
+ // seconds token (after); otherwise it's month. This must be decided **per
335
+ // occurrence**, because a single format string can contain both roles —
336
+ // e.g. in `"yyyy-mm-dd hh:mm:ss"` the first `mm` is month and the second
337
+ // is minutes. A single global `hasTimeContext` flag would miscategorise
338
+ // all `mm` as minutes in such mixed formats.
339
+ result = resolveMonthOrMinute(result);
292
340
  result = result.replace(/\bm\b/gi, "\x00M1\x00");
293
341
  // AM/PM
294
342
  result = result.replace(/AM\/PM/gi, "\x00AMPM\x00");
@@ -896,6 +944,16 @@ function isDateDisplayFormat(fmt) {
896
944
  }
897
945
  return false;
898
946
  }
947
+ /**
948
+ * Default format applied to Date values whose numFmt is `General` or empty.
949
+ *
950
+ * Excel itself substitutes a locale-dependent short date in this case (US:
951
+ * `m/d/yyyy`). We pick an ISO-like `yyyy-mm-dd` so consumers who never set a
952
+ * `numFmt` still get a sensible, unambiguous rendering instead of the raw
953
+ * Excel serial number.
954
+ */
955
+ const DEFAULT_DATE_FORMAT = "yyyy-mm-dd";
956
+ const DEFAULT_DATETIME_FORMAT = "yyyy-mm-dd hh:mm:ss";
899
957
  /**
900
958
  * Format a value according to the given format string.
901
959
  * Handles Date objects with timezone-independent Excel serial conversion.
@@ -910,8 +968,22 @@ function formatCellValue(value, fmt, dateFormat) {
910
968
  }
911
969
  return format(fmt, serial);
912
970
  }
913
- const actualFmt = dateFormat && isDateDisplayFormat(fmt) ? dateFormat : fmt;
914
- return format(actualFmt, serial);
971
+ // For Date values whose numFmt is missing or General, Excel substitutes a
972
+ // default short-date format. Without this, `format("General", serial)`
973
+ // would emit the raw Excel serial (e.g. "43567") — almost never what the
974
+ // caller wants. Pick a datetime-aware default based on whether the value
975
+ // carries a non-midnight time component.
976
+ let effectiveFmt;
977
+ if (dateFormat && isDateDisplayFormat(fmt)) {
978
+ effectiveFmt = dateFormat;
979
+ }
980
+ else if (!fmt || isGeneral(fmt)) {
981
+ effectiveFmt = serial % 1 === 0 ? DEFAULT_DATE_FORMAT : DEFAULT_DATETIME_FORMAT;
982
+ }
983
+ else {
984
+ effectiveFmt = fmt;
985
+ }
986
+ return format(effectiveFmt, serial);
915
987
  }
916
988
  return format(fmt, value);
917
989
  }
@@ -1063,6 +1063,55 @@ class Workbook {
1063
1063
  calculateFormulas() {
1064
1064
  (0, host_registry_1.invokeFormulaEngine)(this);
1065
1065
  }
1066
+ /**
1067
+ * Register (or replace) a custom formula function on this workbook.
1068
+ *
1069
+ * The function becomes visible to `calculateFormulas()` on this
1070
+ * workbook only — the built-in registry stays untouched. Names are
1071
+ * case-insensitive (normalised to uppercase) and must not include
1072
+ * the `_XLFN.` prefix — the engine strips that automatically.
1073
+ *
1074
+ * @param name Function name (case-insensitive).
1075
+ * @param fn Implementation. Receives already-evaluated RuntimeValue
1076
+ * arguments; return a RuntimeValue. Wrap failures with
1077
+ * `rvError("#VALUE!")` rather than throwing — throws are
1078
+ * caught at the evaluator boundary and surface as
1079
+ * `#VALUE!` so a buggy custom function doesn't tear
1080
+ * down the whole calculation pass.
1081
+ * @param options Optional arity bounds. Defaults to `minArity=0`,
1082
+ * `maxArity=255` (Excel's universal argument cap), so
1083
+ * simple variadic functions work without extra config.
1084
+ * Set `volatile: true` when the function should be
1085
+ * re-evaluated on every calc cycle (analogous to
1086
+ * built-in `RAND`, `NOW`). Currently reserved for
1087
+ * future use; the engine recomputes every formula on
1088
+ * each `calculateFormulas()` call regardless.
1089
+ *
1090
+ * ```ts
1091
+ * import { rvNumber } from "@cj-tech-master/excelts/formula";
1092
+ * workbook.registerFunction("DOUBLE", ([x]) => {
1093
+ * return rvNumber((x as any).value * 2);
1094
+ * }, { minArity: 1, maxArity: 1 });
1095
+ * ```
1096
+ */
1097
+ registerFunction(name, fn, options) {
1098
+ if (!this.userFunctions) {
1099
+ this.userFunctions = new Map();
1100
+ }
1101
+ this.userFunctions.set(name.toUpperCase(), {
1102
+ minArity: options?.minArity ?? 0,
1103
+ maxArity: options?.maxArity ?? 255,
1104
+ invoke: fn,
1105
+ volatile: options?.volatile ?? false
1106
+ });
1107
+ }
1108
+ /**
1109
+ * Remove a user-registered function. No-op when the name isn't
1110
+ * registered; returns `true` when an entry was removed.
1111
+ */
1112
+ unregisterFunction(name) {
1113
+ return this.userFunctions?.delete(name.toUpperCase()) ?? false;
1114
+ }
1066
1115
  // ===========================================================================
1067
1116
  // Themes
1068
1117
  // ===========================================================================
@@ -20,7 +20,7 @@ const defaultNumFormats = {
20
20
  19: { f: "h:mm:ss AM/PM" },
21
21
  20: { f: "h:mm" },
22
22
  21: { f: "h:mm:ss" },
23
- 22: { f: 'm/d/yy "h":mm' },
23
+ 22: { f: "m/d/yy h:mm" },
24
24
  27: {
25
25
  "zh-tw": "[$-404]e/m/d",
26
26
  "zh-cn": 'yyyy"年"m"月"',
@@ -78,8 +78,8 @@ const defaultNumFormats = {
78
78
  },
79
79
  37: { f: "#,##0 ;(#,##0)" },
80
80
  38: { f: "#,##0 ;[Red](#,##0)" },
81
- 39: { f: "#,##0.00 ;(#,##0.00)" },
82
- 40: { f: "#,##0.00 ;[Red](#,##0.00)" },
81
+ 39: { f: "#,##0.00;(#,##0.00)" },
82
+ 40: { f: "#,##0.00;[Red](#,##0.00)" },
83
83
  45: { f: "mm:ss" },
84
84
  46: { f: "[h]:mm:ss" },
85
85
  47: { f: "mmss.0" },
@@ -107,6 +107,8 @@ function bind(node, ctx) {
107
107
  return bindName(node.name, ctx);
108
108
  case 15 /* NodeType.StructuredRef */:
109
109
  return bindStructuredRef(node.tableName, node.columns, node.specials, ctx);
110
+ case 17 /* NodeType.UnionRef */:
111
+ return bindUnionRef(node, ctx);
110
112
  default:
111
113
  return assertNever(node);
112
114
  }
@@ -162,6 +164,11 @@ function bindRangeRef(node, ctx) {
162
164
  const bottom = Math.max(startRow, endRow);
163
165
  const left = Math.min(startCol, endCol);
164
166
  const right = Math.max(startCol, endCol);
167
+ // Bounds-check the rectangle against Excel's sheet limits. Defined-name
168
+ // strings that bypass the tokenizer can carry arbitrary addresses.
169
+ if (top < 1 || bottom > 1048576 || left < 1 || right > 16384) {
170
+ return (0, bound_ast_1.boundErrorLiteral)("#REF!");
171
+ }
165
172
  // 3D range reference: Sheet1:Sheet3!A1:B2
166
173
  if (node.endSheet) {
167
174
  const sheets = getSheetsInRange(ctx.snapshot, sheet, node.endSheet);
@@ -175,6 +182,13 @@ function bindRangeRef(node, ctx) {
175
182
  inner
176
183
  };
177
184
  }
185
+ // Validate sheet exists — matches the parity check `bindCellRef` and
186
+ // `bindColRangeRef` / `bindRowRangeRef` perform. Without this, a range
187
+ // like `NoSuchSheet!A1:B2` would silently bind to an empty-read at
188
+ // runtime instead of surfacing as `#REF!` at compile time.
189
+ if (!ctx.snapshot.worksheetsByName.has(sheet.toLowerCase())) {
190
+ return (0, bound_ast_1.boundErrorLiteral)("#REF!");
191
+ }
178
192
  return (0, bound_ast_1.boundAreaRef)(sheet, top, left, bottom, right);
179
193
  }
180
194
  function bindColRangeRef(node, ctx) {
@@ -183,6 +197,12 @@ function bindColRangeRef(node, ctx) {
183
197
  const endCol = (0, address_utils_1.colLetterToNumber)(node.endCol);
184
198
  const leftCol = Math.min(startCol, endCol);
185
199
  const rightCol = Math.max(startCol, endCol);
200
+ // Excel's maximum column is 16384 (XFD). The tokenizer enforces this
201
+ // for plain refs, but defined-name range strings that bypass the
202
+ // tokenizer could carry larger letter sequences (e.g. `ZZZ`).
203
+ if (leftCol < 1 || rightCol > 16384) {
204
+ return (0, bound_ast_1.boundErrorLiteral)("#REF!");
205
+ }
186
206
  // Validate sheet exists
187
207
  if (!ctx.snapshot.worksheetsByName.has(sheet.toLowerCase())) {
188
208
  return (0, bound_ast_1.boundErrorLiteral)("#REF!");
@@ -212,6 +232,11 @@ function bindRowRangeRef(node, ctx) {
212
232
  const sheet = node.sheet ?? ctx.currentSheet;
213
233
  const topRow = Math.min(node.startRow, node.endRow);
214
234
  const bottomRow = Math.max(node.startRow, node.endRow);
235
+ // Excel's maximum row is 1048576. Re-check here because defined-name
236
+ // strings can bypass the tokenizer.
237
+ if (topRow < 1 || bottomRow > 1048576) {
238
+ return (0, bound_ast_1.boundErrorLiteral)("#REF!");
239
+ }
215
240
  // Validate sheet exists
216
241
  if (!ctx.snapshot.worksheetsByName.has(sheet.toLowerCase())) {
217
242
  return (0, bound_ast_1.boundErrorLiteral)("#REF!");
@@ -237,6 +262,24 @@ function bindRowRangeRef(node, ctx) {
237
262
  };
238
263
  }
239
264
  // ============================================================================
265
+ // Union Reference Binding — `(A1:B2, D4:E5)`
266
+ // ============================================================================
267
+ function bindUnionRef(node, ctx) {
268
+ // Each area must bind to a reference-producing expression. If any
269
+ // member is a non-reference literal, the whole union collapses to
270
+ // `#REF!` — Excel rejects things like `(1, A1)` outright. We defer
271
+ // the runtime-reference check (INDIRECT/OFFSET) to the evaluator.
272
+ const bounds = [];
273
+ for (const area of node.areas) {
274
+ const bound = bind(area, ctx);
275
+ bounds.push(bound);
276
+ }
277
+ return {
278
+ kind: 16 /* BoundExprKind.UnionRef */,
279
+ areas: bounds
280
+ };
281
+ }
282
+ // ============================================================================
240
283
  // Name Binding
241
284
  // ============================================================================
242
285
  function bindName(name, ctx) {
@@ -326,12 +369,11 @@ function findTable(snapshot, tableName) {
326
369
  if (!tableName) {
327
370
  return null;
328
371
  }
329
- // Use the pre-built tablesByName index for O(1) lookup
330
- const resolved = snapshot.tablesByName.get(tableName.toLowerCase());
331
- if (resolved) {
332
- return { table: resolved.table, sheetName: resolved.sheetName };
333
- }
334
- return null;
372
+ // Use the pre-built tablesByName index for O(1) lookup. The snapshot's
373
+ // `ResolvedTable` already matches our `TableWithSheet` shape (same
374
+ // `{ table, sheetName }` pair), so we can return it directly instead
375
+ // of wrapping every hit in a fresh object.
376
+ return snapshot.tablesByName.get(tableName.toLowerCase()) ?? null;
335
377
  }
336
378
  function resolveStructuredRefBounds(tw, columns, specials) {
337
379
  const t = tw.table;
@@ -213,6 +213,13 @@ function walkDeps(expr, cells, areas, tablesByName, nameResolver, visitedNames)
213
213
  }
214
214
  }
215
215
  break;
216
+ case 16 /* BoundExprKind.UnionRef */:
217
+ // Each member of a `(a1, a2, ...)` union contributes its own
218
+ // dependencies — downstream reads target cells in every area.
219
+ for (const area of expr.areas) {
220
+ walkDeps(area, cells, areas, tablesByName, nameResolver, visitedNames);
221
+ }
222
+ break;
216
223
  }
217
224
  }
218
225
  // ============================================================================
@@ -279,9 +286,15 @@ function detectDynamicArrayFunction(ast, bound) {
279
286
  return true;
280
287
  }
281
288
  }
282
- // Check bound expression level
283
- if (bound.kind === 10 /* BoundExprKind.Call */ && DYNAMIC_ARRAY_FUNCTION_NAMES.has(bound.name)) {
284
- return true;
289
+ // Check bound expression level. Strip `_XLFN.` prefix here too —
290
+ // `boundCall` preserves the prefix on the bound name, so without the
291
+ // strip a synthesised bound call (e.g. from INDIRECT re-parse) would
292
+ // miss detection.
293
+ if (bound.kind === 10 /* BoundExprKind.Call */) {
294
+ const canonical = (0, token_types_1.stripFunctionPrefix)(bound.name);
295
+ if (DYNAMIC_ARRAY_FUNCTION_NAMES.has(canonical)) {
296
+ return true;
297
+ }
285
298
  }
286
299
  return false;
287
300
  }
@@ -302,9 +315,15 @@ function detectSubtotalOutput(ast, bound) {
302
315
  return true;
303
316
  }
304
317
  }
305
- if (bound.kind === 10 /* BoundExprKind.Call */ &&
306
- (bound.name === "SUBTOTAL" || bound.name === "AGGREGATE")) {
307
- return true;
318
+ if (bound.kind === 10 /* BoundExprKind.Call */) {
319
+ // Strip `_XLFN.` / `_XLFN._XLWS.` prefixes before matching — otherwise
320
+ // `_XLFN.AGGREGATE(...)` silently wouldn't be marked as a subtotal
321
+ // output, so an outer SUBTOTAL / AGGREGATE over its cell would
322
+ // double-count the aggregated value.
323
+ const canonical = (0, token_types_1.stripFunctionPrefix)(bound.name);
324
+ if (canonical === "SUBTOTAL" || canonical === "AGGREGATE") {
325
+ return true;
326
+ }
308
327
  }
309
328
  return false;
310
329
  }
@@ -323,15 +342,24 @@ function analyzeExpr(expr, nameResolver) {
323
342
  return { isVolatile, hasDynamicRefs, containsLambda };
324
343
  function walkAnalyze(e) {
325
344
  switch (e.kind) {
326
- case 10 /* BoundExprKind.Call */:
327
- if (VOLATILE_FUNCTIONS.has(e.name)) {
345
+ case 10 /* BoundExprKind.Call */: {
346
+ // `boundCall` stores the function name uppercased but preserves
347
+ // any `_XLFN.` / `_XLFN._XLWS.` prefix the source text contained.
348
+ // Strip the prefix before VOLATILE_FUNCTIONS lookup so e.g.
349
+ // `_XLFN.RANDARRAY()` (an XLFN-prefixed volatile) correctly
350
+ // invalidates the session cache across calc cycles.
351
+ const canonical = (0, token_types_1.stripFunctionPrefix)(e.name);
352
+ if (VOLATILE_FUNCTIONS.has(canonical)) {
328
353
  isVolatile = true;
329
354
  }
330
355
  for (const arg of e.args) {
331
356
  walkAnalyze(arg);
332
357
  }
333
358
  break;
359
+ }
334
360
  case 11 /* BoundExprKind.SpecialCall */:
361
+ // Special-call names are already stripped of any `_XLFN.` prefix
362
+ // by `canonicalSpecialForm` in the binder, so no re-strip here.
335
363
  if (DYNAMIC_REF_FUNCTIONS.has(e.name)) {
336
364
  hasDynamicRefs = true;
337
365
  }
@@ -379,6 +407,11 @@ function analyzeExpr(expr, nameResolver) {
379
407
  }
380
408
  }
381
409
  break;
410
+ case 16 /* BoundExprKind.UnionRef */:
411
+ for (const area of e.areas) {
412
+ walkAnalyze(area);
413
+ }
414
+ break;
382
415
  default:
383
416
  // Literal, CellRef, AreaRef, etc. — no children to analyze
384
417
  break;
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.checkError = checkError;
13
13
  exports.argToNumber = argToNumber;
14
14
  exports.flattenNumbers = flattenNumbers;
15
+ exports.forEachNumber = forEachNumber;
15
16
  exports.flattenAll = flattenAll;
16
17
  exports.firstError = firstError;
17
18
  exports.asArray = asArray;
@@ -91,6 +92,53 @@ function flattenNumbers(args) {
91
92
  }
92
93
  return result;
93
94
  }
95
+ /**
96
+ * Streaming fold over numeric arguments.
97
+ *
98
+ * Same selection rules as `flattenNumbers` (array cells contribute only
99
+ * Number/Error; direct scalar coercion via `toNumberRV`; blanks dropped),
100
+ * but the caller's `onNumber` callback fires inline — no intermediate
101
+ * array is allocated. On the first error encountered the scan short-
102
+ * circuits and returns that error.
103
+ *
104
+ * Returns:
105
+ * - `null` when iteration finished without encountering an error, or
106
+ * - the `ErrorValue` that aborted the scan.
107
+ *
108
+ * Prefer this over `flattenNumbers` + `firstError` + manual loop in hot
109
+ * aggregates (SUM / AVERAGE / MIN / MAX / …). The allocation saved is
110
+ * one `NumberValue | ErrorValue` array per invocation — meaningful
111
+ * when the engine sums tens of thousands of cells.
112
+ */
113
+ function forEachNumber(args, onNumber) {
114
+ for (const arg of args) {
115
+ if (arg.kind === 5 /* RVKind.Array */) {
116
+ for (const row of arg.rows) {
117
+ for (const cell of row) {
118
+ if (cell.kind === 4 /* RVKind.Error */) {
119
+ return cell;
120
+ }
121
+ if (cell.kind === 1 /* RVKind.Number */) {
122
+ onNumber(cell.value);
123
+ }
124
+ // Booleans, strings, blanks inside arrays are skipped.
125
+ }
126
+ }
127
+ }
128
+ else if (arg.kind === 4 /* RVKind.Error */) {
129
+ return arg;
130
+ }
131
+ else if (arg.kind !== 0 /* RVKind.Blank */) {
132
+ const n = (0, values_1.toNumberRV)(arg);
133
+ if (n.kind === 4 /* RVKind.Error */) {
134
+ return n;
135
+ }
136
+ onNumber(n.value);
137
+ }
138
+ // Direct blank scalars are dropped.
139
+ }
140
+ return null;
141
+ }
94
142
  /**
95
143
  * Flatten all cells from the arguments into a flat list of ScalarValue,
96
144
  * preserving every cell (including blanks, errors, booleans, strings).