- pum_md_tool.py 가 700줄을 넘어(707) 책 설정·쪽 줄 읽기·머리 찾기를 pum_md_base.py 로 옮김 (pum_md_tool 이 다시 내보냄) - read_rows: 한 자리 수끼리 틈이 글자 높이보다 좁으면 이어 셈(「체 감 온 도 3 3 도」→33) · 표 칸 틈(1.2 이상)은 그대로 - md 주석 <!-- 자간 벌린 수: 「1 . 3」→「1.3」 --> 에 적힌 글자열만 PDF 쪽에서 붙여 셈 · 기계검사 칸 「자간 벌린 수 N곳」 · PDF 에 없거나 공백 뗀 것이 아니면 불통 (브레인 지시) - 작성된 md 218개 판정 고치기 전후 같음(작업 중이던 건설 1-02 만 달라짐) - 작성규칙 6장 한 줄 - test_산림_산출도_그대로_금액_불변 하나만 skip 표시(옛 사슬 동결 · 원천 벌 json 줄바꿈 지문 · 단가 연결 때 되살림 — 브레인 지시) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
179 lines
7.1 KiB
Python
179 lines
7.1 KiB
Python
# -*- 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
|