- 계약 더함: 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
305 lines
11 KiB
TypeScript
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;
|
|
}
|