@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
@@ -40,8 +40,17 @@ function registerFunction(desc) {
40
40
  * `_XLFN._XLWS.` prefixed variants by stripping the prefix before lookup
41
41
  * (a no-op for plain names, so plain lookups also go through a single
42
42
  * Map.get call — avoiding the double-lookup pattern used previously).
43
+ *
44
+ * Fast-path: the overwhelming majority of lookups use plain names
45
+ * (`SUM`, `IF`, `VLOOKUP`, …). Checking the prefix sentinel byte up
46
+ * front lets those callers skip the `slice`/`startsWith` machinery in
47
+ * `stripFunctionPrefix` entirely.
43
48
  */
44
49
  function lookupFunction(name) {
50
+ // Plain names don't start with `_` — skip the prefix-strip call.
51
+ if (name.length === 0 || name.charCodeAt(0) !== 95 /* `_` */) {
52
+ return registryMap.get(name);
53
+ }
45
54
  return registryMap.get((0, token_types_1.stripFunctionPrefix)(name));
46
55
  }
47
56
  /**
@@ -111,20 +120,26 @@ function registerNativeInformationAndLogical() {
111
120
  if (v.kind === 4 /* RVKind.Error */) {
112
121
  return v;
113
122
  }
114
- if (v.kind !== 1 /* RVKind.Number */) {
123
+ // Excel coerces numeric strings and booleans for ISEVEN / ISODD
124
+ // (e.g. `ISEVEN("3")` → FALSE, `ISODD(TRUE)` → TRUE). Only genuine
125
+ // non-numeric text falls through to #VALUE!. Previously we rejected
126
+ // every non-Number kind outright.
127
+ const n = (0, values_1.toNumberRV)(v);
128
+ if (n.kind === 4 /* RVKind.Error */) {
115
129
  return values_1.ERRORS.VALUE;
116
130
  }
117
- return (0, values_1.rvBoolean)(Math.floor(Math.abs(v.value)) % 2 === 0);
131
+ return (0, values_1.rvBoolean)(Math.floor(Math.abs(n.value)) % 2 === 0);
118
132
  });
