- 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
629 lines
27 KiB
Python
629 lines
27 KiB
Python
# -*- 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+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("<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:]))
|