- 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>
283 lines
9.9 KiB
Python
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
|