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:
@@ -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:]))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user