- 옛 B08 식 풀이기(git 8472fc9f)의 분수 · 파서를 ui_template/sheet/ 로 되살림 — 필요한 함수만
- 열 참조 [열id] · 셀 참조 [열id@줄id] · 변수 [$이름] · SUM · INT · ROUND · ROUNDUP · ROUNDDOWN · MIN · MAX · IF
- 한 칸만 다른 식(줄.식) · 합계 줄 열마다 다른 식 · 끝수(반올림 · 올림 · 버림) · 순환은 그 칸만 오류
- 서버 껍데기 common_util_sheet_recalc.py · build:formula 가 새 진입점을 가리킴(깨진 B08 경로 고침)
- 시험 — 실무 울진 2공구 구조물집계표 합계 캐시값 · 파이썬 Decimal 거울 40문서
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_sheet_formula.ts
|
|
* 표 식 읽기 · 풀기 — 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 파서를 되살려 표 참조를 더함.
|
|
*
|
|
* 식 말: 사칙 · 괄호 · 비교(= <> < <= > >=) · 함수 SUM · INT · ROUND · ROUNDUP · ROUNDDOWN ·
|
|
* MIN · MAX · IF · 참조 `[열id]`(같은 줄) · `[열id@줄id]`(한 칸) · `[$이름]`(문서 변수).
|
|
* `SUM([열id])` = 그 열의 본문 줄 전부. 빈 칸은 0(엑셀과 같음).
|
|
* ⚠ `eval` 을 쓰지 않음 — 식은 사용자 글이라 직접 짠 파서로만 읽음.
|
|
* 우선순위는 엑셀과 같음 — 부호 > `* /` > `+ -` > 비교. 같은 단은 왼쪽부터.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
add,
|
|
cmp,
|
|
div,
|
|
type Frac,
|
|
frac,
|
|
FormulaError,
|
|
type IntMode,
|
|
mul,
|
|
parseDecimal,
|
|
roundAt,
|
|
sub,
|
|
toInteger,
|
|
ZERO,
|
|
} from "./ui_template_sheet_frac";
|
|
|
|
type Token =
|
|
| { kind: "num"; text: string }
|
|
| { kind: "str"; text: string }
|
|
| { kind: "id"; text: string }
|
|
| { kind: "ref"; text: string }
|
|
| { kind: "op"; text: string };
|
|
|
|
export type Node =
|
|
| { type: "num"; value: Frac }
|
|
| { type: "str"; value: string }
|
|
| { type: "ref"; col: string; row: string | null }
|
|
| { type: "var"; name: string }
|
|
| { type: "unary"; op: string; arg: Node }
|
|
| { type: "binary"; op: string; left: Node; right: Node }
|
|
| { type: "call"; name: string; args: Node[] };
|
|
|
|
export type Value = Frac | string | boolean;
|
|
|
|
/** 풀 때 참조를 대 주는 쪽 — 표 풀이(`_recalc`)가 채움. */
|
|
export interface Scope {
|
|
cell(col: string, row: string | null): Value;
|
|
column(col: string): Frac[];
|
|
variable(name: string): Value;
|
|
}
|
|
|
|
const OPERATORS = ["<=", ">=", "<>", "+", "-", "*", "/", "(", ")", ",", "=", "<", ">"];
|
|
const COMPARISONS = new Set(["=", "<>", "<", "<=", ">", ">="]);
|
|
const ROUNDERS: Record<string, IntMode> = { ROUND: "round", ROUNDUP: "away", ROUNDDOWN: "trunc" };
|
|
|
|
function tokenize(source: string): Token[] {
|
|
const tokens: Token[] = [];
|
|
let i = 0;
|
|
while (i < source.length) {
|
|
const ch = source[i];
|
|
if (/\s/.test(ch)) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
const number = /^(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/.exec(source.slice(i));
|
|
if (number) {
|
|
tokens.push({ kind: "num", text: number[0] });
|
|
i += number[0].length;
|
|
continue;
|
|
}
|
|
if (ch === "[") {
|
|
const end = source.indexOf("]", i + 1);
|
|
if (end < 0) throw new FormulaError("「[」가 닫히지 않음");
|
|
tokens.push({ kind: "ref", text: source.slice(i + 1, end).trim() });
|
|
i = end + 1;
|
|
continue;
|
|
}
|
|
if (ch === "'" || ch === '"') {
|
|
const end = source.indexOf(ch, i + 1);
|
|
if (end < 0) throw new FormulaError("따옴표가 닫히지 않음");
|
|
tokens.push({ kind: "str", text: source.slice(i + 1, end) });
|
|
i = end + 1;
|
|
continue;
|
|
}
|
|
const identifier = /^[\p{L}_][\p{L}\p{N}_]*/u.exec(source.slice(i));
|
|
if (identifier) {
|
|
tokens.push({ kind: "id", text: identifier[0].toUpperCase() });
|
|
i += identifier[0].length;
|
|
continue;
|
|
}
|
|
const op = OPERATORS.find((candidate) => source.startsWith(candidate, i));
|
|
if (!op) throw new FormulaError(`읽을 수 없는 글자: ${ch}`);
|
|
tokens.push({ kind: "op", text: op });
|
|
i += op.length;
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function refNode(text: string): Node {
|
|
if (!text) throw new FormulaError("빈 참조 []");
|
|
if (text.startsWith("$")) return { type: "var", name: text.slice(1).trim() };
|
|
const at = text.indexOf("@");
|
|
if (at < 0) return { type: "ref", col: text, row: null };
|
|
return { type: "ref", col: text.slice(0, at).trim(), row: text.slice(at + 1).trim() };
|
|
}
|
|
|
|
/** 앞에 `=` 가 붙어 있어도 됨(엑셀 버릇). */
|
|
export function parseFormula(source: string): Node {
|
|
const tokens = tokenize(source.trim().replace(/^=/, ""));
|
|
let at = 0;
|
|
const peek = (): Token | undefined => tokens[at];
|
|
const isOp = (text: string): boolean => peek()?.kind === "op" && peek()?.text === text;
|
|
const expect = (text: string): void => {
|
|
if (!isOp(text)) throw new FormulaError(`「${text}」가 있어야 함`);
|
|
at += 1;
|
|
};
|
|
|
|
const comparison = (): Node => {
|
|
let left = additive();
|
|
while (peek()?.kind === "op" && COMPARISONS.has(peek()!.text)) {
|
|
const op = tokens[at++].text;
|
|
left = { type: "binary", op, left, right: additive() };
|
|
}
|
|
return left;
|
|
};
|
|
const additive = (): Node => {
|
|
let left = term();
|
|
while (isOp("+") || isOp("-")) {
|
|
const op = tokens[at++].text;
|
|
left = { type: "binary", op, left, right: term() };
|
|
}
|
|
return left;
|
|
};
|
|
const term = (): Node => {
|
|
let left = unary();
|
|
while (isOp("*") || isOp("/")) {
|
|
const op = tokens[at++].text;
|
|
left = { type: "binary", op, left, right: unary() };
|
|
}
|
|
return left;
|
|
};
|
|
const unary = (): Node => {
|
|
if (isOp("-") || isOp("+")) {
|
|
const op = tokens[at++].text;
|
|
return { type: "unary", op, arg: unary() };
|
|
}
|
|
return primary();
|
|
};
|
|
const primary = (): Node => {
|
|
const token = peek();
|
|
if (!token) throw new FormulaError("식이 중간에 끝남");
|
|
at += 1;
|
|
if (token.kind === "num") return { type: "num", value: parseDecimal(token.text) };
|
|
if (token.kind === "str") return { type: "str", value: token.text };
|
|
if (token.kind === "ref") return refNode(token.text);
|
|
if (token.kind === "id") {
|
|
if (!isOp("(")) throw new FormulaError(`모르는 이름: ${token.text} — 칸은 [열id] 로`);
|
|
at += 1;
|
|
const args: Node[] = [];
|
|
if (!isOp(")")) {
|
|
args.push(comparison());
|
|
while (isOp(",")) {
|
|
at += 1;
|
|
args.push(comparison());
|
|
}
|
|
}
|
|
expect(")");
|
|
return { type: "call", name: token.text, args };
|
|
}
|
|
if (token.text === "(") {
|
|
const inner = comparison();
|
|
expect(")");
|
|
return inner;
|
|
}
|
|
throw new FormulaError(`여기에 올 수 없음: ${token.text}`);
|
|
};
|
|
|
|
if (!tokens.length) throw new FormulaError("빈 식");
|
|
const tree = comparison();
|
|
if (at < tokens.length) throw new FormulaError(`식 끝에 남은 글: ${tokens[at].text}`);
|
|
return tree;
|
|
}
|
|
|
|
/** 식이 가리키는 참조 — 순환 · 없는 열 검사용. */
|
|
export function* refsOf(node: Node): Generator<Node & { type: "ref" | "var" }> {
|
|
if (node.type === "ref" || node.type === "var") yield node;
|
|
else if (node.type === "unary") yield* refsOf(node.arg);
|
|
else if (node.type === "binary") {
|
|
yield* refsOf(node.left);
|
|
yield* refsOf(node.right);
|
|
} else if (node.type === "call") for (const arg of node.args) yield* refsOf(arg);
|
|
}
|
|
|
|
const isFrac = (v: Value): v is Frac => typeof v === "object";
|
|
|
|
export function asFrac(value: Value): Frac {
|
|
if (isFrac(value)) return value;
|
|
if (typeof value === "boolean") return frac(value ? 1n : 0n);
|
|
throw new FormulaError(`수 자리에 글이 옴: ${value}`);
|
|
}
|
|
|
|
function truthy(value: Value): boolean {
|
|
if (typeof value === "boolean") return value;
|
|
if (isFrac(value)) return value.n !== 0n;
|
|
throw new FormulaError(`조건 자리에 글이 옴: ${value}`);
|
|
}
|
|
|
|
function compareValues(a: Value, b: Value): number {
|
|
if (typeof a === "string" || typeof b === "string") {
|
|
if (typeof a !== "string" || typeof b !== "string") {
|
|
throw new FormulaError("글과 수를 견줄 수 없음");
|
|
}
|
|
return a === b ? 0 : a < b ? -1 : 1;
|
|
}
|
|
return cmp(asFrac(a), asFrac(b));
|
|
}
|
|
|
|
function digitsOf(node: Node | undefined, scope: Scope): number {
|
|
if (!node) return 0;
|
|
const value = asFrac(evaluate(node, scope));
|
|
if (value.d !== 1n) throw new FormulaError("자리 수는 정수");
|
|
return Number(value.n);
|
|
}
|
|
|
|
export function evaluate(node: Node, scope: Scope): Value {
|
|
switch (node.type) {
|
|
case "num":
|
|
case "str":
|
|
return node.value;
|
|
case "ref":
|
|
return scope.cell(node.col, node.row);
|
|
case "var":
|
|
return scope.variable(node.name);
|
|
case "unary": {
|
|
const value = asFrac(evaluate(node.arg, scope));
|
|
return node.op === "-" ? sub(ZERO, value) : value;
|
|
}
|
|
case "binary": {
|
|
const left = evaluate(node.left, scope);
|
|
const right = evaluate(node.right, scope);
|
|
if (COMPARISONS.has(node.op)) {
|
|
const order = compareValues(left, right);
|
|
return {
|
|
"=": order === 0,
|
|
"<>": order !== 0,
|
|
"<": order < 0,
|
|
"<=": order <= 0,
|
|
">": order > 0,
|
|
">=": order >= 0,
|
|
}[node.op]!;
|
|
}
|
|
const a = asFrac(left);
|
|
const b = asFrac(right);
|
|
if (node.op === "+") return add(a, b);
|
|
if (node.op === "-") return sub(a, b);
|
|
if (node.op === "*") return mul(a, b);
|
|
return div(a, b);
|
|
}
|
|
case "call": {
|
|
const { name, args } = node;
|
|
if (name === "IF") {
|
|
if (args.length !== 3) throw new FormulaError("IF 는 인자가 셋(조건, 참, 거짓)");
|
|
// 고른 갈래만 풂 — 안 고른 갈래의 오류가 칸을 막지 않게
|
|
return evaluate(truthy(evaluate(args[0], scope)) ? args[1] : args[2], scope);
|
|
}
|
|
if (name === "SUM") {
|
|
let total = ZERO;
|
|
for (const arg of args) {
|
|
const values =
|
|
arg.type === "ref" && arg.row === null
|
|
? scope.column(arg.col)
|
|
: [asFrac(evaluate(arg, scope))];
|
|
for (const value of values) total = add(total, value);
|
|
}
|
|
return total;
|
|
}
|
|
if (name === "INT") {
|
|
if (args.length !== 1) throw new FormulaError("INT 는 인자가 하나");
|
|
return frac(toInteger(asFrac(evaluate(args[0], scope)), "floor"));
|
|
}
|
|
if (name in ROUNDERS) {
|
|
if (args.length < 1 || args.length > 2) throw new FormulaError(`${name} 는 (값, 자리)`);
|
|
return roundAt(asFrac(evaluate(args[0], scope)), digitsOf(args[1], scope), ROUNDERS[name]);
|
|
}
|
|
if (name === "MIN" || name === "MAX") {
|
|
const values = args.map((arg) => asFrac(evaluate(arg, scope)));
|
|
if (!values.length) throw new FormulaError(`${name} 에 인자가 없음`);
|
|
return values.reduce((best, v) => (cmp(v, best) < 0 === (name === "MIN") ? v : best));
|
|
}
|
|
throw new FormulaError(`모르는 함수: ${name}`);
|
|
}
|
|
}
|
|
}
|