初始化
This commit is contained in:
191
src/components/canvas-spreadsheet/formula/evaluator.ts
Normal file
191
src/components/canvas-spreadsheet/formula/evaluator.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user