192 lines
4.4 KiB
TypeScript
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);
|
|
}
|