- 제4장 일곱(다른 창 시범분)은 건너뜀 → 산림 전체 장머리 14 · 절 189 - 절 이름·표지는 본문 머리 글자 그대로(번호 뒤 마침표) · 띄어쓰기는 쪽 그림의 글자 틈으로 되살림 - 도구: 장마다 0-00_장머리.md · 이미 있는 자리는 번호로 봐서 건너뜀(이 폴더·다른 창 브랜치) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
313 lines
12 KiB
Python
313 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)")
|
||
|
||
|
||
#: 글자 사이 빈칸으로 볼 틈(글자 크기 대비) — 본문 머리 실측: 빈칸 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
|
||
|
||
|
||
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:]))
|