# -*- coding: utf-8 -*- """품셈 md 도구의 바탕 — 책 설정 · 머리 칸 · 쪽 줄 읽기 · 머리 찾기. `pum_md_tool.py` 가 다시 내보냄. 700줄 제한으로 `pum_md_tool.py` 에서 떼어 냄(2026-09-19). """ from __future__ import annotations import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[4] # 저장소 뿌리 sys.path.insert(0, str(ROOT)) from resources.tester.test_const_pdf_coverage import ( # noqa: E402,F401 _NUM, _ROW_TOLERANCE, _page_rows, ) COST = ROOT / "resources/knowledge/original/원가계산" #: 책마다 — PDF · 차례 쪽(PDF 쪽 번호) · 인쇄 1쪽이 놓인 PDF 쪽 · 머리 「문서」 칸 · 새 md 뿌리 · #: 차례 모양(block = 번호·이름·쪽이 줄마다 · inline = 한 줄에) · 절 머리 번호 뒤 마침표 · 쪽 꼬리(맨 아래 줄) BOOKS = { "산림": { "pdf": COST / "산림_표준품셈/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.pdf", "toc": (3, 12), "first_body": 13, "doc": "산림사업 표준품셈(산림청고시 제2025-82호)", "out": COST / "산림_표준품셈/본문", "toc_layout": "block", "dot": True, "footer": re.compile(r"^-\s*\d+\s*-$"), # 「- 123 -」 }, "건설": { "pdf": COST / "건설공사_표준품셈/2026년_건설공사_표준품셈.pdf", "toc": (3, 51), "first_body": 57, "doc": "2026 건설공사 표준품셈", "out": COST / "건설공사_표준품셈/본문", "toc_layout": "inline", "dot": False, # 「1-5 기타」 "footer": re.compile( r"^\d+\s+\S+부문$|^제\s*\d+\s*장.*\s\d+$" ), # 「30 공통부문」·「제1장 적용기준 31」 }, } HEAD_KEYS = ( "문서", "절", "PDF쪽", "인쇄쪽", "시작표지", "끝표지", "상태", "작성", "검증", "원천", "기계검사", "확정지문", ) STATES = ("대기", "작성중", "검증대기", "확정") #: 가운뎃점 여러 벌 — 차례는 「보수․복구」(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, dot: bool = True) -> re.Pattern: """본문 머리 줄 — **번호로** 찾음. 차례 이름에 오타가 있어서(「면벅」=면벽 · 「조림」=조립 …).""" if ident.startswith("제"): return re.compile(r"^제\s*" + ident[1:-1] + r"\s*장(?!\d)") if ident.startswith("부록"): return re.compile(r"^\[?부록\s*" + ident[2:] + r"\]?(?!\d)") # 「[부록 1]」 return re.compile(r"^" + re.escape(ident) + (r"\." if dot else "") + 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, dot: bool = True ) -> tuple[int, int, str]: """(PDF 쪽, 쪽 안 줄 차례, 머리 글자) — 머리 글자는 PDF 그대로(번호 뒤 마침표 포함) · 띄어쓰기는 쪽 그림대로.""" pattern = heading_pattern(ident, dot) 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()): title = visual_text(doc[number - 1], row.strip()) cut = pattern.match(title).end() lead, name = title[:cut].strip(), title[cut:].strip() return number, index, f"{lead} {join_spaced(name)}".strip() raise ValueError(f"머리를 못 찾음: {ident} (PDF {first_pdf}~{first_pdf + slack})") def join_spaced(name: str) -> str: """자간을 벌려 찍은 이름은 붙임(「측 량」 → 「측량」 · 규칙 3장) — 이름 전체가 한 글자씩일 때만.""" parts = name.split(" ") if len(parts) > 1 and all(len(p) == 1 and "가" <= p <= "힣" for p in parts): return "".join(parts) return name #: 자간을 벌려 한 자리씩 찍은 수(「1 0 %」 · 「ø 1 6 × 7 5 0」)로 볼 틈 — 글자 높이 대비. #: 건설 실측: 벌린 수 0.2~0.7 · 표 칸 사이 1.2 이상(「인 1 1 1 2 2」). #: 1.2~1.4 는 가를 수 없어 안 이음(「S T S 3 0 4」 — md 가 글자층대로 띄워 적음). _DIGIT_GAP = 1.0 def read_rows(page) -> list[str]: """`_page_rows` 와 같은 줄 — 다만 자간을 벌린 한 자리 수는 이어 붙임. ① 수 대조가 「10」 을 「1」「0」 으로 세지 않게. """ words = sorted(page.get_text("words"), key=lambda w: (round(w[1] / _ROW_TOLERANCE), w[0])) grouped: list[list] = [] current: list = [] top: float | None = None for word in words: if top is None or abs(word[1] - top) <= _ROW_TOLERANCE: current.append(word) top = word[1] if top is None else top else: grouped.append(current) current, top = [word], word[1] if current: grouped.append(current) out = [] for row in grouped: row.sort(key=lambda w: w[0]) text = row[0][4] for prev, word in zip(row, row[1:]): digits = len(prev[4]) == len(word[4]) == 1 and prev[4].isdigit() and word[4].isdigit() tight = digits and word[0] - prev[2] < (prev[3] - prev[1]) * _DIGIT_GAP text += ("" if tight else " ") + word[4] out.append(text) return out