- 산림 차례(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
304 lines
12 KiB
Python
304 lines
12 KiB
Python
# -*- 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:]))
|