# -*- coding: utf-8 -*- """STC(ZIP) 무손실 열기·다시 묶기 + 구조·버전 검사(Chk_STC_Ver 대응). 근거: STC_컨테이너_구조.md §1~§4 · 24_모듈구조_전수.md(ST_U_Archive: Chk_STC_Ver·ComP_Data·UnPack · CPMARC Info-ZIP) 실측(실무 STC 7건): deflate(8) · 일반 플래그 2(최대 압축) · 만든 버전 22 · 풀 버전 20 · 만든 체계 11 · 외부 속성 0x81800020 · 추가 칸·주석 없음 · 이름 대소문자 섞임(Start8x.Ini / Start8x.ini). 다시 묶기: 내용이 같은 엔트리는 로컬 헤더+압축 바이트를 그대로 옮김(→ 무변경 저장 = 원본과 같은 바이트) · 바뀐 엔트리만 zlib 9 로 다시 눌러 CRC·크기·시각 갱신 · 나머지 머리 값·차례·이름은 원본 그대로. """ from __future__ import annotations import datetime import pathlib import struct import zlib EOCD = b"PK\x05\x06" CEN = b"PK\x01\x02" LOC = b"PK\x03\x04" _CEN_FMT = "<4sHHHHHHIIIHHHHHII" _LOC_FMT = "<4sHHHHHIIIHH" # 실측 기본 머리 값 — 새 엔트리·폴더에서 묶을 때 DEFAULT_META = { "ver_made": 22 | (11 << 8), "ver_need": 20, "flag": 2, "method": 8, "iattr": 0, "eattr": 0x81800020, } # 실무 STC 엔트리 차례(봉화·영월 등 공통) — 폴더에서 묶을 때 STD_ORDER = [ "BDQTY.DBF", "BOQ.DBF", "BOQ11.DBF", "BOQ12.DBF", "BOQ13.DBF", "BOQ14.DBF", "COSTA.DBF", "COSTN.DBF", "COSTN.SMT", "GRASS.DBF", "KONGJONG.DBF", "PRJLST.DBF", "PRJLST.SMT", "QCODE.DBF", "QCODE.SMT", "RATE.DBF", "RATE_M.DBF", "RATE_R.DBF", "RATE_RA.DBF", "SET_REP.DBF", "Start8x.Ini", "STini.MEM", "SYSINFO.DBF", ] def dos_time(dt: datetime.datetime) -> tuple[int, int]: return (dt.hour << 11) | (dt.minute << 5) | (dt.second // 2), ((dt.year - 1980) << 9) | ( dt.month << 5 ) | dt.day def read_entries(b: bytes) -> list[dict]: """중앙 목록 차례대로 {name, meta, data(풀린), raw_local(로컬 헤더+압축 바이트)}.""" at = b.rfind(EOCD) if at < 0: raise ValueError("ZIP 끝 레코드 없음 — STC 아님") _sig, _d, _cd, _n1, n, _size, cd_off, _clen = struct.unpack_from("<4sHHHHIIH", b, at) out, p = [], cd_off for _ in range(n): f = struct.unpack_from(_CEN_FMT, b, p) (_s, vm, vn, flag, meth, tm, dt, crc, csz, usz, nl, xl, cl, _dk, ia, ea, lho) = f raw_name = b[p + 46 : p + 46 + nl] extra = b[p + 46 + nl : p + 46 + nl + xl] comment = b[p + 46 + nl + xl : p + 46 + nl + xl + cl] p += 46 + nl + xl + cl lf = struct.unpack_from(_LOC_FMT, b, lho) lnl, lxl = lf[9], lf[10] dstart = lho + 30 + lnl + lxl comp = b[dstart : dstart + csz] data = zlib.decompress(comp, -15) if meth == 8 else comp if zlib.crc32(data) != crc: raise ValueError(f"CRC 불일치: {raw_name!r}") enc = "utf-8" if flag & 0x800 else "cp949" out.append( { "name": raw_name.decode(enc, "replace"), "raw_name": raw_name, "meta": { "ver_made": vm, "ver_need": vn, "flag": flag, "method": meth, "time": tm, "date": dt, "iattr": ia, "eattr": ea, "extra": extra, "comment": comment, }, "data": data, "raw_local": b[lho : dstart + csz], "crc": crc, "csize": csz, } ) return out def files_of(entries: list[dict]) -> dict[str, bytes]: return {pathlib.Path(e["name"]).name.upper(): e["data"] for e in entries} def build_stc(entries: list[dict], files: dict[str, bytes], now=None) -> bytes: """entries(원본 차례·머리 값)에 files(대문자 이름 → 내용)를 얹어 STC 바이트로. files 에 없는 엔트리는 뺌 · entries 에 없는 files 는 STD_ORDER 자리에 기본 머리 값으로 더함. """ now = now or datetime.datetime.now() by_up = {pathlib.Path(e["name"]).name.upper(): e for e in entries} names = [pathlib.Path(e["name"]).name.upper() for e in entries if e.get("name")] names = [n for n in names if n in files] std_up = [s.upper() for s in STD_ORDER] for n in sorted(files, key=lambda x: (std_up.index(x) if x in std_up else 99, x)): if n not in names: pos = len(names) if n in std_up: rank = std_up.index(n) pos = next( (k for k, m in enumerate(names) if m in std_up and std_up.index(m) > rank), len(names), ) names.insert(pos, n) body, cen = bytearray(), bytearray() for n in names: data = files[n] e = by_up.get(n) off = len(body) if e is not None and e["data"] == data: local = e["raw_local"] m, crc, csz = e["meta"], e["crc"], e["csize"] raw_name = e["raw_name"] else: m = dict(e["meta"]) if e else dict(DEFAULT_META, extra=b"", comment=b"") if not e: proper = next((s for s in STD_ORDER if s.upper() == n), n) raw_name = proper.encode("cp949") else: raw_name = e["raw_name"] m["time"], m["date"] = dos_time(now) comp = data if m["method"] == 8: co = zlib.compressobj(9, zlib.DEFLATED, -15) comp = co.compress(data) + co.flush() crc, csz = zlib.crc32(data), len(comp) local = ( struct.pack( _LOC_FMT, LOC, m["ver_need"], m["flag"] & ~0x08, m["method"], m["time"], m["date"], crc, csz, len(data), len(raw_name), 0, ) + raw_name + comp ) body += local cen += struct.pack( _CEN_FMT, CEN, m["ver_made"], m["ver_need"], m["flag"] & ~0x08, m["method"], m["time"], m["date"], crc, csz, len(data), len(raw_name), len(m.get("extra", b"")), len(m.get("comment", b"")), 0, m["iattr"], m["eattr"], off, ) cen += raw_name + m.get("extra", b"") + m.get("comment", b"") eocd = struct.pack("<4sHHHHIIH", EOCD, 0, 0, len(names), len(names), len(cen), len(body), 0) return bytes(body + cen + eocd) # ── 구조·버전 검사 ── REQUIRED = ("PRJLST.DBF", "PRJLST.SMT", "COSTN.DBF", "COSTN.SMT", "BOQ11.DBF") SCHEMA_VARIANTS = { # STC_컨테이너_구조.md §4 — 표본에 있는 필드 수 "BOQ11.DBF": (83, 94), "COSTN.DBF": (99, 101), "PRJLST.DBF": (47, 48, 55), "BDQTY.DBF": (37, 38), } def _dbf_head(b: bytes) -> dict: n, hlen, rlen = struct.unpack_from(" list[str]: """메모 포인터가 SMT 안(머리 뒤 · 파일 끝 안)을 가리키는지.""" from dbf_memo import ptr_unpack, ptr_valid from dbf_rw import DbfTable t = DbfTable(dbf) if fld not in t.fields or smt[8:15] != b"SIxMemo": return [] bs = struct.unpack_from(" len(smt) return [f"{name}.{fld} 메모 포인터 {bad}개가 SMT 밖"] if bad else [] def check_stc(b: bytes) -> dict: """ZIP·필수 엔트리·DBF 머리·SMT 서명·메모 포인터 범위 검사와 판(구형/2020 확장) 추정.""" res: dict = {"ok": True, "problems": [], "tables": {}, "edition": ""} try: ents = read_entries(b) except Exception as e: # noqa: BLE001 return {"ok": False, "problems": [f"ZIP 열기 실패: {e}"], "tables": {}, "edition": ""} files = files_of(ents) res["entries"] = [e["name"] for e in ents] for r in REQUIRED: if r not in files: res["problems"].append(f"필수 엔트리 없음: {r}") for n, data in files.items(): if n.endswith(".DBF"): h = _dbf_head(data) res["tables"][n] = {k: v for k, v in h.items() if k != "fields"} if not h["ok"]: res["problems"].append(f"{n} 머리 길이 불일치") if n in SCHEMA_VARIANTS and h["nfield"] not in SCHEMA_VARIANTS[n]: res["problems"].append(f"{n} 필드 수 {h['nfield']} — 표본에 없던 판") elif n.endswith(".SMT") and data[8:15] != b"SIxMemo": res["problems"].append(f"{n} SIxMemo 서명 없음") for dbf, smt, fld in ( ("COSTN.DBF", "COSTN.SMT", "IFORM"), ("PRJLST.DBF", "PRJLST.SMT", "PRJ_MEMO"), ): if dbf in files and smt in files: res["problems"] += _memo_problems(files[dbf], files[smt], fld, dbf) prj = _dbf_head(files["PRJLST.DBF"])["fields"] if "PRJLST.DBF" in files else [] res["edition"] = "8.0 2020 확장(5차 칸)" if "MCOST5" in prj else "8.0 구형(4차 칸)" res["ok"] = not res["problems"] return res