"""품셈 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("목재 운반/적재") == "목재_운반적재" 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
2 |", "", "> 【예시】 10^-7", r"\- 원문 줄표", "![그림 3](pic/9-05_1.png)", ] ) text = " ".join(line for _, line in tool.md_lines(body)) assert "#" not in text and "---" not in text and "
" 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_dropped_and_footer_dropped(): """쪽을 넘은 표의 되풀이 머리 — `pdf_lines` 가 넘어간 쪽 맨 위에서 앞 쪽과 같은 줄을 스스로 뺌(주석 없이 · 규칙 4-5). 쪽 꼬리는 그대로 뺌 · 넘어간 쪽 맨 위 줄이 앞에 없던 줄이면 안 뺌(3쪽째).""" 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 -"], ["Z 3.5", "- 3 -"], ): page = doc.new_page() for n, text in enumerate(lines): page.insert_text((72, 72 + 30 * n), text) head = {"PDF쪽": "1~3", "시작표지": "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", "Y 2.0", "Z 3.5"] md = "\n".join( [ "## 9-5. A", "", "| NAME | QTY |", "|---|---|", "| X | 1.0 |", "| Y | 2.0 |", "", "Z 3.5", ] ) assert tool.verdict(tool.compare(pdf, tool.md_lines(md))).startswith("통과") def test_repeated_head_kept_when_md_has_no_room(): """md 가 못 가진 글자 몫 안에서만 뺌 — 같은 머리가 새 표의 첫 줄이면 md 가 그만큼 갖고 있어 안 빠짐.""" 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 -"], ["NAME QTY", "Z 3.5", "- 3 -"], ): page = doc.new_page() for n, text in enumerate(lines): page.insert_text((72, 72 + 30 * n), text) head = {"PDF쪽": "1~3", "시작표지": "9-5. A", "끝표지": "끝"} body = "\n".join( [ "## 9-5. A", "", "| NAME | QTY |", "|---|---|", "| X | 1.0 |", "| Y | 2.0 |", "", "| NAME | QTY |", "|---|---|", "| Z | 3.5 |", ] ) # 몫을 안 주면 둘 다 뺌 — md 가 둘째 표 머리를 가진 것을 모름 bare = tool.pdf_lines(doc, head, tool.BOOKS["산림"]["footer"]) assert [row for _, row in bare].count("NAME QTY") == 1 # 몫을 주면 한 번만 뺌 — PDF 셋 · md 둘 kept = tool.pdf_lines(doc, head, tool.BOOKS["산림"]["footer"], body) assert [row for _, row in kept].count("NAME QTY") == 2 assert tool.verdict(tool.compare(kept, tool.md_lines(body))).startswith("통과") def test_numbered_head_line_is_not_a_repeated_head(): """「1.」·「가.」·「[주]」 로 시작하는 줄은 되풀이 머리로 안 봄 — 새 목의 첫 줄이라 빼면 안 됨.""" pymupdf = pytest.importorskip("pymupdf") doc = pymupdf.open() for lines in ( ["9-5. A", "1. B", "NAME QTY", "X 1.0", "- 1 -"], ["1. B", "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", "1. B", "NAME QTY", "X 1.0", "1. B", "NAME QTY", "Y 2.0", ] def test_machine_check_exemption_comment(): """`` 가 있으면 어긋나도 「예외(되풀이 머리)」 — 잠금 시험이 받아들임.""" result = tool.compare([(1, "가 나 다")], tool.md_lines("가 나")) assert tool.verdict(result).startswith("불통") result["exempt"] = True line = tool.verdict(result) assert line.startswith("예외(되풀이 머리)") assert tool.passed(line) and tool.passed("통과 · ①수 결손 0") assert not tool.passed("불통 · ①수 결손 1") def test_landscape_page_side_number_is_footer(): """가로로 눕힌 쪽은 옆 띠 쪽 번호(맨 끝·맨 앞 줄 · 수만)를 꼬리로 뺌 · 세로 쪽의 맨 앞·끝 수 줄은 본문으로 둠.""" pymupdf = pytest.importorskip("pymupdf") doc = pymupdf.open() pages = ( ((842, 595), ["9-5. A", "X 1.0", "757"]), ((842, 595), ["758", "Z 3.0"]), ((595, 842), ["7", "Y 2.0", "10"]), ) for size, lines in pages: page = doc.new_page(width=size[0], height=size[1]) for n, text in enumerate(lines): page.insert_text((72, 72 + 30 * n), text) head = {"PDF쪽": "1~3", "시작표지": "9-5. A", "끝표지": "끝"} pdf = tool.pdf_lines(doc, head, tool.BOOKS["산림"]["footer"]) assert [row for _, row in pdf] == ["9-5. A", "X 1.0", "Z 3.0", "7", "Y 2.0", "10"] 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("불통") # 뗀 자리는 가름 — 분수선 「/」 양쪽 수가 한 수로 붙지 않음 frac = tool.md_lines( "q = (185+174)/2" ) assert tool.verdict(tool.compare([(1, "185+174"), (1, "q = 2")], frac)).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("불통") def test_glued_numbers_marker_splits_only_listed_strings(): """md 주석 「글자층 붙은 수」 에 적힌 글자열만 PDF 쪽에서 갈라 셈 · 없거나 공백만 뺀 것이 아니면 불통.""" pdf = [(834, "Foundation Chipping 특별인부 인/㎡ 0.3350.3350.335"), (834, "0.3350 따로")] body = ( "Foundation Chipping 특별인부 인/㎡ 0.335 0.335 0.335\n0.3350 따로\n" "" ) before = tool.compare(pdf, tool.md_lines(body)) assert before["num_missing"] and before["num_extra"] # 주석 없으면 ① 결손·허구 split, count, bad = tool.split_glued_numbers(pdf, body) assert count == 1 and not bad assert split[1] == (834, "0.3350 따로") # 적히지 않은 곳은 그대로 result = tool.compare(split, tool.md_lines(body)) result["glued"] = (count, bad) line = tool.verdict(result) assert line.startswith("통과") and "글자층 붙은 수 1곳" in line, line # PDF 에 없는 글자열 · 공백만 뺀 것이 아닌 짝 · 가르지 않은 짝은 못 씀 → 불통 _, _, bad = tool.split_glued_numbers( pdf, "", ) assert bad == ["9.99.9", "0.3350.335", "0.3350"] result["glued"] = (0, bad) assert tool.verdict(result).startswith("불통")