Files
Aislo/resources/knowledge/original/_pipeline/pum_md_base.py
T
eomsangdonandClaude Opus 5 bd167e5275 knowledge(품셈md): 도구 — 바탕(pum_md_base.py) 떼어 냄 · 자간 벌린 수 이어 셈 · 「자간 벌린 수」 주석
- 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
2026-09-19 03:30:42 +09:00

179 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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