diff --git a/resources/knowledge/original/_pipeline/pum_md_check.py b/resources/knowledge/original/_pipeline/pum_md_check.py
new file mode 100644
index 000000000..27d83c936
--- /dev/null
+++ b/resources/knowledge/original/_pipeline/pum_md_check.py
@@ -0,0 +1,411 @@
+# -*- coding: utf-8 -*-
+"""품셈 md 도구 — 기계검사(check): 수 대조 · 글자 대조 (`pum_md_tool` 에서 나눔 · 밖에서는 `pum_md_tool` 로 부름)."""
+
+from __future__ import annotations
+
+import collections
+import difflib
+import hashlib
+import re
+import unicodedata
+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,
+)
+
+from pum_md_plan import _is_footer, _side_number, read_head # noqa: E402
+
+# ── check ──────────────────────────────────────────────────────────────
+#: md 문법 — 글자 대조·수 대조에서 뺌(원문 글자가 아님)
+_COMMENT = re.compile(r"", re.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"")
+
+
+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부터.
+
+ 쪽을 넘은 표가 되풀이 찍은 머리 줄은 md 표에 아예 안 적음(규칙 4-5) — `pdf_lines` 가 스스로 뺌.
+ """
+ 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
+
+
+#: 항 경계 — 「9-17-4 제목」·「(7505) 제목」 같은 줄이 나오면 그 앞까지 본 줄은 잊음(딴 항의 흔한 머리말과
+#: 안 헷갈리게). 분류번호(4자리-4자리)는 안 걸리게 앞 수를 1~2자리로 잡음.
+_ITEM_HEAD = re.compile(r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s\S|^\(\d+\)\s\S")
+#: 되풀이 머리로 안 보는 줄 — 목·항 머리, 번호 매긴 줄, 「[주]」·「[참고]」·「<…>」. 표 머리는 이런 모양이 아님.
+#: 여기서 되풀이 빼기를 멈춤 — 같은 글이 앞에 또 있어도 새 표·새 목의 첫 줄이므로 빼면 안 됨.
+_NOT_REPEAT = re.compile(
+ r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s" # 13-6-3 수문 제작
+ r"|^\(\d+\)\s" # (1) 제목 · (7505) 분류
+ r"|^\d{1,2}\.\s" # 1. 제목
+ r"|^[가-힣]\.\s" # 가. 제목
+ r"|^[①-⑳㉮-㉻]" # ① ㉮
+ r"|^\[[가-힣]{1,6}\]" # [주] [참고] [설계예]
+ r"|^[<※◦○●□■]" # <…> ※ ◦ ○ ● □ ■
+)
+
+
+def _chars(row: str) -> collections.Counter:
+ """그 줄의 글자 셈 — 글자층 밖 글리프·공백을 뺀 뒤 호환 한자를 통합 한자로(②글자 대조와 같은 잣대)."""
+ kept, _ = _split_off([(0, row)])
+ return collections.Counter(_tight(kept)[0])
+
+
+def _repeat_budget(rows: list[tuple[int, str]], body: str) -> collections.Counter:
+ """되풀이 머리로 뺄 수 있는 글자 셈 — PDF 가 md 보다 더 가진 만큼.
+
+ md 는 되풀이된 머리를 안 적으므로(규칙 4-5) 그 차이가 곧 뺄 몫 — 이 몫을 넘겨 빼지 않음.
+ 딴 표의 흔한 머리말이 우연히 앞 글과 같아도 몫이 없으면 안 빠짐.
+ """
+ kept_pdf, _ = _split_off(rows)
+ kept_md, _ = _split_off(md_lines(body))
+ return collections.Counter(_tight(kept_pdf)[0]) - collections.Counter(_tight(kept_md)[0])
+
+
+def pdf_lines(
+ doc, head: dict[str, str], footer: re.Pattern, body: str = ""
+) -> list[tuple[int, str]]:
+ """그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 머리 PDF쪽 밖은 안 봄 · 쪽 꼬리 뺌.
+
+ 쪽을 넘거나(다음 쪽 맨 위) 한 쪽 안에서 「→」로 끊긴 표가 되풀이 찍은 머리 줄(규칙 4-5)은 스스로 뺌.
+ 셋을 다 갖춘 줄만 뺌 — 하나라도 어긋나면 그 자리에서 빼기를 멈춤:
+
+ ① 이번 항에서 이미 나온 글자와 공백 빼고 이어 붙음(줄 나눔은 안 가림 — 쪽마다 줄 묶임이 달라도 잡음).
+ 새 항이 시작하면(「9-17-4 …」·「(7505) …」) 이미 본 글자를 잊음.
+ ② 목·항 머리 모양이 아님 — 「1.」·「가.」·「①」·「[주]」·「<…>」 로 시작하는 줄은 새 표·새 목의 첫 줄.
+ ③ md 가 못 가진 글자 몫 안임(`body` 를 줬을 때) — PDF 가 md 보다 더 가진 만큼만 뺌.
+ """
+ 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
+ raw: list[tuple[int, str, bool]] = [] # (쪽, 줄, 이어진 자리의 첫 줄인가)
+ 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 _is_footer(doc[number - 1], rows[-1], footer):
+ rows[-1] = "" # 쪽 꼬리
+ if rows and _side_number(doc[number - 1], rows[0]):
+ rows[0] = "" # 눕힌 쪽 옆 띠 쪽 번호
+ fresh = number > start_page # 다음 쪽 맨 위
+ for index, row in enumerate(rows):
+ if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row):
+ continue
+ if not row:
+ continue
+ if row == "→": # 한 쪽 안에서 표가 끊긴 자리
+ fresh = True
+ continue
+ raw.append((number, row, fresh))
+ fresh = False
+ budget = _repeat_budget([(n, r) for n, r, _ in raw], body) if body else None
+ gone, later = [False] * len(raw), []
+ since_head, at = "", 0
+ while at < len(raw):
+ number, row, fresh = raw[at]
+ if fresh and (run := _repeat_run(raw, at, since_head)):
+ chars = sum((_chars(raw[k][1]) for k in run), collections.Counter())
+ if budget is None or not (chars - budget): # 통째로 몫에 드는 것부터 뺌
+ if budget is not None:
+ budget -= chars
+ for k in run:
+ gone[k] = True
+ at = run[-1] + 1
+ continue
+ later.append(run) # 몫이 모자란 것은 뒤로 미룸
+ if _ITEM_HEAD.match(row):
+ since_head = ""
+ since_head += re.sub(r"\s+", "", row)
+ at += 1
+ for run in later: # 남은 몫으로 앞 토막만 더 뺌 — 표 머리 뒤에 몸 줄이 딸려 잡힌 자리
+ for k in run:
+ chars = _chars(raw[k][1])
+ if chars - budget:
+ break
+ budget -= chars
+ gone[k] = True
+ return [(number, row) for (number, row, _), out in zip(raw, gone) if not out]
+
+
+def _repeat_run(raw: list[tuple[int, str, bool]], at: int, since_head: str) -> list[int]:
+ """이어진 자리에서 되풀이 머리로 볼 줄 차례 — 이미 나온 글자에 이어 붙는 줄만, 첫 다른 줄 앞까지."""
+ run, trial = [], ""
+ for k in range(at, len(raw)):
+ _, row, fresh = raw[k]
+ if (k > at and fresh) or _NOT_REPEAT.match(row):
+ break
+ grown = trial + re.sub(r"\s+", "", row)
+ if not grown or grown not in since_head:
+ break
+ trial = grown
+ run.append(k)
+ return run
+
+
+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):
+ # 뗀 자리는 가름 — 분수선 양쪽 수가 붙지 않게(「174」·「2」 ≠ 「1742」)
+ keep.append(" ")
+ 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 주석 `` (규칙 3-13).
+#: 짐작으로 붙이지 않고 **적힌 글자열만** PDF 쪽에서 붙여 셈 · 오른쪽은 왼쪽에서 공백만 뺀 것이어야 함.
+_SPACED = re.compile(r"", re.S)
+_SPACED_PAIR = re.compile(r"「([^」]+)」\s*→\s*「([^」]+)」")
+#: 글자층 붙은 수 — 자간 벌린 수의 거꾸로. 글자층이 여러 칸 값을 공백 없이 한 덩이로 담은 것
+#: md 주석 `` · 왼쪽은 오른쪽에서 공백만 뺀 것이어야 함.
+_GLUED = re.compile(r"", re.S)
+#: 기계검사 예외 — md 주석 `` (규칙 6장 · 머리 바로 아래 한 줄).
+#: 도구가 스스로 못 가리는 되풀이 머리를 쓴 절 — 사람이 쪽 그림으로 대조하고 손으로 붙임.
+#: 붙이면 ①② 가 어긋나도 「예외(되풀이 머리)」 로 적고 확정 파일이면 지문도 적음.
+_EXEMPT = re.compile(r"")
+
+
+def _rewrite(pdf: list[tuple[int, str]], body: str, mark: re.Pattern, ok):
+ """(PDF 줄, 고친 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자열만 PDF 줄에서 바꿈(PDF 에 있고 ok 일 때만)."""
+ done, bad = 0, []
+ for block in mark.findall(body):
+ for old, new in _SPACED_PAIR.findall(block):
+ hits = sum(line.count(old) for _, line in pdf)
+ if not hits or not ok(old, new):
+ bad.append(old)
+ continue
+ pdf = [(at, line.replace(old, new)) for at, line in pdf]
+ done += hits
+ return pdf, done, bad
+
+
+def join_spaced_numbers(pdf: list[tuple[int, str]], body: str):
+ """(PDF 줄, 붙인 곳 수, 못 쓴 글자열) — md 주석에 적힌 자간 벌린 수만 PDF 줄에서 붙임."""
+ return _rewrite(pdf, body, _SPACED, lambda spaced, tight: re.sub(r"\s", "", spaced) == tight)
+
+
+def split_glued_numbers(pdf: list[tuple[int, str]], body: str):
+ """(PDF 줄, 가른 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자층 붙은 수만 PDF 줄에서 가름."""
+ return _rewrite(
+ pdf, body, _GLUED, lambda glued, split: glued != split and re.sub(r"\s", "", split) == glued
+ )
+
+
+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, []))
+ split, bad_glued = result.get("glued", (0, []))
+ clean = not any(counts) and not bad and not bad_glued
+ word = "통과" if clean else ("예외(되풀이 머리)" if result.get("exempt") 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)
+ if split: # ③ 이 그림으로 칸마다 값을 꼭 보게
+ line += f" · 글자층 붙은 수 {split}곳"
+ if bad_glued:
+ line += " · 글자층 붙은 수 못 씀 " + " ".join(f"「{s}」" for s in bad_glued)
+ return line
+
+
+def passed(line: str) -> str:
+ """기계검사 한 줄이 받아들일 만한가 — 「통과」 또는 「예외(되풀이 머리)」."""
+ return line.startswith(("통과", "예외"))
+
+
+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"], body)
+ pdf, joined, bad = join_spaced_numbers(pdf, body)
+ pdf, split, bad_glued = split_glued_numbers(pdf, body)
+ result = compare(pdf, md_lines(body))
+ result["spaced"] = (joined, bad)
+ result["glued"] = (split, bad_glued)
+ result["exempt"] = bool(_EXEMPT.search(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 passed(line) and not head.get("확정지문"):
+ text = set_head(text, "확정지문", fingerprint(text))
+ path.write_text(text, encoding="utf-8", newline="\n")
+ return 0 if passed(line) else 1
diff --git a/resources/knowledge/original/_pipeline/pum_md_plan.py b/resources/knowledge/original/_pipeline/pum_md_plan.py
new file mode 100644
index 000000000..7f4915d1a
--- /dev/null
+++ b/resources/knowledge/original/_pipeline/pum_md_plan.py
@@ -0,0 +1,307 @@
+# -*- coding: utf-8 -*-
+"""품셈 md 도구 — 차례 읽기 · 새 md 틀 만들기(plan) (`pum_md_tool` 에서 나눔 · 밖에서는 `pum_md_tool` 로 부름)."""
+
+from __future__ import annotations
+
+import re
+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:
+ # 글자층 밖 글리프(PUA)는 이름에서 뺌
+ text = "".join(c for c in text if not 0xE000 <= ord(c) <= 0xF8FF)
+ return _BAD_NAME.sub("", " ".join(text.split())).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 _side_number(page, row: str) -> bool:
+ """가로로 눕힌 쪽(건설 813~817)의 옆 띠 쪽 번호 — 맨 끝(홀수 쪽) 또는 맨 앞(짝수 쪽) 줄에 수만 남음."""
+ return page.rect.width > page.rect.height and row.isdigit()
+
+
+def _is_footer(page, row: str, footer: re.Pattern) -> bool:
+ return bool(footer.match(row)) or _side_number(page, row)
+
+
+def _body_rows(page, footer: re.Pattern) -> list[str]:
+ """쪽의 줄 — 맨 아래 쪽 꼬리(쪽 번호 · 장 이름)와 이어짐 표지 「→」 는 뺌."""
+ rows = [row.strip() for row in _page_rows(page)]
+ if rows and _side_number(page, rows[0]):
+ rows = rows[1:]
+ if rows and _is_footer(page, rows[-1], footer):
+ 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
diff --git a/resources/knowledge/original/_pipeline/pum_md_tool.py b/resources/knowledge/original/_pipeline/pum_md_tool.py
index afcfdb026..c7e78297e 100644
--- a/resources/knowledge/original/_pipeline/pum_md_tool.py
+++ b/resources/knowledge/original/_pipeline/pum_md_tool.py
@@ -15,12 +15,7 @@
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
@@ -40,670 +35,73 @@ from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·
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:
- # 글자층 밖 글리프(PUA)는 이름에서 뺌
- text = "".join(c for c in text if not 0xE000 <= ord(c) <= 0xF8FF)
- return _BAD_NAME.sub("", " ".join(text.split())).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 _side_number(page, row: str) -> bool:
- """가로로 눕힌 쪽(건설 813~817)의 옆 띠 쪽 번호 — 맨 끝(홀수 쪽) 또는 맨 앞(짝수 쪽) 줄에 수만 남음."""
- return page.rect.width > page.rect.height and row.isdigit()
-
-
-def _is_footer(page, row: str, footer: re.Pattern) -> bool:
- return bool(footer.match(row)) or _side_number(page, row)
-
-
-def _body_rows(page, footer: re.Pattern) -> list[str]:
- """쪽의 줄 — 맨 아래 쪽 꼬리(쪽 번호 · 장 이름)와 이어짐 표지 「→」 는 뺌."""
- rows = [row.strip() for row in _page_rows(page)]
- if rows and _side_number(page, rows[0]):
- rows = rows[1:]
- if rows and _is_footer(page, rows[-1], footer):
- 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)
-_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부터.
-
- 쪽을 넘은 표가 되풀이 찍은 머리 줄은 md 표에 아예 안 적음(규칙 4-5) — `pdf_lines` 가 스스로 뺌.
- """
- 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
-
-
-#: 항 경계 — 「9-17-4 제목」·「(7505) 제목」 같은 줄이 나오면 그 앞까지 본 줄은 잊음(딴 항의 흔한 머리말과
-#: 안 헷갈리게). 분류번호(4자리-4자리)는 안 걸리게 앞 수를 1~2자리로 잡음.
-_ITEM_HEAD = re.compile(r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s\S|^\(\d+\)\s\S")
-#: 되풀이 머리로 안 보는 줄 — 목·항 머리, 번호 매긴 줄, 「[주]」·「[참고]」·「<…>」. 표 머리는 이런 모양이 아님.
-#: 여기서 되풀이 빼기를 멈춤 — 같은 글이 앞에 또 있어도 새 표·새 목의 첫 줄이므로 빼면 안 됨.
-_NOT_REPEAT = re.compile(
- r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s" # 13-6-3 수문 제작
- r"|^\(\d+\)\s" # (1) 제목 · (7505) 분류
- r"|^\d{1,2}\.\s" # 1. 제목
- r"|^[가-힣]\.\s" # 가. 제목
- r"|^[①-⑳㉮-㉻]" # ① ㉮
- r"|^\[[가-힣]{1,6}\]" # [주] [참고] [설계예]
- r"|^[<※◦○●□■]" # <…> ※ ◦ ○ ● □ ■
+from pum_md_plan import ( # noqa: E402,F401 — 차례 읽기 · 새 md 틀은 pum_md_plan.py
+ Entry,
+ _BAD_NAME,
+ _DIVISION,
+ _EMPTY_PAGE_CHARS,
+ _INLINE,
+ _LEADER,
+ _MAX_PAGES,
+ _REVISION,
+ _SLOT,
+ _TOC_ID,
+ _TOC_SKIP,
+ _blank,
+ _body_rows,
+ _is_footer,
+ _side_number,
+ _span,
+ _span_of,
+ claimed_elsewhere,
+ file_label,
+ head_text,
+ name_of,
+ parse_toc,
+ parse_toc_inline,
+ plan,
+ read_head,
+ safe,
+ toc_entries,
+)
+from pum_md_check import ( # noqa: E402,F401 — 기계검사는 pum_md_check.py
+ _COMMENT,
+ _ESCAPED,
+ _EXEMPT,
+ _GLUED,
+ _HOLD,
+ _IMAGE,
+ _ITEM_HEAD,
+ _NOT_REPEAT,
+ _OFF_CLOSE,
+ _OFF_MARK,
+ _OFF_OPEN,
+ _PUA,
+ _SPACED,
+ _SPACED_PAIR,
+ _TABLE_RULE,
+ _chars,
+ _numbers,
+ _repeat_budget,
+ _repeat_run,
+ _rewrite,
+ _same_han,
+ _split_off,
+ _tight,
+ book_of,
+ check,
+ check_file,
+ compare,
+ fingerprint,
+ join_spaced_numbers,
+ md_lines,
+ passed,
+ pdf_lines,
+ set_head,
+ split_glued_numbers,
+ split_head,
+ verdict,
)
-
-
-def _chars(row: str) -> collections.Counter:
- """그 줄의 글자 셈 — 글자층 밖 글리프·공백을 뺀 뒤 호환 한자를 통합 한자로(②글자 대조와 같은 잣대)."""
- kept, _ = _split_off([(0, row)])
- return collections.Counter(_tight(kept)[0])
-
-
-def _repeat_budget(rows: list[tuple[int, str]], body: str) -> collections.Counter:
- """되풀이 머리로 뺄 수 있는 글자 셈 — PDF 가 md 보다 더 가진 만큼.
-
- md 는 되풀이된 머리를 안 적으므로(규칙 4-5) 그 차이가 곧 뺄 몫 — 이 몫을 넘겨 빼지 않음.
- 딴 표의 흔한 머리말이 우연히 앞 글과 같아도 몫이 없으면 안 빠짐.
- """
- kept_pdf, _ = _split_off(rows)
- kept_md, _ = _split_off(md_lines(body))
- return collections.Counter(_tight(kept_pdf)[0]) - collections.Counter(_tight(kept_md)[0])
-
-
-def pdf_lines(
- doc, head: dict[str, str], footer: re.Pattern, body: str = ""
-) -> list[tuple[int, str]]:
- """그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 머리 PDF쪽 밖은 안 봄 · 쪽 꼬리 뺌.
-
- 쪽을 넘거나(다음 쪽 맨 위) 한 쪽 안에서 「→」로 끊긴 표가 되풀이 찍은 머리 줄(규칙 4-5)은 스스로 뺌.
- 셋을 다 갖춘 줄만 뺌 — 하나라도 어긋나면 그 자리에서 빼기를 멈춤:
-
- ① 이번 항에서 이미 나온 글자와 공백 빼고 이어 붙음(줄 나눔은 안 가림 — 쪽마다 줄 묶임이 달라도 잡음).
- 새 항이 시작하면(「9-17-4 …」·「(7505) …」) 이미 본 글자를 잊음.
- ② 목·항 머리 모양이 아님 — 「1.」·「가.」·「①」·「[주]」·「<…>」 로 시작하는 줄은 새 표·새 목의 첫 줄.
- ③ md 가 못 가진 글자 몫 안임(`body` 를 줬을 때) — PDF 가 md 보다 더 가진 만큼만 뺌.
- """
- 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
- raw: list[tuple[int, str, bool]] = [] # (쪽, 줄, 이어진 자리의 첫 줄인가)
- 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 _is_footer(doc[number - 1], rows[-1], footer):
- rows[-1] = "" # 쪽 꼬리
- if rows and _side_number(doc[number - 1], rows[0]):
- rows[0] = "" # 눕힌 쪽 옆 띠 쪽 번호
- fresh = number > start_page # 다음 쪽 맨 위
- for index, row in enumerate(rows):
- if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row):
- continue
- if not row:
- continue
- if row == "→": # 한 쪽 안에서 표가 끊긴 자리
- fresh = True
- continue
- raw.append((number, row, fresh))
- fresh = False
- budget = _repeat_budget([(n, r) for n, r, _ in raw], body) if body else None
- gone, later = [False] * len(raw), []
- since_head, at = "", 0
- while at < len(raw):
- number, row, fresh = raw[at]
- if fresh and (run := _repeat_run(raw, at, since_head)):
- chars = sum((_chars(raw[k][1]) for k in run), collections.Counter())
- if budget is None or not (chars - budget): # 통째로 몫에 드는 것부터 뺌
- if budget is not None:
- budget -= chars
- for k in run:
- gone[k] = True
- at = run[-1] + 1
- continue
- later.append(run) # 몫이 모자란 것은 뒤로 미룸
- if _ITEM_HEAD.match(row):
- since_head = ""
- since_head += re.sub(r"\s+", "", row)
- at += 1
- for run in later: # 남은 몫으로 앞 토막만 더 뺌 — 표 머리 뒤에 몸 줄이 딸려 잡힌 자리
- for k in run:
- chars = _chars(raw[k][1])
- if chars - budget:
- break
- budget -= chars
- gone[k] = True
- return [(number, row) for (number, row, _), out in zip(raw, gone) if not out]
-
-
-def _repeat_run(raw: list[tuple[int, str, bool]], at: int, since_head: str) -> list[int]:
- """이어진 자리에서 되풀이 머리로 볼 줄 차례 — 이미 나온 글자에 이어 붙는 줄만, 첫 다른 줄 앞까지."""
- run, trial = [], ""
- for k in range(at, len(raw)):
- _, row, fresh = raw[k]
- if (k > at and fresh) or _NOT_REPEAT.match(row):
- break
- grown = trial + re.sub(r"\s+", "", row)
- if not grown or grown not in since_head:
- break
- trial = grown
- run.append(k)
- return run
-
-
-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):
- # 뗀 자리는 가름 — 분수선 양쪽 수가 붙지 않게(「174」·「2」 ≠ 「1742」)
- keep.append(" ")
- 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 주석 `` (규칙 3-13).
-#: 짐작으로 붙이지 않고 **적힌 글자열만** PDF 쪽에서 붙여 셈 · 오른쪽은 왼쪽에서 공백만 뺀 것이어야 함.
-_SPACED = re.compile(r"", re.S)
-_SPACED_PAIR = re.compile(r"「([^」]+)」\s*→\s*「([^」]+)」")
-#: 글자층 붙은 수 — 자간 벌린 수의 거꾸로. 글자층이 여러 칸 값을 공백 없이 한 덩이로 담은 것
-#: md 주석 `` · 왼쪽은 오른쪽에서 공백만 뺀 것이어야 함.
-_GLUED = re.compile(r"", re.S)
-#: 기계검사 예외 — md 주석 `` (규칙 6장 · 머리 바로 아래 한 줄).
-#: 도구가 스스로 못 가리는 되풀이 머리를 쓴 절 — 사람이 쪽 그림으로 대조하고 손으로 붙임.
-#: 붙이면 ①② 가 어긋나도 「예외(되풀이 머리)」 로 적고 확정 파일이면 지문도 적음.
-_EXEMPT = re.compile(r"")
-
-
-def _rewrite(pdf: list[tuple[int, str]], body: str, mark: re.Pattern, ok):
- """(PDF 줄, 고친 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자열만 PDF 줄에서 바꿈(PDF 에 있고 ok 일 때만)."""
- done, bad = 0, []
- for block in mark.findall(body):
- for old, new in _SPACED_PAIR.findall(block):
- hits = sum(line.count(old) for _, line in pdf)
- if not hits or not ok(old, new):
- bad.append(old)
- continue
- pdf = [(at, line.replace(old, new)) for at, line in pdf]
- done += hits
- return pdf, done, bad
-
-
-def join_spaced_numbers(pdf: list[tuple[int, str]], body: str):
- """(PDF 줄, 붙인 곳 수, 못 쓴 글자열) — md 주석에 적힌 자간 벌린 수만 PDF 줄에서 붙임."""
- return _rewrite(pdf, body, _SPACED, lambda spaced, tight: re.sub(r"\s", "", spaced) == tight)
-
-
-def split_glued_numbers(pdf: list[tuple[int, str]], body: str):
- """(PDF 줄, 가른 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자층 붙은 수만 PDF 줄에서 가름."""
- return _rewrite(
- pdf, body, _GLUED, lambda glued, split: glued != split and re.sub(r"\s", "", split) == glued
- )
-
-
-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, []))
- split, bad_glued = result.get("glued", (0, []))
- clean = not any(counts) and not bad and not bad_glued
- word = "통과" if clean else ("예외(되풀이 머리)" if result.get("exempt") 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)
- if split: # ③ 이 그림으로 칸마다 값을 꼭 보게
- line += f" · 글자층 붙은 수 {split}곳"
- if bad_glued:
- line += " · 글자층 붙은 수 못 씀 " + " ".join(f"「{s}」" for s in bad_glued)
- return line
-
-
-def passed(line: str) -> str:
- """기계검사 한 줄이 받아들일 만한가 — 「통과」 또는 「예외(되풀이 머리)」."""
- return line.startswith(("통과", "예외"))
-
-
-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"], body)
- pdf, joined, bad = join_spaced_numbers(pdf, body)
- pdf, split, bad_glued = split_glued_numbers(pdf, body)
- result = compare(pdf, md_lines(body))
- result["spaced"] = (joined, bad)
- result["glued"] = (split, bad_glued)
- result["exempt"] = bool(_EXEMPT.search(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 passed(line) and not head.get("확정지문"):
- text = set_head(text, "확정지문", fingerprint(text))
- path.write_text(text, encoding="utf-8", newline="\n")
- return 0 if passed(line) else 1
-
# ── pages · status ─────────────────────────────────────────────────────
PAGES_DIR = ROOT / "tmp/pum_pages"