# -*- coding: utf-8 -*- """정본 로직을 식 줄로 펴 보이고, 고친 식을 되돌려 자체 로직으로 저장 (PLAN 3-4). 규칙은 `resources/master_data/ref/_설계_식_복사.md` 그대로 — `찾기(…)`·`로직(…)` 부르기 한 덩이를 이름 하나(별칭)로 바꿔 보이고 뒤에 원문을 둠. 별칭은 저장하지 않음(겉치레) — 되돌릴 때 별칭표의 원문으로 갈아 끼워 원래 식이 됨. 식 자르기는 `master_formula` 파서를 그대로 씀 — 정규식으로 괄호를 세지 않음. """ from __future__ import annotations import copy import re import master_formula as mf MAX_NAME = 12 # 이 글자 수를 넘는 표 이름은 억지로 줄이지 않고 표 키를 그대로 보임 _DROP = re.compile(r"[^\w]", re.UNICODE) # 별칭에 못 쓰는 글자(빈칸 · 괄호 · 가운뎃점 …) _SPACE = re.compile(r"\s+") _QUOTED = re.compile(r"'[^']*'") RESERVED = frozenset((*mf.FUNCS, *mf.COST_ITEMS, "찾기", "로직", "그리고", "또는", "계", "줄")) # ── 부르기 자리 자르기 ───────────────────────────────────────────────── class _Catch(mf._Parser): """식을 읽으면서 `찾기(…)`·`로직(…)` 한 덩이의 자리를 적어 둠(겹친 것 포함).""" def __init__(self, text: str): super().__init__(text) self.spots: list[tuple[int, int, str, str]] = [] def primary(self): self.ws() at = self.i node = super().primary() if node[0] in ("find", "logic"): self.spots.append((at, self.i, node[0], node[1])) return node def spots(text: str) -> list[tuple[int, int, str, str]]: """식 글 안 부르기 한 덩이 — (처음, 끝, 종류, 참조). 겹친 것은 바깥만 · 못 읽는 글은 빈 목록. """ try: found = _Catch(str(text)) found.parse() except (mf.FormulaError, RecursionError): return [] out = [ one for one in found.spots if not any(o[0] <= one[0] and one[1] <= o[1] and o[:2] != one[:2] for o in found.spots) ] return sorted(out) def _holes(text: str) -> list[tuple[int, int]]: """별칭을 갈아 끼우면 안 되는 자리 — 이미 원문으로 적힌 부르기 안 · 작은따옴표 안.""" return [(s, e) for s, e, _, _ in spots(text)] + [ (m.start(), m.end()) for m in _QUOTED.finditer(text) ] # ── 식이 든 자리 ─────────────────────────────────────────────────────── def slots(row: dict): """(자리표, 갈래, 차례, 칸 이름, 그 칸을 담은 묶음) — 번호 매기는 차례(호표 → 중간 → 덧줄).""" for i, one in enumerate(row.get("호표") or []): for col in ("수량", "요소"): if isinstance(one.get(col), str): yield f"호표[{i}].{col}", "호표", i, col, one for i, one in enumerate(row.get("중간") or []): if isinstance(one.get("식"), str): yield f"중간[{i}].식", "중간", i, "식", one for i, one in enumerate(row.get("덧줄") or []): if isinstance(one.get("식"), str): yield f"덧줄[{i}].식", "덧줄", i, "식", one end = row.get("결과") if isinstance(end, dict) and isinstance(end.get("식"), str): yield "결과.식", "결과", 0, "식", end def _taken(row: dict) -> set[str]: """이미 식에서 쓰는 이름 — 입력 · 중간 · 덧줄 · 비목 · 함수 이름.""" out = set(RESERVED) for part in ("입력", "중간", "덧줄"): for one in row.get(part) or []: name = str(one.get("이름") or "") if name: out.add(name) return out # ── 별칭 짓기 ───────────────────────────────────────────────────────── def _base(ref: str, master) -> str: """별칭 밑이름 — 표·로직의 `이름` 칸에서 · 비거나 너무 길면 키를 그대로 보임.""" try: name = _DROP.sub("", str(master.get(ref).get("이름") or "")) except mf.FormulaError: name = "" if not name or len(name) > MAX_NAME or name[0].isdigit(): return _DROP.sub("", ref) or "표" return name def _free(want: str, taken: set[str]) -> str: name, at = want, 1 while name in taken: at += 1 name = f"{want}_{at}" taken.add(name) return name def aliases(row: dict, master) -> list[dict]: """부르기마다 별칭 하나 — 완전히 같은 부르기는 합치고, 갈리면 등장 차례로 번호.""" order: list[str] = [] seen: dict[str, tuple[str, str]] = {} # 서명 → (종류, 참조) raw_of: dict[str, str] = {} # 서명 → 원문(처음 나온 글 그대로) where_of: dict[str, list[str]] = {} for where, _, _, col, box in slots(row): text = box[col] for start, end, kind, ref in spots(text): raw = text[start:end] sign = _SPACE.sub(" ", raw) if sign not in seen: order.append(sign) seen[sign] = (kind, ref) raw_of[sign] = raw where_of.setdefault(sign, []).append(where) bunch: dict[str, list[str]] = {} for sign in order: bunch.setdefault(_base(seen[sign][1], master), []).append(sign) taken, name_of = _taken(row), {} for kind, ref in seen.values(): # 표의 값 칸 이름과 헷갈리지 않게 if kind == "find": taken |= set(_about(kind, ref, master)["값칸"]) for base, signs in bunch.items(): gap = "_" if base[-1:].isdigit() else "" # 키를 밑이름으로 쓴 자리 — 번호가 붙지 않게 for at, sign in enumerate(signs): name_of[sign] = _free(f"{base}{gap}{at + 1}" if len(signs) > 1 else base, taken) out = [] for sign in order: kind, ref = seen[sign] out.append( { "이름": name_of[sign], "종류": "찾기" if kind == "find" else "로직", "참조": ref, "원문": raw_of[sign], **_about(kind, ref, master), "자리": where_of[sign], } ) return out def _about(kind: str, ref: str, master) -> dict: """모달에 보일 한 마디 — 표는 표 이름과 값 칸 · 로직은 로직 이름과 결과 단위.""" try: row = master.get(ref) except mf.FormulaError: return {"밑이름": "", "값칸": {}, "결과단위": ""} if kind == "find": return {"밑이름": row.get("이름") or "", "값칸": row.get("값칸") or {}, "결과단위": ""} return {"밑이름": row.get("이름") or "", "값칸": {}, "결과단위": row.get("결과단위") or ""} # ── 펴기 · 되돌리기 ──────────────────────────────────────────────────── def _mask(text: str, name_of: dict[str, str]) -> str: """부르기 한 덩이를 별칭 이름으로 — 뒤에서부터 갈아 끼움(자리가 밀리지 않게).""" out = text for start, end, _, _ in reversed(spots(text)): name = name_of.get(_SPACE.sub(" ", text[start:end])) if name: out = out[:start] + name + out[end:] return out def _glued(text: str, m: re.Match) -> bool: """앞뒤가 낱말 글자면 다른 이름의 한 토막 · 점 뒤면 표의 값 칸 이름 — 갈아 끼우지 않음.""" side = text[m.start() - 1 : m.start()] + text[m.end() : m.end() + 1] return any(ch.isalnum() or ch == "_" for ch in side) or text[: m.start()].rstrip().endswith(".") def _unmask(text: str, raw_of: dict[str, str]) -> str: """별칭 이름을 원문 부르기로 — 이미 원문인 부르기 안과 따옴표 안은 안 건드림.""" if not raw_of: return text holes = _holes(text) catch = re.compile("|".join(re.escape(n) for n in sorted(raw_of, key=len, reverse=True))) out, at = [], 0 for m in catch.finditer(text): if any(s <= m.start() and m.end() <= e for s, e in holes) or _glued(text, m): continue out.append(text[at : m.start()]) out.append(raw_of[m.group()]) at = m.end() out.append(text[at:]) return "".join(out) def spread(master, row: dict) -> dict: """로직 한 줄 → 별칭 꼴 식 묶음. `로직` = 식만 별칭으로 바뀐 줄 통째 · `별칭` 에 원문.""" marks = aliases(row, master) name_of = {_SPACE.sub(" ", one["원문"]): one["이름"] for one in marks} shown = copy.deepcopy(row) for _, _, _, col, box in slots(shown): box[col] = _mask(box[col], name_of) return {"로직": shown, "별칭": marks, "줄": lines(shown)} def lines(row: dict) -> list[dict]: """화면에 쌓을 식 줄 — 읽는 차례(중간 → 호표 → 덧줄 → 결과).""" out = [] for i, one in enumerate(row.get("중간") or []): out.append(_line(f"중간[{i}].식", "중간", i, one, one.get("식"))) for i, one in enumerate(row.get("호표") or []): got = _line(f"호표[{i}].수량", "호표", i, one, one.get("수량")) out.append({**got, "종류": one.get("종류") or "", "요소": one.get("요소")}) for i, one in enumerate(row.get("덧줄") or []): out.append(_line(f"덧줄[{i}].식", "덧줄", i, one, one.get("식"))) end = row.get("결과") if isinstance(end, dict): out.append(_line("결과.식", "결과", 0, end, end.get("식"))) return out def _line(where: str, kind: str, at: int, one: dict, text) -> dict: return { "자리": where, "갈래": kind, "차례": at, "이름": one.get("이름") or "", "단위": one.get("단위") or "", "비목": one.get("비목") or "", "식": text, "출처": one.get("출처") or "", } def gather(shown: dict, marks: list[dict]) -> dict: """별칭 꼴 식 묶음 → 저장할 로직 한 줄. 별칭 원문은 부르기 하나여야 함(아무 글이나 못 넣음). 사용자가 편집 중 별칭과 같은 이름의 입력·중간·덧줄을 새로 만들면(예: `메쌓기인력 = 1`) 그 뒤로는 식 글 속 그 이름이 사용자 것인지 별칭인지 가릴 수 없음(둘 다 같은 토큰) — 자동으로 별칭 쪽만 다시 이름 지어 피하려 하면 사용자가 실제로 쓴 자리를 건드릴 위험이 있어(화면 개입 없이는 안전하게 못 가림) 조용히 바꾸지 않고 저장을 막음. """ raw_of: dict[str, str] = {} taken = _taken(shown) # 지금 입력·중간·덧줄에 실제로 있는 이름(사용자 것이 우선) for one in marks or []: name, raw = str(one.get("이름") or ""), str(one.get("원문") or "") if not name or not raw: raise mf.FormulaError("별칭에 「이름」 과 「원문」 이 있어야 함") if mf.parse(raw)[0] not in ("find", "logic"): raise mf.FormulaError(f"별칭 「{name}」 의 원문이 찾기·로직 부르기가 아님 — 「{raw}」") if name in raw_of and raw_of[name] != raw: raise mf.FormulaError(f"별칭 「{name}」 이 두 가지 원문을 가리킴") if name in taken: raise mf.FormulaError( f"「{name}」 을 입력·중간·덧줄 이름으로도 쓰고 별칭으로도 씀 — " "어느 쪽 식이 그 이름을 가리키는지 가릴 수 없음 · 이름을 바꿀 것" ) raw_of[name] = raw out = copy.deepcopy(shown) for _, _, _, col, box in slots(out): box[col] = _unmask(box[col], raw_of) return out def check(master, row: dict) -> list[str]: """펴고 그대로 되돌리면 원래 식으로 돌아오는지 — 어긋난 자리 글 목록(비면 됨).""" view = spread(master, row) again = gather(view["로직"], view["별칭"]) out = [] for (where, _, _, col, box), (_, _, _, _, was) in zip(slots(again), slots(row)): if box[col] != was[col]: out.append(f"{row.get('키')} {where} — 「{was[col]}」 → 「{box[col]}」") return out