Files
Aislo/resources/knowledge/original/_pipeline/pum_md_tool.py
T
eomsangdonandClaude Opus 5 56343d8088 knowledge(품셈md): 도구 check · pages · status + 잠금 시험
- check: ① 수(갯수까지) ② 글자(공백 뗌) 양방향 대조 · 어긋난 자리를 쪽·줄로 출력 · 머리 기계검사 칸에 적음
- md 쪽은 머리 · 주석 · 그림 · 표 문법 · 제목·인용 표시 · 첨자 표시를 빼고, 막음표(\) 뒤 글자는 원문으로 셈
- 확정 파일은 통과할 때 확정지문(LF 로 센 sha256)을 한 번만 적음 — 있는 지문은 안 덮음
- pages: 쪽 그림 150dpi → tmp/pum_pages(git 밖) · status: 장별 상태 표
- test_pum_md_locked.py: 확정 파일 지문·①② 0 · 머리 칸 다 있음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
2026-09-19 01:27:15 +09:00

543 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""품셈 PDF → 절별 md 도구 (규칙: `원가계산/_품셈md_작성규칙.md` · 2026-09-19).
판정 기준은 PDF 뿐 — 원가계산(B09)·공종 마스터·옛 md 는 보지 않음.
사용: python pum_md_tool.py plan <산림|건설> 차례에서 장·절 → 본문/ 빈 파일(머리만 · 상태 대기)
python pum_md_tool.py pages <산림|건설> [쪽범위] 쪽 그림 150dpi PNG → tmp/pum_pages/ (git 밖)
python pum_md_tool.py check <md 파일> ① 수 대조 ② 글자 대조 → 머리 「기계검사」 칸
python pum_md_tool.py status 장별 대기/작성중/검증대기/확정 표
절 경계 = 시작표지·끝표지 글자가 쪽 안에서 놓인 줄(좌표로 묶은 줄의 맨 앞).
좌표로 줄 묶기는 `resources/tester/test_const_pdf_coverage.py` 의 것을 그대로 씀(그 시험은 안 고침).
"""
from __future__ import annotations
import collections
import difflib
import hashlib
import re
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[4] # 저장소 뿌리
sys.path.insert(0, str(ROOT))
from resources.tester.test_const_pdf_coverage import _NUM, _page_rows # noqa: E402
COST = ROOT / "resources/knowledge/original/원가계산"
#: 책마다 — PDF · 차례 쪽(PDF 쪽 번호) · 인쇄 1쪽이 놓인 PDF 쪽 · 머리 「문서」 칸 · 새 md 뿌리
BOOKS = {
"산림": {
"pdf": COST / "산림_표준품셈/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.pdf",
"toc": (3, 12),
"first_body": 13,
"doc": "산림사업 표준품셈(산림청고시 제2025-82호)",
"out": COST / "산림_표준품셈/본문",
},
}
HEAD_KEYS = (
"문서",
"절",
"PDF쪽",
"인쇄쪽",
"시작표지",
"끝표지",
"상태",
"작성",
"검증",
"원천",
"기계검사",
"확정지문",
)
STATES = ("대기", "작성중", "검증대기", "확정")
_TOC_SKIP = {"장", "내 용", "페이지", "- 목 차 -"}
_TOC_ID = re.compile(r"제\d+장|\d+-\d+(?:-\d+)*|부록\d*")
_BAD_NAME = re.compile(r'[/:*?"<>|\\]')
_LEADER = re.compile(r"\s*[·․…]{3,}.*$")
#: 글자층이 없다고 볼 쪽 글자 수(쪽 번호만 남은 쪽)
_EMPTY_PAGE_CHARS = 20
@dataclass
class Entry:
"""차례 한 줄 — 장(`제1장`) · 절(`1-1`) · 항(`1-1-1`) · 부록."""
ident: str
name: str
page: int # 인쇄 쪽
def toc_entries(doc, first: int, last: int) -> list[Entry]:
"""차례 쪽(PDF first~last)의 줄 — 번호 줄 · 이름 줄(점선) · 쪽 줄이 차례로 옴."""
lines = [
line
for number in range(first - 1, last)
for line in doc[number].get_text().splitlines()
if line.strip() not in _TOC_SKIP
]
return parse_toc(lines)
def parse_toc(raw: list[str]) -> list[Entry]:
lines = [
t for t in (_LEADER.sub("", line).strip() for line in raw) if t
] # 점선 뗌(「부록 ····」)
out, i = [], 0
while i < len(lines):
if not _TOC_ID.fullmatch(lines[i]):
raise ValueError(f"차례 모양이 다름: {lines[i]!r}")
j, name = i + 1, []
while j < len(lines) and not lines[j].isdigit():
name.append(lines[j])
j += 1
if j == len(lines):
raise ValueError(f"차례 쪽 번호 없음: {lines[i]}")
out.append(Entry(lines[i], " ".join(name), int(lines[j])))
i = j + 1
return out
#: 가운뎃점 여러 벌 — 차례는 「보수․복구」(U+2024) · 본문은 「보수·복구」(U+00B7). 맞대기에서만 같게 봄.
_DOTS = str.maketrans({"": "·", "ㆍ": "·", "・": "·", "‧": "·"})
def key(text: str) -> str:
"""표지 맞대기 — 공백·대괄호를 떼고 번호 뒤 점을 뗌(`9-5. 발파암` = `9-5 발파암`)."""
tight = re.sub(r"[\s\[\]]", "", text).translate(_DOTS)
return re.sub(r"^(\d+(?:-\d+)*)\.", r"\1", tight)
def locate(doc, marker: str, first_pdf: int, slack: int = 3) -> tuple[int, int]:
"""표지가 줄 맨 앞에 놓인 (PDF 쪽, 그 쪽 안 줄 차례) — first_pdf 부터 slack 쪽 안에서 찾음."""
want = key(marker)
for number in range(first_pdf, min(first_pdf + slack, doc.page_count) + 1):
for index, row in enumerate(_page_rows(doc[number - 1])):
if key(row).startswith(want):
return number, index
raise ValueError(f"표지를 못 찾음: {marker} (PDF {first_pdf}~{first_pdf + slack})")
def heading_pattern(ident: str) -> re.Pattern:
"""본문 머리 줄 — **번호로** 찾음. 차례 이름에 오타가 있어서(「면벅」=면벽 · 「조림」=조립 …)."""
if ident.startswith("제"):
return re.compile(r"^제\s*" + ident[1:-1] + r"\s*장(?!\d)")
if ident.startswith("부록"):
return re.compile(r"^\[?" + re.escape(ident) + r"\]?(?!\d)")
return re.compile(r"^" + re.escape(ident) + r"\.(?!\d)")
#: 글자 사이 빈칸으로 볼 틈(글자 크기 대비) — 본문 머리 실측: 빈칸 7.7pt · 붙은 글자 0.7pt(14pt 글꼴)
_GAP = 0.25
def visual_text(page, row: str) -> str:
"""쪽 그림대로 띄운 줄 — 글자층은 빈칸을 잃어서(「설계및수량」) 글자 사이 틈으로 되살림."""
want = re.sub(r"\s", "", row)
for block in page.get_text("rawdict")["blocks"]:
for line in block.get("lines", []):
chars = [c for span in line["spans"] for c in span["chars"]]
if re.sub(r"\s", "", "".join(c["c"] for c in chars)) != want:
continue
size = max(span["size"] for span in line["spans"])
out = ""
for prev, ch in zip([None, *chars], chars):
gap = ch["bbox"][0] - prev["bbox"][2] if prev else 0
if prev and not prev["c"].isspace() and gap > size * _GAP:
out += " "
out += ch["c"]
return " ".join(out.split())
return row
def find_heading(doc, ident: str, first_pdf: int, slack: int = 3) -> tuple[int, int, str]:
"""(PDF 쪽, 쪽 안 줄 차례, 머리 글자) — 머리 글자는 PDF 그대로(번호 뒤 마침표 포함) · 띄어쓰기는 쪽 그림대로."""
pattern = heading_pattern(ident)
for number in range(first_pdf, min(first_pdf + slack, doc.page_count) + 1):
for index, row in enumerate(_page_rows(doc[number - 1])):
if pattern.match(row.strip()):
return number, index, visual_text(doc[number - 1], row.strip())
raise ValueError(f"머리를 못 찾음: {ident} (PDF {first_pdf}~{first_pdf + slack})")
def name_of(ident: str, title: str) -> str:
"""머리 글자에서 번호를 뗀 이름(「9-5. 발파암」 → 「발파암」)."""
return title[heading_pattern(ident).match(title).end() :].strip()
def claimed_elsewhere(root: Path) -> set[str]:
"""다른 창 브랜치에 이미 있는 새 md 경로 — 만들면 합칠 때 같은 자리 두 벌로 부딪힘."""
import subprocess
rel = root.relative_to(ROOT).as_posix()
refs = subprocess.run(
["git", "-C", str(ROOT), "for-each-ref", "--format=%(refname)", "refs/remotes/origin"],
capture_output=True,
text=True,
check=True,
).stdout.split()
out: set[str] = set()
for ref in refs:
listed = subprocess.run(
[
"git",
"-C",
str(ROOT),
"-c",
"core.quotepath=off",
"ls-tree",
"-r",
"--name-only",
ref,
"--",
rel,
],
capture_output=True,
text=True,
encoding="utf-8",
)
out.update(line[len(rel) + 1 :] for line in listed.stdout.splitlines() if line)
return out # root 아래 상대 경로
def safe(text: str) -> str:
return _BAD_NAME.sub("", text).replace(" ", "_")
def _span(a: int, b: int) -> str:
return str(a) if a == b else f"{a}~{b}"
def head_text(values: dict[str, str]) -> str:
return (
"---\n" + "".join(f"{k}: {values.get(k, '')}".rstrip() + "\n" for k in HEAD_KEYS) + "---\n"
)
def read_head(path: Path) -> dict[str, str]:
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0] != "---":
raise ValueError(f"머리 없음: {path}")
out = {}
for line in lines[1:]:
if line == "---":
return out
k, _, v = line.partition(":")
out[k.strip()] = v.strip()
raise ValueError(f"머리 끝 없음: {path}")
_SLOT = re.compile(r"제(\d+)장_[^/]*/(\d+-\d+)_")
def plan(book_name: str) -> list[Path]:
"""장마다 `0-00_장머리.md` + 절마다 빈 파일(머리만 · 상태 대기).
**이미 있는 자리는 안 건드림** — 이 폴더에 있거나 다른 창 브랜치에 있는 장·절(이름이 달라도 번호로 봄).
"""
import pymupdf
book = BOOKS[book_name]
out = book["out"]
offset = book["first_body"] - 1 # 인쇄 쪽 + offset = PDF 쪽
have = claimed_elsewhere(out) | {p.relative_to(out).as_posix() for p in out.rglob("*.md")}
taken = {f"{int(m[1])}/{m[2]}" for m in map(_SLOT.match, have) if m}
folders = {int(m[1]): rel.split("/")[0] for rel in have if (m := _SLOT.match(rel))}
written = []
with pymupdf.open(book["pdf"]) as doc:
entries = toc_entries(doc, *book["toc"])
# 경계가 되는 차례 줄 — 장 · 절 · 부록N(항은 절 안 · 머리 없는 「부록」 묶음 줄은 뺌)
bounds = [e for e in entries if e.ident != "부록" and e.ident.count("-") < 2]
chapter_no = None
for n, entry in enumerate(bounds):
if entry.ident.startswith("부록"):
continue
start_pdf, _, title = find_heading(doc, entry.ident, entry.page + offset)
following = bounds[n + 1] if n + 1 < len(bounds) else None
if following is None: # 문서 맨 끝 절
end_pdf, end_marker = doc.page_count, "끝"
else: # 장 마지막 절이면 다음 장 제목 · 부록 앞이면 부록 제목
next_pdf, at, end_marker = find_heading(
doc, following.ident, following.page + offset
)
end_pdf = max(next_pdf - 1 if at == 0 else next_pdf, start_pdf)
if entry.ident.startswith("제"):
chapter_no = int(entry.ident[1:-1])
folders.setdefault(
chapter_no, f"제{chapter_no:02d}장_{safe(name_of(entry.ident, title))}"
)
slot, file_name = "0-00", "0-00_장머리.md"
else:
major, minor = entry.ident.split("-")
slot = f"{major}-{int(minor):02d}"
file_name = f"{slot}_{safe(name_of(entry.ident, title))}.md"
if chapter_no is None or f"{chapter_no}/{slot}" in taken:
continue
empty = any(
len(re.sub(r"\s", "", doc[p - 1].get_text())) < _EMPTY_PAGE_CHARS
for p in range(start_pdf, end_pdf + 1)
)
path = out / folders[chapter_no] / file_name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
head_text(
{
"문서": book["doc"],
"절": title,
"PDF쪽": _span(start_pdf, end_pdf),
"인쇄쪽": _span(start_pdf - offset, end_pdf - offset),
"시작표지": title,
"끝표지": end_marker,
"상태": "대기",
"원천": "그림" if empty else "글자층",
}
),
encoding="utf-8",
)
written.append(path)
return written
# ── check ──────────────────────────────────────────────────────────────
#: 쪽 번호 줄(「- 123 -」) — 절 글자에서 뺌
_PAGE_NO = re.compile(r"^-\s*\d+\s*-$")
#: md 문법 — 글자 대조·수 대조에서 뺌(원문 글자가 아님)
_COMMENT = re.compile(r"<!--.*?-->", re.S)
_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
_TABLE_RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
#: 막음표 — 원문 글자가 md 문법으로 읽히지 않게 앞에 `\` 를 붙인 것(`\*` `\-` `\#` `\>` `\|` …)
_ESCAPED = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~^])")
_HOLD = "\x00"
def book_of(path: Path) -> dict:
path = path.resolve()
for book in BOOKS.values():
if path.is_relative_to(book["out"].resolve()):
return book
raise ValueError(f"본문/ 아래 파일이 아님: {path}")
def split_head(text: str) -> tuple[str, str]:
"""(머리, 몸) — 머리는 첫 `---` 부터 다음 `---` 까지."""
lines = text.split("\n")
end = lines.index("---", 1)
return "\n".join(lines[: end + 1]), "\n".join(lines[end + 1 :])
def md_lines(body: str) -> list[tuple[int, str]]:
"""몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터."""
body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body)
out = []
for number, line in enumerate(body.split("\n"), start=1):
if _TABLE_RULE.match(line):
continue
line = _IMAGE.sub("", line).replace("<br>", " ")
held = _ESCAPED.findall(line) # 막음표 뒤 원문 글자는 아래 문법 떼기에서 지킴
line = _ESCAPED.sub(_HOLD, line)
line = re.sub(r"^\s*#+\s", "", line) # 제목
line = re.sub(r"^\s*(>\s*)+", "", line) # 인용
line = re.sub(r"[|^_]", " ", line) # 표 칸 · 첨자 표시(`10^-7` · `C_m`)
for ch in held:
line = line.replace(_HOLD, ch, 1)
out.append((number, line))
return out
def pdf_lines(doc, head: dict[str, str]) -> list[tuple[int, str]]:
"""그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 쪽 번호 줄 뺌."""
first, _, last = head["PDF쪽"].partition("~")
first, last = int(first), int(last or first)
start_page, start_row = locate(doc, head["시작표지"], first, slack=0)
end_page, end_row = last + 1, 0
if head["끝표지"] and head["끝표지"] != "끝":
try:
end_page, end_row = locate(doc, head["끝표지"], last, slack=1)
except ValueError:
pass
out = []
for number in range(start_page, min(end_page, doc.page_count) + 1):
for index, row in enumerate(_page_rows(doc[number - 1])):
if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row):
continue
if not _PAGE_NO.match(row.strip()):
out.append((number, row))
return out
def _tight(lines: list[tuple[int, str]]) -> tuple[str, list[int]]:
"""공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄)."""
text, where = [], []
for at, line in lines:
for ch in re.sub(r"\s", "", line):
text.append(ch)
where.append(at)
return "".join(text), where
def _numbers(lines: list[tuple[int, str]]) -> tuple[collections.Counter, dict[str, list[int]]]:
counts, where = collections.Counter(), collections.defaultdict(list)
for at, line in lines:
for token in _NUM.findall(line):
counts[token] += 1
where[token].append(at)
return counts, where
def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict:
"""① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향."""
pdf_n, pdf_at = _numbers(pdf)
md_n, md_at = _numbers(md)
pdf_t, pdf_where = _tight(pdf)
md_t, md_where = _tight(md)
missing_c = collections.Counter(pdf_t) - collections.Counter(md_t)
extra_c = collections.Counter(md_t) - collections.Counter(pdf_t)
spots = []
if missing_c or extra_c: # 자리 — 차례대로 맞대 어긋난 토막(갯수 차가 난 글자가 든 것만)
matcher = difflib.SequenceMatcher(None, pdf_t, md_t, autojunk=False)
for op, i1, i2, j1, j2 in matcher.get_opcodes():
gone, added = pdf_t[i1:i2], md_t[j1:j2]
if op == "equal" or not (set(gone) & set(missing_c) or set(added) & set(extra_c)):
continue
spots.append(
{
"pdf": pdf_where[min(i1, len(pdf_where) - 1)] if pdf_where else None,
"md": md_where[min(j1, len(md_where) - 1)] if md_where else None,
"context": pdf_t[max(0, i1 - 8) : i1],
"pdf_only": gone,
"md_only": added,
}
)
return {
"num_missing": {k: (v, pdf_at[k][:v]) for k, v in (pdf_n - md_n).items()},
"num_extra": {k: (v, md_at[k][:v]) for k, v in (md_n - pdf_n).items()},
"char_missing": missing_c,
"char_extra": extra_c,
"spots": spots,
}
def verdict(result: dict) -> str:
counts = (
sum(v for v, _ in result["num_missing"].values()),
sum(v for v, _ in result["num_extra"].values()),
sum(result["char_missing"].values()),
sum(result["char_extra"].values()),
)
word = "통과" if not any(counts) else "불통"
return (
f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}"
)
def fingerprint(text: str) -> str:
"""확정지문 — 줄끝을 LF 로 고쳐 셈(git 이 CRLF 로 풀어도 같게) · 도구가 적는 두 칸(기계검사·확정지문)은 뺌."""
lines = text.replace("\r\n", "\n").split("\n")
kept = [ln for ln in lines if not ln.startswith(("기계검사:", "확정지문:"))]
return hashlib.sha256("\n".join(kept).encode("utf-8")).hexdigest()
def set_head(text: str, field: str, value: str) -> str:
head, body = split_head(text)
head = re.sub(rf"(?m)^{field}:.*$", f"{field}: {value}".rstrip(), head, count=1)
return head + "\n" + body
def check_file(path: Path) -> tuple[dict, str]:
"""(대조 결과, 한 줄 판정) — 파일은 안 고침."""
import pymupdf
_, body = split_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"))
with pymupdf.open(book_of(path)["pdf"]) as doc:
result = compare(pdf_lines(doc, read_head(path)), md_lines(body))
return result, verdict(result)
def check(path: Path) -> int:
"""검사하고 머리 「기계검사」 칸에 적음 · 상태 `확정` 이고 통과인데 확정지문이 비었으면 지문도 적음.
⚠ 있는 확정지문은 안 고침 — 확정 뒤 바뀐 파일을 지문을 다시 적어 덮으면 시험이 못 잡음.
"""
result, line = check_file(path)
print(f"{path.name}: {line}")
for token, (count, at) in sorted(result["num_missing"].items()):
print(f" ① 결손 {token} ×{count} (PDF {', '.join(map(str, at))}쪽)")
for token, (count, at) in sorted(result["num_extra"].items()):
print(f" ① 허구 {token} ×{count} (md 몸 {', '.join(map(str, at))}줄)")
for spot in result["spots"][:40]:
print(
f" ② PDF {spot['pdf']}쪽 · md 몸 {spot['md']}줄 · …{spot['context']}"
f" [PDF만 「{spot['pdf_only']}」 · md만 「{spot['md_only']}」]"
)
text = set_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"), "기계검사", line)
head = read_head(path)
if head.get("상태") == "확정" and line.startswith("통과") and not head.get("확정지문"):
text = set_head(text, "확정지문", fingerprint(text))
path.write_text(text, encoding="utf-8", newline="\n")
return 0 if line.startswith("통과") else 1
# ── pages · status ─────────────────────────────────────────────────────
PAGES_DIR = ROOT / "tmp/pum_pages"
def pages(book_name: str, span: str = "") -> list[Path]:
"""쪽 그림 150dpi PNG — `tmp/pum_pages/<책>/p0135.png`(git 밖)."""
import pymupdf
out = PAGES_DIR / book_name
out.mkdir(parents=True, exist_ok=True)
made = []
with pymupdf.open(BOOKS[book_name]["pdf"]) as doc:
first, _, last = (span or f"1~{doc.page_count}").replace("-", "~").partition("~")
for number in range(int(first), int(last or first) + 1):
path = out / f"p{number:04d}.png"
doc[number - 1].get_pixmap(dpi=150).save(path)
made.append(path)
return made
def status() -> str:
"""장별 상태 표(md) — 머리 「상태」 칸을 모음."""
rows = [
"| 책 | 장 | " + " | ".join(STATES) + " | 계 |",
"|---|---|" + "---|" * (len(STATES) + 1),
]
for name, book in BOOKS.items():
by_chapter: dict[str, collections.Counter] = {}
for path in sorted(book["out"].rglob("*.md")):
chapter = path.relative_to(book["out"]).parts[0]
by_chapter.setdefault(chapter, collections.Counter())[
read_head(path).get("상태", "")
] += 1
for chapter, counts in by_chapter.items():
cells = " | ".join(str(counts[s]) for s in STATES)
rows.append(f"| {name} | {chapter} | {cells} | {sum(counts.values())} |")
return "\n".join(rows)
def main(argv: list[str]) -> int:
command, args = (argv[0], argv[1:]) if argv else ("", [])
if command == "plan" and args:
print(f"새 파일 {len(plan(args[0]))}")
return 0
if command == "pages" and args:
made = pages(args[0], args[1] if len(args) > 1 else "")
print(f"쪽 그림 {len(made)}{PAGES_DIR / args[0]}")
return 0
if command == "check" and args:
return max(check(Path(a)) for a in args)
if command == "status":
print(status())
return 0
print(__doc__)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))