- PDF 사용자 영역 글리프는 ①② 에서 빼고 기계검사 칸에 「글자층 밖 글리프 N개(쪽) · 받아씀 N자」 - md 는 <!-- 글자층 밖 -->…<!-- /글자층 밖 --> 로 감싼 받아쓰기를 ①② 에서 뺌(감싸지 않으면 더함) - 호환 한자(U+F900–FAFF)만 통합 한자로 — NFKC 통째는 안 씀(㎥·①·㎡ 지킴) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
186 lines
8.1 KiB
Python
186 lines
8.1 KiB
Python
"""품셈 PDF → 절별 md 도구(`_pipeline/pum_md_tool.py`) — 순수 함수 · 산림 차례 개수.
|
|
|
|
규칙: `resources/knowledge/original/원가계산/_품셈md_작성규칙.md`.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
|
|
|
|
import pum_md_tool as tool # noqa: E402
|
|
|
|
|
|
def test_parse_toc_strips_leaders_and_reads_appendix():
|
|
lines = ["제1장", "적용기준 ·········", "1", "1-1", "일반사항 ·····", "1", "부록 ······", "204"]
|
|
got = tool.parse_toc(lines)
|
|
assert [(e.ident, e.name, e.page) for e in got] == [
|
|
("제1장", "적용기준", 1),
|
|
("1-1", "일반사항", 1),
|
|
("부록", "", 204),
|
|
]
|
|
with pytest.raises(ValueError):
|
|
tool.parse_toc(["엉뚱한 줄", "1"])
|
|
|
|
|
|
def test_key_ignores_spaces_dot_after_number_and_middle_dots():
|
|
assert tool.key("9-5. 발파암") == tool.key("9-5 발파암")
|
|
assert tool.key("[부록1]할인") == tool.key("부록1 할인")
|
|
assert tool.key("보수·복구") == tool.key("보수․복구")
|
|
|
|
|
|
def test_heading_pattern_by_number_only():
|
|
sec = tool.heading_pattern("7-1")
|
|
assert sec.match("7-1. 벌도")
|
|
assert not sec.match("7-11. 동력상하차기") # 번호가 앞머리만 같음
|
|
assert not sec.match("7-1-1. 항")
|
|
assert tool.heading_pattern("제1장").match("제1장적용기준")
|
|
assert not tool.heading_pattern("제1장").match("제10장자재")
|
|
assert tool.heading_pattern("부록1").match("[부록1]할인")
|
|
assert not tool.heading_pattern("부록1").match("[부록10]x")
|
|
|
|
|
|
def test_name_of_and_safe():
|
|
assert tool.name_of("9-5", "9-5. 발파암") == "발파암"
|
|
assert tool.name_of("제2장", "제2장 소요재료 및 기계손료") == "소요재료 및 기계손료"
|
|
assert tool.safe("목재 운반/적재") == "목재_운반적재"
|
|
|
|
|
|
def test_head_round_trip(tmp_path):
|
|
path = tmp_path / "a.md"
|
|
path.write_text(
|
|
tool.head_text({"절": "9-5. 발파암", "상태": "대기"}) + "\n본문\n", encoding="utf-8"
|
|
)
|
|
head = tool.read_head(path)
|
|
assert list(head) == list(tool.HEAD_KEYS)
|
|
assert head["절"] == "9-5. 발파암" and head["상태"] == "대기" and head["작성"] == ""
|
|
|
|
|
|
def test_forest_toc_counts():
|
|
"""산림 차례 — 14장 · 189절(PDF 3~12쪽)."""
|
|
pymupdf = pytest.importorskip("pymupdf")
|
|
book = tool.BOOKS["산림"]
|
|
with pymupdf.open(book["pdf"]) as doc:
|
|
entries = tool.toc_entries(doc, book)
|
|
assert sum(e.ident.startswith("제") for e in entries) == 14
|
|
assert sum(e.ident.count("-") == 1 for e in entries) == 189
|
|
|
|
|
|
def test_forest_headings_follow_page_spacing():
|
|
"""머리 글자 — 글자층은 빈칸을 잃음(「설계및수량」) · 쪽 그림대로 되살림 · 차례 오타(조림)는 안 따름."""
|
|
pymupdf = pytest.importorskip("pymupdf")
|
|
with pymupdf.open(tool.BOOKS["산림"]["pdf"]) as doc:
|
|
assert tool.find_heading(doc, "1-2", 14)[2] == "1-2. 설계 및 수량"
|
|
assert tool.find_heading(doc, "12-3", 172)[2] == "12-3. 철근 현장가공 및 조립"
|
|
assert tool.find_heading(doc, "제1장", 13)[2] == "제1장 적용기준"
|
|
page, row, _ = tool.find_heading(doc, "9-6", 136)
|
|
assert (page, row) == (136, 0) # 쪽 맨 위 — 앞 절은 135 쪽에서 끝남
|
|
|
|
|
|
def test_md_lines_drop_markup_keep_escaped_text():
|
|
body = "\n".join(
|
|
[
|
|
"## 9-5. 발파암",
|
|
"",
|
|
"(단위: ㎥당)",
|
|
"",
|
|
"| 구분 | 수량 |",
|
|
"|---|---|",
|
|
"| 보통인부 | 0.50 |",
|
|
r"| a\|b | 1,000<br>2 |",
|
|
"<!-- 병합: 1열 2~4행 「인력」 -->",
|
|
"> 【예시】 10^-7",
|
|
r"\- 원문 줄표",
|
|
"",
|
|
]
|
|
)
|
|
text = " ".join(line for _, line in tool.md_lines(body))
|
|
assert "#" not in text and "---" not in text and "<br>" not in text and "병합" not in text
|
|
assert "a|b" in text # 막음표 뗀 원문 글자
|
|
assert "- 원문 줄표" in text and "그림" not in text and "pic" not in text
|
|
assert "10 -7" in text # 첨자 표시 ^ 는 뺌
|
|
|
|
|
|
def test_compare_counts_numbers_and_chars_both_ways():
|
|
pdf = [(135, "보통인부 0.50 1,000"), (135, "비고")]
|
|
same = tool.compare(pdf, tool.md_lines("| 보통인부 | 0.50 | 1,000 |\n비고"))
|
|
assert tool.verdict(same).startswith("통과")
|
|
bad = tool.compare(pdf, [(1, "보통인부 0.5 1000 비고 가")])
|
|
assert set(bad["num_missing"]) == {"0.50", "1,000"} # 자릿수·쉼표 바꾸면 결손
|
|
assert set(bad["num_extra"]) == {"0.5", "1000"}
|
|
assert bad["char_extra"]["가"] == 1 and bad["char_missing"][","] == 1
|
|
assert tool.verdict(bad).startswith("불통") and bad["spots"]
|
|
|
|
|
|
def test_fingerprint_ignores_line_endings_and_tool_fields():
|
|
text = "---\n상태: 확정\n기계검사: 통과\n확정지문:\n---\n본문\n"
|
|
same = text.replace("\n", "\r\n").replace("기계검사: 통과", "기계검사: 다시")
|
|
assert tool.fingerprint(text) == tool.fingerprint(same)
|
|
assert tool.fingerprint(text) != tool.fingerprint(text.replace("본문", "본문!"))
|
|
assert "확정지문: abc" in tool.set_head(text, "확정지문", "abc")
|
|
|
|
|
|
def test_join_spaced_and_file_label():
|
|
assert tool.join_spaced("측 량") == "측량" # 자간 벌림
|
|
assert tool.join_spaced("설계 및 수량") == "설계 및 수량"
|
|
label = tool.file_label("9-5-4", "9-5-4 수치지도 작성('21, '22, '24, '26년 보완)", dot=False)
|
|
assert label == "수치지도_작성" # 개정 표지는 파일 이름에서만 뗌
|
|
|
|
|
|
def test_const_toc_counts():
|
|
"""건설 차례 — 5부문 · 45장(PDF 3~51쪽) · 부문마다 장 번호가 1부터."""
|
|
pymupdf = pytest.importorskip("pymupdf")
|
|
book = tool.BOOKS["건설"]
|
|
with pymupdf.open(book["pdf"]) as doc:
|
|
entries = tool.toc_entries(doc, book)
|
|
assert tool.find_heading(doc, "1-5", 87, dot=False)[2] == "1-5 기타"
|
|
assert [e.ident for e in entries if e.ident.endswith("부문")] == [
|
|
"공통부문",
|
|
"토목부문",
|
|
"건축부문",
|
|
"기계설비부문",
|
|
"유지관리부문",
|
|
]
|
|
assert sum(e.ident.startswith("제") for e in entries) == 45
|
|
|
|
|
|
def test_repeated_head_comment_counts_and_footer_dropped():
|
|
"""쪽을 넘은 표의 되풀이 머리 — md 표에선 빼고 `<!-- 되풀이 머리: … -->` 로 적으면 셈(규칙 4-5) · 쪽 꼬리는 뺌."""
|
|
pymupdf = pytest.importorskip("pymupdf")
|
|
doc = pymupdf.open()
|
|
for lines in (["9-5. A", "NAME QTY", "X 1.0", "- 1 -"], ["NAME QTY", "Y 2.0", "- 2 -"]):
|
|
page = doc.new_page()
|
|
for n, text in enumerate(lines):
|
|
page.insert_text((72, 72 + 30 * n), text)
|
|
head = {"PDF쪽": "1~2", "시작표지": "9-5. A", "끝표지": "끝"}
|
|
pdf = tool.pdf_lines(doc, head, tool.BOOKS["산림"]["footer"])
|
|
assert [row for _, row in pdf] == ["9-5. A", "NAME QTY", "X 1.0", "NAME QTY", "Y 2.0"]
|
|
md = "\n".join(
|
|
[
|
|
"## 9-5. A",
|
|
"",
|
|
"| NAME | QTY |",
|
|
"|---|---|",
|
|
"| X | 1.0 |",
|
|
"<!-- 되풀이 머리: NAME | QTY -->",
|
|
"| Y | 2.0 |",
|
|
]
|
|
)
|
|
assert tool.verdict(tool.compare(pdf, tool.md_lines(md))).startswith("통과")
|
|
|
|
|
|
def test_off_layer_glyphs_and_compat_han():
|
|
"""사용자 영역 글리프는 ①② 에서 빼고 드러냄 · md 는 감싼 받아쓰기를 뺌 · 호환 한자는 통합 한자로(NFKC 통째 아님)."""
|
|
pdf = [(21, "체적 V \ue001\ue002 = 1.5"), (21, "공극\uf9db ㎥ ①")]
|
|
md = tool.md_lines("체적 V <!-- 글자층 밖 -->(m^3)<!-- /글자층 밖 --> = 1.5\n공극率 ㎥ ①")
|
|
result = tool.compare(pdf, md)
|
|
line = tool.verdict(result)
|
|
assert line.startswith("통과"), line
|
|
assert "글자층 밖 글리프 2개(21쪽) · 받아씀 4자" in line # 「(m3)」 네 자
|
|
# 감싸지 않으면 받아쓴 글자가 더함으로 뜸
|
|
bare = tool.compare(pdf, tool.md_lines("체적 V (m^3) = 1.5\n공극率 ㎥ ①"))
|
|
assert tool.verdict(bare).startswith("불통")
|