Files
excel-view/src/components/canvas-spreadsheet/formula/evaluator.ts
2026-06-26 14:27:23 +08:00

192 lines
4.4 KiB
TypeScript

export type CellValueReader = (viewRow: number, col: number) => unknown;
export type FormulaResult = number | 'DIV_ZERO' | null;
class DivZeroError extends Error {
constructor() {
super('Division by zero');
this.name = 'DivZeroError';
}
}
export function colLettersToIndex(letters: string): number {
let n = 0;
for (const ch of letters.toUpperCase()) {
n = n * 26 + (ch.charCodeAt(0) - 64);
}
return n - 1;
}
export function colIndexToLetters(index: number): string {
let n = index + 1;
let result = '';
while (n > 0) {
n -= 1;
result = String.fromCharCode(65 + (n % 26)) + result;
n = Math.floor(n / 26);
}
return result;
}
function toNumber(value: unknown): number {
if (value == null || value === '') return 0;
const n = Number(value);
return Number.isFinite(n) ? n : Number.NaN;
}
function tokenize(expr: string): string[] {
const tokens: string[] = [];
let i = 0;
while (i < expr.length) {
const ch = expr[i]!;
if (ch === ' ' || ch === '\t') {
i += 1;
continue;
}
if ('+-*/()'.includes(ch)) {
tokens.push(ch);
i += 1;
continue;
}
if (/[0-9.]/.test(ch)) {
let num = ch;
i += 1;
while (i < expr.length && /[0-9.]/.test(expr[i]!)) {
num += expr[i];
i += 1;
}
tokens.push(num);
continue;
}
if (/[A-Za-z]/.test(ch)) {
let ref = ch;
i += 1;
while (i < expr.length && /[A-Za-z0-9]/.test(expr[i]!)) {
ref += expr[i];
i += 1;
}
tokens.push(ref);
continue;
}
throw new Error(`Unexpected character: ${ch}`);
}
return tokens;
}
function parseFactor(
tokens: string[],
pos: { i: number },
readCell: CellValueReader,
viewRow: number,
): number {
const token = tokens[pos.i];
if (token === undefined) throw new Error('Unexpected end');
if (token === '(') {
pos.i += 1;
const value = parseExpression(tokens, pos, readCell, viewRow);
if (tokens[pos.i] !== ')') throw new Error('Missing )');
pos.i += 1;
return value;
}
if (token === '+') {
pos.i += 1;
return parseFactor(tokens, pos, readCell, viewRow);
}
if (token === '-') {
pos.i += 1;
return -parseFactor(tokens, pos, readCell, viewRow);
}
if (/^[0-9]+(\.[0-9]+)?$/.test(token)) {
pos.i += 1;
return Number(token);
}
const match = /^([A-Za-z]+)(\d+)$/.exec(token);
if (match) {
pos.i += 1;
const col = colLettersToIndex(match[1]!);
return toNumber(readCell(viewRow, col));
}
throw new Error(`Invalid token: ${token}`);
}
function parseTerm(
tokens: string[],
pos: { i: number },
readCell: CellValueReader,
viewRow: number,
): number {
let value = parseFactor(tokens, pos, readCell, viewRow);
while (pos.i < tokens.length) {
const op = tokens[pos.i];
if (op === '*' || op === '/') {
pos.i += 1;
const rhs = parseFactor(tokens, pos, readCell, viewRow);
if (op === '/') {
if (rhs === 0) throw new DivZeroError();
value /= rhs;
} else {
value *= rhs;
}
} else {
break;
}
}
return value;
}
function parseExpression(
tokens: string[],
pos: { i: number },
readCell: CellValueReader,
viewRow: number,
): number {
let value = parseTerm(tokens, pos, readCell, viewRow);
while (pos.i < tokens.length) {
const op = tokens[pos.i];
if (op === '+' || op === '-') {
pos.i += 1;
const rhs = parseTerm(tokens, pos, readCell, viewRow);
value = op === '+' ? value + rhs : value - rhs;
} else {
break;
}
}
return value;
}
export function evaluateFormula(
formula: string,
viewRow: number,
readCell: CellValueReader,
): FormulaResult {
if (!formula?.trim()) return null;
let expr = formula.trim();
if (expr.startsWith('=')) expr = expr.slice(1);
if (!expr) return null;
try {
const tokens = tokenize(expr);
const pos = { i: 0 };
const result = parseExpression(tokens, pos, readCell, viewRow);
if (pos.i < tokens.length) throw new Error('Trailing tokens');
if (!Number.isFinite(result)) return null;
return result;
} catch (err) {
if (err instanceof DivZeroError) return 'DIV_ZERO';
return null;
}
}
export function formatFormulaResult(value: FormulaResult): string {
if (value === 'DIV_ZERO') return '-';
if (value == null || Number.isNaN(value)) return '#ERROR';
if (Number.isInteger(value)) return String(value);
return String(Math.round(value * 100) / 100);
}