Files
Aislo/resources/knowledge/original/_pipeline/pum_md_tool.py
T
eomsangdonandClaude Opus 5 ad1fe8db32 knowledge(품셈md): 도구 check — 글자층 밖 글리프(U+E000–F8FF) 빼고 드러냄 · 호환 한자 통합 · 작성규칙 6장 두 줄
- 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
2026-09-19 01:52:31 +09:00

708 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 <md 파일> ① 수 대조 ② 글자 대조 → 머리 「기계검사」 칸
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"<!--\s*되풀이 머리:(.*?)-->")
_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"<!--\s*(/?)글자층 밖\s*-->")
def _same_han(ch: str) -> str:
"""호환 한자(U+F900FAFF) → 통합 한자(率 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("<br>", " ")
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:]))