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:
2026-09-19 03:30:42 +09:00
co-authored by Claude Opus 5
parent 4a339baac2
commit bd167e5275
5 changed files with 270 additions and 126 deletions
@@ -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+F900FAFF · 率 U+F9DB)는 ② 에서 통합 한자(率 U+7387)와 같게 봄. 다른 글자(㎥·①·㎡ 등)는 그대로 견줌.
- 자간을 벌려 한 자리씩 찍은 수(「체 감 온 도 3 3 도」)는 도구가 이어 셈(틈 < 글자 높이). 틈이 넓어 표 칸과 가를 수 없는 것(「S T S 3 0 4」 · 「1 . 3」)은 md 에 붙여 쓰고 `<!-- 자간 벌린 수: 「3 0 4」→「304」 -->` 주석 — 도구가 PDF 쪽의 그 글자열만 붙여 셈(PDF 에 있고 공백만 뺀 것일 때만 · 기계검사 칸 「자간 벌린 수 N곳」 → ③ 이 그림으로 봄 · 못 쓰면 불통).
- ①②가 0 이어도 칸이 뒤바뀐 것은 못 잡음 → ③ 없이는 `확정` 아님.
- ③에서 고칠 곳이 나오면 검증 창이 고치지 않고 **작성 창에 돌려보냄**(줄 번호 + PDF 쪽 + 무엇이 다른지).
- `확정` 뒤 파일이 바뀌면 시험이 빨강. 고치려면 상태를 `작성중` 으로 되돌리고 ①②③ 다시.
+43
View File
@@ -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"))