# -*- coding: utf-8 -*- """건설 PDF ↔ md 구멍을 **장별 목록 파일**로 뽑는다 — 2026-09-18 브레인 B18. 왜 — 구멍 총량(`test_const_pdf_coverage.py`)만으로는 누가 무엇을 고칠지 모른다. **쪽 · 표 제목 · 줄 글자**를 담아 장별로 나눠 두면 md 를 고치는 창(랩탑서브·안티그래비티)이 제 몫만 열어 고칠 수 있다. 담는 것 - **결손** — PDF 줄에 있는 값이 md 에 없음. 줄 글자를 그대로 적어 어디를 옮겨 적을지 보임. - **허구** — md 줄에만 있는 값. 대개 **붙은 줄**(`0230-00020.23,0008900.90`)이라 같은 자리에서 결손과 짝을 이룬다 — 그 줄을 풀면 둘이 함께 사라진다. ⚠ 값 셈은 `test_const_pdf_coverage.py` 의 잣대를 그대로 쓴다(두 벌 금지). 돌리기: `./venv/Scripts/python.exe resources/tester/_pdf_gap_report.py` """ from __future__ import annotations import collections import importlib.util import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] OUT = ROOT / "docs/raw/verification/2026-09-18_건설PDF대조" _spec = importlib.util.spec_from_file_location( "cov", Path(__file__).with_name("test_const_pdf_coverage.py") ) cov = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(cov) #: 쪽 머리의 장 이름 — 「제8장 건설기계」·「공통부문」. _CHAPTER = re.compile(r"제\s*(\d+)\s*장\s*([가-힣]{2,10})") #: 표 제목 자리 — 「(0230) 대형 브레이커」·「8-3-1 …」·「2. 전동환산(q 표)」. #: ⚠ **글자가 있어야 제목**이다 — 수만 늘어선 자료 줄을 제목으로 잡으면 목록이 헷갈린다. _CAPTION = re.compile(r"^\(?\d[\d\-.]*\)?[.\s]*[가-힣A-Za-z]") _HANGUL = re.compile(r"[가-힣]") def _missing_lines(limit_pages: int | None = None) -> dict[str, list[dict]]: """장별 결손 줄 — `{장: [{쪽, 제목, 줄, 빠진값}]}`.""" import pymupdf left = collections.Counter(cov.md_numbers()) # 아직 안 쓴 md 값 found: dict[str, list[dict]] = collections.defaultdict(list) chapter = "장 모름" # 쪽 머리가 없는 쪽은 앞 쪽의 장을 잇는다 with pymupdf.open(cov.PDF) as doc: for number, page in enumerate(doc, start=1): if limit_pages and number > limit_pages: break rows = cov._page_rows(page) # ⚠ **쪽 머리를 먼저 훑는다** — 쪽 머리가 줄 차례의 첫 줄이 아니라서(좌표 정렬) # 차례대로 보면 앞 줄들이 「장 모름」으로 떨어진다. for line in rows: if m := _CHAPTER.search(line): chapter = f"제{m.group(1)}장 {m.group(2)}".strip() break caption = "" for line in rows: if cov._LEADER.search(line): continue tokens = [ cov._norm(x) for x in cov._NUM.findall(line) if cov._valuish(cov._norm(x)) ] if not tokens or cov._ONLY_NUMBER.fullmatch(line.strip()): # 값이 없는 줄은 **표 제목 후보**로 기억한다 text = line.strip() if _CAPTION.match(text) and len(_HANGUL.findall(text)) >= 2: caption = text[:60] continue missing = [] for token in tokens: if left[token] > 0: left[token] -= 1 else: missing.append(token) if missing and not cov._PAGE_HEAD.search(line): # 쪽 머리 줄은 목록에서 가림 found[chapter].append( {"쪽": number, "제목": caption, "줄": line.strip()[:120], "빠진값": missing} ) return found def _invented_lines() -> dict[str, list[dict]]: """장별 허구 줄 — md 에만 있는 값이 든 줄(대개 붙은 줄).""" left = collections.Counter(cov.pdf_numbers()[0]) found: dict[str, list[dict]] = collections.defaultdict(list) chapter = "장 모름" for number, line in enumerate(cov.MD.read_text(encoding="utf-8").splitlines(), start=1): if m := _CHAPTER.search(line): chapter = f"제{m.group(1)}장 {m.group(2)}".strip() if cov._LEADER.search(line): continue tokens = [cov._norm(x) for x in cov._NUM.findall(line) if cov._valuish(cov._norm(x))] if not tokens or cov._ONLY_NUMBER.fullmatch(line.strip()): continue extra = [] for token in tokens: if left[token] > 0: left[token] -= 1 else: extra.append(token) # 쪽 머리가 본문에 붙은 줄(`173제8장 건설기계`)은 고칠 거리가 아니라 가린다 if extra and not (cov._PAGE_HEAD.search(line) and len(line.strip()) < 30): found[chapter].append({"md줄": number, "줄": line.strip()[:160], "없는값": extra}) return found def write() -> dict[str, int]: missing, invented = _missing_lines(), _invented_lines() OUT.mkdir(parents=True, exist_ok=True) for stale in OUT.glob("*.md"): # 다시 뽑을 때 옛 목록이 남지 않게 — 이 폴더는 이 스크립트 것 stale.unlink() counts: dict[str, int] = {} for chapter in sorted(set(missing) | set(invented)): safe = re.sub(r"[^\w가-힣]+", "_", chapter).strip("_") or "장모름" rows_missing, rows_invented = missing.get(chapter, []), invented.get(chapter, []) counts[chapter] = len(rows_missing) + len(rows_invented) lines = [ f"# {chapter} — PDF ↔ md 구멍 (2026-09-18 뽑음)", "", f"결손 줄 {len(rows_missing)} · 허구 줄 {len(rows_invented)}", "", "## 결손 — PDF 에 있는데 md 에 없음", "", "| 쪽 | 표 제목 | PDF 줄 | 빠진 값 |", "| --- | --- | --- | --- |", ] for row in rows_missing: line = row["줄"].replace("|", "·") lines.append( f"| {row['쪽']} | {row['제목'].replace('|', '·')} | {line} | {' '.join(row['빠진값'])} |" ) lines += [ "", "## 허구 — md 에만 있음 (대개 붙은 줄 · 위 결손과 같은 자리)", "", "| md 줄 | 줄 글자 | PDF 에 없는 값 |", "| --- | --- | --- |", ] for row in rows_invented: lines.append( f"| {row['md줄']} | {row['줄'].replace('|', '·')} | {' '.join(row['없는값'])} |" ) (OUT / f"{safe}.md").write_text("\n".join(lines) + "\n", encoding="utf-8") index = [ "# 건설 PDF ↔ md 구멍 — 장별 목록 (2026-09-18 뽑음)", "", "쓰는 법 — 제 장 파일을 열어 **결손** 줄을 PDF 그 쪽에서 확인해 md 에 옮겨 적고,", "**허구** 줄(대개 붙은 줄)은 PDF 대로 풀어 적는다. 고친 뒤 다시 뽑으면 줄어든다.", "", "```", "다시 뽑기 ./venv/Scripts/python.exe resources/tester/_pdf_gap_report.py", "총량 시험 ./venv/Scripts/python.exe -m pytest resources/tester/test_const_pdf_coverage.py", "```", "", "| 장 | 줄 수 | 파일 |", "| --- | --- | --- |", ] for chapter, count in sorted(counts.items(), key=lambda kv: -kv[1]): name = re.sub(r"[^\w가-힣]+", "_", chapter).strip("_") or "장모름" index.append(f"| {chapter} | {count} | [{name}.md]({name}.md) |") (OUT / "000_목차.md").write_text("\n".join(index) + "\n", encoding="utf-8") return counts if __name__ == "__main__": got = write() for chapter, count in sorted(got.items(), key=lambda kv: -kv[1]): print(f"{count:5d} {chapter}") print(f"합 {sum(got.values())} 줄 · {OUT.relative_to(ROOT)}")