Files
Aislo/resources/knowledge/original/_pipeline/pum_md_tool.py
T
eomsangdonandClaude Opus 5 bd167e5275 knowledge(품셈md): 도구 — 바탕(pum_md_base.py) 떼어 냄 · 자간 벌린 수 이어 셈 · 「자간 벌린 수」 주석
- pum_md_tool.py 가 700줄을 넘어(707) 책 설정·쪽 줄 읽기·머리 찾기를 pum_md_base.py 로 옮김 (pum_md_tool 이 다시 내보냄)
- read_rows: 한 자리 수끼리 틈이 글자 높이보다 좁으면 이어 셈(「체 감 온 도 3 3 도」→33) · 표 칸 틈(1.2 이상)은 그대로
- md 주석 <!-- 자간 벌린 수: 「1 . 3」→「1.3」 --> 에 적힌 글자열만 PDF 쪽에서 붙여 셈 · 기계검사 칸 「자간 벌린 수 N곳」 · PDF 에 없거나 공백 뗀 것이 아니면 불통 (브레인 지시)
- 작성된 md 218개 판정 고치기 전후 같음(작업 중이던 건설 1-02 만 달라짐)
- 작성규칙 6장 한 줄
- test_산림_산출도_그대로_금액_불변 하나만 skip 표시(옛 사슬 동결 · 원천 벌 json 줄바꿈 지문 · 단가 연결 때 되살림 — 브레인 지시)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
2026-09-19 03:30:42 +09:00

629 lines
27 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
from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·머리 찾기)은 pum_md_base.py
_NUM,
BOOKS,
COST,
HEAD_KEYS,
ROOT,
STATES,
_page_rows,
find_heading,
heading_pattern,
join_spaced,
key,
locate,
read_rows,
visual_text,
)
_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
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 read_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),
}
#: 자간 벌린 수 — md 주석 `<!-- 자간 벌린 수: 「1 . 3」→「1.3」 · 「3 0 4」→「304」 -->` (규칙 3-13).
#: 짐작으로 붙이지 않고 **적힌 글자열만** PDF 쪽에서 붙여 셈 · 오른쪽은 왼쪽에서 공백만 뺀 것이어야 함.
_SPACED = re.compile(r"<!--\s*자간 벌린 수:(.*?)-->", re.S)
_SPACED_PAIR = re.compile(r"「([^」]+)」\s*→\s*「([^」]+)」")
def join_spaced_numbers(pdf: list[tuple[int, str]], body: str):
"""(PDF 줄, 붙인 곳 수, 못 쓴 글자열) — md 주석에 적힌 자간 벌린 수만 PDF 줄에서 붙임."""
joined, bad = 0, []
for block in _SPACED.findall(body):
for spaced, tight in _SPACED_PAIR.findall(block):
hits = sum(line.count(spaced) for _, line in pdf)
if not hits or re.sub(r"\s", "", spaced) != tight:
bad.append(spaced)
continue
pdf = [(at, line.replace(spaced, tight)) for at, line in pdf]
joined += hits
return pdf, joined, bad
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()),
)
joined, bad = result.get("spaced", (0, []))
word = "통과" if not any(counts) and not bad 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)}자"
if joined: # ③ 이 그림으로 그 수를 꼭 보게
line += f" · 자간 벌린 수 {joined}곳"
if bad:
line += " · 자간 벌린 수 못 씀 " + " ".join(f"「{s}」" for s in bad)
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:
pdf = pdf_lines(doc, read_head(path), book["footer"])
pdf, joined, bad = join_spaced_numbers(pdf, body)
result = compare(pdf, md_lines(body))
result["spaced"] = (joined, bad)
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:]))