Files
Aislo/A00_Common/line_stack_tex.ts
T
eomsangdonandClaude Opus 5.5 af576e5a7a feat(804-5): 줄 쌓기 왼쪽 손질 — 자원 이름 단추 · 항목 띠 · 끌어 옮기기 · X 지움 되돌리기 · 율 번지 읽는 이름
- 인력 · 기계 · 재료 · 단가산출 줄 자원 이름 = 단추(이름 · 규격) → 찾아 넣기 창 → 바꿈 · [찾아 바꾸기] 걷음
- 항목 머리 = 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 한 단 더 · 접기
- 순서 = 손잡이 끌기(줄 = 항목 안 · 사이 · 항목 끌기 · 자리 선 표시) · [↑][↓] 걷음
- 지움 = X 아이콘 · 확인 없이 지우고 알림 눌러 되돌리기(그 뒤 고침 있으면 못 함)
- 산출근거 율 줄 「번지.복사N.노무비」 → 그 조각 이름 · 값 식 = 번지 값 · 합계 덧셈 항 넷 넘으면 항목 소계끼리
- 알림 부품: 누름 동작 알림은 같은 문구 버림 안 함(두 번째 지움 되돌리기 안 뜨던 흠)
- 글 · 근거 카드 높이 늘던 흠 고침 · 시험 test_804_4 끌어옮김 · 번지 이름 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:29:31 +09:00

152 lines
5.4 KiB
TypeScript

/* =============================================================================
* line_stack_tex.ts
* 줄 쌓기 산출근거 수식 조판(PLAN 804-2 · 요구 677) — 식 나무 → TeX → KaTeX(MIT) · 분수 · 첨자 · 괄호
* 기호 식(side "sym") = 기호(첨자) · 값 식(side "val") = 값(모르면 기호 빨강) · 만약 = 탄 갈래만 · 반올림 · 올림 · 버림 감싸개 숨김
* 금액 줄 꼴(`inline`) = ÷ 한 줄(경쟁사 「노무비 : 59,020 ÷ Q」) · 공식 줄 = 분수
* 규칙은 수식 탭 그림(`formula_doc_render.ts` renderNode)과 같음 — 셈 정본은 계산기
* ========================================================================== */
import katex from "katex";
import "../config/node_modules/katex/dist/katex.min.css";
import { eqText } from "@ui/ui_template_equation";
import { splitId, type Node } from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import { leafNum, takenOf, type Values } from "../common_util/common_util_formula_doc_view";
export interface TexCtx {
side: "sym" | "val";
vals: Values;
inline?: boolean;
/** 묶음 값의 비목(기계 · 단가산출 줄 노 · 재 · 경) */
cost?: string;
/** 번지 읽는 이름(804-5) — 「번지.복사N.노무비」 → 「항목 이름 노무비」 */
place?: Record<string, string>;
}
const HIDE = new Set(["반올림", "올림", "버림"]);
const OPS: Record<string, string> = {
"+": "+",
"-": "-",
"*": "\\times",
"/": "\\div",
"<": "<",
"<=": "\\le",
">": ">",
">=": "\\ge",
"==": "=",
"!=": "\\ne",
그리고: "\\;\\text{그리고}\\;",
또는: "\\;\\text{또는}\\;",
};
const PREC: Record<string, number> = {
또는: 1,
그리고: 2,
"<": 3,
"<=": 3,
">": 3,
">=": 3,
"==": 3,
"!=": 3,
"+": 4,
"-": 4,
"*": 5,
"/": 5,
};
const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9);
/** TeX 글 안전 — \text{} 안 */
const txt = (s: string): string => `\\text{${s.replace(/[\\{}$&#^_%~]/g, (c) => `\\${c}`)}}`;
const word = (s: string): string =>
/^[A-Za-z]$/.test(s) ? s : /^[A-Za-z]+$/.test(s) ? `\\mathit{${s}}` : txt(s);
/** 기호 글(`Q_작업^표`) → TeX(첨자) */
export function symTex(id: string): string {
const [s, lo, up] = splitId(id);
return `${word(s)}${lo ? `_{${word(lo)}}` : ""}${up ? `^{${word(up)}}` : ""}`;
}
/** 값 → TeX(천 자리 쉼표) */
export const valTex = (v: unknown): string => {
const t = eqText(v);
return isNum(v) ? t.replace(/,/g, "{,}") : txt(t);
};
/** 칸 값 — `번지.복사1.노무비` 처럼 겹친 칸까지 */
const look = (n: Node, vals: Values): unknown => {
if (n[0] === "var") return vals[n[1]];
if (n[0] !== "field") return undefined;
const v = look(n[1], vals);
return v && typeof v === "object" ? (v as Values)[n[2]] : undefined;
};
const paren = (t: string): string => `\\left(${t}\\right)`;
const miss = (t: string): string => `\\htmlClass{lstk-miss}{${t}}`;
export function tex(n: Node, c: TexCtx): string {
const go = (x: Node): string => tex(x, c);
switch (n[0]) {
case "num":
return valTex(n[1]);
case "str":
return txt(`'${n[1]}'`);
case "var": {
if (c.side === "sym") return symTex(n[1]);
let v = c.vals[n[1]];
if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost];
return v === null || v === undefined || typeof v === "object"
? miss(symTex(n[1]))
: valTex(v);
}
case "field": {
const [, of, key] = n;
if (c.side === "val") {
const got = look(n, c.vals);
if (isNum(got)) return valTex(got);
}
if (of[0] === "field" && of[1][0] === "var" && of[1][1] === "번지" && c.place?.[of[2]])
return txt(`${c.place[of[2]]} ${key}`);
return `${n[1][0] === "var" ? symTex(n[1][1]) : go(n[1])}.${txt(n[2])}`;
}
case "neg":
return `-${n[1][0] === "bin" ? paren(go(n[1])) : go(n[1])}`;
case "pct":
return `${prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1])}\\,\\%`;
case "call": {
const [, name, items] = n;
if (name === "만약") {
const pick = takenOf(n, c.vals);
if (pick) return go(items[pick]);
}
if (HIDE.has(name) && items.length >= 1 && items.length <= 2) return go(items[0]);
const fn = name === "최소" ? "\\min" : name === "최대" ? "\\max" : txt(name);
return `${fn}${paren(items.map(go).join(",\\,"))}`;
}
case "bin": {
const [, o, a, b] = n;
const na = leafNum(a, c.vals);
const nb = leafNum(b, c.vals);
if ((o === "+" || o === "-") && nb === 0) return go(a);
if ((o === "*" || o === "/") && nb === 1) return go(a);
if ((o === "+" && na === 0) || (o === "*" && na === 1)) return go(b);
if (o === "/" && !c.inline) return `\\dfrac{${go(a)}}{${go(b)}}`;
const mine = PREC[o];
const left = prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(go(a)) : go(a);
const right = prec(b) <= mine ? paren(go(b)) : go(b);
return `${left} ${OPS[o] ?? txt(o)} ${right}`;
}
}
}
/** TeX → 조판 요소(못 그리면 글 그대로) */
export function katexNode(t: string): HTMLElement {
const box = document.createElement("span");
box.className = "lstk-tex";
katex.render(t, box, {
throwOnError: false,
strict: "ignore",
trust: (x) => x.command === "\\htmlClass",
output: "html",
});
return box;
}