119
133
  defineEager("ISODD", 1, 1, args => {
120
134
  const v = scalar(args);
121
135
  if (v.kind === 4 /* RVKind.Error */) {
122
136
  return v;
123
137
  }
124
- if (v.kind !== 1 /* RVKind.Number */) {
138
+ const n = (0, values_1.toNumberRV)(v);
139
+ if (n.kind === 4 /* RVKind.Error */) {
125
140
  return values_1.ERRORS.VALUE;
126
141
  }
127
- return (0, values_1.rvBoolean)(Math.floor(Math.abs(v.value)) % 2 === 1);
142
+ return (0, values_1.rvBoolean)(Math.floor(Math.abs(n.value)) % 2 === 1);
128
143
  });
129
144
  defineEager("N", 1, 1, args => {
130
145
  const v = scalar(args);
@@ -175,6 +190,11 @@ function registerNativeInformationAndLogical() {
175
190
  return map[v.code] !== undefined ? (0, values_1.rvNumber)(map[v.code]) : values_1.ERRORS.NA;
176
191
  });
177
192
  defineEager("NA", 0, 0, () => values_1.ERRORS.NA);
193
+ // TRUE() and FALSE() — Excel accepts both the literal and the
194
+ // zero-arg function form. The tokenizer routes `TRUE(` to a Function
195
+ // token, so we need to register these so the call binds.
196
+ defineEager("TRUE", 0, 0, () => (0, values_1.rvBoolean)(true));
197
+ defineEager("FALSE", 0, 0, () => (0, values_1.rvBoolean)(false));
178
198
  // ── Stubs — limited implementations for functions that need runtime context ──
179
199
  // INFO returns a handful of environment-describing strings. We implement
180
200
  // the subset that's meaningful in a headless engine: `"release"` (engine
@@ -186,7 +206,10 @@ function registerNativeInformationAndLogical() {
186
206
  if (args.length === 0) {
187
207
  return values_1.ERRORS.NA;
188
208
  }
189
- const t = args[0];
209
+ // Implicit intersection — without topLeft, passing an array would
210
+ // route through the default `.value = ""` branch and silently
211
+ // surface #VALUE! instead of using the first cell.
212
+ const t = (0, values_1.topLeft)(args[0]);
190
213
  if (t.kind === 4 /* RVKind.Error */) {
191
214
  return t;
192
215
  }
@@ -245,7 +268,10 @@ function registerNativeInformationAndLogical() {
245
268
  if (url.kind === 4 /* RVKind.Error */) {
246
269
  return url;
247
270
  }
248
- return (0, values_1.rvString)(url.kind === 2 /* RVKind.String */ ? url.value : String(url));
271
+ // Previously `String(url)` stringified a RuntimeValue object to
272
+ // the literal `"[object Object]"`. Route through `toStringRV`
273
+ // so numbers / booleans / blanks produce the expected text.
274
+ return (0, values_1.rvString)((0, values_1.toStringRV)(url));
249
275
  }
250
276
  if (display.kind === 2 /* RVKind.String */) {
251
277
  return display;
@@ -270,6 +296,21 @@ function registerNativeInformationAndLogical() {
270
296
  if (v.kind === 1 /* RVKind.Number */) {
271
297
  return (0, values_1.rvBoolean)(v.value === 0);
272
298
  }
299
+ // Excel accepts "TRUE" / "FALSE" strings (case-insensitive) and
300
+ // Blank cells (treated as FALSE). Previously any non-boolean,
301
+ // non-numeric kind fell through to #VALUE!.
302
+ if (v.kind === 0 /* RVKind.Blank */) {
303
+ return (0, values_1.rvBoolean)(true);
304
+ }
305
+ if (v.kind === 2 /* RVKind.String */) {
306
+ const upper = v.value.toUpperCase();
307
+ if (upper === "TRUE") {
308
+ return (0, values_1.rvBoolean)(false);
309
+ }
310
+ if (upper === "FALSE") {
311
+ return (0, values_1.rvBoolean)(true);
312
+ }
313
+ }
273
314
  return values_1.ERRORS.VALUE;
274
315
  });
275
316
  defineEager("AND", 1, 255, args => boolAggregate(args, true, (cur, val) => cur && val));
@@ -385,6 +426,11 @@ function registerNativeTextFunctions() {
385
426
  defineEager("PROPER", 1, 1, text_1.fnPROPER);
386
427
  defineEager("SUBSTITUTE", 3, 4, text_1.fnSUBSTITUTE);
387
428
  defineEager("REPLACE", 4, 4, text_1.fnREPLACE);
429
+ // REPLACEB is REPLACE's double-byte alias. In non-DBCS locales Excel
430
+ // treats them identically, so aliasing to the same implementation
431
+ // matches behaviour without duplicating logic (matches the existing
432
+ // LEFTB / RIGHTB / MIDB / LENB / FINDB / SEARCHB wiring below).
433
+ defineEager("REPLACEB", 4, 4, text_1.fnREPLACE);
388
434
  defineEager("FIND", 2, 3, text_1.fnFIND);
389
435
  defineEager("FINDB", 2, 3, text_1.fnFIND);
390
436
  defineEager("SEARCH", 2, 3, text_1.fnSEARCH);
@@ -698,12 +744,12 @@ function registerNativeMathFunctions() {
698
744
  defineEager("SERIESSUM", 4, 4, math_1.fnSERIESSUM);
699
745
  defineEager("ABS", 1, 1, math_1.fnABS);
700
746
  defineEager("CEILING", 2, 2, math_1.fnCEILING);
701
- defineEager("CEILING.MATH", 1, 3, math_1.fnCEILING);
702
- defineEager("CEILING.PRECISE", 1, 2, math_1.fnCEILING);
703
- defineEager("ISO.CEILING", 1, 2, math_1.fnCEILING);
747
+ defineEager("CEILING.MATH", 1, 3, math_1.fnCEILING_MATH);
748
+ defineEager("CEILING.PRECISE", 1, 2, math_1.fnCEILING_PRECISE);
749
+ defineEager("ISO.CEILING", 1, 2, math_1.fnCEILING_PRECISE);
704
750
  defineEager("FLOOR", 2, 2, math_1.fnFLOOR);
705
- defineEager("FLOOR.MATH", 1, 3, math_1.fnFLOOR);
706
- defineEager("FLOOR.PRECISE", 1, 2, math_1.fnFLOOR);
751
+ defineEager("FLOOR.MATH", 1, 3, math_1.fnFLOOR_MATH);
752
+ defineEager("FLOOR.PRECISE", 1, 2, math_1.fnFLOOR_PRECISE);
707
753
  defineEager("INT", 1, 1, math_1.fnINT);
708
754
  defineEager("MOD", 2, 2, math_1.fnMOD);
709
755
  defineEager("POWER", 2, 2, math_1.fnPOWER);
@@ -24,6 +24,7 @@ exports.rvString = rvString;
24
24
  exports.rvBoolean = rvBoolean;
25
25
  exports.rvError = rvError;
26
26
  exports.rvArray = rvArray;
27
+ exports.rvArrayRect = rvArrayRect;
27
28
  exports.rvRef = rvRef;
28
29
  exports.rvCellRef = rvCellRef;
29
30
  exports.rvLambda = rvLambda;
@@ -112,10 +113,28 @@ function rvArray(rows, originRow, originCol, subtotalMask, hiddenRowMask) {
112
113
  }
113
114
  }
114
115
  }
116
+ return buildArrayValue(normalisedRows, height, width, originRow, originCol, subtotalMask, hiddenRowMask);
117
+ }
118
+ /**
119
+ * Fast-path rectangular ArrayValue constructor.
120
+ *
121
+ * Callers that have already produced strictly-rectangular `rows` (every
122
+ * row is the same length — the length they explicitly `new Array(width)`
123
+ * allocated) can skip the two-pass width-scan + padding loop in
124
+ * `rvArray`. Examples: `buildRangeArray`, `broadcastBinaryOp`,
125
+ * `evaluateArrayLiteral`, `TRANSPOSE` — they all know `width` up front.
126
+ *
127
+ * Rows MUST be rectangular; passing ragged data will silently surface as
128
+ * `undefined` cells downstream.
129
+ */
130
+ function rvArrayRect(rows, height, width, originRow, originCol, subtotalMask, hiddenRowMask) {
131
+ return buildArrayValue(rows, height, width, originRow, originCol, subtotalMask, hiddenRowMask);
132
+ }
133
+ function buildArrayValue(rows, height, width, originRow, originCol, subtotalMask, hiddenRowMask) {
115
134
  return originRow !== undefined
116
135
  ? {
117
136
  kind: 5 /* RVKind.Array */,
118
- rows: normalisedRows,
137
+ rows,
119
138
  height,
120
139
  width,
121
140
  originRow,
@@ -125,7 +144,7 @@ function rvArray(rows, originRow, originCol, subtotalMask, hiddenRowMask) {
125
144
  }
126
145
  : {
127
146
  kind: 5 /* RVKind.Array */,
128
- rows: normalisedRows,
147
+ rows,
129
148
  height,
130
149
  width,
131
150
  ...(subtotalMask ? { subtotalMask } : {}),
@@ -23,9 +23,15 @@ function prefixBindingPower(op) {
23
23
  switch (op) {
24
24
  case "+":
25
25
  case "-":
26
- // Must be lower than ^ (60/61) so that -2^3 parses as -(2^3), not (-2)^3.
27
- // Excel: -2^2 = -4, not 4.
28
- return 55;
26
+ // Excel's unary `-` binds TIGHTER than `^` unique among
27
+ // spreadsheets and most programming languages. Microsoft's
28
+ // published precedence table places "Negation (as in –1)" at
29
+ // rank 1 (highest) and "Exponentiation" at rank 4.
30
+ // =-2^2 → (-2)^2 → 4 (NOT -(2^2) = -4)
31
+ // =-2^3 → (-2)^3 → -8
32
+ // Previously we used 55 which routed via `^` (60/61) and produced
33
+ // `-(2^3)` — matching most languages but not Excel.
34
+ return 70;
29
35
  default:
30
36
  return 0;
31
37
  }
@@ -50,7 +56,11 @@ function infixBindingPower(op) {
50
56
  case "/":
51
57
  return [40, 41];
52
58
  case "^":
53
- return [61, 60]; // right-associative
59
+ // Excel is unusual: `^` is LEFT-associative (not right-associative
60
+ // like the math convention). `=2^3^2` evaluates to `(2^3)^2 = 64`,
61
+ // not `2^(3^2) = 512`. Using right-associative precedence silently
62
+ // diverged from Excel for any stacked exponent.
63
+ return [60, 61];
54
64
  // Intersection operator — whitespace between two refs. In Excel
55
65
  // precedence this sits between `:` (range, already handled at the
56
66
  // tokenizer level) and unary +/-. Left-associative, binds tighter
@@ -247,12 +257,24 @@ class Parser {
247
257
  this.next();
248
258
  return { type: 4 /* NodeType.Error */, value: t.value };
249
259
  }
250
- // Parenthesized expression
260
+ // Parenthesized expression — also the syntactic entry point for
261
+ // reference unions: `(A1:B2, D4:E5)` is a multi-area reference
262
+ // that `INDEX(..., area_num)` can index into. A single expression
263
+ // inside parens is just an expression group (no UnionRef wrapper).
251
264
  if (t.type === 10 /* TokenType.OpenParen */) {
252
265
  this.next();
253
- const expr = this.parseExpr(0);
266
+ const first = this.parseExpr(0);
267
+ if (this.peek()?.type === 12 /* TokenType.Comma */) {
268
+ const areas = [first];
269
+ while (this.peek()?.type === 12 /* TokenType.Comma */) {
270
+ this.next(); // consume ','
271
+ areas.push(this.parseExpr(0));
272
+ }
273
+ this.expect(11 /* TokenType.CloseParen */);
274
+ return { type: 17 /* NodeType.UnionRef */, areas };
275
+ }
254
276
  this.expect(11 /* TokenType.CloseParen */);
255
- return expr;
277
+ return first;
256
278
  }
257
279
  // Array constant: {1,2;3,4}
258
280
  if (t.type === 15 /* TokenType.OpenBrace */) {
@@ -20,8 +20,17 @@ exports.stripFunctionPrefix = stripFunctionPrefix;
20
20
  * The input may or may not already be uppercased — this helper does not
21
21
  * alter case; callers that compare against an uppercase table should
22
22
  * uppercase first (or compare case-insensitively).
23
+ *
24
+ * Fast path: plain names (99%+ of call sites) start with a letter, so
25
+ * checking the first code unit before the `startsWith` machinery lets
26
+ * those lookups skip the allocation.
23
27
  */
24
28
  function stripFunctionPrefix(name) {
29
+ // `_` is code unit 95 — ASCII letters are 65..90 / 97..122. The XLFN
30
+ // prefix is the only legitimate name shape that begins with `_`.
31
+ if (name.length === 0 || name.charCodeAt(0) !== 95) {
32
+ return name;
33
+ }
25
34
  if (name.startsWith("_XLFN._XLWS.")) {
26
35
  return name.slice(12);
27
36
  }
@@ -444,26 +444,56 @@ function tokenize(formula) {
444
444
  // String literals
445
445
  if (ch === '"') {
446
446
  i++; // skip opening quote
447
- let str = "";
447
+ // Fast path: walk forward looking for a closing quote. In the common
448
+ // case (no escaped quotes) we emit a single `slice` rather than
449
+ // growing a string byte by byte. The slow path falls back to the
450
+ // explicit escape-aware concat when we actually see `""`.
451
+ const start = i;
448
452
  let closed = false;
453
+ let firstEscape = -1;
449
454
  while (i < len) {
450
455
  if (formula[i] === '"') {
451
456
  if (i + 1 < len && formula[i + 1] === '"') {
452
- // Escaped quote
453
- str += '"';
454
- i += 2;
455
- }
456
- else {
457
- i++; // skip closing quote
458
- closed = true;
457
+ firstEscape = i;
459
458
  break;
460
459
  }
460
+ closed = true;
461
+ break;
461
462
  }
462
- else {
463
- str += formula[i];
464
- i++;
463
+ i++;
464
+ }
465
+ let str;
466
+ if (firstEscape !== -1) {
467
+ // At least one escaped quote — use the classic byte-by-byte loop
468
+ // starting from the first escape so the prefix can still come
469
+ // from `slice`.
470
+ str = formula.slice(start, firstEscape);
471
+ i = firstEscape;
472
+ while (i < len) {
473
+ if (formula[i] === '"') {
474
+ if (i + 1 < len && formula[i + 1] === '"') {
475
+ str += '"';
476
+ i += 2;
477
+ }
478
+ else {
479
+ i++;
480
+ closed = true;
481
+ break;
482
+ }
483
+ }
484
+ else {
485
+ str += formula[i];
486
+ i++;
487
+ }
465
488
  }
466
489
  }
490
+ else if (closed) {
491
+ str = formula.slice(start, i);
492
+ i++; // consume closing quote
493
+ }
494
+ else {
495
+ str = formula.slice(start, i);
496
+ }
467
497
  if (!closed) {
468
498
  // Unterminated string literal — reject at tokenize time so we
469
499
  // never hand the parser a truncated value that could alias to a
@@ -725,21 +755,48 @@ function tokenize(formula) {
725
755
  // Quoted sheet name: 'Sheet Name'! or 3D ref 'Sheet1:Sheet3'!
726
756
  if (ch === "'") {
727
757
  i++; // skip opening quote
728
- let sheetName = "";
758
+ // Fast path: scan to the first `'` — most sheet names don't contain
759
+ // an escaped quote, so we can slice the prefix verbatim instead of
760
+ // growing a string byte-by-byte.
761
+ const start = i;
762
+ let firstEscape = -1;
729
763
  while (i < len) {
730
764
  if (formula[i] === "'") {
731
765
  if (i + 1 < len && formula[i + 1] === "'") {
732
- sheetName += "'";
733
- i += 2;
766
+ firstEscape = i;
767
+ break;
768
+ }
769
+ break;
770
+ }
771
+ i++;
772
+ }
773
+ let sheetName;
774
+ if (firstEscape !== -1) {
775
+ sheetName = formula.slice(start, firstEscape);
776
+ i = firstEscape;
777
+ // Slow path from the first escape: consume `''` pairs and
778
+ // literal characters until the terminating single `'`.
779
+ while (i < len) {
780
+ if (formula[i] === "'") {
781
+ if (i + 1 < len && formula[i + 1] === "'") {
782
+ sheetName += "'";
783
+ i += 2;
784
+ }
785
+ else {
786
+ i++;
787
+ break;
788
+ }
734
789
  }
735
790
  else {
736
- i++; // skip closing quote
737
- break;
791
+ sheetName += formula[i];
792
+ i++;
738
793
  }
739
794
  }
740
- else {
741
- sheetName += formula[i];
742
- i++;
795
+ }
796
+ else {
797
+ sheetName = formula.slice(start, i);
798
+ if (i < len && formula[i] === "'") {
799
+ i++; // consume closing quote
743
800
  }
744
801
  }
745
802
  // Expect ! after
package/dist/esm/index.js CHANGED
@@ -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)
@@ -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
  }
@@ -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" },