Files
Aislo/tools/stmate-test/stc_archive.py
T
eomsangdonandClaude Opus 5.5 e987602f24 feat(stmate-test): 무손실 저장 — 손대지 않은 저장 = 원본 STC 바이트 동일 · 고친 칸만 다시 씀
- DBF 고친 칸만 다시 씀(한글 깨짐 · 2칸을 1칸으로 · 3칸 87.745% 덮어쓰기 없앰) · 단가 → 내역 행 → 소계 → 직공비 다시 셈 · 행 끼우기·지우기
- SIxMemo(COSTN.SMT · PRJLST.SMT) 블록 구조 유지 · 넘치면 뒤에 붙이고 가리키는 값 갱신
- STC 다시 묶기(같은 파일은 원본 바이트) · 검사 · 자동 백업·복원 · 새 공사 · 복사등록(차수 인계) · 해당분 압축
- BDQTY 역변환(쓰기 준비) · 모듈 dbf_rw · dbf_memo · dbf_crypt · stc_archive · stc_save · stc_read · stc_project · api_file · scr_file
- 지킴 시험 test_stmate_file_roundtrip · file_project · file_payload — 실무 STC 7건 왕복

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:42:04 +09:00

283 lines
9.9 KiB
Python

# -*- 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("<IHH", b, 4)
nf, o, names, tot = 0, 32, [], 1
while o < hlen and o < len(b) and b[o] != 0x0D:
names.append(b[o : o + 11].split(b"\x00")[0].decode("ascii", "replace"))
tot += b[o + 16]
nf += 1
o += 32
return {
"ver": b[0],
"nrec": n,
"hlen": hlen,
"rlen": rlen,
"nfield": nf,
"fields": names,
"ok": tot == rlen and hlen + n * rlen <= len(b),
}
def _memo_problems(dbf: bytes, smt: bytes, fld: str, name: str) -> 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("<I", smt, 4)[0] or 64
bad = 0
for i in range(len(t)):
ptr = t.raw(i, fld)
if ptr_valid(ptr):
_tag, ln, blk = ptr_unpack(ptr)
bad += blk * bs + ln > 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