"""EST 산근 보이는 글 → 식 복원(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-A` 3 장 · eval 없음). 엑셀엔 식이 없고 인쇄 글뿐 — 「라벨 : 식 = 결과」 로 나눠 식을 되살림. 셈은 `M02_San_Eval.run_est`. 줄 꼴(`꼴`) — 빈 · 그림(분수 ─) · 글 · 글끝(끝이 결과 아님) · 식못읽음 · 변수 · 값 · 돈. - 돈(`종류` 단가줄 · 참조줄 · 상수줄) — 라벨 · 비목(라벨 끝 노무비 · 재료비 · 경비) · 값(보인 결과) · 식(`M02_San_Expr` 나무) · 역할 {수 토큰 차례: 단가(후보 · 정체 · 모호) | 앞줄(같은 블록 앞 결과 줄 차례) | 상수} · 규칙(보인 결과가 되는 줄 자리 속성 · 기본 버림1) - 값(`종류` 값줄 「소 계 : 1,355.9」) — 숨은 = {갈래 복사 | 합, 줄 [차례], 규칙}(앞 결과 하나 또는 바로 앞 돈 · 값 줄 이음 합) - 변수(`종류` 변수식 · 변수값 · 변수기호 · 변수글) — 값 · 규칙 = 얼림(금액엔 반올림된 값이 박힘) """ from __future__ import annotations import collections import re from typing import Any from M02_MasterTemplete import M02_San_Expr as ex from M02_MasterTemplete.M02_San_Book import flat COMPW = {"노무비": "L", "재료비": "M", "경비": "S"} NUMRE = r"\d{1,3}(?:,\d{3})+(?:\.\d*)?|\d+(?:\.\d*)?|\.\d+" EPS = ex.EPS OPS = {"×": "*", "x": "*", "X": "*", "*": "*", "÷": "/", "/": "/", "+": "+", "-": "-"} OPS |= {"-": "-", "−": "-"} _UNIT = re.compile(r"\s*(?:[A-Za-z가-힣㎡㎥㎏ℓ㎝㎜°˚’'\"Øø]+(?:\s*/\s*[A-Za-z가-힣㎡㎥㎏ℓ]+)?)") VARLINE = re.compile(r"^\s*(?:[○●◦·\-]\s*)?[A-Za-z][A-Za-z0-9_]*\s*(?:\([^)]*\))?\s*=") RES = re.compile( r"^\s*(?:[A-Z]\s+)?(" + NUMRE + r")\s*\.?\s*(?:원)?\s*(?:[₩\\]?\s*/\s*\S+)?\s*(?:\([^()]*\))?\s*$" ) _PLAIN = re.compile(r"[\s\d.,×÷+\-()A-Za-z가-힣㎡㎥]*") _LABEL = re.compile(r"^(?P[^::=]{0,40}?)\s*[::]\s*(?P.*)$") TAIL = re.compile(r"^\s*(" + NUMRE + r")\s*\.?\s*[::][^=]*$") # 결과 뒤 꼬리 글 「3,094.2 : 채집」 _HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]") def num(s: str) -> float: return float(s.replace(",", "").rstrip(".") or 0) def tokens(seg: str) -> list[tuple] | None: """식 조각 → Expr 토큰 · 숫자 식이 아니면(기호 · 수 둘 잇달아 · 연산 없음) None.""" s = seg.replace("(", "(").replace(")", ")").replace("x", "x") prev = None while prev != s: # 수 없는 괄호 글 「(품할증)」 prev = s s = re.sub(r"\([^()\d]*\)", " ", s) out: list[tuple] = [] i, want, depth = 0, True, 0 while i < len(s): ch = s[i] if ch.isspace(): i += 1 continue m = re.match(NUMRE, s[i:]) if m and (i == 0 or not (s[i - 1].isalpha() and s[i - 1].isascii())): if not want: return None out.append(("수", num(m.group(0)))) i += len(m.group(0)) want = False while i < len(s): # 단위 글 「인」 「㎡」 「본/일」 m2 = _UNIT.match(s[i:]) if m2 and m2.group(0).strip() and not re.fullmatch(r"\s*[xX]", m2.group(0)): i += len(m2.group(0)) continue break continue if ch == "%": if want: return None out.append(("%",)) i += 1 continue if ch in OPS: if ch in "xX" and want: return None if ch == "/" and not re.match(r"\s*[\d(.]", s[i + 1 :]): return None if ch in "--−" and want: out.append(("연산", "-")) i += 1 continue if want: return None out.append(("연산", OPS[ch])) i += 1 want = True continue if ch == "(": if not want: return None depth += 1 out.append(("(",)) i += 1 continue if ch == ")": depth -= 1 if depth < 0 or want: return None out.append((")",)) i += 1 continue if ch.isalpha() or ch == "_": return None i += 1 # 장식(₩ 등) if depth != 0 or want or not any(t[0] in ("연산", "%") for t in out): return None return out def calc(node: tuple, sub: dict | None = None) -> float | None: try: return ex.value(node, {"V": {}, "sub": sub}) except ex.ExprError: return None def label_comp(label: str) -> str | None: w = flat(label) return next((v for k, v in COMPW.items() if w.endswith(k)), None) def parse_line(text: str) -> dict[str, Any]: """보이는 글 한 줄 → 꼴 · 라벨 · 결과 · 식 나무(역할 전).""" t = text.strip() if not t: return {"꼴": "빈"} if "─" in t or _PLAIN.fullmatch(t) and "=" not in t and ":" not in t: return {"꼴": "그림" if "─" in t else "글"} label, rhs = "", t mc = _LABEL.match(t) if mc and not re.search(r"\d\s*$", mc.group("l")): label, rhs = mc.group("l"), mc.group("r") if VARLINE.match(rhs) or VARLINE.match(t): return {"꼴": "변수", "조각": rhs.split("=")} if "=" not in rhs: m = re.match(r"^\s*(" + NUMRE + r")\s*\.?\s*$", rhs) if label and m: return {"꼴": "값", "라벨": label, "비목": label_comp(label), "값": num(m.group(1))} return {"꼴": "글"} segs = rhs.split("=") mres = RES.match(segs[-1]) or TAIL.match(segs[-1]) # 꼬리 글은 떼고 읽음(797-1a) if not mres: lead = re.match(r"\s*(" + NUMRE + r")", segs[-1]) if ( label and lead and re.search(r"\d", "=".join(segs[:-1])) ): # 금액 줄 못 읽음 — 보인 값 보존 return {"꼴": "글끝", "라벨": label, "보인": num(lead.group(1))} return {"꼴": "글끝"} shown = num(mres.group(1)) toks = None for seg in reversed(segs[:-1]): toks = tokens(seg) if toks is None: # 라벨 없는 줄 — 왼쪽 글을 깎아 가며 starts = {m.start() for m in re.finditer(NUMRE, seg)} starts |= {m.start() for m in re.finditer(r"\(\s*\d", seg)} for p in sorted(starts): if p and (seg[p - 1].isdigit() or seg[p - 1] in ".,"): continue toks = tokens(seg[p:]) if toks: label = label or seg[:p] break if toks: break if not toks: return {"꼴": "식못읽음", "라벨": label, "값": shown} return {"꼴": "돈", "라벨": label, "비목": label_comp(label), "값": shown, "식": ex.parse(toks)} # ── 블록 풀이 ────────────────────────────────────────────────────────── def _ident(p: dict[str, Any]) -> tuple: return (p["종류"], p["이름"], p["규격"]) def _money(L: dict, prices_by_value: dict, results: list) -> None: """돈 줄 — 수 토큰 역할(단가 · 앞줄 · 상수) · 다시 셈 · 자리 규칙.""" comp = L["비목"] nums = [(n[2], n[1]) for n in ex.numbers(L["식"])] roles: dict[int, dict[str, Any]] = {} price_at = None for i, v in nums: if v >= 1 and any(abs(r[1] - v) < EPS for r in results): continue # 앞 결과와 같은 수 = 참조(단가표 우연 일치보다 먼저) cands = [p for p in prices_by_value.get(v, []) if v >= 1 or (v == 0 and comp)] if comp: cands = [p for p in cands if p["비목"] == comp] or cands if v == 0 and comp and not cands: cands = [{"종류": "0", "이름": "-", "규격": "", "비목": comp, "값": 0.0}] first = i == nums[0][0] if cands and (v >= 100 or (first and v > 0)) or (v == 0 and cands): price_at = i roles[i] = {"갈래": "단가", "후보": cands} break for i, v in nums: if i in roles: continue hit = [r for r in reversed(results) if abs(r[1] - v) < EPS] roles[i] = {"갈래": "앞줄", "줄": hit[0][0]} if hit and v >= 1 else {"갈래": "상수"} L["역할"] = roles L["셈"] = calc(L["식"]) L["규칙"] = ex.fit(L["셈"], L["값"]) if price_at is not None: L["종류"] = "단가줄" pc = roles[price_at]["후보"] L["비목"] = L["비목"] or (pc[0]["비목"] if len({p["비목"] for p in pc}) == 1 else None) L["후보수"] = len({_ident(p) for p in pc}) elif any(r["갈래"] == "앞줄" for r in roles.values()): L["종류"] = "참조줄" else: L["종류"] = "상수줄" def _value(L: dict, lines: list, results: list) -> None: """값 줄 — 앞 결과 하나(자리 처리) 또는 바로 앞 돈 · 값 줄 이음 합.""" v, how = L["값"], None for r in reversed(results): rule = ex.fit(r[1], v) if rule: how = {"갈래": "복사", "줄": [r[0]], "규칙": rule} break if not how and flat(L["라벨"]) in ("계", "합계"): got: dict[str, dict] = {} for x in reversed(lines): c = x.get("비목") if c and x.get("꼴") in ("돈", "값") and c not in got: got[c] = x if len(got) == 3: break if got: rule = ex.fit(sum(x["값"] for x in got.values()), v) if rule: how = {"갈래": "합", "줄": sorted(x["차례"] for x in got.values()), "규칙": rule} if not how: acc, used = 0.0, [] for x in reversed([x for x in lines if x.get("꼴") in ("돈", "값")]): if len(used) >= 25: break acc += x["값"] used.append(x["차례"]) rule = ex.fit(acc, v) if rule and len(used) > 1: how = {"갈래": "합", "줄": used[::-1], "규칙": rule} break L["종류"] = "값줄" L["숨은"] = how L["규칙"] = how["규칙"] if how else None def _var(L: dict) -> None: segs = L.pop("조각") m = re.match(r"^\s*(" + NUMRE + r")", segs[-1]) if len(segs) > 1 else None if not m: L["종류"] = "변수글" return shown, toks = num(m.group(1)), None for seg in reversed(segs[1:-1]): toks = tokens(seg) if toks: break L["값"] = shown L["규칙"] = ex.fit(calc(ex.parse(toks)), shown) if toks else None L["종류"] = "변수식" if toks else ("변수값" if len(segs) == 2 else "변수기호") def parse_block(text: list[str], prices: list[dict[str, Any]]) -> list[dict[str, Any]]: """블록 글 줄 → 복원 줄 목록(역할 · 숨은 합 · 자리 규칙 · 단가 정체).""" by_value = collections.defaultdict(list) for p in prices: by_value[p["값"]].append(p) lines: list[dict[str, Any]] = [] results: list[tuple] = [] # (차례, 값, 비목) for k, raw in enumerate(text): L = {"차례": k, "원글": raw, **parse_line(raw)} if L["꼴"] == "돈": _money(L, by_value, results) results.append((k, L["값"], L["비목"])) elif L["꼴"] == "값": _value(L, lines, results) results.append((k, L["값"], L["비목"])) elif L["꼴"] == "변수": _var(L) lines.append(L) identify(lines) return lines def identify(lines: list[dict[str, Any]]) -> None: """단가 토큰 정체 — 값 하나 · 기계 세 줄 겹침 · 문맥(라벨 · 머리 글 이름) · 남으면 모호 표지.""" head = "" for L in lines: if L["꼴"] in ("글", "글끝") or _HEADING.match(L["원글"]): head = L["원글"] if L.get("종류") != "단가줄": continue role = next(r for r in L["역할"].values() if r["갈래"] == "단가") cands = role["후보"] ids = {_ident(p) for p in cands} how = "값" if len(ids) > 1 and cands[0]["종류"] in ("기계", "산근"): k0, inter = L["차례"], set(ids) for M in lines[max(0, k0 - 3) : k0 + 4]: if M is L or M.get("종류") != "단가줄": continue r2 = next(r for r in M["역할"].values() if r["갈래"] == "단가") ids2 = {_ident(p) for p in r2["후보"]} if ids2 & inter and len(ids2 & inter) < len(inter): inter &= ids2 ids, how = inter, "기계 세 줄" if len(ids) > 1: ctx = flat(L["원글"]) + "|" + flat(head) hit = {x for x in ids if x[1] and (x[1] in ctx or x[1][:3] in ctx)} ids, how = ({x for x in hit if not x[2] or x[2] in ctx} or hit or ids), "문맥" role["모호"] = len(ids) != 1 role["갈림"] = how role["남은"] = len(ids) pick = sorted(ids)[0] p = next(p for p in cands if _ident(p) == pick) role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]}