260 lines
9.5 KiB
TypeScript
260 lines
9.5 KiB
TypeScript
/* =============================================================================
|
||
* 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);
|
||
}
|