Files
Aislo/A00_Common/spreadsheet/spreadsheet_eval.ts
T
eomsangdonandClaude Opus 5.5 279ef5fb4f feat(spreadsheet): A 엔진 뺀 셋 채움 · 메모 옮김 — 계약 더함(Sheet.기본 · Sheet.comments)
- 계약 더함: Sheet.기본 { 서식 · 열폭 · 행높이 } — 모두 고르기 서식 · 폭 · 높이 · 칸 서식 물려받기 = 칸 → 행 → 열 → 시트 기본 → 0
- 계약 더함: Sheet.comments — A1 → 메모 글 · 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 · 덮은 칸 메모는 지움)
- 행 · 열 넣기 = 위(왼) 줄 칸 서식 · 높이 · 폭 따라감(숨김은 안 따라감 · 첫 줄 앞 넣기는 안 따라감)
- 범위를 칸 하나 자리에 넣으면 엑셀 암묵 교차(한 열 범위 = 식 든 행 · 한 행 범위 = 식 든 열)
- 풀이 차례를 반복 위상 순서로 — 2만 칸 사슬도 스택 안 넘침
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py 규칙 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:23:32 +09:00

305 lines
11 KiB
TypeScript

/* =============================================================================
* spreadsheet_eval.ts (주인 A)
* 나무 풀기 · 오류 값 전파 · 형 바꾸기(엑셀 규칙). B 의 함수들이 형 바꾸기 도우미를 씀.
* 빈 칸 = 수에서 0 · 글에서 "" · `"12"+1 = 13` · `TRUE+1 = 2` · 글이 수로 안 읽히면 `#VALUE!`.
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수(B · A 같이 `fromDouble`).
* 오류 전파 = 왼쪽부터 처음 만난 오류. 비교 = 수 < 글 < 참거짓 · 글은 대소문자 가리지 않음.
* ========================================================================== */
import {
add,
cmp,
div,
frac,
fracToString,
mul,
parseDecimal,
roundAt,
sub,
toFrac,
ZERO,
} from "@ui/sheet/ui_template_sheet_frac";
import { normRange } from "./spreadsheet_address";
import { getFunction } from "./spreadsheet_functions";
import type {
CalcValue,
ErrorCode,
ErrorValue,
EvalContext,
EvalResult,
FormulaNode,
Frac,
RangeValue,
Scalar,
} from "./spreadsheet_types";
export function err(code: ErrorCode, why?: string): ErrorValue {
return why === undefined ? { error: code } : { error: code, why };
}
export function isError(value: unknown): value is ErrorValue {
return typeof value === "object" && value !== null && "error" in value;
}
export function isFrac(value: unknown): value is Frac {
return typeof value === "object" && value !== null && "n" in value && "d" in value;
}
export function isRange(value: unknown): value is RangeValue {
return typeof value === "object" && value !== null && (value as RangeValue).kind === "range";
}
const ONE = frac(1n);
const HUNDRED = frac(100n);
const BIG_D = 10n ** 40n;
/** 분모가 너무 커지면(나눗셈 사슬) 30 자리 십진으로 굳힘 */
export function tame(x: Frac): Frac {
return x.d > BIG_D ? parseDecimal(fracToString(x)) : x;
}
/** 범위 → 칸 하나(1×1 일 때만) · 아니면 `#VALUE!` */
function single(value: EvalResult): Scalar {
if (!isRange(value)) return value;
return value.rows === 1 && value.cols === 1
? value.at(0, 0)
: err("#VALUE!", "범위를 값 하나로 못 씀");
}
/** 엔진이 만든 범위 — 적힌 자리(끝 자름 전) · 암묵 교차에 씀 */
export interface PlacedRange extends RangeValue {
r0: number;
c0: number;
r1: number;
c1: number;
}
/** 범위 → 칸 하나 — 1×1 이면 그 칸 · 아니면 식 든 행(한 열 범위) · 열(한 행 범위)과 겹친 칸(엑셀 암묵 교차) */
export function intersect(value: EvalResult, at: { r: number; c: number }): Scalar {
if (!isRange(value) || (value.rows === 1 && value.cols === 1)) return single(value);
const p = value as Partial<PlacedRange>;
if (p.r0 !== undefined && p.c0 !== undefined && p.r1 !== undefined && p.c1 !== undefined) {
if (p.c0 === p.c1 && at.r >= p.r0 && at.r <= p.r1) return value.at(at.r - p.r0, 0);
if (p.r0 === p.r1 && at.c >= p.c0 && at.c <= p.c1) return value.at(0, at.c - p.c0);
}
return err("#VALUE!", "범위가 식 든 행 · 열과 안 겹침");
}
/** 수로 — 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` · 범위는 칸 하나일 때만 */
export function asNumber(value: EvalResult): Frac | ErrorValue {
const v = single(value);
if (v === null) return ZERO;
if (typeof v === "boolean") return v ? ONE : ZERO;
if (typeof v === "string") {
const t = v.trim();
const pct = t.endsWith("%");
try {
const n = parseDecimal(pct ? t.slice(0, -1) : t);
return pct ? div(n, HUNDRED) : n;
} catch {
return err("#VALUE!", `수로 못 읽는 글: "${v}"`);
}
}
return v;
}
/** 일반 형식 글 — 유효 15 자리 · 1e15 넘거나 1e-9 밑이면 `1.5E+18` 꼴(엑셀 `&` 와 같게) */
export function numberText(x: Frac): string {
if (x.n === 0n) return "0";
const d = parseFloat(fracToString(x));
const e = Math.floor(Math.log10(Math.abs(d)));
if (e >= 15 || e < -9) {
const [m, ex] = d.toExponential(14).split("e");
const mant = m.replace(/\.?0+$/, "");
const n = Number(ex);
return `${mant}E${n < 0 ? "-" : "+"}${String(Math.abs(n)).padStart(2, "0")}`;
}
return fracToString(roundAt(x, 14 - e, "round"));
}
/** 글로 — 수는 일반 형식 글(엑셀 `&` 와 같게) · 참거짓 `TRUE`/`FALSE` */
export function asText(value: EvalResult): string | ErrorValue {
const v = single(value);
if (v === null) return "";
if (typeof v === "string" || isError(v)) return v;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return numberText(v);
}
export function asBool(value: EvalResult): boolean | ErrorValue {
const v = single(value);
if (v === null) return false;
if (typeof v === "boolean" || isError(v)) return v;
if (typeof v === "string") {
const t = v.toUpperCase();
return t === "TRUE"
? true
: t === "FALSE"
? false
: err("#VALUE!", `참거짓으로 못 읽는 글: "${v}"`);
}
return v.n !== 0n;
}
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
export function fromDouble(x: number): Frac | ErrorValue {
if (!Number.isFinite(x)) return err("#NUM!", "수가 너무 크거나 뜻이 없음");
return x === 0 ? ZERO : parseDecimal(x.toPrecision(15));
}
export const toDouble = (x: Frac): number => parseFloat(fracToString(x));
/** 저장 모양 — 수는 십진 글(30 자리까지) */
export function toCalcValue(value: Scalar): CalcValue {
if (value === null) return { 수: "0" };
if (typeof value === "string") return { 글: value };
if (typeof value === "boolean") return { 참: value };
if (isError(value))
return value.why ? { 오류: value.error, 까닭: value.why } : { 오류: value.error };
return { 수: fracToString(value) };
}
// ── 연산 ────────────────────────────────────────────────────────────────────
function power(a: Frac, b: Frac): Frac | ErrorValue {
if (a.n === 0n) {
if (b.n === 0n) return err("#NUM!", "0^0");
return b.n < 0n ? err("#DIV/0!", "0 의 음수 거듭제곱") : ZERO;
}
if (b.d === 1n) {
const e = b.n < 0n ? -b.n : b.n;
const bits = BigInt(a.n.toString(2).length + a.d.toString(2).length);
// ponytail: 4,000 비트 넘는 정확 거듭제곱은 double 로 — 실무 식엔 없음
if (bits * e <= 4000n) {
const r = frac(a.n ** e, a.d ** e);
return tame(b.n < 0n ? div(ONE, r) : r);
}
} else if (a.n < 0n) return err("#NUM!", "음수의 소수 거듭제곱");
return fromDouble(Math.pow(toDouble(a), toDouble(b)));
}
function arith(op: string, a: Frac, b: Frac): Frac | ErrorValue {
switch (op) {
case "+":
return tame(add(a, b));
case "-":
return tame(sub(a, b));
case "*":
return tame(mul(a, b));
case "/":
return b.n === 0n ? err("#DIV/0!", "0 으로 나눔") : tame(div(a, b));
default:
return power(a, b);
}
}
const RANK = (v: Scalar) => (isFrac(v) ? 0 : typeof v === "string" ? 1 : 2);
/** 엑셀 비교 — 빈 칸은 맞은편 형의 빈 값(0 · "" · FALSE) · 형이 다르면 수 < 글 < 참거짓 */
export function compareValues(a: Scalar, b: Scalar): number {
const blank = (other: Scalar): Scalar =>
isFrac(other)
? ZERO
: typeof other === "string"
? ""
: typeof other === "boolean"
? false
: ZERO;
if (a === null) a = blank(b);
if (b === null) b = blank(a);
const ra = RANK(a);
const rb = RANK(b);
if (ra !== rb) return ra < rb ? -1 : 1;
if (isFrac(a)) return cmp(a, b as Frac);
if (typeof a === "string") {
const x = a.toLowerCase();
const y = (b as string).toLowerCase();
return x === y ? 0 : x < y ? -1 : 1;
}
return a === b ? 0 : a ? 1 : -1;
}
function compareOp(op: string, a: Scalar, b: Scalar): boolean {
const c = compareValues(a, b);
return op === "="
? c === 0
: op === "<>"
? c !== 0
: op === "<"
? c < 0
: op === "<="
? c <= 0
: op === ">"
? c > 0
: c >= 0;
}
// ── 풀기 ────────────────────────────────────────────────────────────────────
export function evaluate(node: FormulaNode, ctx: EvalContext): EvalResult {
switch (node.type) {
case "number":
return node.value;
case "string":
return node.value;
case "bool":
return node.value;
case "error":
return err(node.code);
case "name":
return err("#NAME?", `모르는 이름: ${node.name}`);
case "cell":
case "range": {
const sheet = node.sheet === null ? ctx.sheet : ctx.sheetId(node.sheet);
if (sheet === null) return err("#REF!", `없는 시트: ${node.sheet}`);
if (node.type === "cell") return ctx.cell(sheet, node.ref.r, node.ref.c);
return ctx.range(sheet, normRange(node.from.r, node.from.c, node.to.r, node.to.c));
}
case "unary": {
const v = intersect(evaluate(node.arg, ctx), ctx);
if (node.op === "+") return v;
const n = asNumber(v);
if (isError(n)) return n;
return node.op === "-" ? frac(-n.n, n.d) : div(n, HUNDRED);
}
case "binary": {
const left = intersect(evaluate(node.left, ctx), ctx);
const right = intersect(evaluate(node.right, ctx), ctx);
if (node.op === "&") {
const a = asText(left);
if (isError(a)) return a;
const b = asText(right);
return isError(b) ? b : a + b;
}
if (PLAIN_OPS.has(node.op)) {
const a = asNumber(left);
if (isError(a)) return a;
const b = asNumber(right);
return isError(b) ? b : arith(node.op, a, b);
}
if (isError(left)) return left;
return isError(right) ? right : compareOp(node.op, left, right);
}
case "call": {
const fn = getFunction(node.name);
if (!fn) return err("#NAME?", `모르는 함수: ${node.name}`);
const n = node.args.length;
if (n < fn.min || (fn.max !== undefined && n > fn.max)) {
const want = fn.max === fn.min ? `${fn.min}` : `${fn.min}~${fn.max ?? ""}`;
return err("#VALUE!", `${node.name} 인자 수 ${n} (받는 수 ${want})`);
}
return fn.call(
node.args.map((a) => () => evaluate(a, ctx)),
ctx,
);
}
}
}
const PLAIN_OPS = new Set(["+", "-", "*", "/", "^"]);
/** 수로 온 입력 값 → 풀이 값 */
export function inputValue(v: number | string | boolean): Scalar {
return typeof v === "number" ? toFrac(v) : v;
}