@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
@@ -35,6 +35,7 @@ export type { NodeInput } from "./modules/excel/stream/workbook-reader.js";
35
35
  export { decodeCol, encodeCol, decodeRow, encodeRow, decodeCell, encodeCell, decodeRange, encodeRange } from "./modules/excel/utils/address.js";
36
36
  export type { CellAddress, SheetRange, Origin } from "./modules/excel/utils/address.js";
37
37
  export type { SheetToJSONOptions, AddJSONOptions, AddAOAOptions } from "./modules/excel/worksheet.js";
38
+ export { getCellDisplayText, formatCellValue, isDateDisplayFormat } from "./modules/excel/utils/cell-format.js";
38
39
  export { dateToExcel, excelToDate } from "./utils/utils.base.js";
39
40
  export { base64ToUint8Array, uint8ArrayToBase64 } from "./utils/utils.base.js";
40
41
  export { xmlEncode, xmlDecode } from "./modules/xml/encode.js";
@@ -45,6 +45,8 @@ export { DefinedNames } from "./modules/excel/defined-names.js";
45
45
  // =============================================================================
46
46
  // Cell address encoding/decoding (0-indexed)
47
47
  export { decodeCol, encodeCol, decodeRow, encodeRow, decodeCell, encodeCell, decodeRange, encodeRange } from "./modules/excel/utils/address.js";
48
+ // Cell display-text helpers (apply numFmt to produce an Excel-style string)
49
+ export { getCellDisplayText, formatCellValue, isDateDisplayFormat } from "./modules/excel/utils/cell-format.js";
48
50
  // Date conversion (Excel serial dates <-> JS Date)
49
51
  export { dateToExcel, excelToDate } from "./utils/utils.base.js";
50
52
  // Base64 utilities (cross-platform)
