Files
Aislo/A00_Common/formula_doc_math_model.ts
T

260 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<string, string> = { "<": "≤", ">": "≥", "!": "≠", "=": "==" };
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);
}