Files
Aislo/common_util/common_util_line_stack_frac.ts
T
eomsangdonandClaude Opus 5.5 badc24c0cc fix(845-2): 단가산출 단위 표시 규칙 한 벌 · 기계 산출근거 겹분수 중간 줄로 나눔
- 단위 규칙 한 벌(common_util_line_stack_unit) — 왼쪽 값 칸 · 식 줄 · 옵션 · 오른쪽 산출근거 · 기계 제어 구역 · 기계 보기가 같이 씀
  · 메타 단위(구분 따름 · 수(…) · 원문 단위 없음) 안 그림 · %(설명) = % · 10^-7 = 10⁻⁷ · 기계 칸 연결 = 기계 표 칸 단위 · 코드 값 쉼표 없음
- 비목 · 금액 줄 = 「단가 원/단위 × (수량 식) 단위 = 금액 원」 · 소계 · 합계 · 계 = 원 · 단가 · 수량 한 덩이(줄 바뀜에 단위 안 떨어짐)
- 기계경비식 겹분수 → 중간 줄(상여 · 휴지 · 손료계수 · common_util_line_stack_frac) · 한 줄 분수 한 층
- 기계 제어 구역 단위 = 칸 밖 바로 뒤 · 기계 보기 오른쪽 = 박기 전 기계 칸 단위
- 휴대폰 폭(560 px 밑) 산출근거 금액 칸 넷 걷음(가로 넘침 0) · 분수 줄 위아래 여유
- 전수 1,520 표시 탓 43,218 → 0 · 기계 겹분수 2 층 넘는 줄 1,040 → 0 · 금액 · 식 · 문서 자료 불변(그리기만)
- 시험 test_845_2_unit_show(차린 줄 글 전수 지킴) · shownUnit 걷음(규칙 한 벌로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 22:44:36 +09:00

135 lines
5.5 KiB
TypeScript

/* =============================================================================
* common_util_line_stack_frac.ts
* 산출근거 분수 층(PLAN 845-2 · 요구 880 「분모 · 분자가 쌓여 층층이 → 행으로 나눔」)
* 층 = 조판(`line_stack_tex`)과 같은 셈 — ÷ 하나 = 분수 한 층 · 감싸개(반올림 · 올림 · 버림)는 안 셈
* 기계경비식(`splitFrac`) = 한 줄에 분수 한 층까지:
* ① 곱 · 나눗셈 사슬에 ÷ 가 둘 넘으면 「a ÷ b」 를 짝으로 나란히(겹 대신 · 값 같음)
* ② 「…분자 ÷ …분모」 짝(그림 이름 머리가 줄 이름과 다름) = 중간 줄 「상여 = 16 ÷ 12」
* ③ 분수 안 덧셈 묶음이 또 분수를 품으면 = 중간 줄(그림 사전 `partName` — 「손료계수」)
* ④ 그래도 남는 안쪽 분수 = 조판이 ÷ 한 줄로(`TexCtx.frac`)
* 그리기만 — 문서 식 · 값 · 금액 불변 · 중간 줄 값 = 같은 식 조각을 같은 셈(`evalNode`) · 화면 없음(시험이 Node 로 돌림)
* ========================================================================== */
import type { Node } from "./common_util_formula_doc";
import { evalNode, type Values } from "./common_util_formula_doc_view";
const HIDE = new Set(["반올림", "올림", "버림"]);
const hidden = (n: Node): n is ["call", string, Node[]] =>
n[0] === "call" && HIDE.has(n[1]) && n[2].length >= 1 && n[2].length <= 2;
/** 조판 분수 층 수 */
export function fracDepth(n: Node): number {
switch (n[0]) {
case "neg":
case "pct":
return fracDepth(n[1]);
case "call": {
const items = n[2];
return hidden(n) ? fracDepth(items[0]) : Math.max(0, ...items.map(fracDepth));
}
case "bin": {
const d = Math.max(fracDepth(n[2]), fracDepth(n[3]));
return n[1] === "/" ? d + 1 : d;
}
default:
return 0;
}
}
/** 왼쪽으로 쌓인 곱 · 나눗셈 사슬 → [연산, 항] */
function chain(n: Node): ["*" | "/", Node][] {
if (n[0] === "bin" && (n[1] === "*" || n[1] === "/"))
return [...chain(n[2]), [n[1] as "*" | "/", n[3]]];
return [["*", n]];
}
const product = (xs: Node[]): Node => xs.reduce((a, b) => ["bin", "*", a, b]);
const isFrac = (n: Node): n is ["bin", string, Node, Node] => n[0] === "bin" && n[1] === "/";
/** ① ÷ 둘 넘는 사슬 = 「a ÷ b」 짝을 나란히 — 감싸개 · 괄호 안까지 */
export function regroup(n: Node): Node {
switch (n[0]) {
case "neg":
return ["neg", regroup(n[1])];
case "pct":
return ["pct", regroup(n[1])];
case "call":
return ["call", n[1], n[2].map(regroup)];
case "bin": {
const items = chain(n);
if (items.length < 2 || items.filter(([o]) => o === "/").length < 2)
return ["bin", n[1], regroup(n[2]), regroup(n[3])];
const out: Node[] = [];
for (const [o, raw] of items) {
const x = regroup(raw);
const last = out[out.length - 1];
if (o === "*" || !last) out.push(x);
else if (isFrac(last))
out[out.length - 1] = ["bin", "/", last[2], ["bin", "*", last[3], x]];
else out[out.length - 1] = ["bin", "/", last, x];
}
return product(out);
}
default:
return n;
}
}
export interface FracRow {
/** 중간 줄 이름(그림 전용 — 문서 기호 아님) */
id: string;
node: Node;
}
const numOk = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
/** 기계경비식 한 줄 → 중간 줄 + 줄인 식 · 중간 값(`vals` 에 얹을 것) */
export function splitFrac(
id: string,
n: Node,
alias: Record<string, string>,
taken: Set<string>,
vals: Values,
part?: string,
): { rows: FracRow[]; node: Node; vals: Values } {
const rows: FracRow[] = [];
const got: Values = {};
const name = (x: string): string => alias[x] ?? x;
const own = name(id);
const take = (nm: string, sub: Node): Node | null => {
if (!nm || taken.has(nm) || nm in got) return null;
const v = evalNode(sub, { ...vals, ...got });
if (!numOk(v)) return null;
got[nm] = v;
rows.push({ id: nm, node: sub });
return ["var", nm];
};
// 감싸개(버림 등)는 그대로 두고 안쪽만
const inner = (x: Node, fn: (y: Node) => Node): Node =>
hidden(x) ? ["call", x[1], [inner(x[2][0], fn), ...x[2].slice(1)]] : fn(x);
let node = inner(regroup(n), (x) => {
// ② 분자 · 분모 짝(곱 사슬의 분수 항)
const factors = (y: Node): Node[] =>
y[0] === "bin" && y[1] === "*" ? [...factors(y[2]), y[3]] : [y];
const parts = factors(x).map((f) => {
if (!isFrac(f) || f[2][0] !== "var" || f[3][0] !== "var") return f;
const a = /^(.+)분자$/.exec(name(f[2][1]));
const b = /^(.+)분모$/.exec(name(f[3][1]));
return a && b && a[1] === b[1] && a[1] !== own ? (take(a[1], f) ?? f) : f;
});
return product(parts);
});
// ③ 분수 안 덧셈 묶음이 분수를 품음 = 중간 줄(사전 이름 있을 때만)
if (part && fracDepth(node) > 1) {
const dig = (x: Node, inFrac: boolean): Node => {
if (x[0] === "bin" && (x[1] === "+" || x[1] === "-") && inFrac && fracDepth(x) >= 1)
return take(part, x) ?? x;
if (x[0] === "bin") return ["bin", x[1], dig(x[2], inFrac || x[1] === "/"), dig(x[3], inFrac || x[1] === "/")]; // prettier-ignore
if (x[0] === "call") return ["call", x[1], x[2].map((y) => dig(y, inFrac))];
if (x[0] === "neg" || x[0] === "pct") return [x[0], dig(x[1], inFrac)] as Node;
return x;
};
node = dig(node, false);
}
return { rows, node, vals: got };
}