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
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
# -*- 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
|
||||
@@ -23,55 +23,23 @@ import unicodedata
|
||||
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 _NUM, _page_rows # noqa: E402
|
||||
|
||||
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쪽",
|
||||
"인쇄쪽",
|
||||
"시작표지",
|
||||
"끝표지",
|
||||
"상태",
|
||||
"작성",
|
||||
"검증",
|
||||
"원천",
|
||||
"기계검사",
|
||||
"확정지문",
|
||||
from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·머리 찾기)은 pum_md_base.py
|
||||
_NUM,
|
||||
BOOKS,
|
||||
COST,
|
||||
HEAD_KEYS,
|
||||
ROOT,
|
||||
STATES,
|
||||
_page_rows,
|
||||
find_heading,
|
||||
heading_pattern,
|
||||
join_spaced,
|
||||
key,
|
||||
locate,
|
||||
read_rows,
|
||||
visual_text,
|
||||
)
|
||||
STATES = ("대기", "작성중", "검증대기", "확정")
|
||||
|
||||
_TOC_SKIP = {"장", "내 용", "페이지", "- 목 차 -"}
|
||||
_TOC_ID = re.compile(r"제\d+장|\d+-\d+(?:-\d+)*|부록\d*")
|
||||
_BAD_NAME = re.compile(r'[/:*?"<>|\\]')
|
||||
@@ -144,81 +112,6 @@ def parse_toc_inline(raw: list[str]) -> list[Entry]:
|
||||
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, 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
|
||||
|
||||
|
||||
def name_of(ident: str, title: str, dot: bool = True) -> str:
|
||||
"""머리 글자에서 번호를 뗀 이름(「9-5. 발파암」 → 「발파암」)."""
|
||||
return title[heading_pattern(ident, dot).match(title).end() :].strip()
|
||||
@@ -491,7 +384,7 @@ def pdf_lines(doc, head: dict[str, str], footer: re.Pattern) -> list[tuple[int,
|
||||
pass
|
||||
out = []
|
||||
for number in range(start_page, min(end_page, last) + 1):
|
||||
rows = [row.strip() for row in _page_rows(doc[number - 1])]
|
||||
rows = [row.strip() for row in read_rows(doc[number - 1])]
|
||||
if rows and footer.match(rows[-1]):
|
||||
rows[-1] = "" # 쪽 꼬리
|
||||
for index, row in enumerate(rows):
|
||||
@@ -580,6 +473,26 @@ def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
#: 자간 벌린 수 — md 주석 `<!-- 자간 벌린 수: 「1 . 3」→「1.3」 · 「3 0 4」→「304」 -->` (규칙 3-13).
|
||||
#: 짐작으로 붙이지 않고 **적힌 글자열만** PDF 쪽에서 붙여 셈 · 오른쪽은 왼쪽에서 공백만 뺀 것이어야 함.
|
||||
_SPACED = re.compile(r"<!--\s*자간 벌린 수:(.*?)-->", re.S)
|
||||
_SPACED_PAIR = re.compile(r"「([^」]+)」\s*→\s*「([^」]+)」")
|
||||
|
||||
|
||||
def join_spaced_numbers(pdf: list[tuple[int, str]], body: str):
|
||||
"""(PDF 줄, 붙인 곳 수, 못 쓴 글자열) — md 주석에 적힌 자간 벌린 수만 PDF 줄에서 붙임."""
|
||||
joined, bad = 0, []
|
||||
for block in _SPACED.findall(body):
|
||||
for spaced, tight in _SPACED_PAIR.findall(block):
|
||||
hits = sum(line.count(spaced) for _, line in pdf)
|
||||
if not hits or re.sub(r"\s", "", spaced) != tight:
|
||||
bad.append(spaced)
|
||||
continue
|
||||
pdf = [(at, line.replace(spaced, tight)) for at, line in pdf]
|
||||
joined += hits
|
||||
return pdf, joined, bad
|
||||
|
||||
|
||||
def verdict(result: dict) -> str:
|
||||
counts = (
|
||||
sum(v for v, _ in result["num_missing"].values()),
|
||||
@@ -587,7 +500,8 @@ def verdict(result: dict) -> str:
|
||||
sum(result["char_missing"].values()),
|
||||
sum(result["char_extra"].values()),
|
||||
)
|
||||
word = "통과" if not any(counts) else "불통"
|
||||
joined, bad = result.get("spaced", (0, []))
|
||||
word = "통과" if not any(counts) and not bad else "불통"
|
||||
line = (
|
||||
f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}"
|
||||
)
|
||||
@@ -595,6 +509,10 @@ def verdict(result: dict) -> str:
|
||||
if glyphs or result.get("off_md"): # ③ 이 그림으로 그 식을 꼭 보게
|
||||
where = f"({', '.join(map(str, pages))}쪽)" if pages else ""
|
||||
line += f" · 글자층 밖 글리프 {glyphs}개{where} · 받아씀 {result.get('off_md', 0)}자"
|
||||
if joined: # ③ 이 그림으로 그 수를 꼭 보게
|
||||
line += f" · 자간 벌린 수 {joined}곳"
|
||||
if bad:
|
||||
line += " · 자간 벌린 수 못 씀 " + " ".join(f"「{s}」" for s in bad)
|
||||
return line
|
||||
|
||||
|
||||
@@ -618,7 +536,10 @@ def check_file(path: Path) -> tuple[dict, str]:
|
||||
_, body = split_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"))
|
||||
book = book_of(path)
|
||||
with pymupdf.open(book["pdf"]) as doc:
|
||||
result = compare(pdf_lines(doc, read_head(path), book["footer"]), md_lines(body))
|
||||
pdf = pdf_lines(doc, read_head(path), book["footer"])
|
||||
pdf, joined, bad = join_spaced_numbers(pdf, body)
|
||||
result = compare(pdf, md_lines(body))
|
||||
result["spaced"] = (joined, bad)
|
||||
return result, verdict(result)
|
||||
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ PDF쪽: 135~136
|
||||
- ①② 대조에서 빼는 것 — PDF 쪽: 쪽 맨 아래 꼬리(「- 51 -」 · 「30 공통부문」 · 「제1장 적용기준 31」) · 쪽 끝 이어짐 표지 「→」 / md 쪽: 머리 · `<!-- -->` · 그림 링크 · 표 문법(`|` · 구분 줄 · `<br>`) · 제목 `#` · 인용 `>` · 첨자 표시 `^` `_` (막음표 `\` 뒤 글자는 원문으로 셈).
|
||||
- 글자층 밖 — PDF 글자층의 사용자 영역 글리프(U+E000–F8FF · 식의 「(m³)」·분수선)는 ①② 에서 빼고 기계검사 칸에 「글자층 밖 글리프 N개(쪽)」로 적음. 그 자리를 그림 보고 받아쓴 md 글자는 `<!-- 글자층 밖 -->(m^3)<!-- /글자층 밖 -->` 로 감쌈(①② 에서 빠지고 「받아씀 N자」로 드러남) — **이 칸이 뜬 파일은 ③ 이 그림으로 그 식을 꼭 봄.** 사용자 영역 글리프 자리 말고는 감싸지 않음.
|
||||
- 호환 한자(U+F900–FAFF · 率 U+F9DB)는 ② 에서 통합 한자(率 U+7387)와 같게 봄. 다른 글자(㎥·①·㎡ 등)는 그대로 견줌.
|
||||
- 자간을 벌려 한 자리씩 찍은 수(「체 감 온 도 3 3 도」)는 도구가 이어 셈(틈 < 글자 높이). 틈이 넓어 표 칸과 가를 수 없는 것(「S T S 3 0 4」 · 「1 . 3」)은 md 에 붙여 쓰고 `<!-- 자간 벌린 수: 「3 0 4」→「304」 -->` 주석 — 도구가 PDF 쪽의 그 글자열만 붙여 셈(PDF 에 있고 공백만 뺀 것일 때만 · 기계검사 칸 「자간 벌린 수 N곳」 → ③ 이 그림으로 봄 · 못 쓰면 불통).
|
||||
- ①②가 0 이어도 칸이 뒤바뀐 것은 못 잡음 → ③ 없이는 `확정` 아님.
|
||||
- ③에서 고칠 곳이 나오면 검증 창이 고치지 않고 **작성 창에 돌려보냄**(줄 번호 + PDF 쪽 + 무엇이 다른지).
|
||||
- `확정` 뒤 파일이 바뀌면 시험이 빨강. 고치려면 상태를 `작성중` 으로 되돌리고 ①②③ 다시.
|
||||
|
||||
@@ -183,3 +183,46 @@ def test_off_layer_glyphs_and_compat_han():
|
||||
# 감싸지 않으면 받아쓴 글자가 더함으로 뜸
|
||||
bare = tool.compare(pdf, tool.md_lines("체적 V (m^3) = 1.5\n공극率 ㎥ ①"))
|
||||
assert tool.verdict(bare).startswith("불통")
|
||||
|
||||
|
||||
def test_read_rows_joins_letter_spaced_digits_only():
|
||||
"""자간을 벌린 한 자리 수(틈 < 글자 높이)만 이음 · 표 칸 사이 넓은 틈은 그대로 · 한 줄 묶기는 `_page_rows` 와 같음."""
|
||||
|
||||
class Page:
|
||||
def __init__(self, words):
|
||||
self.words = words
|
||||
|
||||
def get_text(self, kind):
|
||||
return self.words
|
||||
|
||||
words = [
|
||||
(0, 0, 5, 10, "1"), # 「1 0 %」 — 틈 1pt(높이 10pt)
|
||||
(6, 0, 11, 10, "0"),
|
||||
(12, 0, 17, 10, "%"),
|
||||
(40, 0, 45, 10, "2"), # 표 칸 — 틈 12pt
|
||||
(57, 0, 62, 10, "2"),
|
||||
(0, 20, 5, 30, "3"), # 다음 줄
|
||||
]
|
||||
assert tool.read_rows(Page(words)) == ["10 % 2 2", "3"]
|
||||
assert tool.read_rows(Page(words)) != tool._page_rows(Page(words))
|
||||
assert len(tool.read_rows(Page(words))) == len(tool._page_rows(Page(words)))
|
||||
|
||||
|
||||
def test_spaced_numbers_marker_joins_only_listed_strings():
|
||||
"""md 주석 「자간 벌린 수」 에 적힌 글자열만 PDF 쪽에서 붙여 셈 · 없거나 공백 뗀 것이 아니면 불통."""
|
||||
pdf = [(395, "굴착기 ( 1 . 3 ㎥/ m i n ) 0 . 7 ㎥"), (395, "칸 1 3 따로")]
|
||||
body = "굴착기(1.3㎥/min) 0.7㎥\n칸 1 3 따로\n<!-- 자간 벌린 수: 「1 . 3」→「1.3」 · 「0 . 7」→「0.7」 -->"
|
||||
joined, count, bad = tool.join_spaced_numbers(pdf, body)
|
||||
assert count == 2 and not bad
|
||||
assert joined[1] == (395, "칸 1 3 따로") # 적히지 않은 곳은 그대로
|
||||
result = tool.compare(joined, tool.md_lines(body))
|
||||
result["spaced"] = (count, bad)
|
||||
line = tool.verdict(result)
|
||||
assert line.startswith("통과") and "자간 벌린 수 2곳" in line, line
|
||||
# PDF 에 없는 글자열 · 공백만 뗀 것이 아닌 짝은 못 씀 → 불통
|
||||
_, _, bad = tool.join_spaced_numbers(
|
||||
pdf, "<!-- 자간 벌린 수: 「9 . 9」→「9.9」 · 「1 . 3」→「13」 -->"
|
||||
)
|
||||
assert bad == ["9 . 9", "1 . 3"]
|
||||
result["spaced"] = (0, bad)
|
||||
assert tool.verdict(result).startswith("불통")
|
||||
|
||||
@@ -135,6 +135,7 @@ def test_산출_파일이_지금_코드로_뽑은_것(built: tuple, master: dict
|
||||
_same_except_time(built[0], master)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="품셈 md 확정 단계 — 옛 사슬 동결 · 원천 벌 json 줄바꿈 지문 · 단가 연결 때 되살림")
|
||||
def test_산림_산출도_그대로_금액_불변() -> None:
|
||||
"""표 읽기를 떼어내고(`_Table`) 건설과 나눠 써도 산림 산출은 한 글자도 안 바뀜."""
|
||||
saved = json.loads((OUT / "work_item_master_2026-01-01.json").read_text(encoding="utf-8"))
|
||||
|
||||
Reference in New Issue
Block a user