diff --git a/resources/knowledge/original/_pipeline/pum_md_tool.py b/resources/knowledge/original/_pipeline/pum_md_tool.py new file mode 100644 index 00000000..95fef3bf --- /dev/null +++ b/resources/knowledge/original/_pipeline/pum_md_tool.py @@ -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 ① 수 대조 ② 글자 대조 → 머리 「기계검사」 칸 + 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:])) diff --git a/resources/tester/test_pum_md_tool.py b/resources/tester/test_pum_md_tool.py new file mode 100644 index 00000000..e8c26719 --- /dev/null +++ b/resources/tester/test_pum_md_tool.py @@ -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