knowledge(품셈md): 절별 md 도구 pum_md_tool.py — plan(차례 → 빈 파일)

- 산림 차례(PDF 3~12쪽)에서 장 14 · 절 189 · 쪽 범위 · 시작/끝 표지
- 절 머리는 번호로 찾음 — 차례 이름 오타 다섯(면벅·조림·우드그래풀·소형 운재) 때문
- 표지는 PDF 글자 그대로(번호 뒤 마침표 포함) · 띄어쓰기만 차례를 따름
- 다른 창 브랜치에 이미 있는 파일은 안 만듦(합칠 때 부딪힘 막음)
- 시험 test_pum_md_tool.py: 순수 함수 · 산림 차례 개수

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
2026-09-19 01:19:46 +09:00
co-authored by Claude Opus 5
parent c7ee558924
commit 1964797de2
2 changed files with 388 additions and 0 deletions
@@ -0,0 +1,303 @@
# -*- 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 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 _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)")
def find_heading(doc, ident: str, first_pdf: int, slack: int = 3) -> tuple[int, int, str]:
"""(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, row.strip()
raise ValueError(f"머리를 못 찾음: {ident} (PDF {first_pdf}~{first_pdf + slack})")
def respace(body: str, toc: str) -> str:
"""본문 글자에 차례 띄어쓰기를 입힘 — 글자층은 띄어쓰기가 빠져 있음(「철근현장가공및조립」).
글자는 본문 것(가운뎃점 `·` 등) · 띄어쓰기만 차례에서. 공백 뺀 글자가 다르면(차례 오타) 본문 그대로.
"""
plain = re.sub(r"\s", "", body)
if plain.translate(_DOTS) != re.sub(r"\s", "", toc).translate(_DOTS):
return body
glyphs = iter(plain)
return "".join(" " if ch.isspace() else next(glyphs) for ch in toc)
def heading_text(entry: Entry, row: str) -> str:
"""머리 글자 — PDF 그대로(번호 뒤 마침표 포함 · 「4-5. 벌도 위험목 점검」), 띄어쓰기는 차례를 따름."""
found = heading_pattern(entry.ident).match(row)
lead, rest = row[: found.end()].strip(), row[found.end() :].strip()
name = respace(rest, entry.name)
return f"{lead} {name}".strip() if name else lead
def section_name(entry: Entry, row: str) -> str:
"""파일 이름에 쓸 절 이름 — 머리 글자에서 번호를 뗀 것."""
return heading_text(entry, row)[heading_pattern(entry.ident).match(row).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 section_file(out: Path, chapter: Entry, section: Entry) -> Path:
number = int(chapter.ident[1:-1])
major, minor = section.ident.split("-")
folder = f"{number:02d}장_{safe(chapter.name)}"
return out / folder / f"{major}-{int(minor):02d}_{safe(section.name)}.md"
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}")
def plan(book_name: str) -> list[Path]:
"""절마다 빈 파일(머리만 · 상태 대기) — **있는 파일은 안 건드림**(다른 창이 집어 쓰는 중일 수 있음)."""
import pymupdf
book = BOOKS[book_name]
offset = book["first_body"] - 1 # 인쇄 쪽 + offset = PDF 쪽
written = []
claimed = claimed_elsewhere(book["out"])
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 = None
for n, entry in enumerate(bounds):
if entry.ident.startswith(""):
chapter = entry
continue
if entry.ident.startswith("부록") or chapter is None:
continue
start_pdf, _, row = find_heading(doc, entry.ident, entry.page + offset)
title = heading_text(entry, row)
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, next_row = find_heading(doc, following.ident, following.page + offset)
end_pdf = next_pdf - 1 if at == 0 else next_pdf
end_marker = heading_text(following, next_row)
end_pdf = max(end_pdf, start_pdf)
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 = section_file(
book["out"], chapter, Entry(entry.ident, section_name(entry, row), entry.page)
)
if path.exists() or path.relative_to(book["out"]).as_posix() in claimed:
continue
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
def main(argv: list[str]) -> int:
if len(argv) >= 2 and argv[0] == "plan":
made = plan(argv[1])
print(f"새 파일 {len(made)}")
return 0
print(__doc__)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+85
View File
@@ -0,0 +1,85 @@
"""품셈 PDF → 절별 md 도구(`_pipeline/pum_md_tool.py`) — 순수 함수 · 산림 차례 개수.
규칙: `resources/knowledge/original/원가계산/_품셈md_작성규칙.md`.
"""
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
import pum_md_tool as tool # noqa: E402
def test_parse_toc_strips_leaders_and_reads_appendix():
lines = ["제1장", "적용기준 ·········", "1", "1-1", "일반사항 ·····", "1", "부록 ······", "204"]
got = tool.parse_toc(lines)
assert [(e.ident, e.name, e.page) for e in got] == [
("제1장", "적용기준", 1),
("1-1", "일반사항", 1),
("부록", "", 204),
]
with pytest.raises(ValueError):
tool.parse_toc(["엉뚱한 줄", "1"])
def test_key_ignores_spaces_dot_after_number_and_middle_dots():
assert tool.key("9-5. 발파암") == tool.key("9-5 발파암")
assert tool.key("[부록1]할인") == tool.key("부록1 할인")
assert tool.key("보수·복구") == tool.key("보수․복구")
def test_heading_pattern_by_number_only():
sec = tool.heading_pattern("7-1")
assert sec.match("7-1. 벌도")
assert not sec.match("7-11. 동력상하차기") # 번호가 앞머리만 같음
assert not sec.match("7-1-1. 항")
assert tool.heading_pattern("제1장").match("제1장적용기준")
assert not tool.heading_pattern("제1장").match("제10장자재")
assert tool.heading_pattern("부록1").match("[부록1]할인")
assert not tool.heading_pattern("부록1").match("[부록10]x")
def test_respace_takes_toc_spacing_but_body_glyphs():
toc = "임산물 운반로 신설 및 보수․복구"
assert tool.respace("임산물운반로신설및보수·복구", toc) == "임산물 운반로 신설 및 보수·복구"
# 차례 오타(조림 ≠ 조립) — 본문 그대로
assert tool.respace("철근현장가공및조립", "철근 현장가공 및 조림") == "철근현장가공및조립"
def test_heading_text_keeps_number_dot():
entry = tool.Entry("4-5", "벌도 위험목 점검", 51)
assert tool.heading_text(entry, "4-5. 벌도위험목점검") == "4-5. 벌도 위험목 점검"
assert tool.section_name(entry, "4-5. 벌도위험목점검") == "벌도 위험목 점검"
chapter = tool.Entry("제2장", "소요재료 및 기계손료", 28)
assert tool.heading_text(chapter, "제2장소요재료및기계손료") == "제2장 소요재료 및 기계손료"
def test_section_file_name():
chapter = tool.Entry("제10장", "자재․장비 운반", 143)
section = tool.Entry("10-5", "목재 운반/적재", 150)
got = tool.section_file(Path("본문"), chapter, section)
assert got.as_posix() == "본문/제10장_자재․장비_운반/10-05_목재_운반적재.md"
def test_head_round_trip(tmp_path):
path = tmp_path / "a.md"
path.write_text(
tool.head_text({"": "9-5. 발파암", "상태": "대기"}) + "\n본문\n", encoding="utf-8"
)
head = tool.read_head(path)
assert list(head) == list(tool.HEAD_KEYS)
assert head[""] == "9-5. 발파암" and head["상태"] == "대기" and head["작성"] == ""
def test_forest_toc_counts():
"""산림 차례 — 14장 · 189절(PDF 3~12쪽)."""
pymupdf = pytest.importorskip("pymupdf")
book = tool.BOOKS["산림"]
with pymupdf.open(book["pdf"]) as doc:
entries = tool.toc_entries(doc, *book["toc"])
assert sum(e.ident.startswith("") for e in entries) == 14
assert sum(e.ident.count("-") == 1 for e in entries) == 189