# -*- coding: utf-8 -*- """품셈 PDF → 절별 md 도구 (규칙: `원가계산/_품셈md_작성규칙.md` · 2026-09-19). 판정 기준은 PDF 뿐 — 원가계산(B09)·공종 마스터·옛 md 는 보지 않음. 사용: python pum_md_tool.py plan <산림|건설> 차례에서 장·절 → 본문/ 빈 파일(머리만 · 상태 대기) python pum_md_tool.py pages <산림|건설> [쪽범위] 쪽 그림 150dpi PNG → tmp/pum_pages/ (git 밖) python pum_md_tool.py check ① 수 대조 ② 글자 대조 → 머리 「기계검사」 칸 python pum_md_tool.py status 장별 대기/작성중/검증대기/확정 표 절 경계 = 시작표지·끝표지 글자가 쪽 안에서 놓인 줄(좌표로 묶은 줄의 맨 앞). 좌표로 줄 묶기는 `resources/tester/test_const_pdf_coverage.py` 의 것을 그대로 씀(그 시험은 안 고침). """ from __future__ import annotations import collections import difflib import hashlib import re import sys 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쪽", "인쇄쪽", "시작표지", "끝표지", "상태", "작성", "검증", "원천", "기계검사", "확정지문", ) STATES = ("대기", "작성중", "검증대기", "확정") _TOC_SKIP = {"장", "내 용", "페이지", "- 목 차 -"} _TOC_ID = re.compile(r"제\d+장|\d+-\d+(?:-\d+)*|부록\d*") _BAD_NAME = re.compile(r'[/:*?"<>|\\]') _LEADER = re.compile(r"\s*[·․…]{3,}.*$") #: 글자층이 없다고 볼 쪽 글자 수(쪽 번호만 남은 쪽) _EMPTY_PAGE_CHARS = 20 @dataclass class Entry: """차례 한 줄 — 장(`제1장`) · 절(`1-1`) · 항(`1-1-1`) · 부록.""" ident: str name: str page: int # 인쇄 쪽 def toc_entries(doc, book: dict) -> list[Entry]: """차례 쪽의 줄 → 차례 줄 목록(건설은 부문 줄 `공통부문` 도 끼움 · 쪽 0).""" first, last = book["toc"] raw = [ line for number in range(first - 1, last) for line in doc[number].get_text().splitlines() ] if book["toc_layout"] == "inline": return parse_toc_inline(raw) return parse_toc([line for line in raw if line.strip() not in _TOC_SKIP]) def parse_toc(raw: list[str]) -> list[Entry]: lines = [ t for t in (_LEADER.sub("", line).strip() for line in raw) if t ] # 점선 뗌(「부록 ····」) out, i = [], 0 while i < len(lines): if not _TOC_ID.fullmatch(lines[i]): raise ValueError(f"차례 모양이 다름: {lines[i]!r}") j, name = i + 1, [] while j < len(lines) and not lines[j].isdigit(): name.append(lines[j]) j += 1 if j == len(lines): raise ValueError(f"차례 쪽 번호 없음: {lines[i]}") out.append(Entry(lines[i], " ".join(name), int(lines[j]))) i = j + 1 return out _INLINE = re.compile(r"^(\d+-\d+(?:-\d+)*)\s*(.*?)\s*[·․…]{3,}\s*(\d+)\s*$") _DIVISION = re.compile(r"^\S+부문$") def parse_toc_inline(raw: list[str]) -> list[Entry]: """건설 차례 — 절·항은 「1-1 일반사항····3」 한 줄 · 장은 「제1장 / 적용기준 / 3」 · 부문은 「공통부문」 한 줄.""" lines = [line.strip() for line in raw if line.strip()] out, i = [], 0 while i < len(lines): line = lines[i] if found := _INLINE.match(line): out.append(Entry(found[1], found[2], int(found[3]))) elif re.fullmatch(r"제\d+장", line): out.append(Entry(line, lines[i + 1], int(lines[i + 2]))) i += 2 elif _DIVISION.match(line): out.append(Entry(line, "", 0)) elif not ( line.replace(" ", "") == "목차" or line.isdigit() ): # 쪽 머리 「목차」 · 차례 쪽 번호 raise ValueError(f"차례 모양이 다름: {line!r}") i += 1 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() #: 파일 이름에서 뗄 개정 표지 — 「수치지도 작성('21, '22, '24, '26년 보완)」(머리 글자에는 남김) _REVISION = re.compile(r"\s*\([^()]*(?:보완|신설|개정|삭제)[^()]*\)\s*$") def file_label(ident: str, title: str, dot: bool = True) -> str: return safe(_REVISION.sub("", name_of(ident, title, dot))) def claimed_elsewhere(root: Path) -> set[str]: """다른 창 브랜치에 이미 있는 새 md 경로 — 만들면 합칠 때 같은 자리 두 벌로 부딪힘.""" import subprocess rel = root.relative_to(ROOT).as_posix() refs = subprocess.run( ["git", "-C", str(ROOT), "for-each-ref", "--format=%(refname)", "refs/remotes/origin"], capture_output=True, text=True, check=True, ).stdout.split() out: set[str] = set() for ref in refs: listed = subprocess.run( [ "git", "-C", str(ROOT), "-c", "core.quotepath=off", "ls-tree", "-r", "--name-only", ref, "--", rel, ], capture_output=True, text=True, encoding="utf-8", ) out.update(line[len(rel) + 1 :] for line in listed.stdout.splitlines() if line) return out # root 아래 상대 경로 def safe(text: str) -> str: return _BAD_NAME.sub("", text).replace(" ", "_") def _span(a: int, b: int) -> str: return str(a) if a == b else f"{a}~{b}" def head_text(values: dict[str, str]) -> str: return ( "---\n" + "".join(f"{k}: {values.get(k, '')}".rstrip() + "\n" for k in HEAD_KEYS) + "---\n" ) def read_head(path: Path) -> dict[str, str]: lines = path.read_text(encoding="utf-8").splitlines() if not lines or lines[0] != "---": raise ValueError(f"머리 없음: {path}") out = {} for line in lines[1:]: if line == "---": return out k, _, v = line.partition(":") out[k.strip()] = v.strip() raise ValueError(f"머리 끝 없음: {path}") #: 자리 — (부문 폴더) / 장 번호 / 절(·항) 번호. 이름이 달라도 번호가 같으면 같은 자리 _SLOT = re.compile(r"(?:([^/]+)/)?제(\d+)장_[^/]*/(\d+-\d+(?:-\d+)?)_") #: 한 파일 쪽 상한 — 넘는 절은 항(N-N-N) 단위로 쪼갬(규칙 1장) _MAX_PAGES = 40 def _body_rows(page, footer: re.Pattern) -> list[str]: """쪽의 줄 — 맨 아래 쪽 꼬리(쪽 번호 · 장 이름)와 이어짐 표지 「→」 는 뺌.""" rows = [row.strip() for row in _page_rows(page)] if rows and footer.match(rows[-1]): rows = rows[:-1] return [row for row in rows if row != "→"] def _blank(page, footer: re.Pattern) -> bool: return len(re.sub(r"\s", "", "".join(_body_rows(page, footer)))) < _EMPTY_PAGE_CHARS def _span_of(doc, book: dict, entry: Entry, following: Entry | None, cover: str = ""): """(시작 PDF 쪽, 머리 글자, 끝 PDF 쪽, 끝표지) — 끝은 다음 머리 앞 · 부문 표지 앞 · 뒤 빈 쪽 뺌.""" offset, dot, footer = book["first_body"] - 1, book["dot"], book["footer"] start, _, title = find_heading(doc, entry.ident, entry.page + offset, dot=dot) if following is None: # 문서 맨 끝 end, marker = doc.page_count, "끝" else: # 장 마지막 절이면 다음 장 제목 · 부록 앞이면 부록 제목 nxt, at, marker = find_heading(doc, following.ident, following.page + offset, dot=dot) end = nxt - 1 if at == 0 else nxt for page in range(nxt - 1, start, -1) if cover else (): # 다음 부문 표지 쪽 앞에서 끊음 if any(r.strip() == cover for r in _page_rows(doc[page - 1])[:3]): end = page - 1 break while end > start and _blank(doc[end - 1], footer): end -= 1 return start, title, max(end, start), marker def plan(book_name: str) -> list[Path]: """장마다 `0-00_장머리.md` + 절마다 빈 파일(머리만 · 상태 대기) · 40쪽 넘는 절은 `N-NN-00_절머리.md` + 항마다. **이미 있는 자리는 안 건드림** — 이 폴더에 있거나 다른 창 브랜치에 있는 장·절(이름이 달라도 번호로 봄). 건설은 부문 폴더(`01_공통부문`) 아래 · 부문마다 장 번호가 1부터. """ import pymupdf book = BOOKS[book_name] out, dot, footer = book["out"], book["dot"], book["footer"] offset = book["first_body"] - 1 # 인쇄 쪽 + offset = PDF 쪽 have = claimed_elsewhere(out) | {p.relative_to(out).as_posix() for p in out.rglob("*.md")} taken = {f"{m[1] or ''}/{int(m[2])}/{m[3]}" for m in map(_SLOT.match, have) if m} folders = { (m[1] or "", int(m[2])): rel.split("/")[-2] for rel in have if (m := _SLOT.match(rel)) } written = [] def write(division, chapter_no, slot, file_name, start, title, end, marker, doc) -> None: if f"{division}/{chapter_no}/{slot}" in taken: return # 그림 쪽 — 글자층 없이 그림만 있는 쪽이 끼면 받아쓰기는 그림 기준 drawn = any( _blank(doc[p - 1], footer) and doc[p - 1].get_images() for p in range(start, end + 1) ) path = out / division / folders[(division, chapter_no)] / file_name path.parent.mkdir(parents=True, exist_ok=True) values = {"문서": book["doc"], "절": title, "시작표지": title, "끝표지": marker} values |= {"PDF쪽": _span(start, end), "인쇄쪽": _span(start - offset, end - offset)} values |= {"상태": "대기", "원천": "그림" if drawn else "글자층"} path.write_text(head_text(values), encoding="utf-8") written.append(path) with pymupdf.open(book["pdf"]) as doc: entries = toc_entries(doc, book) # 경계가 되는 차례 줄 — 부문 · 장 · 절 · 부록N(항은 절 안 · 머리 없는 「부록」 묶음 줄은 뺌) at = [i for i, e in enumerate(entries) if e.ident != "부록" and e.ident.count("-") < 2] bounds = [entries[i] for i in at] division, divisions, chapter_no = "", 0, None for n, entry in enumerate(bounds): if _DIVISION.match(entry.ident): divisions += 1 division, chapter_no = f"{divisions:02d}_{entry.ident}", None continue if entry.ident.startswith("부록"): continue j = next( (k for k in range(n + 1, len(bounds)) if not _DIVISION.match(bounds[k].ident)), None ) following = bounds[j] if j is not None else None cover = bounds[j - 1].ident if j is not None and j - 1 > n else "" # 사이에 낀 부문 start, title, end, marker = _span_of(doc, book, entry, following, cover) if entry.ident.startswith("제"): chapter_no = int(entry.ident[1:-1]) folders.setdefault( (division, chapter_no), f"제{chapter_no:02d}장_{file_label(entry.ident, title, dot)}", ) write( division, chapter_no, "0-00", "0-00_장머리.md", start, title, end, marker, doc ) continue if chapter_no is None: continue major, minor = entry.ident.split("-") slot = f"{major}-{int(minor):02d}" region = entries[at[n] + 1 : at[n + 1] if n + 1 < len(at) else len(entries)] items = [e for e in region if e.ident.rsplit("-", 1)[0] == entry.ident] if end - start + 1 <= _MAX_PAGES or not items: name = f"{slot}_{file_label(entry.ident, title, dot)}.md" write(division, chapter_no, slot, name, start, title, end, marker, doc) continue # 40쪽 넘는 절 — 절머리(절 제목 ~ 첫 항 앞) + 항마다 head_end = _span_of(doc, book, entry, items[0]) write(division, chapter_no, f"{slot}-00", f"{slot}-00_절머리.md", *head_end, doc) for k, item in enumerate(items): piece = _span_of( doc, book, item, items[k + 1] if k + 1 < len(items) else following, cover ) if k + 1 == len(items): # 마지막 항은 절 끝까지 piece = (piece[0], piece[1], end, marker) item_slot = f"{slot}-{int(item.ident.rsplit('-', 1)[1]):02d}" name = f"{item_slot}_{file_label(item.ident, piece[1], dot)}.md" write(division, chapter_no, item_slot, name, *piece, doc) return written # ── check ────────────────────────────────────────────────────────────── #: md 문법 — 글자 대조·수 대조에서 뺌(원문 글자가 아님) _COMMENT = re.compile(r"", re.S) _REPEATED = re.compile(r"") _IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)") _TABLE_RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$") #: 막음표 — 원문 글자가 md 문법으로 읽히지 않게 앞에 `\` 를 붙인 것(`\*` `\-` `\#` `\>` `\|` …) _ESCAPED = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~^])") _HOLD = "\x00" #: 글자층 밖 — PDF 글자층의 사용자 영역 글리프(U+E000–F8FF)는 뜻 없는 코드(식의 「(m³)」·분수선). #: ①② 에서 빼고 개수·쪽을 기계검사 칸에 드러냄(③ 이 그림으로 봄). md 에서 그 자리를 받아쓴 글자는 #: `` 로 감싸면 ①② 에서 빼고 「받아씀 N자」로 드러냄. _PUA = re.compile("[\ue000-\uf8ff]") _OFF_OPEN, _OFF_CLOSE = "\x01", "\x02" _OFF_MARK = re.compile(r"") def _same_han(ch: str) -> str: """호환 한자(U+F900–FAFF) → 통합 한자(率 U+F9DB → 率 U+7387). ⚠ NFKC 통째로는 안 씀 — ㎥·①·㎡ 가 풀림.""" return unicodedata.normalize("NFKC", ch) if "\uf900" <= ch <= "\ufaff" else ch def book_of(path: Path) -> dict: path = path.resolve() for book in BOOKS.values(): if path.is_relative_to(book["out"].resolve()): return book raise ValueError(f"본문/ 아래 파일이 아님: {path}") def split_head(text: str) -> tuple[str, str]: """(머리, 몸) — 머리는 첫 `---` 부터 다음 `---` 까지.""" lines = text.split("\n") end = lines.index("---", 1) return "\n".join(lines[: end + 1]), "\n".join(lines[end + 1 :]) def md_lines(body: str) -> list[tuple[int, str]]: """몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터. `` 는 셈 — 쪽을 넘은 표가 PDF 에서 되풀이한 머리 줄(md 표에서는 뺌 · 규칙 4-5). """ body = _REPEATED.sub(r"\1", body) body = _OFF_MARK.sub(lambda m: _OFF_CLOSE if m.group(1) else _OFF_OPEN, body) body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body) out = [] for number, line in enumerate(body.split("\n"), start=1): if _TABLE_RULE.match(line): continue line = _IMAGE.sub("", line).replace("
", " ") held = _ESCAPED.findall(line) # 막음표 뒤 원문 글자는 아래 문법 떼기에서 지킴 line = _ESCAPED.sub(_HOLD, line) line = re.sub(r"^\s*#+\s", "", line) # 제목 line = re.sub(r"^\s*(>\s*)+", "", line) # 인용 line = re.sub(r"[|^_]", " ", line) # 표 칸 · 첨자 표시(`10^-7` · `C_m`) for ch in held: line = line.replace(_HOLD, ch, 1) out.append((number, line)) return out def pdf_lines(doc, head: dict[str, str], footer: re.Pattern) -> list[tuple[int, str]]: """그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 머리 PDF쪽 밖은 안 봄 · 쪽 꼬리 뺌.""" first, _, last = head["PDF쪽"].partition("~") first, last = int(first), int(last or first) start_page, start_row = locate(doc, head["시작표지"], first, slack=0) end_page, end_row = last + 1, 0 if head["끝표지"] and head["끝표지"] != "끝": try: end_page, end_row = locate(doc, head["끝표지"], last, slack=1) except ValueError: pass out = [] for number in range(start_page, min(end_page, last) + 1): rows = [row.strip() for row in _page_rows(doc[number - 1])] if rows and footer.match(rows[-1]): rows[-1] = "" # 쪽 꼬리 for index, row in enumerate(rows): if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row): continue if not row or row == "→": # 이어짐 표지 continue out.append((number, row)) return out def _split_off(lines: list[tuple[int, str]]) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]: """(셀 줄, 글자층 밖 줄) — PDF 는 사용자 영역 글리프 · md 는 `글자층 밖` 으로 감싼 글자를 떼어 냄.""" kept, off, inside = [], [], False for at, line in lines: keep, gone = [], [] for ch in line: if ch in (_OFF_OPEN, _OFF_CLOSE): inside = ch == _OFF_OPEN elif inside or _PUA.match(ch): gone.append(ch) else: keep.append(ch) kept.append((at, "".join(keep))) if "".join(gone).strip(): off.append((at, "".join(gone))) return kept, off def _tight(lines: list[tuple[int, str]]) -> tuple[str, list[int]]: """공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄) · 호환 한자는 통합 한자로.""" text, where = [], [] for at, line in lines: for ch in re.sub(r"\s", "", line): text.append(_same_han(ch)) where.append(at) return "".join(text), where def _numbers(lines: list[tuple[int, str]]) -> tuple[collections.Counter, dict[str, list[int]]]: counts, where = collections.Counter(), collections.defaultdict(list) for at, line in lines: for token in _NUM.findall(line): counts[token] += 1 where[token].append(at) return counts, where def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict: """① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향. 글자층 밖(사용자 영역 글리프 · 감싼 받아쓰기)은 빼고 따로 셈.""" pdf, pdf_off = _split_off(pdf) md, md_off = _split_off(md) pdf_n, pdf_at = _numbers(pdf) md_n, md_at = _numbers(md) pdf_t, pdf_where = _tight(pdf) md_t, md_where = _tight(md) missing_c = collections.Counter(pdf_t) - collections.Counter(md_t) extra_c = collections.Counter(md_t) - collections.Counter(pdf_t) spots = [] if missing_c or extra_c: # 자리 — 차례대로 맞대 어긋난 토막(갯수 차가 난 글자가 든 것만) matcher = difflib.SequenceMatcher(None, pdf_t, md_t, autojunk=False) for op, i1, i2, j1, j2 in matcher.get_opcodes(): gone, added = pdf_t[i1:i2], md_t[j1:j2] if op == "equal" or not (set(gone) & set(missing_c) or set(added) & set(extra_c)): continue spots.append( { "pdf": pdf_where[min(i1, len(pdf_where) - 1)] if pdf_where else None, "md": md_where[min(j1, len(md_where) - 1)] if md_where else None, "context": pdf_t[max(0, i1 - 8) : i1], "pdf_only": gone, "md_only": added, } ) return { "num_missing": {k: (v, pdf_at[k][:v]) for k, v in (pdf_n - md_n).items()}, "num_extra": {k: (v, md_at[k][:v]) for k, v in (md_n - pdf_n).items()}, "char_missing": missing_c, "char_extra": extra_c, "spots": spots, "off_pdf": ( sum(len(re.sub(r"\s", "", t)) for _, t in pdf_off), sorted({a for a, _ in pdf_off}), ), "off_md": sum(len(re.sub(r"\s", "", t)) for _, t in md_off), } def verdict(result: dict) -> str: counts = ( sum(v for v, _ in result["num_missing"].values()), sum(v for v, _ in result["num_extra"].values()), sum(result["char_missing"].values()), sum(result["char_extra"].values()), ) word = "통과" if not any(counts) else "불통" line = ( f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}" ) glyphs, pages = result.get("off_pdf", (0, [])) 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)}자" return line def fingerprint(text: str) -> str: """확정지문 — 줄끝을 LF 로 고쳐 셈(git 이 CRLF 로 풀어도 같게) · 도구가 적는 두 칸(기계검사·확정지문)은 뺌.""" lines = text.replace("\r\n", "\n").split("\n") kept = [ln for ln in lines if not ln.startswith(("기계검사:", "확정지문:"))] return hashlib.sha256("\n".join(kept).encode("utf-8")).hexdigest() def set_head(text: str, field: str, value: str) -> str: head, body = split_head(text) head = re.sub(rf"(?m)^{field}:.*$", f"{field}: {value}".rstrip(), head, count=1) return head + "\n" + body def check_file(path: Path) -> tuple[dict, str]: """(대조 결과, 한 줄 판정) — 파일은 안 고침.""" import pymupdf _, 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)) return result, verdict(result) def check(path: Path) -> int: """검사하고 머리 「기계검사」 칸에 적음 · 상태 `확정` 이고 통과인데 확정지문이 비었으면 지문도 적음. ⚠ 있는 확정지문은 안 고침 — 확정 뒤 바뀐 파일을 지문을 다시 적어 덮으면 시험이 못 잡음. """ result, line = check_file(path) print(f"{path.name}: {line}") for token, (count, at) in sorted(result["num_missing"].items()): print(f" ① 결손 {token} ×{count} (PDF {', '.join(map(str, at))}쪽)") for token, (count, at) in sorted(result["num_extra"].items()): print(f" ① 허구 {token} ×{count} (md 몸 {', '.join(map(str, at))}줄)") for spot in result["spots"][:40]: print( f" ② PDF {spot['pdf']}쪽 · md 몸 {spot['md']}줄 · …{spot['context']}" f" [PDF만 「{spot['pdf_only']}」 · md만 「{spot['md_only']}」]" ) text = set_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"), "기계검사", line) head = read_head(path) if head.get("상태") == "확정" and line.startswith("통과") and not head.get("확정지문"): text = set_head(text, "확정지문", fingerprint(text)) path.write_text(text, encoding="utf-8", newline="\n") return 0 if line.startswith("통과") else 1 # ── pages · status ───────────────────────────────────────────────────── PAGES_DIR = ROOT / "tmp/pum_pages" def pages(book_name: str, span: str = "") -> list[Path]: """쪽 그림 150dpi PNG — `tmp/pum_pages/<책>/p0135.png`(git 밖).""" import pymupdf out = PAGES_DIR / book_name out.mkdir(parents=True, exist_ok=True) made = [] with pymupdf.open(BOOKS[book_name]["pdf"]) as doc: first, _, last = (span or f"1~{doc.page_count}").replace("-", "~").partition("~") for number in range(int(first), int(last or first) + 1): path = out / f"p{number:04d}.png" doc[number - 1].get_pixmap(dpi=150).save(path) made.append(path) return made def status() -> str: """장별 상태 표(md) — 머리 「상태」 칸을 모음.""" rows = [ "| 책 | 장 | " + " | ".join(STATES) + " | 계 |", "|---|---|" + "---|" * (len(STATES) + 1), ] for name, book in BOOKS.items(): by_chapter: dict[str, collections.Counter] = {} for path in sorted(book["out"].rglob("*.md")): chapter = path.parent.relative_to(book["out"]).as_posix() by_chapter.setdefault(chapter, collections.Counter())[ read_head(path).get("상태", "") ] += 1 for chapter, counts in by_chapter.items(): cells = " | ".join(str(counts[s]) for s in STATES) rows.append(f"| {name} | {chapter} | {cells} | {sum(counts.values())} |") return "\n".join(rows) def main(argv: list[str]) -> int: command, args = (argv[0], argv[1:]) if argv else ("", []) if command == "plan" and args: print(f"새 파일 {len(plan(args[0]))}") return 0 if command == "pages" and args: made = pages(args[0], args[1] if len(args) > 1 else "") print(f"쪽 그림 {len(made)} → {PAGES_DIR / args[0]}") return 0 if command == "check" and args: return max(check(Path(a)) for a in args) if command == "status": print(status()) return 0 print(__doc__) return 2 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))