@@ -130,6 +130,24 @@ declare class Cell {
130
130
  get comment(): Note | undefined;
131
131
  set comment(comment: Note | NoteConfig | undefined);
132
132
  get text(): string;
133
+ /**
134
+ * The cell's display text — the value formatted the way Excel would render
135
+ * it, applying the cell's `numFmt`. For a Date cell with `numFmt` `"mm-dd-yy"`,
136
+ * this returns e.g. `"04-12-19"` rather than the JS `Date.prototype.toString()`
137
+ * output you'd get from `cell.text`.
138
+ *
139
+ * Handles primitive values, dates, and formula results. For rich text,
140
+ * hyperlinks, errors, and other complex types, falls back to `cell.text`.
141
+ *
142
+ * Note: numFmt codes that are locale-dependent in Excel (e.g. built-in
143
+ * numFmtId 14 renders as `dd.mm.yyyy` under German locale but is stored
144
+ * as `mm-dd-yy`) are applied literally — excelts does not perform
145
+ * Excel's locale-based format substitution. If you need a specific date
146
+ * style across cells regardless of per-cell numFmts, call the exported
147
+ * {@link getCellDisplayText} helper with a `dateFormat` argument, or use
148
+ * `worksheet.toJSON({ dateFormat })`.
149
+ */
150
+ get displayText(): string;
133
151
  get html(): string;
134
152
  toString(): string;
135
153
  get formula(): string | undefined;
@@ -1,6 +1,7 @@
1
1
  import { Enums } from "./enums.js";
2
2
  import { ExcelError, InvalidValueTypeError } from "./errors.js";
3
3
  import { Note } from "./note.js";
4
+ import { getCellDisplayText } from "./utils/cell-format.js";
4
5
  import { colCache } from "./utils/col-cache.js";
5
6
  import { copyStyle } from "./utils/copy-style.js";
6
7
  import { slideFormula } from "./utils/shared-formula.js";
@@ -271,6 +272,26 @@ class Cell {
271
272
  get text() {
272
273
  return this._value.toString();
273
274
  }
275
+ /**
276
+ * The cell's display text — the value formatted the way Excel would render
277
+ * it, applying the cell's `numFmt`. For a Date cell with `numFmt` `"mm-dd-yy"`,
278
+ * this returns e.g. `"04-12-19"` rather than the JS `Date.prototype.toString()`
279
+ * output you'd get from `cell.text`.
280
+ *
281
+ * Handles primitive values, dates, and formula results. For rich text,
282
+ * hyperlinks, errors, and other complex types, falls back to `cell.text`.
283
+ *
284
+ * Note: numFmt codes that are locale-dependent in Excel (e.g. built-in
285
+ * numFmtId 14 renders as `dd.mm.yyyy` under German locale but is stored
286
+ * as `mm-dd-yy`) are applied literally — excelts does not perform
287
+ * Excel's locale-based format substitution. If you need a specific date
288
+ * style across cells regardless of per-cell numFmts, call the exported
289
+ * {@link getCellDisplayText} helper with a `dateFormat` argument, or use
290
+ * `worksheet.toJSON({ dateFormat })`.
291
+ */
292
+ get displayText() {
293
+ return getCellDisplayText(this);
294
+ }
274
295
  get html() {
275
296
  return escapeHtml(this.text);
276
297
  }
@@ -20,7 +20,7 @@ const TABLE_FMT = {
20
20
  11: "0.00E+00",
21
21
  12: "# ?/?",
22
22
  13: "# ??/??",
23
- 14: "m/d/yy",
23
+ 14: "mm-dd-yy",
24
24
  15: "d-mmm-yy",
25
25
  16: "d-mmm",
26
26
  17: "mmm-yy",
@@ -210,6 +210,56 @@ const MONTHS_LONG = [
210
210
  const MONTHS_LETTER = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
211
211
  const DAYS_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
212
212
  const DAYS_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
213
+ /**
214
+ * Disambiguate each `mm` occurrence in a format string that has already been
215
+ * placeholder-substituted for the other date/time tokens.
216
+ *
217
+ * Excel's rule: `mm` is minutes when it's adjacent to an hour or seconds
218
+ * token (with no intervening date tokens); otherwise it's a zero-padded
219
+ * month. This must be decided per occurrence — a single format string can
220
+ * contain both roles (e.g. `"yyyy-mm-dd hh:mm:ss"`).
221
+ *
222
+ * The caller has already replaced `yyyy`/`yy` → `Y4/Y2`, month-name tokens
223
+ * `mmmmm/mmmm/mmm` → `MN5/MN4/MN3`, `dd`/`d` → `D2/D1`, `hh`/`h` → `H2/H1`,
224
+ * `ss`/`s` → `S2/S1`. So any remaining literal `mm` substrings here are
225
+ * ambiguous between minute and month.
226
+ *
227
+ * Returns the input with each `mm` replaced by either `\x00MI2\x00` (minutes)
228
+ * or `\x00M2\x00` (month, zero-padded).
229
+ */
230
+ function resolveMonthOrMinute(s) {
231
+ // Tokens that, when present between an `mm` and a time anchor, break the
232
+ // "adjacent time context" chain and push the `mm` back into month-land.
233
+ const DATE_TOKEN = /\x00(?:Y[24]|D[12]|MN[345])\x00/;
234
+ const HOUR_TOKEN = /\x00H[12]\x00/g;
235
+ const SEC_TOKEN = /\x00S[12]\x00/g;
236
+ let out = "";
237
+ let work = s;
238
+ let idx = work.search(/mm/i);
239
+ while (idx !== -1) {
240
+ const before = work.slice(0, idx);
241
+ const after = work.slice(idx + 2);
242
+ // Find the *nearest* hour token preceding this `mm` (scan from the right).
243
+ let nearestHourIdx = -1;
244
+ let m;
245
+ HOUR_TOKEN.lastIndex = 0;
246
+ while ((m = HOUR_TOKEN.exec(before)) !== null) {
247
+ nearestHourIdx = m.index;
248
+ }
249
+ // Find the *nearest* seconds token following this `mm`.
250
+ SEC_TOKEN.lastIndex = 0;
251
+ const secMatch = SEC_TOKEN.exec(after);
252
+ const nearestSecIdx = secMatch ? secMatch.index : -1;
253
+ const hourInRange = nearestHourIdx !== -1 && !DATE_TOKEN.test(before.slice(nearestHourIdx));
254
+ const secInRange = nearestSecIdx !== -1 && !DATE_TOKEN.test(after.slice(0, nearestSecIdx));
255
+ const isMinutes = hourInRange || secInRange;
256
+ out += before + (isMinutes ? "\x00MI2\x00" : "\x00M2\x00");
257
+ work = after;
258
+ idx = work.search(/mm/i);
259
+ }
260
+ out += work;
261
+ return out;
262
+ }
213
263
  /**
214
264
  * Format a date value using Excel date format
215
265
  * @param serial Excel serial number (days since 1900-01-01)
@@ -270,16 +320,14 @@ function formatDate(serial, fmt) {
270
320
  // Seconds (before mm to avoid confusion)
271
321
  result = result.replace(/ss/gi, "\x00S2\x00");
272
322
  result = result.replace(/\bs\b/gi, "\x00S1\x00");
273
- // Minutes/Month mm - context dependent
274
- // If near h or s, it's minutes; otherwise month
275
- // For simplicity, check if we already have hour tokens nearby
276
- const hasTimeContext = /\x00H[12]\x00.*mm|mm.*\x00S[12]\x00/i.test(result);
277
- if (hasTimeContext) {
278
- result = result.replace(/mm/gi, "\x00MI2\x00");
279
- }
280
- else {
281
- result = result.replace(/mm/gi, "\x00M2\x00");
282
- }
323
+ // Minutes/Month `mm` — position-dependent. Excel treats `mm` as minutes
324
+ // when the nearest neighboring time-token is an hour (before) or a
325
+ // seconds token (after); otherwise it's month. This must be decided **per
326
+ // occurrence**, because a single format string can contain both roles —
327
+ // e.g. in `"yyyy-mm-dd hh:mm:ss"` the first `mm` is month and the second
328
+ // is minutes. A single global `hasTimeContext` flag would miscategorise
329
+ // all `mm` as minutes in such mixed formats.
330
+ result = resolveMonthOrMinute(result);
283
331
  result = result.replace(/\bm\b/gi, "\x00M1\x00");
284
332
  // AM/PM
285
333
  result = result.replace(/AM\/PM/gi, "\x00AMPM\x00");
@@ -887,6 +935,16 @@ export function isDateDisplayFormat(fmt) {
887
935
  }
888
936
  return false;
889
937
  }
938
+ /**
939
+ * Default format applied to Date values whose numFmt is `General` or empty.
940
+ *
941
+ * Excel itself substitutes a locale-dependent short date in this case (US:
942
+ * `m/d/yyyy`). We pick an ISO-like `yyyy-mm-dd` so consumers who never set a
943
+ * `numFmt` still get a sensible, unambiguous rendering instead of the raw
944
+ * Excel serial number.
945
+ */
946
+ const DEFAULT_DATE_FORMAT = "yyyy-mm-dd";
947
+ const DEFAULT_DATETIME_FORMAT = "yyyy-mm-dd hh:mm:ss";
890
948
  /**
891
949
  * Format a value according to the given format string.
892
950
  * Handles Date objects with timezone-independent Excel serial conversion.
@@ -901,8 +959,22 @@ export function formatCellValue(value, fmt, dateFormat) {
901
959
  }
902
960
  return format(fmt, serial);
903
961
  }
904
- const actualFmt = dateFormat && isDateDisplayFormat(fmt) ? dateFormat : fmt;
905
- return format(actualFmt, serial);
962
+ // For Date values whose numFmt is missing or General, Excel substitutes a
963
+ // default short-date format. Without this, `format("General", serial)`
964
+ // would emit the raw Excel serial (e.g. "43567") — almost never what the
965
+ // caller wants. Pick a datetime-aware default based on whether the value
966
+ // carries a non-midnight time component.
967
+ let effectiveFmt;
968
+ if (dateFormat && isDateDisplayFormat(fmt)) {
969
+ effectiveFmt = dateFormat;
970
+ }
971
+ else if (!fmt || isGeneral(fmt)) {
972
+ effectiveFmt = serial % 1 === 0 ? DEFAULT_DATE_FORMAT : DEFAULT_DATETIME_FORMAT;
973
+ }
974
+ else {
975
+ effectiveFmt = fmt;
976
+ }
977
+ return format(effectiveFmt, serial);
906
978
  }
907
979
  return format(fmt, value);
908
980
  }
@@ -603,6 +603,63 @@ declare class Workbook {
603
603
  * ```
604
604
  */
605
605
  calculateFormulas(): void;
606
+ /**
607
+ * Per-workbook registry of user-defined functions. The formula engine
608
+ * consults this map before the built-in 433-function registry, so a
609
+ * registered name either adds a new function (`MYFN`) or shadows a
610
+ * built-in (`IRR` → project-specific variant).
611
+ *
612
+ * Populated by {@link registerFunction}; read by the formula engine
613
+ * when the host calls `calculateFormulas()` — see
614
+ * `@formula/runtime/evaluator.ts::evaluateCall`.
615
+ */
616
+ userFunctions?: Map<string, {
617
+ minArity: number;
618
+ maxArity: number;
619
+ invoke: (args: unknown[]) => unknown;
620
+ volatile?: boolean;
621
+ }>;
622
+ /**
623
+ * Register (or replace) a custom formula function on this workbook.
624
+ *
625
+ * The function becomes visible to `calculateFormulas()` on this
626
+ * workbook only — the built-in registry stays untouched. Names are
627
+ * case-insensitive (normalised to uppercase) and must not include
628
+ * the `_XLFN.` prefix — the engine strips that automatically.
629
+ *
630
+ * @param name Function name (case-insensitive).
631
+ * @param fn Implementation. Receives already-evaluated RuntimeValue
632
+ * arguments; return a RuntimeValue. Wrap failures with
633
+ * `rvError("#VALUE!")` rather than throwing — throws are
634
+ * caught at the evaluator boundary and surface as
635
+ * `#VALUE!` so a buggy custom function doesn't tear
636
+ * down the whole calculation pass.
637
+ * @param options Optional arity bounds. Defaults to `minArity=0`,
638
+ * `maxArity=255` (Excel's universal argument cap), so
639
+ * simple variadic functions work without extra config.
640
+ * Set `volatile: true` when the function should be
641
+ * re-evaluated on every calc cycle (analogous to
642
+ * built-in `RAND`, `NOW`). Currently reserved for
643
+ * future use; the engine recomputes every formula on
644
+ * each `calculateFormulas()` call regardless.
645
+ *
646
+ * ```ts
647
+ * import { rvNumber } from "@cj-tech-master/excelts/formula";
648
+ * workbook.registerFunction("DOUBLE", ([x]) => {
649
+ * return rvNumber((x as any).value * 2);
650
+ * }, { minArity: 1, maxArity: 1 });
651
+ * ```
652
+ */
653
+ registerFunction(name: string, fn: (args: unknown[]) => unknown, options?: {
654
+ minArity?: number;
655
+ maxArity?: number;
656
+ volatile?: boolean;
657
+ }): void;
658
+ /**
659
+ * Remove a user-registered function. No-op when the name isn't
660
+ * registered; returns `true` when an entry was removed.
661
+ */
662
+ unregisterFunction(name: string): boolean;
606
663
  clearThemes(): void;
607
664
  /**
608
665
  * Add Image to Workbook and return the id
@@ -1060,6 +1060,55 @@ class Workbook {
1060
1060
  calculateFormulas() {
1061
1061
  invokeFormulaEngine(this);
1062
1062
  }
1063
+ /**
1064
+ * Register (or replace) a custom formula function on this workbook.
1065
+ *
1066
+ * The function becomes visible to `calculateFormulas()` on this
1067
+ * workbook only — the built-in registry stays untouched. Names are
1068
+ * case-insensitive (normalised to uppercase) and must not include
1069
+ * the `_XLFN.` prefix — the engine strips that automatically.
1070
+ *
1071
+ * @param name Function name (case-insensitive).
1072
+ * @param fn Implementation. Receives already-evaluated RuntimeValue
1073
+ * arguments; return a RuntimeValue. Wrap failures with
1074
+ * `rvError("#VALUE!")` rather than throwing — throws are
1075
+ * caught at the evaluator boundary and surface as
1076
+ * `#VALUE!` so a buggy custom function doesn't tear
1077
+ * down the whole calculation pass.
1078
+ * @param options Optional arity bounds. Defaults to `minArity=0`,
1079
+ * `maxArity=255` (Excel's universal argument cap), so
1080
+ * simple variadic functions work without extra config.
1081
+ * Set `volatile: true` when the function should be
1082
+ * re-evaluated on every calc cycle (analogous to
1083
+ * built-in `RAND`, `NOW`). Currently reserved for
1084
+ * future use; the engine recomputes every formula on
1085
+ * each `calculateFormulas()` call regardless.
1086
+ *
1087
+ * ```ts
1088
+ * import { rvNumber } from "@cj-tech-master/excelts/formula";
1089
+ * workbook.registerFunction("DOUBLE", ([x]) => {
1090
+ * return rvNumber((x as any).value * 2);
1091
+ * }, { minArity: 1, maxArity: 1 });
1092
+ * ```
1093
+ */
1094
+ registerFunction(name, fn, options) {
1095
+ if (!this.userFunctions) {
1096
+ this.userFunctions = new Map();
1097
+ }
1098
+ this.userFunctions.set(name.toUpperCase(), {
1099
+ minArity: options?.minArity ?? 0,
1100
+ maxArity: options?.maxArity ?? 255,
1101
+ invoke: fn,
1102
+ volatile: options?.volatile ?? false
1103
+ });
1104
+ }
1105
+ /**
1106
+ * Remove a user-registered function. No-op when the name isn't
1107
+ * registered; returns `true` when an entry was removed.
1108
+ */
1109
+ unregisterFunction(name) {
1110
+ return this.userFunctions?.delete(name.toUpperCase()) ?? false;
1111
+ }
1063
1112
  // ===========================================================================
1064
1113
  // Themes
1065
1114
  // ===========================================================================
@@ -17,7 +17,7 @@ const defaultNumFormats = {
17
17
  19: { f: "h:mm:ss AM/PM" },
18
18
  20: { f: "h:mm" },
19
19
  21: { f: "h:mm:ss" },
20
- 22: { f: 'm/d/yy "h":mm' },
20
+ 22: { f: "m/d/yy h:mm" },
21
21
  27: {
22
22
  "zh-tw": "[$-404]e/m/d",
23
23
  "zh-cn": 'yyyy"年"m"月"',
@@ -75,8 +75,8 @@ const defaultNumFormats = {
75
75
  },
76
76
  37: { f: "#,##0 ;(#,##0)" },
77
77
  38: { f: "#,##0 ;[Red](#,##0)" },
78
- 39: { f: "#,##0.00 ;(#,##0.00)" },
79
- 40: { f: "#,##0.00 ;[Red](#,##0.00)" },
78
+ 39: { f: "#,##0.00;(#,##0.00)" },
79
+ 40: { f: "#,##0.00;[Red](#,##0.00)" },
80
80
  45: { f: "mm:ss" },
81
81
  46: { f: "[h]:mm:ss" },
82
82
  47: { f: "mmss.0" },
@@ -105,6 +105,8 @@ export function bind(node, ctx) {
105
105
  return bindName(node.name, ctx);
106
106
  case NodeType.StructuredRef:
107
107
  return bindStructuredRef(node.tableName, node.columns, node.specials, ctx);
108
+ case NodeType.UnionRef:
109
+ return bindUnionRef(node, ctx);
108
110
  default:
109
111
  return assertNever(node);
110
112
  }
@@ -160,6 +162,11 @@ function bindRangeRef(node, ctx) {
160
162
  const bottom = Math.max(startRow, endRow);
161
163
  const left = Math.min(startCol, endCol);
162
164
  const right = Math.max(startCol, endCol);
165
+ // Bounds-check the rectangle against Excel's sheet limits. Defined-name
166
+ // strings that bypass the tokenizer can carry arbitrary addresses.
167
+ if (top < 1 || bottom > 1048576 || left < 1 || right > 16384) {
168
+ return boundErrorLiteral("#REF!");
169
+ }
163
170
  // 3D range reference: Sheet1:Sheet3!A1:B2
164
171
  if (node.endSheet) {
165
172
  const sheets = getSheetsInRange(ctx.snapshot, sheet, node.endSheet);
@@ -173,6 +180,13 @@ function bindRangeRef(node, ctx) {
173
180
  inner
174
181
  };
175
182
  }
183
+ // Validate sheet exists — matches the parity check `bindCellRef` and
184
+ // `bindColRangeRef` / `bindRowRangeRef` perform. Without this, a range
185
+ // like `NoSuchSheet!A1:B2` would silently bind to an empty-read at
186
+ // runtime instead of surfacing as `#REF!` at compile time.
187
+ if (!ctx.snapshot.worksheetsByName.has(sheet.toLowerCase())) {
188
+ return boundErrorLiteral("#REF!");
189
+ }
176
190
  return boundAreaRef(sheet, top, left, bottom, right);
177
191
  }
178
192
  function bindColRangeRef(node, ctx) {
@@ -181,6 +195,12 @@ function bindColRangeRef(node, ctx) {
181
195
  const endCol = colLetterToNumber(node.endCol);
182
196
  const leftCol = Math.min(startCol, endCol);
183
197
  const rightCol = Math.max(startCol, endCol);
198
+ // Excel's maximum column is 16384 (XFD). The tokenizer enforces this
199
+ // for plain refs, but defined-name range strings that bypass the
200
+ // tokenizer could carry larger letter sequences (e.g. `ZZZ`).
201
+ if (leftCol < 1 || rightCol > 16384) {
202
+ return boundErrorLiteral("#REF!");
203
+ }
184
204
  // Validate sheet exists
185
205
  if (!ctx.snapshot.worksheetsByName.has(sheet.toLowerCase())) {
186
206
  return boundErrorLiteral("#REF!");
@@ -210,6 +230,11 @@ function bindRowRangeRef(node, ctx) {
210
230
  const sheet = node.sheet ?? ctx.currentSheet;
211
231
  const topRow = Math.min(node.startRow, node.endRow);
212
232
  const bottomRow = Math.max(node.startRow, node.endRow);
233
+ // Excel's maximum row is 1048576. Re-check here because defined-name
234
+ // strings can bypass the tokenizer.
235
+ if (topRow < 1 || bottomRow > 1048576) {
236
+ return boundErrorLiteral("#REF!");
237
+ }
213
238
  // Validate sheet exists
214
239
  if (!ctx.snapshot.worksheetsByName.has(sheet.toLowerCase())) {
215
240
  return boundErrorLiteral("#REF!");
@@ -235,6 +260,24 @@ function bindRowRangeRef(node, ctx) {
235
260
  };
236
261
  }
237
262
  // ============================================================================
263
+ // Union Reference Binding — `(A1:B2, D4:E5)`
264
+ // ============================================================================
265
+ function bindUnionRef(node, ctx) {
266
+ // Each area must bind to a reference-producing expression. If any
267
+ // member is a non-reference literal, the whole union collapses to
268
+ // `#REF!` — Excel rejects things like `(1, A1)` outright. We defer
269
+ // the runtime-reference check (INDIRECT/OFFSET) to the evaluator.
270
+ const bounds = [];
271
+ for (const area of node.areas) {
272
+ const bound = bind(area, ctx);
273
+ bounds.push(bound);
274
+ }
275
+ return {
276
+ kind: BoundExprKind.UnionRef,
277
+ areas: bounds
278
+ };
279
+ }
280
+ // ============================================================================
238
281
  // Name Binding
239
282
  // ============================================================================
240
283
  function bindName(name, ctx) {
@@ -324,12 +367,11 @@ function findTable(snapshot, tableName) {
324
367
  if (!tableName) {
325
368
  return null;
326
369
  }
327
- // Use the pre-built tablesByName index for O(1) lookup
328
- const resolved = snapshot.tablesByName.get(tableName.toLowerCase());
329
- if (resolved) {
330
- return { table: resolved.table, sheetName: resolved.sheetName };
331
- }
332
- return null;
370
+ // Use the pre-built tablesByName index for O(1) lookup. The snapshot's
371
+ // `ResolvedTable` already matches our `TableWithSheet` shape (same
372
+ // `{ table, sheetName }` pair), so we can return it directly instead
373
+ // of wrapping every hit in a fresh object.
374
+ return snapshot.tablesByName.get(tableName.toLowerCase()) ?? null;
333
375
  }
334
376
  function resolveStructuredRefBounds(tw, columns, specials) {
335
377
  const t = tw.table;
@@ -46,7 +46,8 @@ export declare const enum BoundExprKind {
46
46
  Array = 12,
47
47
  NameExpr = 13,
48
48
  Lambda = 14,
49
- StructuredRef = 15
49
+ StructuredRef = 15,
50
+ UnionRef = 16
50
51
  }
51
52
  /**
52
53
  * A resolved literal value.
@@ -220,7 +221,20 @@ export interface BoundStructuredRef {
220
221
  /** Special items (#Headers, #Data, #Totals, #All, #This Row). */
221
222
  readonly specials: readonly string[];
222
223
  }
223
- export type BoundExpr = BoundLiteral | BoundCellRef | BoundAreaRef | BoundColRangeRef | BoundRowRangeRef | BoundRef3D | BoundBinaryOp | BoundUnaryOp | BoundPercent | BoundCall | BoundSpecialCall | BoundArray | BoundNameExpr | BoundLambda | BoundStructuredRef;
224
+ /**
225
+ * A union of reference-producing sub-expressions — `(A1:B2, D4:E5)`.
226
+ *
227
+ * Produced only by parenthesised comma lists, and only used by callers
228
+ * that explicitly know how to consume a multi-area reference (INDEX's
229
+ * `area_num`, AREAS, union-operator arithmetic). Evaluating a
230
+ * UnionRef in any other context surfaces as `#VALUE!` since Excel
231
+ * forbids arithmetic / coercion on disjoint areas.
232
+ */
233
+ export interface BoundUnionRef {
234
+ readonly kind: BoundExprKind.UnionRef;
235
+ readonly areas: readonly BoundExpr[];
236
+ }
237
+ export type BoundExpr = BoundLiteral | BoundCellRef | BoundAreaRef | BoundColRangeRef | BoundRowRangeRef | BoundRef3D | BoundBinaryOp | BoundUnaryOp | BoundPercent | BoundCall | BoundSpecialCall | BoundArray | BoundNameExpr | BoundLambda | BoundStructuredRef | BoundUnionRef;
224
238
  export declare function boundLiteral(value: number | string | boolean | null, errorCode?: string): BoundLiteral;
225
239
  export declare function boundCellRef(sheet: string, row: number, col: number): BoundCellRef;
226
240
  export declare function boundAreaRef(sheet: string, top: number, left: number, bottom: number, right: number): BoundAreaRef;
@@ -51,6 +51,7 @@ export var BoundExprKind;
51
51
  BoundExprKind[BoundExprKind["NameExpr"] = 13] = "NameExpr";
52
52
  BoundExprKind[BoundExprKind["Lambda"] = 14] = "Lambda";
53
53
  BoundExprKind[BoundExprKind["StructuredRef"] = 15] = "StructuredRef";
54
+ BoundExprKind[BoundExprKind["UnionRef"] = 16] = "UnionRef";
54
55
  })(BoundExprKind || (BoundExprKind = {}));
55
56
  // ============================================================================
56
57
  // Constructor Helpers
@@ -209,6 +209,13 @@ function walkDeps(expr, cells, areas, tablesByName, nameResolver, visitedNames)
209
209
  }
210
210
  }
211
211
  break;
212
+ case BoundExprKind.UnionRef:
213
+ // Each member of a `(a1, a2, ...)` union contributes its own
214
+ // dependencies — downstream reads target cells in every area.
215
+ for (const area of expr.areas) {
216
+ walkDeps(area, cells, areas, tablesByName, nameResolver, visitedNames);
217
+ }
218
+ break;
212
219
  }
213
220
  }
214
221
  // ============================================================================
@@ -275,9 +282,15 @@ export function detectDynamicArrayFunction(ast, bound) {
275
282
  return true;
276
283
  }
277
284
  }
278
- // Check bound expression level
279
- if (bound.kind === BoundExprKind.Call && DYNAMIC_ARRAY_FUNCTION_NAMES.has(bound.name)) {
280
- return true;
285
+ // Check bound expression level. Strip `_XLFN.` prefix here too —
286
+ // `boundCall` preserves the prefix on the bound name, so without the
287
+ // strip a synthesised bound call (e.g. from INDIRECT re-parse) would
288
+ // miss detection.
289
+ if (bound.kind === BoundExprKind.Call) {
290
+ const canonical = stripFunctionPrefix(bound.name);
291
+ if (DYNAMIC_ARRAY_FUNCTION_NAMES.has(canonical)) {
292
+ return true;
293
+ }
281
294
  }
282
295
  return false;
283
296
  }
@@ -298,9 +311,15 @@ export function detectSubtotalOutput(ast, bound) {
298
311
  return true;
299
312
  }
300
313
  }
301
- if (bound.kind === BoundExprKind.Call &&
302
- (bound.name === "SUBTOTAL" || bound.name === "AGGREGATE")) {
303
- return true;
314
+ if (bound.kind === BoundExprKind.Call) {
315
+ // Strip `_XLFN.` / `_XLFN._XLWS.` prefixes before matching — otherwise
316
+ // `_XLFN.AGGREGATE(...)` silently wouldn't be marked as a subtotal
317
+ // output, so an outer SUBTOTAL / AGGREGATE over its cell would
318
+ // double-count the aggregated value.
319
+ const canonical = stripFunctionPrefix(bound.name);
320
+ if (canonical === "SUBTOTAL" || canonical === "AGGREGATE") {
321
+ return true;
322
+ }
304
323
  }
305
324
  return false;
306
325
  }
@@ -319,15 +338,24 @@ export function analyzeExpr(expr, nameResolver) {
319
338
  return { isVolatile, hasDynamicRefs, containsLambda };
320
339
  function walkAnalyze(e) {
321
340
  switch (e.kind) {
322
- case BoundExprKind.Call:
323
- if (VOLATILE_FUNCTIONS.has(e.name)) {
341
+ case BoundExprKind.Call: {
342
+ // `boundCall` stores the function name uppercased but preserves
343
+ // any `_XLFN.` / `_XLFN._XLWS.` prefix the source text contained.
344
+ // Strip the prefix before VOLATILE_FUNCTIONS lookup so e.g.
345
+ // `_XLFN.RANDARRAY()` (an XLFN-prefixed volatile) correctly
346
+ // invalidates the session cache across calc cycles.
347
+ const canonical = stripFunctionPrefix(e.name);
348
+ if (VOLATILE_FUNCTIONS.has(canonical)) {
324
349
  isVolatile = true;
325
350
  }
326
351
  for (const arg of e.args) {
327
352
  walkAnalyze(arg);
328
353
  }
329
354
  break;
355
+ }
330
356
  case BoundExprKind.SpecialCall:
357
+ // Special-call names are already stripped of any `_XLFN.` prefix
358
+ // by `canonicalSpecialForm` in the binder, so no re-strip here.
331
359
  if (DYNAMIC_REF_FUNCTIONS.has(e.name)) {
332
360
  hasDynamicRefs = true;
333
361
  }
@@ -375,6 +403,11 @@ export function analyzeExpr(expr, nameResolver) {
375
403
  }
376
404
  }
377
405
  break;
406
+ case BoundExprKind.UnionRef:
407
+ for (const area of e.areas) {
408
+ walkAnalyze(area);
409
+ }
410
+ break;
378
411
  default:
379
412
  // Literal, CellRef, AreaRef, etc. — no children to analyze
380
413
  break;
@@ -33,6 +33,25 @@ export declare function argToNumber(arg: RuntimeValue): NumberValue | ErrorValue
33
33
  * `number[]` after an error check should map `.value` themselves.
34
34
  */
35
35
  export declare function flattenNumbers(args: RuntimeValue[]): (NumberValue | ErrorValue)[];
36
+ /**
37
+ * Streaming fold over numeric arguments.
38
+ *
39
+ * Same selection rules as `flattenNumbers` (array cells contribute only
40
+ * Number/Error; direct scalar coercion via `toNumberRV`; blanks dropped),
41
+ * but the caller's `onNumber` callback fires inline — no intermediate
42
+ * array is allocated. On the first error encountered the scan short-
43
+ * circuits and returns that error.
44
+ *
45
+ * Returns:
46
+ * - `null` when iteration finished without encountering an error, or
47
+ * - the `ErrorValue` that aborted the scan.
48
+ *
49
+ * Prefer this over `flattenNumbers` + `firstError` + manual loop in hot
50
+ * aggregates (SUM / AVERAGE / MIN / MAX / …). The allocation saved is
51
+ * one `NumberValue | ErrorValue` array per invocation — meaningful
52
+ * when the engine sums tens of thousands of cells.
53
+ */
54
+ export declare function forEachNumber(args: readonly RuntimeValue[], onNumber: (n: number) => void): ErrorValue | null;
36
55
  /**
37
56
  * Flatten all cells from the arguments into a flat list of ScalarValue,
38
57
  * preserving every cell (including blanks, errors, booleans, strings).