diff --git a/lib/xlsx/export.go b/lib/xlsx/export.go index 96ab8ac4..b9888ae1 100644 --- a/lib/xlsx/export.go +++ b/lib/xlsx/export.go @@ -60,7 +60,7 @@ func Export(wb *sheet.Workbook) ([]byte, error) { return nil, err } if strings.HasPrefix(cell.Raw, "=") { - if err := f.SetCellFormula(name, axis, cell.Raw[1:]); err != nil { + if err := f.SetCellFormula(name, axis, addFunctionPrefixes(cell.Raw[1:])); err != nil { return nil, err } } else if n, err := strconv.ParseFloat(cell.Raw, 64); err == nil && cell.Raw != "" { diff --git a/lib/xlsx/formulanames.go b/lib/xlsx/formulanames.go new file mode 100644 index 00000000..6cd7dcff --- /dev/null +++ b/lib/xlsx/formulanames.go @@ -0,0 +1,110 @@ +package xlsx + +import "strings" + +// Excel stores every function added after 2007 under a namespace prefix in the +// file format: "_xlfn.XLOOKUP(...)", and "_xlfn._xlws.SORT(...)" for the +// worksheet-scoped dynamic-array ones. The prefix is invisible in Excel's UI +// and unknown to our formula engine, so it is stripped on import and added back +// on export — without it Excel shows #NAME? for formulas we wrote. + +// xlwsFunctions need the worksheet-scoped prefix instead of the plain one. +var xlwsFunctions = map[string]bool{ + "SORT": true, + "FILTER": true, +} + +// prefixedFunctions lists the non-dotted functions Excel namespaces. Dotted +// names (NORM.DIST, RANK.AVG, ...) are all post-2007 too and handled by rule. +var prefixedFunctions = map[string]bool{ + "ARABIC": true, "ARRAYTOTEXT": true, "BASE": true, "BITAND": true, "BITLSHIFT": true, + "BITOR": true, "BITRSHIFT": true, "BITXOR": true, "CHOOSECOLS": true, + "CHOOSEROWS": true, "COMBINA": true, "CONCAT": true, "COT": true, "COTH": true, + "CSC": true, "CSCH": true, "DAYS": true, "DECIMAL": true, "DROP": true, "ENCODEURL": true, + "EXPAND": true, "FILTER": true, "FORMULATEXT": true, "GAMMA": true, "GAUSS": true, + "HSTACK": true, "IFNA": true, "IFS": true, "IMCOSH": true, "IMCOT": true, "IMCSC": true, + "IMCSCH": true, "IMSEC": true, "IMSECH": true, "IMSINH": true, "IMTAN": true, + "ISFORMULA": true, "ISOMITTED": true, "ISOWEEKNUM": true, "MAXIFS": true, "MINIFS": true, + "MUNIT": true, "NUMBERVALUE": true, "PDURATION": true, "PERMUTATIONA": true, "PHI": true, + "RANDARRAY": true, "RRI": true, "SEC": true, "SECH": true, "SEQUENCE": true, "SHEET": true, + "SHEETS": true, "SORT": true, "SORTBY": true, "SWITCH": true, "TAKE": true, + "TEXTAFTER": true, "TEXTBEFORE": true, "TEXTJOIN": true, "TEXTSPLIT": true, "TOCOL": true, + "TOROW": true, "UNICHAR": true, "UNICODE": true, "UNIQUE": true, "VSTACK": true, + "WEBSERVICE": true, "XLOOKUP": true, "XMATCH": true, "XOR": true, +} + +func needsPrefix(name string) bool { + if prefixedFunctions[name] { + return true + } + // Dotted names are the 2010+ statistical/compatibility set (NORM.DIST, + // MODE.SNGL, CEILING.MATH, ...), all of which Excel namespaces. + return strings.Contains(name, ".") +} + +// stripFunctionPrefixes removes Excel's namespace prefixes from a formula so +// our engine sees plain function names. +func stripFunctionPrefixes(formula string) string { + for _, p := range []string{"_xlfn._xlws.", "_xlfn.", "_xlws."} { + formula = strings.ReplaceAll(formula, p, "") + } + return formula +} + +// addFunctionPrefixes namespaces the post-2007 function names in a formula. +// Function names are identifiers directly followed by '('; string literals are +// skipped so text like "SORT(" inside quotes is left alone. +func addFunctionPrefixes(formula string) string { + var out strings.Builder + for i := 0; i < len(formula); { + c := formula[i] + if c == '"' { + j := i + 1 + for j < len(formula) { + if formula[j] == '"' { + // "" is an escaped quote inside the literal. + if j+1 < len(formula) && formula[j+1] == '"' { + j += 2 + continue + } + break + } + j++ + } + if j < len(formula) { + j++ + } + out.WriteString(formula[i:j]) + i = j + continue + } + if !isNameStart(c) { + out.WriteByte(c) + i++ + continue + } + j := i + for j < len(formula) && isNameByte(formula[j]) { + j++ + } + name := formula[i:j] + if j < len(formula) && formula[j] == '(' && needsPrefix(strings.ToUpper(name)) { + if xlwsFunctions[strings.ToUpper(name)] { + out.WriteString("_xlfn._xlws.") + } else { + out.WriteString("_xlfn.") + } + } + out.WriteString(name) + i = j + } + return out.String() +} + +func isNameStart(c byte) bool { + return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' +} + +func isNameByte(c byte) bool { + return isNameStart(c) || c >= '0' && c <= '9' || c == '.' || c == '_' +} diff --git a/lib/xlsx/formulanames_test.go b/lib/xlsx/formulanames_test.go new file mode 100644 index 00000000..60347168 --- /dev/null +++ b/lib/xlsx/formulanames_test.go @@ -0,0 +1,37 @@ +package xlsx + +import "testing" + +func TestAddFunctionPrefixes(t *testing.T) { + cases := []struct{ in, want string }{ + {"SUM(A1:A2)", "SUM(A1:A2)"}, + {"IF(A1>0,1,2)", "IF(A1>0,1,2)"}, + {"XLOOKUP(A1,B:B,C:C)", "_xlfn.XLOOKUP(A1,B:B,C:C)"}, + {"SORT(A1:A4)", "_xlfn._xlws.SORT(A1:A4)"}, + {"UNIQUE(SORT(A1:A4))", "_xlfn.UNIQUE(_xlfn._xlws.SORT(A1:A4))"}, + {"RANK.AVG(A1,B1:B4)", "_xlfn.RANK.AVG(A1,B1:B4)"}, + {"NORM.DIST(1,0,1,TRUE)", "_xlfn.NORM.DIST(1,0,1,TRUE)"}, + // Text literals must not be rewritten, even when they look like calls. + {`CONCAT("SORT(","x")`, `_xlfn.CONCAT("SORT(","x")`}, + {`IF(A1="UNIQUE(",1,2)`, `IF(A1="UNIQUE(",1,2)`}, + // Sheet-qualified references stay untouched. + {"SUM(Sheet2!A1:A2)", "SUM(Sheet2!A1:A2)"}, + } + for _, c := range cases { + if got := addFunctionPrefixes(c.in); got != c.want { + t.Errorf("addFunctionPrefixes(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestStripFunctionPrefixesRoundTrip(t *testing.T) { + for _, f := range []string{"SUM(A1:A2)", "_xlfn.UNIQUE(_xlfn._xlws.SORT(A1:A4))", "_xlfn.TEXTBEFORE(A1,\"-\")"} { + stripped := stripFunctionPrefixes(f) + if got := addFunctionPrefixes(stripped); got != f { + t.Errorf("round trip of %q gave %q", f, got) + } + } + if got := stripFunctionPrefixes("_xlws.FILTER(A1:A4,B1:B4)"); got != "FILTER(A1:A4,B1:B4)" { + t.Errorf("stripFunctionPrefixes dropped the wrong part: %q", got) + } +} diff --git a/lib/xlsx/import.go b/lib/xlsx/import.go index 30fd0f64..43a93108 100644 --- a/lib/xlsx/import.go +++ b/lib/xlsx/import.go @@ -57,7 +57,7 @@ func Import(r io.Reader) (sheet.WorkbookSnapshot, error) { } raw := val if formula, ferr := f.GetCellFormula(name, axis); ferr == nil && formula != "" { - raw = "=" + formula + raw = "=" + stripFunctionPrefixes(formula) } styleId := 0 if xid, serr := f.GetCellStyle(name, axis); serr == nil && xid != 0 { diff --git a/ui/src/js/sheet/excelFunctions.test.ts b/ui/src/js/sheet/excelFunctions.test.ts new file mode 100644 index 00000000..77b6f8d5 --- /dev/null +++ b/ui/src/js/sheet/excelFunctions.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { FormulaEngine } from './formulaEngine'; +import { excelExtraFunctionNames } from './excelFunctions'; + +// One engine for all cases: formulas go into column Z and read fixtures from +// A:D, so each case is a single setCell + read. +let e: FormulaEngine; + +const FIXTURE = [ + // A B C D + ['3', 'apple', '10', '2020-01-01'], + ['1', 'banana', '20', '2021-01-01'], + ['3', 'apple', '30', '2022-01-01'], + ['7', 'cherry', '40', '2023-01-01'], +]; + +const evalAt = (formula: string, row = 0, col = 25): string => e.setCell(row, col, formula).value; + +beforeAll(() => { + e = new FormulaEngine(); + FIXTURE.forEach((cells, r) => cells.forEach((raw, c) => e.setCell(r, c, raw))); +}); + +describe('registration', () => { + it('exposes the new functions to autocomplete', () => { + const names = new FormulaEngine().functionNames(); + for (const n of excelExtraFunctionNames) expect(names).toContain(n); + }); +}); + +describe('text', () => { + it('CONCAT joins scalars and ranges', () => { + expect(evalAt('=CONCAT("a",1,TRUE(),B1:B2)')).toBe('a1TRUEapplebanana'); + }); + + it('TEXTBEFORE / TEXTAFTER pick by instance', () => { + expect(evalAt('=TEXTBEFORE("a-b-c","-")')).toBe('a'); + expect(evalAt('=TEXTBEFORE("a-b-c","-",2)')).toBe('a-b'); + expect(evalAt('=TEXTBEFORE("a-b-c","-",-1)')).toBe('a-b'); + expect(evalAt('=TEXTAFTER("a-b-c","-")')).toBe('b-c'); + expect(evalAt('=TEXTAFTER("a-b-c","-",-1)')).toBe('c'); + expect(evalAt('=TEXTAFTER("a-b-c","X",1,0,0,"none")')).toBe('none'); + expect(evalAt('=TEXTBEFORE("aXb","x",1,1)')).toBe('a'); // case-insensitive + expect(evalAt('=TEXTBEFORE("abc","-")')).toBe('#N/A'); + expect(evalAt('=TEXTBEFORE("abc","-",1,0,1)')).toBe('abc'); // match_end + }); + + it('NUMBERVALUE honours separators and percent signs', () => { + expect(evalAt('=NUMBERVALUE("1.234,56",",",".")')).toBe('1234.56'); + expect(evalAt('=NUMBERVALUE("50%")')).toBe('0.5'); + expect(evalAt('=NUMBERVALUE("abc")')).toBe('#VALUE!'); + }); + + it('FIXED and DOLLAR format numbers', () => { + expect(evalAt('=FIXED(1234.567)')).toBe('1,234.57'); + expect(evalAt('=FIXED(1234.567,1,TRUE())')).toBe('1234.6'); + expect(evalAt('=FIXED(1234.567,-2)')).toBe('1,200'); + expect(evalAt('=DOLLAR(1234.567)')).toBe('$1,234.57'); + expect(evalAt('=DOLLAR(-1234.567)')).toBe('($1,234.57)'); + }); +}); + +describe('lookup', () => { + it('XMATCH finds exact, closest and wildcard matches', () => { + expect(evalAt('=XMATCH(7,A1:A4)')).toBe('4'); + expect(evalAt('=XMATCH(3,A1:A4)')).toBe('1'); + expect(evalAt('=XMATCH(3,A1:A4,0,-1)')).toBe('3'); // search from the end + expect(evalAt('=XMATCH(5,A1:A4,-1)')).toBe('1'); // next smaller (3) + expect(evalAt('=XMATCH(5,A1:A4,1)')).toBe('4'); // next larger (7) + expect(evalAt('=XMATCH("ban*",B1:B4,2)')).toBe('2'); + expect(evalAt('=XMATCH(99,A1:A4)')).toBe('#N/A'); + }); + + it('LOOKUP handles the vector and the array form', () => { + expect(evalAt('=LOOKUP(3,A1:A4,C1:C4)')).toBe('30'); + expect(evalAt('=LOOKUP(4,A1:A4,C1:C4)')).toBe('30'); // largest value <= 4 + expect(evalAt('=LOOKUP(3,A1:C4)')).toBe('30'); // taller than wide: last column + expect(evalAt('=LOOKUP(0,A1:A4,C1:C4)')).toBe('#N/A'); + }); +}); + +describe('dynamic arrays', () => { + // Array results spill; read the spilled cells directly. + const spill = (formula: string, row: number, col: number): string => { + e.setCell(10, 10, formula); // K11 + return e.getValue(row, col).value; + }; + + it('UNIQUE drops duplicate rows', () => { + expect(spill('=UNIQUE(B1:B4)', 10, 10)).toBe('apple'); + expect(spill('=UNIQUE(B1:B4)', 11, 10)).toBe('banana'); + expect(spill('=UNIQUE(B1:B4)', 12, 10)).toBe('cherry'); + expect(spill('=UNIQUE(B1:B4,FALSE(),TRUE())', 10, 10)).toBe('banana'); // exactly once + }); + + it('SORT orders rows by a column', () => { + expect(spill('=SORT(A1:A4)', 10, 10)).toBe('1'); + expect(spill('=SORT(A1:A4,1,-1)', 10, 10)).toBe('7'); + }); + + it('SORTBY orders one range by another', () => { + expect(spill('=SORTBY(B1:B4,C1:C4,-1)', 10, 10)).toBe('cherry'); + }); + + it('TAKE and DROP slice from either end', () => { + expect(spill('=TAKE(A1:A4,2)', 11, 10)).toBe('1'); + expect(spill('=TAKE(A1:A4,-1)', 10, 10)).toBe('7'); + expect(spill('=DROP(A1:A4,3)', 10, 10)).toBe('7'); + expect(spill('=DROP(A1:A4,-3)', 10, 10)).toBe('3'); + }); + + it('VSTACK and HSTACK combine ranges', () => { + expect(spill('=VSTACK(A1:A2,C1:C2)', 12, 10)).toBe('10'); + expect(spill('=HSTACK(A1:A2,C1:C2)', 10, 11)).toBe('10'); + }); + + it('TOCOL and TOROW flatten', () => { + expect(spill('=TOROW(A1:A4)', 10, 13)).toBe('7'); + expect(spill('=TOCOL(A1:C1)', 12, 10)).toBe('10'); + }); + + it('CHOOSECOLS and CHOOSEROWS pick by index', () => { + expect(spill('=CHOOSECOLS(A1:C1,3)', 10, 10)).toBe('10'); + expect(spill('=CHOOSEROWS(A1:A4,-1)', 10, 10)).toBe('7'); + expect(spill('=CHOOSECOLS(A1:C1,9)', 10, 10)).toBe('#VALUE!'); + }); + + it('EXPAND pads to a larger size', () => { + expect(spill('=EXPAND(A1:A2,3,1,0)', 12, 10)).toBe('0'); + expect(spill('=EXPAND(A1:A2,1)', 10, 10)).toBe('#VALUE!'); // cannot shrink + }); + + it('FREQUENCY buckets values', () => { + e.setCell(20, 0, '5'); // A21 bin + e.setCell(21, 0, '25'); // A22 bin + expect(spill('=FREQUENCY(C1:C4,A21:A22)', 10, 10)).toBe('0'); // <=5 + expect(spill('=FREQUENCY(C1:C4,A21:A22)', 11, 10)).toBe('2'); // <=25 + expect(spill('=FREQUENCY(C1:C4,A21:A22)', 12, 10)).toBe('2'); // rest + }); +}); + +describe('statistics', () => { + it('AVERAGEIFS averages with multiple criteria', () => { + expect(evalAt('=AVERAGEIFS(C1:C4,A1:A4,3)')).toBe('20'); // (10+30)/2 + expect(evalAt('=AVERAGEIFS(C1:C4,A1:A4,3,C1:C4,">15")')).toBe('30'); + expect(evalAt('=AVERAGEIFS(C1:C4,B1:B4,"a*")')).toBe('20'); + expect(evalAt('=AVERAGEIFS(C1:C4,B1:B4,"<>apple")')).toBe('30'); // (20+40)/2 + expect(evalAt('=AVERAGEIFS(C1:C4,A1:A4,99)')).toBe('#DIV/0!'); + }); + + it('RANK and RANK.AVG rank ties', () => { + expect(evalAt('=RANK(7,A1:A4)')).toBe('1'); + expect(evalAt('=RANK(3,A1:A4)')).toBe('2'); + expect(evalAt('=RANK(3,A1:A4,1)')).toBe('2'); // ascending: 1 is smaller + expect(evalAt('=RANK.AVG(3,A1:A4)')).toBe('2.5'); + expect(evalAt('=RANK.EQ(3,A1:A4)')).toBe('2'); + expect(evalAt('=RANK(99,A1:A4)')).toBe('#N/A'); + }); + + it('MODE returns the most frequent value', () => { + expect(evalAt('=MODE(A1:A4)')).toBe('3'); + expect(evalAt('=MODE.SNGL(A1:A4)')).toBe('3'); + expect(evalAt('=MODE(C1:C4)')).toBe('#N/A'); // all distinct + }); + + it('TRIMMEAN drops the extremes', () => { + expect(evalAt('=TRIMMEAN(C1:C4,0.5)')).toBe('25'); // trims 10 and 40 + expect(evalAt('=TRIMMEAN(C1:C4,0)')).toBe('25'); + }); + + it('PERMUT and PERMUTATIONA count arrangements', () => { + expect(evalAt('=PERMUT(5,2)')).toBe('20'); + expect(evalAt('=PERMUTATIONA(5,2)')).toBe('25'); + expect(evalAt('=PERMUT(2,5)')).toBe('#NUM!'); + }); + + it('INTERCEPT and FORECAST fit a line', () => { + // C = 10, 20, 30, 40 against x = 1..4 in B21:B24 + e.setCell(20, 1, '1'); + e.setCell(21, 1, '2'); + e.setCell(22, 1, '3'); + e.setCell(23, 1, '4'); + expect(evalAt('=INTERCEPT(C1:C4,B21:B24)')).toBe('0'); + expect(evalAt('=FORECAST(5,C1:C4,B21:B24)')).toBe('50'); + expect(evalAt('=FORECAST.LINEAR(5,C1:C4,B21:B24)')).toBe('50'); + }); +}); + +describe('information', () => { + it('ERROR.TYPE maps error values to codes', () => { + expect(evalAt('=ERROR.TYPE(1/0)')).toBe('2'); + expect(evalAt('=ERROR.TYPE(NA())')).toBe('7'); + expect(evalAt('=ERROR.TYPE(1)')).toBe('#N/A'); + }); + + it('TYPE classifies values', () => { + expect(evalAt('=TYPE(1)')).toBe('1'); + expect(evalAt('=TYPE("x")')).toBe('2'); + expect(evalAt('=TYPE(TRUE())')).toBe('4'); + expect(evalAt('=TYPE(NA())')).toBe('16'); + // ponytail: a range argument collapses to its first value (no array type 64) — + // accepting errors (16) matters more in practice than TYPE of an array. + expect(evalAt('=TYPE(A1:A2)')).toBe('1'); + }); +}); + +describe('financial', () => { + it('XIRR solves for the irregular-interval rate', () => { + e.setCell(30, 0, '-1000'); + e.setCell(31, 0, '1100'); + e.setCell(30, 1, '=DATE(2020,1,1)'); + e.setCell(31, 1, '=DATE(2021,1,1)'); // 366 days later + const r = Number(evalAt('=XIRR(A31:A32,B31:B32)')); + expect(r).toBeCloseTo(0.0997, 3); // ~10% over 366/365 years + }); + + it('reports #NUM! without a sign change', () => { + e.setCell(30, 0, '-1000'); + e.setCell(31, 0, '-1100'); + expect(evalAt('=XIRR(A31:A32,B31:B32)')).toBe('#NUM!'); + }); +}); diff --git a/ui/src/js/sheet/excelFunctions.ts b/ui/src/js/sheet/excelFunctions.ts new file mode 100644 index 00000000..3547268a --- /dev/null +++ b/ui/src/js/sheet/excelFunctions.ts @@ -0,0 +1,1042 @@ +// Excel functions HyperFormula 3.3 does not ship. Registered as one plugin, so +// they behave exactly like built-ins: same argument coercion, same error +// values, and the formula-bar autocomplete picks them up automatically via +// HyperFormula.getRegisteredFunctionNames(). +import { + ArraySize, + CellError, + ErrorType, + FunctionArgumentType as T, + FunctionPlugin, + HyperFormula, + SimpleRangeValue, +} from 'hyperformula'; + +// HyperFormula's interpreter types (ProcedureAst, InterpreterState, +// InternalScalarValue) are not part of its public API, so plugin methods and +// cell values are typed loosely on purpose. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Val = any; + +// HyperFormula wraps typed numbers (dates, currency, percentages) in a +// RichNumber with a `val` field; unwrap before doing arithmetic on them. +const raw = (v: Val): Val => (v !== null && typeof v === 'object' && 'val' in v ? v.val : v); + +const na = (m = 'Value not available.'): CellError => new CellError(ErrorType.NA, m); +const numErr = (m = 'Value out of range.'): CellError => new CellError(ErrorType.NUM, m); +const valErr = (m = 'Wrong type of argument.'): CellError => new CellError(ErrorType.VALUE, m); +const divZero = (m = 'Division by zero.'): CellError => new CellError(ErrorType.DIV_BY_ZERO, m); + +const flat = (r: SimpleRangeValue): Val[] => r.valuesFromTopLeftCorner().map(raw); +const nums = (vs: Val[]): number[] => vs.filter((v) => typeof v === 'number'); +const firstError = (vs: Val[]): CellError | undefined => vs.find((v) => v instanceof CellError) as CellError | undefined; + +const str = (v: Val): string => { + const x = raw(v); + if (typeof x === 'boolean') return x ? 'TRUE' : 'FALSE'; + if (typeof x === 'number' || typeof x === 'string') return String(x); + return ''; +}; + +// cmp orders two like-typed scalars; null means incomparable (Excel then fails +// the comparison rather than coercing across types). +const cmp = (a: Val, b: Val): number | null => { + if (typeof a === 'number' && typeof b === 'number') return a - b; + if (typeof a === 'string' && typeof b === 'string') { + const x = a.toLowerCase(); + const y = b.toLowerCase(); + return x < y ? -1 : x > y ? 1 : 0; + } + if (typeof a === 'boolean' && typeof b === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0); + return null; +}; +const eq = (a: Val, b: Val): boolean => cmp(a, b) === 0; + +// wildcardRe builds Excel's '*'/'?' matcher ('~' escapes them). +const wildcardRe = (pattern: string): RegExp => { + let out = ''; + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i]; + if (ch === '~' && (pattern[i + 1] === '*' || pattern[i + 1] === '?' || pattern[i + 1] === '~')) { + out += pattern[++i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } else if (ch === '*') out += '.*'; + else if (ch === '?') out += '.'; + else out += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + return new RegExp(`^${out}$`, 'i'); +}; + +// criterion compiles a SUMIF-style criterion ("<5", "<>x", "a*", 42) into a +// predicate. Shared by AVERAGEIFS. +const criterion = (crit: Val): ((v: Val) => boolean) => { + const c = raw(crit); + if (typeof c !== 'string') return (v) => eq(raw(v), c); + const m = /^(<=|>=|<>|<|>|=)?([\s\S]*)$/.exec(c) as RegExpExecArray; + const op = m[1] ?? '='; + const rest = m[2]; + const target: Val = + rest.trim() !== '' && Number.isFinite(Number(rest)) ? Number(rest) : rest === 'TRUE' ? true : rest === 'FALSE' ? false : rest; + if (op === '=' || op === '<>') { + const test = + typeof target === 'string' && /[*?]/.test(target) + ? (v: Val) => wildcardRe(target).test(str(v)) + : (v: Val) => eq(raw(v), target) || (typeof target === 'string' && str(v).toLowerCase() === target.toLowerCase()); + return op === '=' ? test : (v) => !test(v); + } + return (v) => { + const d = cmp(raw(v), target); + if (d === null) return false; + return op === '<' ? d < 0 : op === '<=' ? d <= 0 : op === '>' ? d > 0 : d >= 0; + }; +}; + +const grouped = (s: string): string => { + const neg = s.startsWith('-'); + const body = neg ? s.slice(1) : s; + const [int, frac] = body.split('.'); + const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return `${neg ? '-' : ''}${withSep}${frac === undefined ? '' : `.${frac}`}`; +}; + +// fixedText is FIXED()'s formatting, reused by DOLLAR(). Negative `decimals` +// rounds to the left of the decimal point, like Excel. +const fixedText = (n: number, decimals: number, commas: boolean): string => { + const d = Math.trunc(decimals); + const rounded = d < 0 ? Math.round(n / 10 ** -d) * 10 ** -d : n; + const s = rounded.toFixed(Math.max(0, d)); + return commas ? grouped(s) : s; +}; + +const linreg = (ys: number[], xs: number[]): { slope: number; intercept: number } | CellError => { + const n = Math.min(ys.length, xs.length); + if (n === 0) return na(); + let sx = 0; + let sy = 0; + for (let i = 0; i < n; i++) { + sx += xs[i]; + sy += ys[i]; + } + const mx = sx / n; + const my = sy / n; + let num = 0; + let den = 0; + for (let i = 0; i < n; i++) { + num += (xs[i] - mx) * (ys[i] - my); + den += (xs[i] - mx) ** 2; + } + if (den === 0) return divZero(); + const slope = num / den; + return { slope, intercept: my - slope * mx }; +}; + +// numericPairs pulls same-length numeric pairs out of two ranges, dropping +// positions where either side is non-numeric (Excel's behaviour for the +// regression family). +const numericPairs = (a: SimpleRangeValue, b: SimpleRangeValue): [number[], number[]] | CellError => { + const va = flat(a); + const vb = flat(b); + if (va.length !== vb.length) return na('Ranges must be of equal length.'); + const err = firstError(va) ?? firstError(vb); + if (err) return err; + const xs: number[] = []; + const ys: number[] = []; + for (let i = 0; i < va.length; i++) { + if (typeof va[i] === 'number' && typeof vb[i] === 'number') { + ys.push(va[i]); + xs.push(vb[i]); + } + } + return [ys, xs]; +}; + +const rows = (r: SimpleRangeValue): Val[][] => r.data.map((row) => row.slice()); +const NA_PAD = (): CellError => na(); + +const pad = (data: Val[][], width: number, filler: () => Val): Val[][] => + data.map((row) => (row.length >= width ? row : row.concat(Array.from({ length: width - row.length }, filler)))); + +// takeRange implements TAKE/DROP index math: negative counts come from the end. +const slice1 = (len: number, count: number | undefined, drop: boolean): [number, number] => { + if (count === undefined) return [0, len]; + const c = Math.trunc(count); + if (drop) return c >= 0 ? [Math.min(c, len), len] : [0, Math.max(0, len + c)]; + return c >= 0 ? [0, Math.min(c, len)] : [Math.max(0, len + c), len]; +}; + +const idx1 = (len: number, n: number): number | null => { + const i = Math.trunc(n); + if (i === 0) return null; + const zero = i > 0 ? i - 1 : len + i; + return zero < 0 || zero >= len ? null : zero; +}; + +const toArray = (data: Val[][]): SimpleRangeValue | CellError => + data.length === 0 || data[0].length === 0 ? na('Empty result.') : SimpleRangeValue.onlyValues(data); + +export class ExcelExtrasPlugin extends FunctionPlugin { + // --- text ----------------------------------------------------------------- + + concat(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('CONCAT'), (...args: Val[]) => { + let out = ''; + for (const a of args) { + if (a instanceof CellError) return a; + if (a instanceof SimpleRangeValue) { + for (const v of flat(a)) { + if (v instanceof CellError) return v; + out += str(v); + } + } else out += str(a); + } + return out; + }); + } + + private static split(text: string, delim: string, instance: number, ignoreCase: boolean): number | null { + if (delim === '') return null; + const hay = ignoreCase ? text.toLowerCase() : text; + const needle = ignoreCase ? delim.toLowerCase() : delim; + const hits: number[] = []; + for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) hits.push(i); + const n = Math.trunc(instance); + if (n === 0) return null; + const hit = n > 0 ? hits[n - 1] : hits[hits.length + n]; + return hit === undefined ? null : hit; + } + + textbefore(ast: Val, state: Val): Val { + return this.runFunction( + ast.args, + state, + this.metadata('TEXTBEFORE'), + (text: string, delim: string, instance: number, matchMode: number, matchEnd: number, ifNotFound: Val) => { + if (delim === '') return ''; + if (instance === 0) return valErr('Instance number must not be 0.'); + const at = ExcelExtrasPlugin.split(text, delim, instance, matchMode !== 0); + if (at === null) return matchEnd !== 0 ? text : ifNotFound === undefined ? na() : ifNotFound; + return text.slice(0, at); + }, + ); + } + + textafter(ast: Val, state: Val): Val { + return this.runFunction( + ast.args, + state, + this.metadata('TEXTAFTER'), + (text: string, delim: string, instance: number, matchMode: number, matchEnd: number, ifNotFound: Val) => { + if (delim === '') return text; + if (instance === 0) return valErr('Instance number must not be 0.'); + const at = ExcelExtrasPlugin.split(text, delim, instance, matchMode !== 0); + if (at === null) return matchEnd !== 0 ? '' : ifNotFound === undefined ? na() : ifNotFound; + return text.slice(at + delim.length); + }, + ); + } + + numbervalue(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('NUMBERVALUE'), (text: string, dec: string, group: string) => { + if (dec === '' || group === '') return valErr('Separator must not be empty.'); + const d = dec[0]; + const g = group[0]; + if (d === g) return valErr('Separators must differ.'); + let s = text.split(g).join('').trim(); + if (s === '') return 0; + let percents = 0; + while (s.endsWith('%')) { + percents++; + s = s.slice(0, -1); + } + s = s.split(d).join('.'); + if (!/^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(s)) return valErr('Not a number.'); + return Number(s) / 100 ** percents; + }); + } + + fixed(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('FIXED'), (n: number, decimals: number, noCommas: boolean) => + fixedText(n, decimals, !noCommas), + ); + } + + dollar(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('DOLLAR'), (n: number, decimals: number) => { + const body = fixedText(Math.abs(n), decimals, true); + return n < 0 ? `($${body})` : `$${body}`; + }); + } + + // --- lookup --------------------------------------------------------------- + + xmatch(ast: Val, state: Val): Val { + return this.runFunction( + ast.args, + state, + this.metadata('XMATCH'), + (needle: Val, range: SimpleRangeValue, matchMode: number, searchMode: number) => { + const key = raw(needle); + if (key instanceof CellError) return key; + const values = flat(range); + const order = searchMode < 0 ? [...values.keys()].reverse() : [...values.keys()]; + if (matchMode === 2) { + const re = wildcardRe(str(key)); + for (const i of order) if (typeof values[i] === 'string' && re.test(values[i])) return i + 1; + return na(); + } + for (const i of order) if (eq(values[i], key)) return i + 1; + if (matchMode === 0) return na(); + // ponytail: -1/1 scan linearly for the closest value instead of assuming + // sorted input; binary search modes (±2) fall back to the same scan. + let best = -1; + for (const i of order) { + const d = cmp(values[i], key); + if (d === null) continue; + if (matchMode === -1 ? d > 0 : d < 0) continue; + if (best === -1 || (matchMode === -1 ? cmp(values[i], values[best])! > 0 : cmp(values[i], values[best])! < 0)) best = i; + } + return best === -1 ? na() : best + 1; + }, + ); + } + + lookup(ast: Val, state: Val): Val { + return this.runFunction( + ast.args, + state, + this.metadata('LOOKUP'), + (needle: Val, range: SimpleRangeValue, result: SimpleRangeValue | undefined) => { + const key = raw(needle); + if (key instanceof CellError) return key; + let search: Val[]; + let out: Val[]; + if (result !== undefined) { + search = flat(range); + out = flat(result); + } else if (range.width() > range.height()) { + // Array form: wider than tall searches the first row, returns the last. + search = range.data[0].map(raw); + out = range.data[range.height() - 1].map(raw); + } else { + search = range.data.map((r) => raw(r[0])); + out = range.data.map((r) => raw(r[range.width() - 1])); + } + let best = -1; + for (let i = 0; i < search.length; i++) { + const d = cmp(search[i], key); + if (d === null || d > 0) continue; + if (best === -1 || cmp(search[i], search[best])! >= 0) best = i; + } + if (best === -1 || out[best] === undefined) return na(); + return out[best]; + }, + ); + } + + // --- dynamic arrays ------------------------------------------------------- + + unique(ast: Val, state: Val): Val { + return this.runFunction( + ast.args, + state, + this.metadata('UNIQUE'), + (range: SimpleRangeValue, byCol: boolean, exactlyOnce: boolean) => { + const data = byCol ? transpose(rows(range)) : rows(range); + const counts = new Map(); + const keys = data.map((row) => row.map((v) => `${typeof raw(v)}:${str(v).toLowerCase()}`).join('')); + for (const k of keys) counts.set(k, (counts.get(k) ?? 0) + 1); + const seen = new Set(); + const kept: Val[][] = []; + for (let i = 0; i < data.length; i++) { + if (exactlyOnce ? counts.get(keys[i]) !== 1 : seen.has(keys[i])) continue; + seen.add(keys[i]); + kept.push(data[i]); + } + return toArray(byCol ? transpose(kept) : kept); + }, + ); + } + + sort(ast: Val, state: Val): Val { + return this.runFunction( + ast.args, + state, + this.metadata('SORT'), + (range: SimpleRangeValue, index: number, order: number, byCol: boolean) => { + const data = byCol ? transpose(rows(range)) : rows(range); + const i = Math.trunc(index) - 1; + if (i < 0 || i >= (data[0]?.length ?? 0)) return valErr('Sort index out of range.'); + const dir = order < 0 ? -1 : 1; + const sorted = data + .map((row, pos) => ({ row, pos })) + .sort((a, b) => (cmp(raw(a.row[i]), raw(b.row[i])) ?? typeRank(a.row[i]) - typeRank(b.row[i])) * dir || a.pos - b.pos) + .map((e) => e.row); + return toArray(byCol ? transpose(sorted) : sorted); + }, + ); + } + + sortby(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('SORTBY'), (range: SimpleRangeValue, ...rest: Val[]) => { + const data = rows(range); + const keys: Array<{ vals: Val[]; dir: number }> = []; + for (let i = 0; i < rest.length; i += 2) { + const by = rest[i]; + if (!(by instanceof SimpleRangeValue)) return valErr('SORTBY expects ranges to sort by.'); + const vals = flat(by); + if (vals.length !== data.length) return valErr('Sort-by range must match the array height.'); + keys.push({ vals, dir: (raw(rest[i + 1]) ?? 1) < 0 ? -1 : 1 }); + } + if (keys.length === 0) return valErr('SORTBY needs at least one range to sort by.'); + const sorted = data + .map((row, pos) => ({ row, pos })) + .sort((a, b) => { + for (const k of keys) { + const d = (cmp(k.vals[a.pos], k.vals[b.pos]) ?? typeRank(k.vals[a.pos]) - typeRank(k.vals[b.pos])) * k.dir; + if (d !== 0) return d; + } + return a.pos - b.pos; + }) + .map((e) => e.row); + return toArray(sorted); + }); + } + + take(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('TAKE'), (range: SimpleRangeValue, r: Val, c: Val) => + cut(rows(range), raw(r), raw(c), false), + ); + } + + drop(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('DROP'), (range: SimpleRangeValue, r: Val, c: Val) => + cut(rows(range), raw(r), raw(c), true), + ); + } + + vstack(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('VSTACK'), (...args: Val[]) => { + const parts = args.map((a) => (a instanceof SimpleRangeValue ? rows(a) : [[raw(a)]])); + const width = Math.max(...parts.map((p) => Math.max(...p.map((r) => r.length)))); + return toArray(parts.flatMap((p) => pad(p, width, NA_PAD))); + }); + } + + hstack(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('HSTACK'), (...args: Val[]) => { + const parts = args.map((a) => (a instanceof SimpleRangeValue ? rows(a) : [[raw(a)]])); + const height = Math.max(...parts.map((p) => p.length)); + const out: Val[][] = Array.from({ length: height }, () => [] as Val[]); + for (const p of parts) { + const width = Math.max(...p.map((r) => r.length)); + for (let i = 0; i < height; i++) out[i].push(...pad([p[i] ?? []], width, NA_PAD)[0]); + } + return toArray(out); + }); + } + + tocol(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('TOCOL'), (range: SimpleRangeValue, ignore: number, byCol: boolean) => + toArray(linearize(rows(range), ignore, byCol).map((v) => [v])), + ); + } + + torow(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('TOROW'), (range: SimpleRangeValue, ignore: number, byCol: boolean) => + toArray([linearize(rows(range), ignore, byCol)]), + ); + } + + choosecols(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('CHOOSECOLS'), (range: SimpleRangeValue, ...cols: Val[]) => { + const data = rows(range); + const picked = cols.map((c) => idx1(range.width(), raw(c))); + if (picked.some((p) => p === null)) return valErr('Column index out of range.'); + return toArray(data.map((row) => picked.map((p) => row[p as number]))); + }); + } + + chooserows(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('CHOOSEROWS'), (range: SimpleRangeValue, ...rs: Val[]) => { + const data = rows(range); + const picked = rs.map((r) => idx1(range.height(), raw(r))); + if (picked.some((p) => p === null)) return valErr('Row index out of range.'); + return toArray(picked.map((p) => data[p as number])); + }); + } + + expand(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('EXPAND'), (range: SimpleRangeValue, r: Val, c: Val, padWith: Val) => { + const data = rows(range); + const height = raw(r) === undefined ? data.length : Math.trunc(raw(r)); + const width = raw(c) === undefined ? range.width() : Math.trunc(raw(c)); + if (height < data.length || width < range.width()) return valErr('Cannot shrink with EXPAND.'); + const filler = (): Val => (padWith === undefined ? na() : raw(padWith)); + const out = pad(data, width, filler); + while (out.length < height) out.push(Array.from({ length: width }, filler)); + return toArray(out); + }); + } + + // --- statistics ----------------------------------------------------------- + + averageifs(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('AVERAGEIFS'), (avgRange: SimpleRangeValue, ...rest: Val[]) => { + const values = flat(avgRange); + const conditions: Array<{ vals: Val[]; test: (v: Val) => boolean }> = []; + for (let i = 0; i < rest.length; i += 2) { + const range = rest[i]; + if (!(range instanceof SimpleRangeValue)) return valErr('AVERAGEIFS expects criteria ranges.'); + const vals = flat(range); + if (vals.length !== values.length) return valErr('Criteria ranges must match the average range.'); + conditions.push({ vals, test: criterion(rest[i + 1]) }); + } + if (conditions.length === 0) return valErr('AVERAGEIFS needs at least one criterion.'); + let sum = 0; + let count = 0; + for (let i = 0; i < values.length; i++) { + if (values[i] instanceof CellError) return values[i]; + if (typeof values[i] !== 'number') continue; + if (!conditions.every((c) => c.test(c.vals[i]))) continue; + sum += values[i]; + count++; + } + return count === 0 ? divZero('No cell matched the criteria.') : sum / count; + }); + } + + rank(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('RANK'), (n: number, range: SimpleRangeValue, order: number) => + rankOf(n, range, order, false), + ); + } + + rankavg(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('RANK.AVG'), (n: number, range: SimpleRangeValue, order: number) => + rankOf(n, range, order, true), + ); + } + + mode(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('MODE'), (...args: Val[]) => { + const modes = modesOf(args); + return modes instanceof CellError ? modes : modes[0]; + }); + } + + modemult(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('MODE.MULT'), (...args: Val[]) => { + const modes = modesOf(args); + return modes instanceof CellError ? modes : toArray(modes.map((m) => [m])); + }); + } + + trimmean(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('TRIMMEAN'), (range: SimpleRangeValue, percent: number) => { + const values = flat(range); + const err = firstError(values); + if (err) return err; + const sorted = nums(values).sort((a, b) => a - b); + if (sorted.length === 0) return divZero('Empty range.'); + // Excel trims FLOOR(n*percent, 2)/2 values from each end. + const k = Math.floor((sorted.length * percent) / 2); + const kept = sorted.slice(k, sorted.length - k); + if (kept.length === 0) return divZero('Everything trimmed.'); + return kept.reduce((a, b) => a + b, 0) / kept.length; + }); + } + + permut(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('PERMUT'), (n: number, k: number) => { + if (k > n) return numErr('Chosen must not exceed total.'); + let out = 1; + for (let i = 0; i < k; i++) out *= n - i; + return out; + }); + } + + permutationa(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('PERMUTATIONA'), (n: number, k: number) => n ** k); + } + + intercept(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('INTERCEPT'), (ys: SimpleRangeValue, xs: SimpleRangeValue) => { + const pairs = numericPairs(ys, xs); + if (pairs instanceof CellError) return pairs; + const fit = linreg(pairs[0], pairs[1]); + return fit instanceof CellError ? fit : fit.intercept; + }); + } + + forecast(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('FORECAST'), (x: number, ys: SimpleRangeValue, xs: SimpleRangeValue) => { + const pairs = numericPairs(ys, xs); + if (pairs instanceof CellError) return pairs; + const fit = linreg(pairs[0], pairs[1]); + return fit instanceof CellError ? fit : fit.intercept + fit.slope * x; + }); + } + + frequency(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('FREQUENCY'), (data: SimpleRangeValue, bins: SimpleRangeValue) => { + const values = flat(data); + const binValues = flat(bins); + const err = firstError(values) ?? firstError(binValues); + if (err) return err; + const edges = nums(binValues).sort((a, b) => a - b); + const counts = new Array(edges.length + 1).fill(0); + for (const v of nums(values)) { + const at = edges.findIndex((e) => v <= e); + counts[at === -1 ? edges.length : at]++; + } + return toArray(counts.map((c) => [c])); + }); + } + + // --- information ---------------------------------------------------------- + + errortype(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('ERROR.TYPE'), (v: Val) => { + if (!(v instanceof CellError)) return na(); + switch (v.type) { + case ErrorType.DIV_BY_ZERO: + return 2; + case ErrorType.VALUE: + return 3; + case ErrorType.REF: + return 4; + case ErrorType.NAME: + return 5; + case ErrorType.NUM: + return 6; + case ErrorType.NA: + return 7; + default: + return 8; + } + }); + } + + type(ast: Val, state: Val): Val { + return this.runFunction(ast.args, state, this.metadata('TYPE'), (v: Val) => { + if (v instanceof CellError) return 16; + if (v instanceof SimpleRangeValue) return 64; + const x = raw(v); + if (typeof x === 'number') return 1; + if (typeof x === 'boolean') return 4; + return 2; + }); + } + + // --- financial ------------------------------------------------------------ + + xirr(ast: Val, state: Val): Val { + return this.runFunction( + ast.args, + state, + this.metadata('XIRR'), + (values: SimpleRangeValue, dates: SimpleRangeValue, guess: number) => { + const vs = flat(values); + const ds = flat(dates); + const err = firstError(vs) ?? firstError(ds); + if (err) return err; + const cash = nums(vs); + const when = nums(ds); + if (cash.length !== when.length || cash.length < 2) return numErr('XIRR needs matching values and dates.'); + if (!cash.some((v) => v > 0) || !cash.some((v) => v < 0)) return numErr('XIRR needs a positive and a negative value.'); + const t0 = when[0]; + const npv = (rate: number): number => + cash.reduce((acc, v, i) => acc + v / (1 + rate) ** ((when[i] - t0) / 365), 0); + // Newton first (fast, matches Excel), bisection as the safety net. + let rate = guess; + for (let i = 0; i < 50; i++) { + const f = npv(rate); + if (Math.abs(f) < 1e-9) return rate; + const d = (npv(rate + 1e-6) - f) / 1e-6; + if (!Number.isFinite(d) || d === 0) break; + const next = rate - f / d; + if (!Number.isFinite(next) || next <= -1) break; + rate = next; + } + let lo = -0.999999; + let hi = 1e6; + if (npv(lo) * npv(hi) > 0) return numErr('XIRR did not converge.'); + for (let i = 0; i < 200; i++) { + const mid = (lo + hi) / 2; + if (npv(lo) * npv(mid) <= 0) hi = mid; + else lo = mid; + } + return (lo + hi) / 2; + }, + ); + } + + // --- array-size predictions ---------------------------------------------- + // HyperFormula reserves the spill range before evaluating, so every + // array-returning function needs a size hint. A hint that is too large is + // harmless (extra cells stay empty, like built-in FILTER); too small would + // truncate, so each hint below is an upper bound. + + private argSizes(ast: Val, state: Val): ArraySize[] { + return ast.args.map((arg: Val) => this.arraySizeForAst(arg, state)); + } + + inputSize(ast: Val, state: Val): ArraySize { + const sizes = this.argSizes(ast, state); + if (sizes.length === 0) return ArraySize.error(); + return new ArraySize(Math.max(...sizes.map((s) => s.width)), Math.max(...sizes.map((s) => s.height))); + } + + colSize(ast: Val, state: Val): ArraySize { + const first = this.argSizes(ast, state)[0]; + return first === undefined ? ArraySize.error() : new ArraySize(1, first.width * first.height); + } + + rowSize(ast: Val, state: Val): ArraySize { + const first = this.argSizes(ast, state)[0]; + return first === undefined ? ArraySize.error() : new ArraySize(first.width * first.height, 1); + } + + vstackSize(ast: Val, state: Val): ArraySize { + const sizes = this.argSizes(ast, state); + if (sizes.length === 0) return ArraySize.error(); + return new ArraySize( + Math.max(...sizes.map((s) => s.width)), + sizes.reduce((a, s) => a + s.height, 0), + ); + } + + hstackSize(ast: Val, state: Val): ArraySize { + const sizes = this.argSizes(ast, state); + if (sizes.length === 0) return ArraySize.error(); + return new ArraySize( + sizes.reduce((a, s) => a + s.width, 0), + Math.max(...sizes.map((s) => s.height)), + ); + } + + chooseColsSize(ast: Val, state: Val): ArraySize { + const sizes = this.argSizes(ast, state); + if (sizes.length < 2) return ArraySize.error(); + return new ArraySize(Math.max(sizes[0].width, sizes.length - 1), sizes[0].height); + } + + chooseRowsSize(ast: Val, state: Val): ArraySize { + const sizes = this.argSizes(ast, state); + if (sizes.length < 2) return ArraySize.error(); + return new ArraySize(sizes[0].width, Math.max(sizes[0].height, sizes.length - 1)); + } + + // ponytail: EXPAND only knows its target size when rows/cols are literals; + // with computed sizes the hint stays at the input size (result then spills + // only as far as the input). Literal args cover the realistic usage. + expandSize(ast: Val, state: Val): ArraySize { + const sizes = this.argSizes(ast, state); + if (sizes.length < 2) return ArraySize.error(); + const literal = (i: number): number | undefined => + ast.args[i] !== undefined && ast.args[i].type === 'NUMBER' ? ast.args[i].value : undefined; + return new ArraySize(literal(2) ?? sizes[0].width, literal(1) ?? sizes[0].height); + } + + frequencySize(ast: Val, state: Val): ArraySize { + const sizes = this.argSizes(ast, state); + if (sizes.length !== 2) return ArraySize.error(); + return new ArraySize(1, sizes[1].width * sizes[1].height + 1); + } +} + +const transpose = (data: Val[][]): Val[][] => { + const width = Math.max(0, ...data.map((r) => r.length)); + return Array.from({ length: width }, (_, c) => data.map((r) => r[c])); +}; + +// typeRank orders across types the way Excel sorts: numbers < text < booleans. +const typeRank = (v: Val): number => { + const x = raw(v); + if (typeof x === 'number') return 0; + if (typeof x === 'string') return 1; + if (typeof x === 'boolean') return 2; + return 3; +}; + +const cut = (data: Val[][], r: Val, c: Val, drop: boolean): SimpleRangeValue | CellError => { + const [r0, r1] = slice1(data.length, r, drop); + const width = Math.max(0, ...data.map((row) => row.length)); + const [c0, c1] = slice1(width, c, drop); + return toArray(data.slice(r0, r1).map((row) => row.slice(c0, c1))); +}; + +const linearize = (data: Val[][], ignore: number, byCol: boolean): Val[] => { + const flatVals = (byCol ? transpose(data) : data).flat(); + const skipBlank = ignore === 1 || ignore === 3; + const skipError = ignore === 2 || ignore === 3; + return flatVals.filter((v) => { + const x = raw(v); + if (skipError && x instanceof CellError) return false; + if (skipBlank && (x === null || x === undefined || x === '' || typeof x === 'symbol')) return false; + return true; + }); +}; + +const rankOf = (n: number, range: SimpleRangeValue, order: number, average: boolean): number | CellError => { + const values = flat(range); + const err = firstError(values); + if (err) return err; + const pool = nums(values); + if (!pool.some((v) => v === n)) return na('Value not found in the range.'); + const better = pool.filter((v) => (order === 0 ? v > n : v < n)).length; + const ties = pool.filter((v) => v === n).length; + return better + 1 + (average ? (ties - 1) / 2 : 0); +}; + +const modesOf = (args: Val[]): Val[] | CellError => { + const values: Val[] = []; + for (const a of args) { + if (a instanceof CellError) return a; + if (a instanceof SimpleRangeValue) { + const inner = flat(a); + const err = firstError(inner); + if (err) return err; + values.push(...nums(inner)); + } else if (typeof raw(a) === 'number') values.push(raw(a)); + } + if (values.length === 0) return na('No numeric values.'); + const counts = new Map(); + for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1); + const top = Math.max(...counts.values()); + if (top < 2) return na('No repeated value.'); + const seen = new Set(); + const out: Val[] = []; + for (const v of values) { + if (counts.get(v) === top && !seen.has(v)) { + seen.add(v); + out.push(v); + } + } + return out; +}; + +const optional = { optionalArg: true } as const; +const anyArg = { argumentType: T.ANY } as const; +const rangeArg = { argumentType: T.RANGE } as const; + +ExcelExtrasPlugin.implementedFunctions = { + CONCAT: { method: 'concat', parameters: [anyArg], repeatLastArgs: 1 }, + TEXTBEFORE: { + method: 'textbefore', + parameters: [ + { argumentType: T.STRING }, + { argumentType: T.STRING }, + { argumentType: T.NUMBER, defaultValue: 1 }, + { argumentType: T.NUMBER, defaultValue: 0 }, + { argumentType: T.NUMBER, defaultValue: 0 }, + { argumentType: T.SCALAR, ...optional }, + ], + }, + TEXTAFTER: { + method: 'textafter', + parameters: [ + { argumentType: T.STRING }, + { argumentType: T.STRING }, + { argumentType: T.NUMBER, defaultValue: 1 }, + { argumentType: T.NUMBER, defaultValue: 0 }, + { argumentType: T.NUMBER, defaultValue: 0 }, + { argumentType: T.SCALAR, ...optional }, + ], + }, + NUMBERVALUE: { + method: 'numbervalue', + parameters: [ + { argumentType: T.STRING }, + { argumentType: T.STRING, defaultValue: '.' }, + { argumentType: T.STRING, defaultValue: ',' }, + ], + }, + FIXED: { + method: 'fixed', + parameters: [ + { argumentType: T.NUMBER }, + { argumentType: T.NUMBER, defaultValue: 2 }, + { argumentType: T.BOOLEAN, defaultValue: false }, + ], + }, + DOLLAR: { + method: 'dollar', + parameters: [{ argumentType: T.NUMBER }, { argumentType: T.NUMBER, defaultValue: 2 }], + }, + XMATCH: { + method: 'xmatch', + parameters: [ + { argumentType: T.SCALAR }, + rangeArg, + { argumentType: T.NUMBER, defaultValue: 0 }, + { argumentType: T.NUMBER, defaultValue: 1 }, + ], + }, + LOOKUP: { + method: 'lookup', + parameters: [{ argumentType: T.SCALAR }, rangeArg, { argumentType: T.RANGE, ...optional }], + }, + UNIQUE: { + method: 'unique', + parameters: [rangeArg, { argumentType: T.BOOLEAN, defaultValue: false }, { argumentType: T.BOOLEAN, defaultValue: false }], + sizeOfResultArrayMethod: 'inputSize', + vectorizationForbidden: true, + }, + SORT: { + method: 'sort', + parameters: [ + rangeArg, + { argumentType: T.NUMBER, defaultValue: 1 }, + { argumentType: T.NUMBER, defaultValue: 1 }, + { argumentType: T.BOOLEAN, defaultValue: false }, + ], + sizeOfResultArrayMethod: 'inputSize', + vectorizationForbidden: true, + }, + SORTBY: { + method: 'sortby', + parameters: [rangeArg, anyArg, { argumentType: T.SCALAR, ...optional }], + repeatLastArgs: 2, + sizeOfResultArrayMethod: 'inputSize', + vectorizationForbidden: true, + }, + TAKE: { + method: 'take', + parameters: [rangeArg, { argumentType: T.NUMBER, ...optional }, { argumentType: T.NUMBER, ...optional }], + sizeOfResultArrayMethod: 'inputSize', + vectorizationForbidden: true, + }, + DROP: { + method: 'drop', + parameters: [rangeArg, { argumentType: T.NUMBER, ...optional }, { argumentType: T.NUMBER, ...optional }], + sizeOfResultArrayMethod: 'inputSize', + vectorizationForbidden: true, + }, + VSTACK: { + method: 'vstack', + parameters: [anyArg], + repeatLastArgs: 1, + sizeOfResultArrayMethod: 'vstackSize', + vectorizationForbidden: true, + }, + HSTACK: { + method: 'hstack', + parameters: [anyArg], + repeatLastArgs: 1, + sizeOfResultArrayMethod: 'hstackSize', + vectorizationForbidden: true, + }, + TOCOL: { + method: 'tocol', + parameters: [rangeArg, { argumentType: T.NUMBER, defaultValue: 0 }, { argumentType: T.BOOLEAN, defaultValue: false }], + sizeOfResultArrayMethod: 'colSize', + vectorizationForbidden: true, + }, + TOROW: { + method: 'torow', + parameters: [rangeArg, { argumentType: T.NUMBER, defaultValue: 0 }, { argumentType: T.BOOLEAN, defaultValue: false }], + sizeOfResultArrayMethod: 'rowSize', + vectorizationForbidden: true, + }, + CHOOSECOLS: { + method: 'choosecols', + parameters: [rangeArg, { argumentType: T.NUMBER }], + repeatLastArgs: 1, + sizeOfResultArrayMethod: 'chooseColsSize', + vectorizationForbidden: true, + }, + CHOOSEROWS: { + method: 'chooserows', + parameters: [rangeArg, { argumentType: T.NUMBER }], + repeatLastArgs: 1, + sizeOfResultArrayMethod: 'chooseRowsSize', + vectorizationForbidden: true, + }, + EXPAND: { + method: 'expand', + parameters: [ + rangeArg, + { argumentType: T.NUMBER, ...optional }, + { argumentType: T.NUMBER, ...optional }, + { argumentType: T.SCALAR, ...optional }, + ], + sizeOfResultArrayMethod: 'expandSize', + vectorizationForbidden: true, + }, + AVERAGEIFS: { + method: 'averageifs', + parameters: [rangeArg, rangeArg, { argumentType: T.NOERROR }], + repeatLastArgs: 2, + }, + RANK: { + method: 'rank', + parameters: [{ argumentType: T.NUMBER }, rangeArg, { argumentType: T.NUMBER, defaultValue: 0 }], + }, + 'RANK.AVG': { + method: 'rankavg', + parameters: [{ argumentType: T.NUMBER }, rangeArg, { argumentType: T.NUMBER, defaultValue: 0 }], + }, + MODE: { method: 'mode', parameters: [anyArg], repeatLastArgs: 1 }, + 'MODE.MULT': { + method: 'modemult', + parameters: [anyArg], + repeatLastArgs: 1, + sizeOfResultArrayMethod: 'colSize', + vectorizationForbidden: true, + }, + TRIMMEAN: { + method: 'trimmean', + parameters: [rangeArg, { argumentType: T.NUMBER, minValue: 0, lessThan: 1 }], + }, + PERMUT: { + method: 'permut', + parameters: [ + { argumentType: T.INTEGER, minValue: 0 }, + { argumentType: T.INTEGER, minValue: 0 }, + ], + }, + PERMUTATIONA: { + method: 'permutationa', + parameters: [ + { argumentType: T.INTEGER, minValue: 0 }, + { argumentType: T.INTEGER, minValue: 0 }, + ], + }, + INTERCEPT: { method: 'intercept', parameters: [rangeArg, rangeArg] }, + FORECAST: { method: 'forecast', parameters: [{ argumentType: T.NUMBER }, rangeArg, rangeArg] }, + FREQUENCY: { + method: 'frequency', + parameters: [rangeArg, rangeArg], + sizeOfResultArrayMethod: 'frequencySize', + vectorizationForbidden: true, + }, + 'ERROR.TYPE': { method: 'errortype', parameters: [{ argumentType: T.SCALAR }] }, + // SCALAR (not ANY) so error values reach the implementation instead of being + // propagated; vectorization off so a range argument stays one call (-> 64). + TYPE: { method: 'type', parameters: [{ argumentType: T.SCALAR }], vectorizationForbidden: true }, + XIRR: { + method: 'xirr', + parameters: [rangeArg, rangeArg, { argumentType: T.NUMBER, defaultValue: 0.1 }], + }, +}; + +ExcelExtrasPlugin.aliases = { + 'RANK.EQ': 'RANK', + 'MODE.SNGL': 'MODE', + 'FORECAST.LINEAR': 'FORECAST', +}; + +const names = [...Object.keys(ExcelExtrasPlugin.implementedFunctions), ...Object.keys(ExcelExtrasPlugin.aliases)]; + +let registered = false; + +// registerExcelFunctions is idempotent — HyperFormula's registry is global and +// throws on a duplicate function id. +export function registerExcelFunctions(): void { + if (registered) return; + registered = true; + const translations = Object.fromEntries(names.map((n) => [n, n])); + HyperFormula.registerFunctionPlugin(ExcelExtrasPlugin, { enGB: translations, enUS: translations }); +} + +export const excelExtraFunctionNames = names; diff --git a/ui/src/js/sheet/formulaEngine.ts b/ui/src/js/sheet/formulaEngine.ts index 891732c8..fbc3199a 100644 --- a/ui/src/js/sheet/formulaEngine.ts +++ b/ui/src/js/sheet/formulaEngine.ts @@ -1,4 +1,5 @@ import { HyperFormula, type ExportedChange, type ExportedCellChange } from 'hyperformula'; +import { registerExcelFunctions } from './excelFunctions'; export interface CellResult { value: string; @@ -18,6 +19,7 @@ export class FormulaEngine { private sheetId: number; constructor() { + registerExcelFunctions(); this.hf = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }); const name = this.hf.addSheet('Sheet1'); this.sheetId = this.hf.getSheetId(name) as number; diff --git a/ui/src/js/sheet/sheetEditor.ts b/ui/src/js/sheet/sheetEditor.ts index 08a83326..05367ba9 100644 --- a/ui/src/js/sheet/sheetEditor.ts +++ b/ui/src/js/sheet/sheetEditor.ts @@ -174,7 +174,13 @@ export function startSheetEditor(root: HTMLElement): void { const displayValue = (r: number, c: number): string => { const cell = collab?.display.getCell(activeSheetId, r, c); - if (!cell || cell.raw === '') return ''; + if (!cell || cell.raw === '') { + // Cell has no content of its own, but an array formula elsewhere may + // spill into it (=UNIQUE(..), =SORT(..), =SEQUENCE(..)). Excel shows + // those spilled values, so ask the engine. + const spilled = engine.getValue(r, c); + return spilled.type === 'empty' ? '' : formatValue(spilled.value, '', propsOf(r, c).numFmt); + } const raw = cell.raw.startsWith('=') ? engine.getValue(r, c).value : cell.raw; return formatValue(raw, '', propsOf(r, c).numFmt); }; @@ -485,7 +491,8 @@ export function startSheetEditor(root: HTMLElement): void { // gate skips non-formula cells. Fold into displayValue if the grid grows. errorOf: (r, c) => { const cell = collab?.display.getCell(activeSheetId, r, c); - if (!cell || !cell.raw.startsWith('=')) return undefined; + // '' included: an array formula can spill an error into a blank cell. + if (cell && cell.raw !== '' && !cell.raw.startsWith('=')) return undefined; const res = engine.getValue(r, c); return res.type === 'error' ? res.value : undefined; },