@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
@@ -10,26 +10,9 @@ import { excelWildcardToRegex, getCell, hasUnescapedWildcard, unescapeExcelWildc
10
10
  function sameType(a, b) {
11
11
  return a.kind === b.kind;
12
12
  }
13
- function scalarIsNumber(v) {
14
- return v.kind === RVKind.Number;
15
- }
16
13
  function scalarIsString(v) {
17
14
  return v.kind === RVKind.String;
18
15
  }
19
- function scalarStringEquals(a, b) {
20
- return scalarIsString(a) && scalarIsString(b) && a.value.toLowerCase() === b.value.toLowerCase();
21
- }
22
- /**
23
- * Ordered comparison of two scalars. Numbers compared by value; strings by
24
- * case-insensitive lexical order. Returns NaN when the two operands have
25
- * incompatible types (e.g. number vs string) so callers can skip them.
26
- */
27
- /**
28
- * @deprecated Use `compareScalarsSameKind` from `runtime/values` directly —
29
- * the two are identical. Retained only as a local alias to keep the diff
30
- * small; callers inside this file are free to migrate.
31
- */
32
- const compareScalar = compareScalarsSameKind;
33
16
  // ============================================================================
34
17
  // Functions
35
18
  // ============================================================================
@@ -62,7 +45,7 @@ export function fnINDEX(args) {
62
45
  return topLeft(args[0]);
63
46
  }
64
47
  const arr = args[0];
65
- const rowNumV = args.length > 1 ? toNumberRV(args[1]) : rvNumber(0);
48
+ const rowNumV = args.length > 1 ? toNumberRV(topLeft(args[1])) : rvNumber(0);
66
49
  if (isError(rowNumV)) {
67
50
  return rowNumV;
68
51
  }
@@ -70,7 +53,7 @@ export function fnINDEX(args) {
70
53
  // Without this, `INDEX(a, 1.5, 1)` would index into `arr.rows[0.5]`, which
71
54
  // in V8 silently returns `undefined` and corrupts downstream values.
72
55
  const rowNum = Math.trunc(rowNumV.value);
73
- const colNumV = args.length > 2 ? toNumberRV(args[2]) : rvNumber(0);
56
+ const colNumV = args.length > 2 ? toNumberRV(topLeft(args[2])) : rvNumber(0);
74
57
  if (isError(colNumV)) {
75
58
  return colNumV;
76
59
  }
@@ -87,6 +70,10 @@ export function fnINDEX(args) {
87
70
  if (c < 0 || c >= arr.width) {
88
71
  return ERRORS.REF;
89
72
  }
73
+ // Single-row source: a whole-column extract collapses to the one cell.
74
+ if (arr.height === 1) {
75
+ return arr.rows[0][c];
76
+ }
90
77
  const rows = [];
91
78
  for (let r = 0; r < arr.height; r++) {
92
79
  rows.push([getCell(arr, r, c)]);
@@ -99,6 +86,12 @@ export function fnINDEX(args) {
99
86
  if (r < 0 || r >= arr.height) {
100
87
  return ERRORS.REF;
101
88
  }
89
+ // Single-column source: a whole-row extract collapses to the one cell.
90
+ // Matches Excel's convention — `INDEX(A1:A5, 2)` yields the scalar A2,
91
+ // not a 1×1 array that downstream arithmetic has to implicit-intersect.
92
+ if (arr.width === 1) {
93
+ return arr.rows[r][0];
94
+ }
102
95
  return rvArray([[...arr.rows[r]]]);
103
96
  }
104
97
  // Single cell
@@ -118,7 +111,11 @@ export function fnMATCH(args) {
118
111
  return ERRORS.NA;
119
112
  }
120
113
  const lookupArr = args[1];
121
- const matchTypeV = args.length > 2 ? toNumberRV(args[2]) : rvNumber(1);
114
+ // Blank `match_type` Excel default 1 (largest value ≤ lookup, ascending sort).
115
+ // Previously a blank coerced to 0 via toNumberRV and silently flipped the
116
+ // function to exact-match mode — a behaviour gap vs. Excel's documented
117
+ // default.
118
+ const matchTypeV = args.length > 2 && args[2].kind !== RVKind.Blank ? toNumberRV(topLeft(args[2])) : rvNumber(1);
122
119
  if (isError(matchTypeV)) {
123
120
  return matchTypeV;
124
121
  }
@@ -146,6 +143,12 @@ export function fnMATCH(args) {
146
143
  wildcardRe = null;
147
144
  }
148
145
  }
146
+ // Pre-compute the literal (unescaped + lowercased) lookup string
147
+ // once. The old code called `unescapeExcelWildcard(lookupValue.value).toLowerCase()`
148
+ // inside the hot per-cell loop, paying O(n) per cell for what is
149
+ // a constant expression. (For ranges without strings the literal
150
+ // is never consulted — `scalarIsString(fi)` short-circuits first.)
151
+ const lookupLiteralLc = lookupStr !== null && !hasWildcard ? unescapeExcelWildcard(lookupStr).toLowerCase() : null;
149
152
  for (let i = 0; i < flat.length; i++) {
150
153
  if (scalarEquals(flat[i], lookupValue)) {
151
154
  return rvNumber(i + 1);
@@ -157,7 +160,7 @@ export function fnMATCH(args) {
157
160
  return rvNumber(i + 1);
158
161
  }
159
162
  }
160
- else {
163
+ else if (lookupLiteralLc !== null) {
161
164
  // No unescaped wildcard — but the pattern may still contain
162
165
  // `~*` / `~?` / `~~` escape sequences that should reduce to
163
166
  // their literal character before comparison. Calling
@@ -165,8 +168,7 @@ export function fnMATCH(args) {
165
168
  // SEARCH and the criteria predicate use; without it,
166
169
  // `MATCH("a~*b", ...)` would literally look for `"a~*b"`
167
170
  // instead of `"a*b"`.
168
- const literal = unescapeExcelWildcard(lookupValue.value).toLowerCase();
169
- if (fi.value.toLowerCase() === literal) {
171
+ if (fi.value.toLowerCase() === lookupLiteralLc) {
170
172
  return rvNumber(i + 1);
171
173
  }
172
174
  }
@@ -179,23 +181,18 @@ export function fnMATCH(args) {
179
181
  let bestIdx = -1;
180
182
  for (let i = 0; i < flat.length; i++) {
181
183
  const v = flat[i];
182
- if (sameType(v, lookupValue)) {
183
- if (scalarIsNumber(v) && scalarIsNumber(lookupValue)) {
184
- if (v.value <= lookupValue.value) {
185
- bestIdx = i;
186
- }
187
- else {
188
- break;
189
- }
190
- }
191
- else if (scalarIsString(v) && scalarIsString(lookupValue)) {
192
- if (v.value.toLowerCase() <= lookupValue.value.toLowerCase()) {
193
- bestIdx = i;
194
- }
195
- else {
196
- break;
197
- }
198
- }
184
+ if (v.kind !== lookupValue.kind) {
185
+ continue;
186
+ }
187
+ const cmp = compareScalarsSameKind(v, lookupValue);
188
+ if (!Number.isFinite(cmp)) {
189
+ continue;
190
+ }
191
+ if (cmp <= 0) {
192
+ bestIdx = i;
193
+ }
194
+ else {
195
+ break;
199
196
  }
200
197
  }
201
198
  return bestIdx >= 0 ? rvNumber(bestIdx + 1) : ERRORS.NA;
@@ -204,23 +201,18 @@ export function fnMATCH(args) {
204
201
  let bestIdx = -1;
205
202
  for (let i = 0; i < flat.length; i++) {
206
203
  const v = flat[i];
207
- if (sameType(v, lookupValue)) {
208
- if (scalarIsNumber(v) && scalarIsNumber(lookupValue)) {
209
- if (v.value >= lookupValue.value) {
210
- bestIdx = i;
211
- }
212
- else {
213
- break;
214
- }
215
- }
216
- else if (scalarIsString(v) && scalarIsString(lookupValue)) {
217
- if (v.value.toLowerCase() >= lookupValue.value.toLowerCase()) {
218
- bestIdx = i;
219
- }
220
- else {
221
- break;
222
- }
223
- }
204
+ if (v.kind !== lookupValue.kind) {
205
+ continue;
206
+ }
207
+ const cmp = compareScalarsSameKind(v, lookupValue);
208
+ if (!Number.isFinite(cmp)) {
209
+ continue;
210
+ }
211
+ if (cmp >= 0) {
212
+ bestIdx = i;
213
+ }
214
+ else {
215
+ break;
224
216
  }
225
217
  }
226
218
  return bestIdx >= 0 ? rvNumber(bestIdx + 1) : ERRORS.NA;
@@ -234,13 +226,18 @@ export function fnVLOOKUP(args) {
234
226
  return ERRORS.NA;
235
227
  }
236
228
  const table = args[1];
237
- const colIndexV = toNumberRV(args[2]);
229
+ const colIndexV = toNumberRV(topLeft(args[2]));
238
230
  if (isError(colIndexV)) {
239
231
  return colIndexV;
240
232
  }
241
233
  // VLOOKUP truncates the column index toward zero before bounds checks.
242
234
  const colIndex = Math.trunc(colIndexV.value);
243
- const rangeLookupV = args.length > 3 ? toBooleanRV(args[3]) : { kind: RVKind.Boolean, value: true };
235
+ // Blank `range_lookup` Excel default TRUE. A blank coerces through
236
+ // `toBooleanRV` to FALSE which silently flips to exact match —
237
+ // opposite of Excel's documented default.
238
+ const rangeLookupV = args.length > 3 && args[3].kind !== RVKind.Blank
239
+ ? toBooleanRV(topLeft(args[3]))
240
+ : { kind: RVKind.Boolean, value: true };
244
241
  if (isError(rangeLookupV)) {
245
242
  return rangeLookupV;
246
243
  }
@@ -249,39 +246,65 @@ export function fnVLOOKUP(args) {
249
246
  return ERRORS.REF;
250
247
  }
251
248
  if (!rangeLookup) {
252
- // Exact match
249
+ // Exact match — `scalarEquals` already handles case-insensitive string
250
+ // comparison (see runtime/values.ts), so the earlier `scalarStringEquals`
251
+ // fallback was dead code.
252
+ //
253
+ // Excel's VLOOKUP supports wildcards (`*`, `?`, `~*`, `~?`, `~~`) in
254
+ // the exact-match mode (range_lookup=FALSE). Three paths, chosen by
255
+ // what the lookup string contains:
256
+ // - unescaped wildcard (`*` or `?`) → regex match
257
+ // - only escape sequences (`~*`, `~?`, `~~`) → unescape then
258
+ // literal case-insensitive compare (so `"a~*b"` matches `"a*b"`)
259
+ // - neither → plain `scalarEquals`
260
+ const lookupStr = lookupValue.kind === RVKind.String ? lookupValue.value : null;
261
+ let wildcardRe = null;
262
+ let literalLc = null;
263
+ if (lookupStr !== null && hasUnescapedWildcard(lookupStr)) {
264
+ try {
265
+ wildcardRe = new RegExp("^" + excelWildcardToRegex(lookupStr) + "$", "i");
266
+ }
267
+ catch {
268
+ wildcardRe = null;
269
+ }
270
+ }
271
+ else if (lookupStr !== null && /~[*?~]/.test(lookupStr)) {
272
+ literalLc = unescapeExcelWildcard(lookupStr).toLowerCase();
273
+ }
253
274
  for (let r = 0; r < table.height; r++) {
254
275
  const cell = getCell(table, r, 0);
255
276
  if (scalarEquals(cell, lookupValue)) {
256
277
  return getCell(table, r, colIndex - 1);
257
278
  }
258
- if (scalarStringEquals(cell, lookupValue)) {
279
+ if (wildcardRe && cell.kind === RVKind.String && wildcardRe.test(cell.value)) {
280
+ return getCell(table, r, colIndex - 1);
281
+ }
282
+ if (literalLc !== null &&
283
+ cell.kind === RVKind.String &&
284
+ cell.value.toLowerCase() === literalLc) {
259
285
  return getCell(table, r, colIndex - 1);
260
286
  }
261
287
  }
262
288
  return ERRORS.NA;
263
289
  }
264
- // Approximate match: sorted ascending by first column.
290
+ // Approximate match: sorted ascending by first column. Binary-search
291
+ // style isn't safe here (Excel allows mixed-type entries which break
292
+ // monotonicity), so walk until we overshoot.
265
293
  let bestRow = -1;
266
294
  for (let r = 0; r < table.height; r++) {
267
295
  const v = getCell(table, r, 0);
268
- if (sameType(v, lookupValue)) {
269
- if (scalarIsNumber(v) && scalarIsNumber(lookupValue)) {
270
- if (v.value <= lookupValue.value) {
271
- bestRow = r;
272
- }
273
- else {
274
- break;
275
- }
276
- }
277
- else if (scalarIsString(v) && scalarIsString(lookupValue)) {
278
- if (v.value.toLowerCase() <= lookupValue.value.toLowerCase()) {
279
- bestRow = r;
280
- }
281
- else {
282
- break;
283
- }
284
- }
296
+ if (v.kind !== lookupValue.kind) {
297
+ continue;
298
+ }
299
+ const cmp = compareScalarsSameKind(v, lookupValue);
300
+ if (!Number.isFinite(cmp)) {
301
+ continue;
302
+ }
303
+ if (cmp <= 0) {
304
+ bestRow = r;
305
+ }
306
+ else {
307
+ break;
285
308
  }
286
309
  }
287
310
  return bestRow >= 0 ? getCell(table, bestRow, colIndex - 1) : ERRORS.NA;
@@ -295,13 +318,16 @@ export function fnHLOOKUP(args) {
295
318
  return ERRORS.NA;
296
319
  }
297
320
  const table = args[1];
298
- const rowIndexV = toNumberRV(args[2]);
321
+ const rowIndexV = toNumberRV(topLeft(args[2]));
299
322
  if (isError(rowIndexV)) {
300
323
  return rowIndexV;
301
324
  }
302
325
  // HLOOKUP truncates the row index toward zero before bounds checks.
303
326
  const rowIndex = Math.trunc(rowIndexV.value);
304
- const rangeLookupV = args.length > 3 ? toBooleanRV(args[3]) : { kind: RVKind.Boolean, value: true };
327
+ // Blank `range_lookup` Excel default TRUE (see VLOOKUP rationale).
328
+ const rangeLookupV = args.length > 3 && args[3].kind !== RVKind.Blank
329
+ ? toBooleanRV(topLeft(args[3]))
330
+ : { kind: RVKind.Boolean, value: true };
305
331
  if (isError(rangeLookupV)) {
306
332
  return rangeLookupV;
307
333
  }
@@ -310,8 +336,33 @@ export function fnHLOOKUP(args) {
310
336
  return ERRORS.REF;
311
337
  }
312
338
  if (!rangeLookup) {
339
+ // Exact match — supports wildcards on string lookups (see VLOOKUP
340
+ // for full rationale; the paths mirror one another).
341
+ const lookupStr = lookupValue.kind === RVKind.String ? lookupValue.value : null;
342
+ let wildcardRe = null;
343
+ let literalLc = null;
344
+ if (lookupStr !== null && hasUnescapedWildcard(lookupStr)) {
345
+ try {
346
+ wildcardRe = new RegExp("^" + excelWildcardToRegex(lookupStr) + "$", "i");
347
+ }
348
+ catch {
349
+ wildcardRe = null;
350
+ }
351
+ }
352
+ else if (lookupStr !== null && /~[*?~]/.test(lookupStr)) {
353
+ literalLc = unescapeExcelWildcard(lookupStr).toLowerCase();
354
+ }
313
355
  for (let c = 0; c < table.width; c++) {
314
- if (scalarEquals(getCell(table, 0, c), lookupValue)) {
356
+ const cell = getCell(table, 0, c);
357
+ if (scalarEquals(cell, lookupValue)) {
358
+ return getCell(table, rowIndex - 1, c);
359
+ }
360
+ if (wildcardRe && cell.kind === RVKind.String && wildcardRe.test(cell.value)) {
361
+ return getCell(table, rowIndex - 1, c);
362
+ }
363
+ if (literalLc !== null &&
364
+ cell.kind === RVKind.String &&
365
+ cell.value.toLowerCase() === literalLc) {
315
366
  return getCell(table, rowIndex - 1, c);
316
367
  }
317
368
  }
@@ -320,18 +371,18 @@ export function fnHLOOKUP(args) {
320
371
  let bestCol = -1;
321
372
  for (let c = 0; c < table.width; c++) {
322
373
  const hv = getCell(table, 0, c);
323
- if (sameType(hv, lookupValue)) {
324
- // For approximate match, find largest <= lookupValue
325
- if (scalarIsNumber(hv) && scalarIsNumber(lookupValue)) {
326
- if (hv.value <= lookupValue.value) {
327
- bestCol = c;
328
- }
329
- }
330
- else if (scalarIsString(hv) && scalarIsString(lookupValue)) {
331
- if (hv.value.toLowerCase() <= lookupValue.value.toLowerCase()) {
332
- bestCol = c;
333
- }
334
- }
374
+ if (hv.kind !== lookupValue.kind) {
375
+ continue;
376
+ }
377
+ const cmp = compareScalarsSameKind(hv, lookupValue);
378
+ if (!Number.isFinite(cmp)) {
379
+ continue;
380
+ }
381
+ // Approximate match: pick the largest value <= lookupValue. We don't
382
+ // break early when we overshoot because HLOOKUP's legacy behaviour
383
+ // scans the whole row even for unsorted data.
384
+ if (cmp <= 0) {
385
+ bestCol = c;
335
386
  }
336
387
  }
337
388
  return bestCol >= 0 ? getCell(table, rowIndex - 1, bestCol) : ERRORS.NA;
@@ -349,17 +400,30 @@ export function fnXLOOKUP(args) {
349
400
  return ERRORS.VALUE;
350
401
  }
351
402
  const returnArr = args[2];
352
- const ifNotFound = args.length > 3 ? topLeft(args[3]) : null;
353
- const matchModeV = args.length > 4 ? toNumberRV(args[4]) : rvNumber(0);
403
+ // Blank `if_not_found` treat as omitted (default #N/A). Without
404
+ // this guard, an explicitly-blank fourth slot would produce BLANK as
405
+ // the fallback, which differs from Excel's "omitted → #N/A" default
406
+ // and from what a user intuitively expects.
407
+ const ifNotFound = args.length > 3 && args[3].kind !== RVKind.Blank ? topLeft(args[3]) : null;
408
+ // Blank `match_mode` → 0 (exact); any non-{-1, 0, 1, 2} is rejected.
409
+ const matchModeV = args.length > 4 && args[4].kind !== RVKind.Blank ? toNumberRV(topLeft(args[4])) : rvNumber(0);
354
410
  if (isError(matchModeV)) {
355
411
  return matchModeV;
356
412
  }
357
413
  const matchMode = matchModeV.value;
358
- const searchModeV = args.length > 5 ? toNumberRV(args[5]) : rvNumber(1);
414
+ if (matchMode !== 0 && matchMode !== -1 && matchMode !== 1 && matchMode !== 2) {
415
+ return ERRORS.VALUE;
416
+ }
417
+ // Blank `search_mode` → 1 (first-to-last). Previously a blank coerced
418
+ // to 0 which silently passed through but is not a valid search mode.
419
+ const searchModeV = args.length > 5 && args[5].kind !== RVKind.Blank ? toNumberRV(topLeft(args[5])) : rvNumber(1);
359
420
  if (isError(searchModeV)) {
360
421
  return searchModeV;
361
422
  }
362
423
  const searchMode = searchModeV.value;
424
+ if (searchMode !== 1 && searchMode !== -1 && searchMode !== 2 && searchMode !== -2) {
425
+ return ERRORS.VALUE;
426
+ }
363
427
  // Flatten lookup array to 1D
364
428
  const flat = [];
365
429
  const isRow = lookupArr.height === 1;
@@ -464,10 +528,6 @@ export function fnXLOOKUP(args) {
464
528
  foundIdx = i;
465
529
  break;
466
530
  }
467
- if (scalarStringEquals(flat[i], lookupValue)) {
468
- foundIdx = i;
469
- break;
470
- }
471
531
  }
472
532
  }
473
533
  else if (matchMode === -1) {
@@ -570,16 +630,23 @@ export function fnXMATCH(args) {
570
630
  return ERRORS.VALUE;
571
631
  }
572
632
  const lookupArr = args[1];
573
- const matchModeV = args.length > 2 ? toNumberRV(args[2]) : rvNumber(0);
633
+ // Blank `match_mode` 0 (exact). Same validation as XLOOKUP.
634
+ const matchModeV = args.length > 2 && args[2].kind !== RVKind.Blank ? toNumberRV(topLeft(args[2])) : rvNumber(0);
574
635
  if (isError(matchModeV)) {
575
636
  return matchModeV;
576
637
  }
577
638
  const matchMode = matchModeV.value;
578
- const searchModeV = args.length > 3 ? toNumberRV(args[3]) : rvNumber(1);
639
+ if (matchMode !== 0 && matchMode !== -1 && matchMode !== 1 && matchMode !== 2) {
640
+ return ERRORS.VALUE;
641
+ }
642
+ const searchModeV = args.length > 3 && args[3].kind !== RVKind.Blank ? toNumberRV(topLeft(args[3])) : rvNumber(1);
579
643
  if (isError(searchModeV)) {
580
644
  return searchModeV;
581
645
  }
582
646
  const searchMode = searchModeV.value;
647
+ if (searchMode !== 1 && searchMode !== -1 && searchMode !== 2 && searchMode !== -2) {
648
+ return ERRORS.VALUE;
649
+ }
583
650
  const flat = [];
584
651
  if (lookupArr.height === 1) {
585
652
  for (let c = 0; c < lookupArr.width; c++) {
@@ -599,9 +666,6 @@ export function fnXMATCH(args) {
599
666
  if (scalarEquals(flat[i], lookupValue)) {
600
667
  return rvNumber(i + 1);
601
668
  }
602
- if (scalarStringEquals(flat[i], lookupValue)) {
603
- return rvNumber(i + 1);
604
- }
605
669
  }
606
670
  return ERRORS.NA;
607
671
  }
@@ -649,11 +713,11 @@ export function fnXMATCH(args) {
649
713
  // Next-smaller-or-equal: largest item <= lookupValue.
650
714
  let best = -1;
651
715
  for (let i = 0; i < flat.length; i++) {
652
- const cmp = compareScalar(flat[i], lookupValue);
716
+ const cmp = compareScalarsSameKind(flat[i], lookupValue);
653
717
  if (Number.isNaN(cmp)) {
654
718
  continue;
655
719
  }
656
- if (cmp <= 0 && (best === -1 || compareScalar(flat[i], flat[best]) > 0)) {
720
+ if (cmp <= 0 && (best === -1 || compareScalarsSameKind(flat[i], flat[best]) > 0)) {
657
721
  best = i;
658
722
  }
659
723
  }
@@ -663,11 +727,11 @@ export function fnXMATCH(args) {
663
727
  // Next-larger-or-equal: smallest item >= lookupValue.
664
728
  let best = -1;
665
729
  for (let i = 0; i < flat.length; i++) {
666
- const cmp = compareScalar(flat[i], lookupValue);
730
+ const cmp = compareScalarsSameKind(flat[i], lookupValue);
667
731
  if (Number.isNaN(cmp)) {
668
732
  continue;
669
733
  }
670
- if (cmp >= 0 && (best === -1 || compareScalar(flat[i], flat[best]) < 0)) {
734
+ if (cmp >= 0 && (best === -1 || compareScalarsSameKind(flat[i], flat[best]) < 0)) {
671
735
  best = i;
672
736
  }
673
737
  }
@@ -676,12 +740,12 @@ export function fnXMATCH(args) {
676
740
  return ERRORS.NA;
677
741
  }
678
742
  export function fnADDRESS(args) {
679
- const rowNumV = toNumberRV(args[0]);
743
+ const rowNumV = toNumberRV(topLeft(args[0]));
680
744
  if (isError(rowNumV)) {
681
745
  return rowNumV;
682
746
  }
683
747
  const rowNum = Math.trunc(rowNumV.value);
684
- const colNumV = toNumberRV(args[1]);
748
+ const colNumV = toNumberRV(topLeft(args[1]));
685
749
  if (isError(colNumV)) {
686
750
  return colNumV;
687
751
  }
@@ -692,7 +756,10 @@ export function fnADDRESS(args) {
692
756
  if (!Number.isFinite(rowNum) || !Number.isFinite(colNum) || rowNum < 1 || colNum < 1) {
693
757
  return ERRORS.VALUE;
694
758
  }
695
- const absNumV = args.length > 2 ? toNumberRV(args[2]) : rvNumber(1);
759
+ // Blank `abs_num` Excel default 1 (fully absolute). Without the
760
+ // blank guard, `toNumberRV(BLANK)` coerces to 0 which falls outside
761
+ // the 1..4 range and surfaces a spurious #VALUE!.
762
+ const absNumV = args.length > 2 && args[2].kind !== RVKind.Blank ? toNumberRV(topLeft(args[2])) : rvNumber(1);
696
763
  if (isError(absNumV)) {
697
764
  return absNumV;
698
765
  }
@@ -704,7 +771,7 @@ export function fnADDRESS(args) {
704
771
  // a1 style (true/default) vs r1c1 (false)
705
772
  const a1Arg = args.length > 3 ? topLeft(args[3]) : { kind: RVKind.Boolean, value: true };
706
773
  const a1 = a1Arg.kind === RVKind.Boolean ? a1Arg.value : true;
707
- const sheetText = args.length > 4 ? toStringRV(args[4]) : "";
774
+ const sheetText = args.length > 4 ? toStringRV(topLeft(args[4])) : "";
708
775
  if (!a1) {
709
776
  // R1C1 style
710
777
  const rPart = absNum === 1 || absNum === 2 ? `R${rowNum}` : `R[${rowNum}]`;
@@ -777,20 +844,7 @@ export function fnLOOKUP(args) {
777
844
  flat.push(getCell(lookupArr, r, 0));
778
845
  }
779
846
  }
780
- let bestIdx = -1;
781
- for (let i = 0; i < flat.length; i++) {
782
- const v = flat[i];
783
- if (sameType(v, lookupValue)) {
784
- if (scalarIsNumber(v) && scalarIsNumber(lookupValue) && v.value <= lookupValue.value) {
785
- bestIdx = i;
786
- }
787
- else if (scalarIsString(v) &&
788
- scalarIsString(lookupValue) &&
789
- v.value.toLowerCase() <= lookupValue.value.toLowerCase()) {
790
- bestIdx = i;
791
- }
792
- }
793
- }
847
+ const bestIdx = findLastLessEqual(flat, lookupValue);
794
848
  if (bestIdx === -1) {
795
849
  return ERRORS.NA;
796
850
  }
@@ -811,38 +865,48 @@ export function fnLOOKUP(args) {
811
865
  return ERRORS.NA;
812
866
  }
813
867
  if (cols >= rows) {
814
- let bestIdx = -1;
868
+ // Array-form LOOKUP, horizontal orientation: lookup runs along first
869
+ // row; result is pulled from the last row of the same column.
870
+ const firstRow = [];
815
871
  for (let c = 0; c < cols; c++) {
816
- const v = getCell(lookupArr, 0, c);
817
- if (sameType(v, lookupValue)) {
818
- if (scalarIsNumber(v) && scalarIsNumber(lookupValue) && v.value <= lookupValue.value) {
819
- bestIdx = c;
820
- }
821
- else if (scalarIsString(v) &&
822
- scalarIsString(lookupValue) &&
823
- v.value.toLowerCase() <= lookupValue.value.toLowerCase()) {
824
- bestIdx = c;
825
- }
826
- }
872
+ firstRow.push(getCell(lookupArr, 0, c));
827
873
  }
874
+ const bestIdx = findLastLessEqual(firstRow, lookupValue);
828
875
  return bestIdx >= 0 ? getCell(lookupArr, rows - 1, bestIdx) : ERRORS.NA;
829
876
  }
830
- let bestIdx = -1;
877
+ // Array-form LOOKUP, vertical orientation: lookup runs down first column;
878
+ // result is pulled from the last column of the same row.
879
+ const firstCol = [];
831
880
  for (let r = 0; r < rows; r++) {
832
- const v = getCell(lookupArr, r, 0);
833
- if (sameType(v, lookupValue)) {
834
- if (scalarIsNumber(v) && scalarIsNumber(lookupValue) && v.value <= lookupValue.value) {
835
- bestIdx = r;
836
- }
837
- else if (scalarIsString(v) &&
838
- scalarIsString(lookupValue) &&
839
- v.value.toLowerCase() <= lookupValue.value.toLowerCase()) {
840
- bestIdx = r;
841
- }
842
- }
881
+ firstCol.push(getCell(lookupArr, r, 0));
843
882
  }
883
+ const bestIdx = findLastLessEqual(firstCol, lookupValue);
844
884
  return bestIdx >= 0 ? getCell(lookupArr, bestIdx, cols - 1) : ERRORS.NA;
845
885
  }
886
+ /**
887
+ * Linear scan for the largest same-kind value that is `<= target`.
888
+ * Returns the flat-index of that value, or `-1` when no same-kind
889
+ * value qualifies. Uses `compareScalarsSameKind` so numbers compare by
890
+ * value, strings case-insensitively — the same ordering Excel uses
891
+ * for legacy LOOKUP / VLOOKUP / HLOOKUP approximate matches.
892
+ */
893
+ function findLastLessEqual(flat, target) {
894
+ let bestIdx = -1;
895
+ for (let i = 0; i < flat.length; i++) {
896
+ const v = flat[i];
897
+ if (v.kind !== target.kind) {
898
+ continue;
899
+ }
900
+ const cmp = compareScalarsSameKind(v, target);
901
+ if (!Number.isFinite(cmp)) {
902
+ continue;
903
+ }
904
+ if (cmp <= 0) {
905
+ bestIdx = i;
906
+ }
907
+ }
908
+ return bestIdx;
909
+ }
846
910
  export function fnTRANSPOSE(args) {
847
911
  if (!isArray(args[0])) {
848
912
  const sv = topLeft(args[0]);
@@ -871,10 +935,13 @@ export function fnAREAS(args) {
871
935
  if (args.length === 0) {
872
936
  return ERRORS.VALUE;
873
937
  }
874
- // Error in the argument propagates (Excel parity). A reference value
875
- // counts as one area; the engine does not yet build multi-area
876
- // references via `(A1, B1)` union syntax when that lands, the arity
877
- // should be `v.areas.length`, not a hardcoded 1.
938
+ // Normally unreachable the evaluator's reference-aware path in
939
+ // `evaluateCall` intercepts AREAS before eager dereference happens,
940
+ // so by the time this fallback runs the reference has already been
941
+ // flattened into a dereferenced array (losing the area count).
942
+ // Keep the fallback behaviour aligned with the intercept path:
943
+ // arrays and scalars that reach here are not references and should
944
+ // surface as `#VALUE!`.
878
945
  const a = args[0];
879
946
  if (a.kind === RVKind.Error) {
880
947
  return a;
@@ -882,5 +949,5 @@ export function fnAREAS(args) {
882
949
  if (a.kind === RVKind.Reference) {
883
950
  return rvNumber(a.areas.length);
884
951
  }
885
- return rvNumber(1);
952
+ return ERRORS.VALUE;
886
953
  }
@@ -42,7 +42,33 @@ export declare const fnPRODUCT: NativeFn;
42
42
  export declare const fnSUMPRODUCT: NativeFn;
43
43
  export declare const fnABS: NativeFn;
44
44
  export declare const fnCEILING: NativeFn;
45
+ /**
46
+ * CEILING.MATH(number, [significance], [mode]) — rounds away from zero
47
+ * by default, or toward zero when `mode` is non-zero AND `number` is
48
+ * negative. Significance is always interpreted by absolute value.
49
+ *
50
+ * Different from CEILING: negative numbers with positive significance
51
+ * are valid (Excel does NOT require same sign), and there is an extra
52
+ * `mode` switch that flips the rounding direction for negatives.
53
+ */
54
+ export declare const fnCEILING_MATH: NativeFn;
55
+ /**
56
+ * CEILING.PRECISE / ISO.CEILING — always rounds toward +∞ (irrespective
57
+ * of sign), using the absolute value of significance.
58
+ */
59
+ export declare const fnCEILING_PRECISE: NativeFn;
45
60
  export declare const fnFLOOR: NativeFn;
61
+ /**
62
+ * FLOOR.MATH(number, [significance], [mode]) — rounds toward zero by
63
+ * default, or away from zero when `mode` is non-zero AND `number` is
64
+ * negative. Uses `|significance|` so negative significance never
65
+ * produces #NUM!.
66
+ */
67
+ export declare const fnFLOOR_MATH: NativeFn;
68
+ /**
69
+ * FLOOR.PRECISE — always rounds toward −∞ using `|significance|`.
70
+ */
71
+ export declare const fnFLOOR_PRECISE: NativeFn;
46
72
  export declare const fnINT: NativeFn;
47
73
  export declare const fnMOD: NativeFn;
48
74
  export declare const fnPOWER: NativeFn;