From bd167e52752d320a7beea97b3c96c914474442b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 19 Sep 2026 03:30:42 +0900 Subject: [PATCH] =?UTF-8?q?knowledge(=ED=92=88=EC=85=88md):=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20=E2=80=94=20=EB=B0=94=ED=83=95(pum=5Fmd=5Fbase.py)?= =?UTF-8?q?=20=EB=96=BC=EC=96=B4=20=EB=83=84=20=C2=B7=20=EC=9E=90=EA=B0=84?= =?UTF-8?q?=20=EB=B2=8C=EB=A6=B0=20=EC=88=98=20=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=85=88=20=C2=B7=20=E3=80=8C=EC=9E=90=EA=B0=84=20=EB=B2=8C?= =?UTF-8?q?=EB=A6=B0=20=EC=88=98=E3=80=8D=20=EC=A3=BC=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pum_md_tool.py 가 700줄을 넘어(707) 책 설정·쪽 줄 읽기·머리 찾기를 pum_md_base.py 로 옮김 (pum_md_tool 이 다시 내보냄) - read_rows: 한 자리 수끼리 틈이 글자 높이보다 좁으면 이어 셈(「체 감 온 도 3 3 도」→33) · 표 칸 틈(1.2 이상)은 그대로 - md 주석 에 적힌 글자열만 PDF 쪽에서 붙여 셈 · 기계검사 칸 「자간 벌린 수 N곳」 · PDF 에 없거나 공백 뗀 것이 아니면 불통 (브레인 지시) - 작성된 md 218개 판정 고치기 전후 같음(작업 중이던 건설 1-02 만 달라짐) - 작성규칙 6장 한 줄 - test_산림_산출도_그대로_금액_불변 하나만 skip 표시(옛 사슬 동결 · 원천 벌 json 줄바꿈 지문 · 단가 연결 때 되살림 — 브레인 지시) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- .../original/_pipeline/pum_md_base.py | 178 ++++++++++++++++++ .../original/_pipeline/pum_md_tool.py | 173 +++++------------ .../original/원가계산/_품셈md_작성규칙.md | 1 + resources/tester/test_pum_md_tool.py | 43 +++++ .../tester/test_work_item_master_const.py | 1 + 5 files changed, 270 insertions(+), 126 deletions(-) create mode 100644 resources/knowledge/original/_pipeline/pum_md_base.py diff --git a/resources/knowledge/original/_pipeline/pum_md_base.py b/resources/knowledge/original/_pipeline/pum_md_base.py new file mode 100644 index 00000000..34cbeebe --- /dev/null +++ b/resources/knowledge/original/_pipeline/pum_md_base.py @@ -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 diff --git a/resources/knowledge/original/_pipeline/pum_md_tool.py b/resources/knowledge/original/_pipeline/pum_md_tool.py index 42b5d5f3..42f72b74 100644 --- a/resources/knowledge/original/_pipeline/pum_md_tool.py +++ b/resources/knowledge/original/_pipeline/pum_md_tool.py @@ -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 주석 `` (규칙 3-13). +#: 짐작으로 붙이지 않고 **적힌 글자열만** PDF 쪽에서 붙여 셈 · 오른쪽은 왼쪽에서 공백만 뺀 것이어야 함. +_SPACED = re.compile(r"", 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) diff --git a/resources/knowledge/original/원가계산/_품셈md_작성규칙.md b/resources/knowledge/original/원가계산/_품셈md_작성규칙.md index 7c11aa41..23ce3dcc 100644 --- a/resources/knowledge/original/원가계산/_품셈md_작성규칙.md +++ b/resources/knowledge/original/원가계산/_품셈md_작성규칙.md @@ -86,6 +86,7 @@ PDF쪽: 135~136 - ①② 대조에서 빼는 것 — PDF 쪽: 쪽 맨 아래 꼬리(「- 51 -」 · 「30 공통부문」 · 「제1장 적용기준 31」) · 쪽 끝 이어짐 표지 「→」 / md 쪽: 머리 · `` · 그림 링크 · 표 문법(`|` · 구분 줄 · `
`) · 제목 `#` · 인용 `>` · 첨자 표시 `^` `_` (막음표 `\` 뒤 글자는 원문으로 셈). - 글자층 밖 — 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 에 붙여 쓰고 `` 주석 — 도구가 PDF 쪽의 그 글자열만 붙여 셈(PDF 에 있고 공백만 뺀 것일 때만 · 기계검사 칸 「자간 벌린 수 N곳」 → ③ 이 그림으로 봄 · 못 쓰면 불통). - ①②가 0 이어도 칸이 뒤바뀐 것은 못 잡음 → ③ 없이는 `확정` 아님. - ③에서 고칠 곳이 나오면 검증 창이 고치지 않고 **작성 창에 돌려보냄**(줄 번호 + PDF 쪽 + 무엇이 다른지). - `확정` 뒤 파일이 바뀌면 시험이 빨강. 고치려면 상태를 `작성중` 으로 되돌리고 ①②③ 다시. diff --git a/resources/tester/test_pum_md_tool.py b/resources/tester/test_pum_md_tool.py index 2cbbc60a..9266dadc 100644 --- a/resources/tester/test_pum_md_tool.py +++ b/resources/tester/test_pum_md_tool.py @@ -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" + 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, "" + ) + assert bad == ["9 . 9", "1 . 3"] + result["spaced"] = (0, bad) + assert tool.verdict(result).startswith("불통") diff --git a/resources/tester/test_work_item_master_const.py b/resources/tester/test_work_item_master_const.py index 6cf7f0e3..95857357 100644 --- a/resources/tester/test_work_item_master_const.py +++ b/resources/tester/test_work_item_master_const.py @@ -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"))