"""경쟁사 EST 원파일(.estx) 파서(PLAN 810-1 · 요구 705 · 706 · 읽기만 · 서버 단독). 거울 = `tools/est-test/import-estx.cjs`(unzip · blob · table — 시험 `test_810_1_estx.py` 가 node 결과와 표 · 줄 · 칸 1:1 대조) · 칸 뜻 = `resources/master_data/_원파일파서_틀.md` · 지식DB `original/경쟁사 프로그램/EST Plus 2/다산소프트/ESTX_파일구조.md` · `ESTX_테이블사전.md`. .estx = ZIP · 표마다 DBISAM `.dat`(머리 512 · 필드 설명자 768 × n · 고정 레코드) + `.blb`(512 블록 체인). 확정 칸만 옮김(추정 0) — Code · LinkIndex(뜻 `LINK`) · SCheck(비목 · 율 대상) · Diget/DigetIndex(자리) · LineData 제어문자(`MARK`) · 저장 금액(TC NC GC JC SC). 없는 것 = 비움 + 표지. """ from __future__ import annotations import io import re import struct import zipfile from typing import Any MAGIC = bytes.fromhex("068abe8e592364cb403d71d2e3bc64d0") LIMIT = 256 * 1024 * 1024 #: 파일 이름(소문자) → 표 이름(node `importEstx` 와 같음) CANON = { n.lower(): n for n in ( "StuffcDat LaborcDat SilJukDat EstiMateDat MachGijunDat MachMainDat MachTitleDat " "CostMainDat CostTitleDat HeadMainDat HeadTitleDat EstMainDat MulMainDat MulTitleDat " "CadMainDat CadTitleDat" ).split() } #: LinkIndex 뜻(`native-engine.js:4-12` · `native-rows.js`) LINK = { 5: "자재", 6: "노임", 21: "표준", 101: "견적", 103: "일위", 104: "산근", 105: "기계", 51: "소계", 61: "소계", 68: "소계", 52: "율", 58: "율", 62: "율", } #: 자원 줄 LinkIndex → 단가 표 SOURCE = { 5: "StuffcDat", 6: "LaborcDat", 21: "SilJukDat", 101: "EstiMateDat", 103: "HeadTitleDat", 104: "CostTitleDat", 105: "MachTitleDat", } #: 금액 칸 → 비목 글자(T 계 · L 노무 · M 재료 · S 경비 · R 실적) AMT = {"TC": "T", "NC": "L", "GC": "M", "JC": "S", "SC": "R"} UNIT_AMT = {"TU": "T", "NU": "L", "GU": "M", "JU": "S", "SU": "R"} #: 산근 줄 SCheck 음수 = 그 줄이 낸 비목 · -5 = 계 SC_LINE = {-1: "L", -2: "M", -3: "S", -4: "R", -5: "T"} #: 율 · 소계 줄 SCheck 양수 = 대상 비목(`native-rows.js` target) SC_RATE = {1: "M", 2: "L", 3: "S", 4: "R"} #: LineData 제어문자(`native-text.js` names) MARK = { "\x0e": "품명", "\x13": "규격", "\x15": "단위", "\x03": "단가", "\x11": "변수", "\x0c": "줄참조", "\x12": "결과", "\x1a": "합계", "\x06": "구간", "\x07": "구간합", } _NUM = r"([-\d.,]+)" _CTRL = re.compile(r"[\x00-\x1f]") class EstxError(ValueError): """못 읽는 .estx.""" # ── DBISAM 표 읽기(거울 = import-estx.cjs blob · table) ───────────────────── def blob(f: bytes | None, start: int, ident: int) -> bytes: """`.blb` 512 블록 체인 → 바이트(첫 블록 선언 길이까지).""" if not f or start == 0: return b"" parts, seen, block, previous, expected, got = [], set(), start, 0, 0, 0 while block: at = block * 512 if block in seen or at + 512 > len(f): raise EstxError("BLOB 체인 범위/순환 오류") seen.add(block) if struct.unpack_from(" 494: raise EstxError("BLOB 조각 길이 오류") if block == start: if struct.unpack_from(" LIMIT: raise EstxError("BLOB 크기 한도 초과") take = min(length, expected - got) parts.append(f[at + 18 : at + 18 + take]) got += take if got == expected: break previous, block = block, struct.unpack_from(" list[dict[str, Any]]: """`.dat` → 행 목록(값 없음 = None · BLOB = bytes · 모르는 형 = {binary, kind}).""" if len(buf) < 512: raise EstxError("테이블 헤더가 짧음") stride, count = struct.unpack_from(" 1024 or start > len(buf) or (len(buf) - start) % stride: raise EstxError("DBISAM 레이아웃과 다른 파일") fields = [] for i in range(count): at = 512 + 768 * i name = buf[at + 3 : at + 3 + buf[at + 2]].decode("ascii", "replace") kind, width = buf[at + 164], struct.unpack_from(" stride: raise EstxError("필드 설명자 오류") fields.append((name, kind, width, payload)) rows = [] for off in range(start, len(buf), stride): row: dict[str, Any] = {} for name, kind, width, payload in fields: flag = buf[off + payload] raw = buf[off + payload + 1 : off + payload + 1 + width] if not flag: row[name] = None continue if flag != 1: raise EstxError(f"행 존재 플래그 {flag}") if kind == 1: b = raw.split(b"\0", 1)[0] try: row[name] = b.decode("cp949") except UnicodeDecodeError: row[name] = b.decode("cp949", "replace") row.setdefault("_originalTextBytes", {})[name] = ( b # 원 바이트 보존(node 와 같음) ) warn.add(f"CP949 범위 밖 문자열: {name}") elif kind == 7 and width == 8: row[name] = struct.unpack(" dict[str, list[dict[str, Any]]]: """.estx 바이트 → {표 이름: 행 목록}(DBISAM 표지가 있는 `.dat` 만 · node 와 같은 이름).""" warn = set() if warn is None else warn if data[:4] != b"PK\x03\x04": raise EstxError(".estx 가 아님 — ZIP 머리 없음") try: book = zipfile.ZipFile(io.BytesIO(data)) except zipfile.BadZipFile as error: raise EstxError("깨진 .estx") from error if any(i.flag_bits & 1 for i in book.infolist()): raise EstxError("암호 걸린 .estx") files = {i.filename.lower(): i.filename for i in book.infolist()} out = {} for low, real in files.items(): if not low.endswith(".dat"): continue d = book.read(real) if d[8:24] != MAGIC: continue blb_name = files.get(low[:-4] + ".blb") try: rows = table(d, book.read(blb_name) if blb_name else None, warn) except (EstxError, struct.error) as error: raise EstxError(f"{low}: {error}") from error out[CANON.get(low[:-4], low[:-4])] = rows if "EstMainDat" not in out: raise EstxError("내역 테이블이 없는 ESTX") return out # ── 구조 줄 ────────────────────────────────────────────────────────── def _num(s: str) -> float: return float(s.replace(",", "")) def _amounts(row: dict[str, Any], names: dict[str, str]) -> dict[str, float] | None: got = {k: row.get(f) for f, k in names.items() if row.get(f) is not None} return got or None def marks(text: str) -> dict[str, Any]: """LineData → 표지별 조각(품명 · 규격 · 단위 = 첫 조각 글 · 나머지 = 수 목록).""" out: dict[str, Any] = {} for ch, key in MARK.items(): if ch not in text: continue if key in ("품명", "규격", "단위"): m = re.search(f"{ch}([^{ch}]*){ch}", text) out[key] = m.group(1) if m else None elif key in ("단가", "변수", "줄참조", "결과"): out[key] = [_num(v) for v in re.findall(f"{ch}{_NUM}{ch}", text)] else: out[key] = True return out def resources(t: dict[str, list[dict[str, Any]]]) -> dict[tuple[int, str], dict[str, Any]]: """(LinkIndex, 코드) → 자원 머리(표 · 이름 · 규격 · 단위 · 저장 금액).""" out = {} for link, name in SOURCE.items(): for r in t.get(name, []): if r.get("Code"): out[(link, r["Code"])] = { "표": name, "이름": r.get("Name"), "규격": r.get("Size"), "단위": r.get("Unite"), "저장": _amounts(r, AMT), } return out def _resource(row: dict[str, Any], res: dict, tags: list[str]) -> dict[str, Any] | None: link, code = row.get("LinkIndex"), row.get("Code") if not code or link not in SOURCE: return None head = res.get((link, code)) if head is None: tags.append(f"자원 못 찾음 {LINK[link]} {code}") return {"코드": code, "종류": LINK[link], **(head or {"표": SOURCE[link]})} def _sc(row: dict[str, Any]) -> str | None: sc = row.get("SCheck") or 0 if sc < 0: return SC_LINE.get(sc) return SC_RATE.get(sc) if LINK.get(row.get("LinkIndex") or 0) in ("율", "소계") else None def _tag(link: int | None, ld: dict[str, Any]) -> str | None: kind = LINK.get(link or 0) if kind in ("율", "소계"): return kind return "합계" if ld.get("합계") else None def _groups(rows: list[dict[str, Any]], title: dict[str, dict]) -> list[tuple[dict, list]]: """Index > 0 줄이 블록 머리(node `groups`) → [(제목, 몸 줄)].""" out: list[tuple[dict, list]] = [] for r in rows: if (r.get("Index") or 0) > 0: out.append((title.get(r.get("Code")) or {"Code": r.get("Code")}, [])) elif out: out[-1][1].append(r) return out def _head(t: dict[str, Any], tags: list[str]) -> dict[str, Any]: if "Name" not in t: tags.append("제목 표에 코드 없음") return { "코드": t.get("Code"), "이름": t.get("Name"), "규격": t.get("Size"), "단위": t.get("Unite"), "저장": _amounts(t, AMT), } def cost_line(at: int, r: dict[str, Any], res: dict, tags: list[str]) -> dict[str, Any]: """CostMainDat 한 줄 → 구조 줄.""" text = r.get("LineData") or "" ld = marks(text) link = r.get("LinkIndex") return { "행": at, "글": text, "보임": _CTRL.sub("", text), "품명": ld.get("품명"), "규격": ld.get("규격"), "단위": ld.get("단위"), "단": r.get("Level"), "색인": r.get("Index"), "연결": link, "연결뜻": LINK.get(link or 0), "비목": _sc(r), "자원": _resource(r, res, tags), "연결단가": ld.get("단가", []), "변수": ld.get("변수", []), "줄참조": ld.get("줄참조", []), "결과": ld.get("결과", []), "합계": bool(ld.get("합계")), "자리": {"자리수": r.get("Diget"), "방법": r.get("DigetIndex")}, "표지": _tag(link, ld), "기계": None, "산근": r.get("Code") if link == 104 else None, "저장": _amounts(r, AMT), } def _machine(lines: list[dict[str, Any]]) -> None: """기계 세 줄 묶음 = 잇단 같은 Code + LinkIndex 105 + 비목 줄(SCheck -1~-5) · 첫 줄 행.""" first = None for i, ln in enumerate(lines): prev = lines[i - 1] if i else None if ln["연결"] == 105 and ln["비목"] in SC_LINE.values(): code = (ln["자원"] or {}).get("코드") same = prev and prev["기계"] and (prev["자원"] or {}).get("코드") == code first = first if same else ln["행"] ln["기계"] = first def cost_sum(lines: list[dict[str, Any]]) -> dict[str, float]: """줄 → 블록 비목 합(셈 없이 저장 결과만) — 합계 줄(\\x1a · 율 62 밖)이 있으면 가장 바깥 단의 비목 줄 결과 · 없으면 비목 줄(-1~-4) 결과 합(소수 그대로).""" auto = [ln for ln in lines if ln["합계"] and ln["연결"] != 62] out = dict.fromkeys("LMSR", 0.0) if auto: low = min(ln["단"] or 0 for ln in auto) for ln in auto: if (ln["단"] or 0) == low and ln["비목"] in out: out[ln["비목"]] = _shown(ln) return out for ln in lines: if ln["비목"] in out and (ln["연결"] or 0) not in (58, 62, 52): out[ln["비목"]] += ln["결과"][-1] if ln["결과"] else (ln["저장"] or {}).get("T") or 0 return out def _shown(ln: dict[str, Any]) -> float: if ln["결과"]: return ln["결과"][-1] tail = ln["보임"].split(":", 1)[1].strip() if ":" in ln["보임"] else "" return _num(tail) if re.fullmatch(r"-?[\d,]+(?:\.\d+)?", tail) else 0.0 def head_line(at: int, r: dict[str, Any], res: dict, tags: list[str]) -> dict[str, Any]: """HeadMainDat(일위대가) 한 줄 → 구조 줄(수량 · 단가 · 금액 칸).""" link = r.get("LinkIndex") return { "행": at, "품명": r.get("Name"), "규격": r.get("Size"), "단위": r.get("Unite"), "수량": r.get("GN"), "연결": link, "연결뜻": LINK.get(link or 0), "비목": SC_RATE.get(r.get("SCheck") or 0) if LINK.get(link or 0) == "율" else None, "자원": _resource(r, res, tags), "산출": r.get("CalcIndex"), "자리": {"자리수": r.get("Diget"), "방법": r.get("DigetIndex")}, "표지": LINK.get(link or 0) if LINK.get(link or 0) in ("율", "소계") else None, "산근": r.get("Code") if link == 104 else None, "단가": _amounts(r, UNIT_AMT), "저장": _amounts(r, AMT), } def head_sum(lines: list[dict[str, Any]]) -> dict[str, float]: """일위대가 줄 → 비목 합(저장 금액 · CalcIndex < 2) — 소계 68 · 율 52 줄에서 누계를 그 줄로 다시 시작(node `EstRows.head`).""" body: list[dict[str, Any]] = [] for ln in lines: body = [ln] if ln["연결"] in (52, 68) else [*body, ln] keep = [ln for ln in body if (ln["산출"] or 0) < 2] return {c: sum((ln["저장"] or {}).get(c) or 0 for ln in keep) for c in "LMSR"} def read(data: bytes) -> dict[str, Any]: """.estx → {꼴, 산근, 일위, 자원, 표지}(블록 = 머리 + 구조 줄 + 줄합).""" warn: set[str] = set() t = tables(data, warn) res = resources(t) out: dict[str, Any] = {"꼴": "EST", "산근": [], "일위": [], "내역": None} for key, main, title, line in ( ("산근", "CostMainDat", "CostTitleDat", cost_line), ("일위", "HeadMainDat", "HeadTitleDat", head_line), ): titles = {r.get("Code"): r for r in t.get(title, [])} for head, body in _groups(t.get(main, []), titles): tags: list[str] = [] blk = _head(head, tags) keep = [r for r in body if key == "산근" or r.get("Name") or r.get("Code")] lines = [line(i, r, res, tags) for i, r in enumerate(keep, 1)] if key == "산근": _machine(lines) blk["줄합"] = cost_sum(lines) else: blk["줄합"] = head_sum(lines) out[key].append({**blk, "줄": lines, "표지": tags}) out["자원"] = {f"{LINK[k[0]]}:{k[1]}": v for k, v in res.items()} out["표지"] = sorted(warn) + ["옵션(datestoption.opt) · 원가 서식(wongadat) 안 읽음"] return out