Files
Aislo/tools/stmate-test/dbf_memo.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

194 lines
6.8 KiB
Python

# -*- coding: utf-8 -*-
"""SIxMemo(COSTN.SMT · PRJLST.SMT · QCODE.SMT) 무손실 읽기·고쳐 쓰기 + PRJLST.SMT 키=값 본문 갱신.
근거: SIxMemo_구조.md · 25_설정키_전수와_식템플릿.md §1 · 실무 STC 7건 실측
구조(실측):
[0..4) 다음 빈 블록 번호(LE) = ceil(파일 길이 / 블록 크기)
[4..8) 블록 크기 = 64
[8..16) "SIxMemo\\0" · 나머지 0 → 512바이트(8블록) 머리
메모 = 블록 경계에서 시작 · 블록 패딩 없이 다음 메모는 다음 블록부터 · 마지막 메모 뒤 패딩 없음
DBF 메모 칸 10바이트 = <HII 꼴(8 · PRJLST 둘째 행 1), 바이트 길이, 블록 번호> · 빈 메모 = 공백 10
고쳐 쓰기(SIx 동작과 같게): 새 본문이 차지하던 블록 수 안에 들면 그 자리에 덮어씀(남는 옛 바이트 유지 —
실무 COSTN.SMT 패딩에 옛 글자가 남은 것과 같음) · 넘치면 '다음 빈 블록'에 붙이고 머리 갱신.
"""
from __future__ import annotations
import re
import struct
SIG = b"SIxMemo\x00"
BLANK_PTR = b" " * 10
HEAD_BLOCKS = 8
DEFAULT_TAG = 8
def ptr_valid(ptr: bytes) -> bool:
if len(ptr) < 10 or ptr[:10] == BLANK_PTR or not ptr[:10].strip(b" \x00"):
return False
_tag, ln, blk = struct.unpack("<HII", ptr[:10])
return ln > 0 and blk >= HEAD_BLOCKS
def ptr_unpack(ptr: bytes) -> tuple[int, int, int]:
return struct.unpack("<HII", ptr[:10])
def ptr_pack(tag: int, ln: int, blk: int) -> bytes:
return struct.pack("<HII", tag, ln, blk)
class SixMemo:
"""SMT 파일 한 개 — buf 를 직접 고침."""
def __init__(self, b: bytes | None = None, block_size: int = 64):
if b and b[8:16] == SIG:
self.buf = bytearray(b)
else:
self.buf = bytearray(block_size * HEAD_BLOCKS)
self.buf[8:16] = SIG
struct.pack_into("<II", self.buf, 0, HEAD_BLOCKS, block_size)
self.dirty = False
@property
def bs(self) -> int:
return struct.unpack_from("<I", self.buf, 4)[0] or 64
@property
def next_free(self) -> int:
return struct.unpack_from("<I", self.buf, 0)[0]
def _blocks(self, n: int) -> int:
return max(1, -(-n // self.bs))
def read(self, ptr: bytes) -> bytes:
if not ptr_valid(ptr):
return b""
_tag, ln, blk = ptr_unpack(ptr)
return bytes(self.buf[blk * self.bs : blk * self.bs + ln])
def write(self, ptr: bytes, data: bytes) -> bytes:
"""본문 data 를 쓰고 새 10바이트 포인터 반환(같으면 원래 포인터)."""
if not data:
return BLANK_PTR
valid = ptr_valid(ptr)
tag = ptr_unpack(ptr)[0] if valid else DEFAULT_TAG
if valid and self.read(ptr) == data:
return bytes(ptr[:10])
self.dirty = True
if valid:
_t, ln, blk = ptr_unpack(ptr)
if self._blocks(len(data)) <= self._blocks(ln):
st = blk * self.bs
if len(self.buf) < st + len(data):
self.buf.extend(b"\x00" * (st + len(data) - len(self.buf)))
self.buf[st : st + len(data)] = data
return ptr_pack(tag, len(data), blk)
blk = max(self.next_free, -(-len(self.buf) // self.bs))
self.buf.extend(b"\x00" * (blk * self.bs - len(self.buf)))
self.buf.extend(data)
struct.pack_into("<I", self.buf, 0, blk + self._blocks(len(data)))
return ptr_pack(tag, len(data), blk)
def to_bytes(self) -> bytes:
return bytes(self.buf)
def memo_text_bytes(text: str) -> bytes:
"""화면 글(\\n) → 저장 본문(\\r\\n · cp949 엄격)."""
norm = str(text).replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
try:
return norm.encode("cp949")
except UnicodeEncodeError as e:
raise ValueError(f"메모에 cp949 로 쓸 수 없는 글자: {e.object[e.start : e.end]!r}") from e
def same_memo(a: bytes, b: bytes) -> bool:
n = lambda x: x.rstrip(b"\x00").replace(b"\r\n", b"\n").replace(b"\r", b"\n") # noqa: E731
return n(a) == n(b)
# ── PRJLST.SMT 본문 = [섹션] 과 키=값 줄(CRLF) ──
_KEY_LINE = re.compile(r"^([A-Za-z0-9_]+)=(.*)$")
def ini_items(text: str) -> list[tuple[str, str, str]]:
"""(섹션, 키, 값) 목록 — 본문 순서 그대로."""
out, sec = [], ""
for ln in text.splitlines():
s = ln.strip()
if s.startswith("[") and s.endswith("]"):
sec = s[1:-1]
continue
m = _KEY_LINE.match(s)
if m:
out.append((sec, m.group(1), m.group(2).strip()))
return out
def delphi_num(v: float) -> str:
"""Delphi FloatToStr 꼴(유효숫자 15) — 실측 '47149628.1818182' · '16.5' · '0'."""
if float(v) == int(v) and abs(v) < 1e15:
return str(int(v))
return f"{v:.15g}"
def _same_ini_value(old: str, new: object) -> bool:
try:
return float(old) == float(delphi_num(float(new))) # type: ignore[arg-type]
except (TypeError, ValueError):
return old.strip() == str(new).strip()
def ini_value_text(new: object) -> str:
if isinstance(new, bool):
return "1" if new else "0"
if isinstance(new, (int, float)):
return delphi_num(float(new))
return str(new).strip()
def ini_update(text: str, updates: dict) -> tuple[str, list[str]]:
"""키 값을 그 줄 자리에서 바꿈 · 없는 키는 접두 차수의 [n_KANJUB] 끝(없으면 [KANJUB])에 덧붙임."""
lines = text.split("\r\n")
changed: list[str] = []
index = {}
sec_end: dict[str, int] = {}
sec = ""
for i, ln in enumerate(lines):
s = ln.strip()
if s.startswith("[") and s.endswith("]"):
sec = s[1:-1]
continue
m = _KEY_LINE.match(s)
if m:
index.setdefault(m.group(1), i)
sec_end[sec] = i
for k, v in updates.items():
if v is None:
continue
if k in index:
old = _KEY_LINE.match(lines[index[k]].strip()).group(2)
if _same_ini_value(old, v):
continue
lines[index[k]] = f"{k}={ini_value_text(v)}"
else:
m = re.match(r"^(\d+)_", k)
want = f"{m.group(1)}_KANJUB" if m else "KANJUB"
if want in sec_end:
at = sec_end[want] + 1
else:
tail = 1 if lines and lines[-1] == "" else 0
at = len(lines) - tail
lines.insert(at, f"[{want}]")
at += 1
lines.insert(at, f"{k}={ini_value_text(v)}")
for s2, e in sec_end.items():
if e >= at:
sec_end[s2] = e + 1
sec_end[want] = at
index = {kk: (ii + 1 if ii >= at else ii) for kk, ii in index.items()}
index[k] = at
changed.append(k)
return "\r\n".join(lines), changed