/* ============================================================================= * formula_doc_math_model.ts * 수식 영역 구조 편집 꼴(PLAN 803-2a · 요구 667-4 · 6) — 발표 자료 수식처럼 선형 입력이 바로 조판으로 · 화면 · DOM 없음 * 한 줄 = 항목 목록 — 글자 · 분수(위 · 아래) · 괄호(안) · 아래첨자 · 위첨자(기호 이름 조각) * 치기(`typeChar`) — `/` = 앞 피연산자를 분자로 · `_` · `^` = 첨자 칸 · `(` = 괄호(닫힘 자동) · `)` = 괄호 밖으로 * · `*` → × · `-` → − · `<=` → ≤ · 첨자 칸에서 연산자 · 빈칸 = 첨자 밖으로 * 선형 글 ↔ 항목 — `fromLinear`(공용 `serializeEq` 글 꼴 `(위) / (아래)`) · `toLinear`(식 글 · 첨자 `_{…}`) * 커서 걸음 — 좌우(구조 안팎) · 위아래(분수 위 ↔ 아래) · 지움(구조 풀기) * ========================================================================== */ export type Item = | { t: "ch"; c: string } | { t: "frac"; num: Item[]; den: Item[] } | { t: "paren"; body: Item[] } | { t: "sub"; body: Item[] } | { t: "sup"; body: Item[] }; export interface Caret { list: Item[]; i: number; } interface Owner { item: Item; /** 이 항목이 든 목록 · 자리 */ list: Item[]; at: number; } /** 연산자 — 피연산자 경계 · 앞뒤 띄움 */ const OPS = new Set(["+", "−", "×", "÷", "=", "==", "<", ">", "≤", "≥", "≠", ","]); const NAME = /^[\p{L}\p{N}′.]$/u; const RUN = /^[\p{L}\p{N}′']+$/u; const MERGE: Record = { "<": "≤", ">": "≥", "!": "≠", "=": "==" }; export const isOp = (it: Item | undefined): boolean => it?.t === "ch" && OPS.has(it.c); const ch = (c: string): Item => ({ t: "ch", c }); /** 항목의 칸들 */ export function slotsOf(it: Item): Item[][] { if (it.t === "frac") return [it.num, it.den]; if (it.t === "ch") return []; return [it.body]; } /** 목록을 쥔 항목(맨 위 줄이면 null) */ export function ownerOf(root: Item[], list: Item[]): Owner | null { const go = (items: Item[]): Owner | null => { for (let at = 0; at < items.length; at++) { const item = items[at]; for (const s of slotsOf(item)) { if (s === list) return { item, list: items, at }; const deep = go(s); if (deep) return deep; } } return null; }; return root === list ? null : go(root); } /** 따옴표 안(글 상수) — 빈칸을 남김 */ const inQuote = (c: Caret): boolean => c.list.slice(0, c.i).filter((x) => x.t === "ch" && x.c === "'").length % 2 === 1; /** 맨 위 줄 머리(첫 = 앞) — 금액 줄 「노무비 형틀목공」 빈칸을 남김 */ const inHead = (root: Item[], c: Caret): boolean => c.list === root && !root.slice(0, c.i).some((x) => x.t === "ch" && x.c === "="); const insert = (c: Caret, it: Item): Caret => { c.list.splice(c.i, 0, it); return { list: c.list, i: c.i + 1 }; }; /** 구조 밖 바로 뒤로 */ function out(root: Item[], c: Caret): Caret { const o = ownerOf(root, c.list); return o ? { list: o.list, i: o.at + 1 } : c; } /** 글자 하나 치기 — `linear` = 선형 글 풀이(분모는 연산자에서 끝남) */ export function typeChar(root: Item[], c: Caret, raw: string, linear = false): Caret { const k = raw === "*" ? "×" : raw === "-" ? "−" : raw; const o = ownerOf(root, c.list); const script = o && (o.item.t === "sub" || o.item.t === "sup"); if (/\s/.test(k)) { if (inQuote(c) || inHead(root, c)) return insert(c, ch(" ")); if (script) return out(root, c); if (!linear && o?.item.t === "frac") return out(root, c); return c; } if (inQuote(c) && k !== "'") return insert(c, ch(k)); if (script && !(NAME.test(k) && k !== ".")) return typeChar(root, out(root, c), k, linear); if (k === "_" || k === "^") { const prev = c.list[c.i - 1]; const ok = (prev?.t === "ch" && NAME.test(prev.c) && prev.c !== ".") || (k === "^" && prev?.t === "sub"); if (!ok) return c; const s: { t: "sub" | "sup"; body: Item[] } = { t: k === "_" ? "sub" : "sup", body: [] }; insert(c, s); return { list: s.body, i: 0 }; } if (k === "(") { const p: Item = { t: "paren", body: [] }; insert(c, p); return { list: p.body, i: 0 }; } if (k === ")") { for (let at = c.list, w = ownerOf(root, at); w; w = ownerOf(root, (at = w.list))) if (w.item.t === "paren") return { list: w.list, i: w.at + 1 }; return c; } if (k === "/") { let start = c.i; while (start > 0 && !isOp(c.list[start - 1])) start--; const pulled = c.list.splice(start, c.i - start); const num = pulled.length === 1 && pulled[0].t === "paren" ? pulled[0].body : pulled; const f: Item = { t: "frac", num, den: [] }; c.list.splice(start, 0, f); return num.length ? { list: f.den, i: 0 } : { list: f.num, i: 0 }; } if (OPS.has(k) || k === "!") { // 선형 풀이 — 분모는 연산자에서 끝남(`(a) / (b) × c`) if (linear && o?.item.t === "frac" && c.list === o.item.den && c.list.length) return typeChar(root, out(root, c), k, linear); const prev = c.list[c.i - 1]; if (k === "=" && prev?.t === "ch" && MERGE[prev.c]) { c.list[c.i - 1] = ch(MERGE[prev.c]); return c; } } return insert(c, ch(k)); } /** 분수 칸이 괄호 하나뿐이면 괄호를 벗김(`(a) / (b)` 꼴) */ function unwrap(items: Item[]): void { for (const it of items) { if (it.t === "frac") { for (const key of ["num", "den"] as const) { const s = it[key]; if (s.length === 1 && s[0].t === "paren") it[key] = s[0].body; } } slotsOf(it).forEach(unwrap); } } /** 선형 글 → 항목(공용 `serializeEq` 글 · 붙여넣기) */ export function fromLinear(text: string, root: Item[] = [], at?: Caret): Caret { let c = at ?? { list: root, i: root.length }; const chars = [...text]; for (let n = 0; n < chars.length; n++) { const k = chars[n]; // 첨자 묶음 `_{a b}` — 닫는 } 까지 그대로 첨자 칸에 if ((k === "_" || k === "^") && chars[n + 1] === "{") { const end = chars.indexOf("}", n + 2); if (end > 0) { const was = c; c = typeChar(root, c, k, true); if (c !== was) { chars.slice(n + 2, end).forEach((x) => (c = insert(c, ch(x)))); c = out(root, c); } n = end; continue; } } c = typeChar(root, c, k, true); } unwrap(root); return c; } /** 항목 → 식 글 */ export function toLinear(items: Item[]): string { const go = (list: Item[]): string => list .map((it) => { if (it.t === "ch") return it.c === "," ? ", " : OPS.has(it.c) ? ` ${it.c} ` : it.c; if (it.t === "frac") return `(${go(it.num)}) / (${go(it.den)})`; if (it.t === "paren") return `(${go(it.body)})`; const body = go(it.body).trim(); if (!body) return ""; return `${it.t === "sub" ? "_" : "^"}${RUN.test(body) ? body : `{${body}}`}`; }) .join(""); return go(items).replace(/\s+/g, " ").trim(); } // ── 커서 걸음 ────────────────────────────────────────────────────────── export function right(root: Item[], c: Caret): Caret { if (c.i < c.list.length) { const s = slotsOf(c.list[c.i]); return s.length ? { list: s[0], i: 0 } : { list: c.list, i: c.i + 1 }; } const o = ownerOf(root, c.list); if (!o) return c; const s = slotsOf(o.item); const k = s.indexOf(c.list); return k < s.length - 1 ? { list: s[k + 1], i: 0 } : { list: o.list, i: o.at + 1 }; } export function left(root: Item[], c: Caret): Caret { if (c.i > 0) { const s = slotsOf(c.list[c.i - 1]); const last = s[s.length - 1]; return last ? { list: last, i: last.length } : { list: c.list, i: c.i - 1 }; } const o = ownerOf(root, c.list); if (!o) return c; const s = slotsOf(o.item); const k = s.indexOf(c.list); return k > 0 ? { list: s[k - 1], i: s[k - 1].length } : { list: o.list, i: o.at }; } /** 분수 위 ↔ 아래(분수 밖이면 null — 줄을 옮김) */ export function vertical(root: Item[], c: Caret, up: boolean): Caret | null { for (let at = c.list, o = ownerOf(root, at); o; o = ownerOf(root, (at = o.list))) { if (o.item.t !== "frac") continue; const from = at === o.item.num ? "num" : "den"; if ((up && from === "num") || (!up && from === "den")) continue; const to = up ? o.item.num : o.item.den; return { list: to, i: Math.min(c.i, to.length) }; } return null; } /** 지움(뒤로) — 글자 지움 · 구조 앞이면 구조 안 끝으로 · 칸 맨 앞이면 구조를 풂 · 빈 줄 맨 앞 = null */ export function backspace(root: Item[], c: Caret): Caret | null { if (c.i > 0) { const prev = c.list[c.i - 1]; if (prev.t === "ch") { c.list.splice(c.i - 1, 1); return { list: c.list, i: c.i - 1 }; } return left(root, c); } const o = ownerOf(root, c.list); if (!o) return root.length ? c : null; const parts = slotsOf(o.item); const flat: Item[] = o.item.t === "frac" && o.item.den.length && o.item.num.length ? [...o.item.num, ch("÷"), ...o.item.den] : parts.flat(); o.list.splice(o.at, 1, ...flat); const before = parts.indexOf(c.list) > 0 ? parts[0].length : 0; return { list: o.list, i: o.at + before }; } /** 지움(앞으로) — 글자 지움 · 구조면 안으로 */ export function del(root: Item[], c: Caret): Caret { if (c.i >= c.list.length) return c; if (c.list[c.i].t === "ch") { c.list.splice(c.i, 1); return c; } return right(root, c); }