diff --git a/resources/knowledge/original/_pipeline/pum_md_tool.py b/resources/knowledge/original/_pipeline/pum_md_tool.py
index 1866a6e5..bf75c103 100644
--- a/resources/knowledge/original/_pipeline/pum_md_tool.py
+++ b/resources/knowledge/original/_pipeline/pum_md_tool.py
@@ -14,6 +14,9 @@
from __future__ import annotations
+import collections
+import difflib
+import hashlib
import re
import sys
from dataclasses import dataclass
@@ -22,7 +25,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[4] # 저장소 뿌리
sys.path.insert(0, str(ROOT))
-from resources.tester.test_const_pdf_coverage import _page_rows # noqa: E402
+from resources.tester.test_const_pdf_coverage import _NUM, _page_rows # noqa: E402
COST = ROOT / "resources/knowledge/original/원가계산"
@@ -299,10 +302,237 @@ def plan(book_name: str) -> list[Path]:
return written
+# ── check ──────────────────────────────────────────────────────────────
+#: 쪽 번호 줄(「- 123 -」) — 절 글자에서 뺌
+_PAGE_NO = re.compile(r"^-\s*\d+\s*-$")
+#: 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"
+
+
+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부터."""
+ body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body)
+ out = []
+ for number, line in enumerate(body.split("\n"), start=1):
+ if _TABLE_RULE.match(line):
+ continue
+ line = _IMAGE.sub("", line).replace("
", " ")
+ held = _ESCAPED.findall(line) # 막음표 뒤 원문 글자는 아래 문법 떼기에서 지킴
+ line = _ESCAPED.sub(_HOLD, line)
+ line = re.sub(r"^\s*#+\s", "", line) # 제목
+ line = re.sub(r"^\s*(>\s*)+", "", line) # 인용
+ line = re.sub(r"[|^_]", " ", line) # 표 칸 · 첨자 표시(`10^-7` · `C_m`)
+ for ch in held:
+ line = line.replace(_HOLD, ch, 1)
+ out.append((number, line))
+ return out
+
+
+def pdf_lines(doc, head: dict[str, str]) -> list[tuple[int, str]]:
+ """그 절 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, doc.page_count) + 1):
+ for index, row in enumerate(_page_rows(doc[number - 1])):
+ if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row):
+ continue
+ if not _PAGE_NO.match(row.strip()):
+ out.append((number, row))
+ return out
+
+
+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(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_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,
+ }
+
+
+def verdict(result: dict) -> str:
+ counts = (
+ sum(v for v, _ in result["num_missing"].values()),
+ sum(v for v, _ in result["num_extra"].values()),
+ sum(result["char_missing"].values()),
+ sum(result["char_extra"].values()),
+ )
+ word = "통과" if not any(counts) else "불통"
+ return (
+ f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}"
+ )
+
+
+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"))
+ with pymupdf.open(book_of(path)["pdf"]) as doc:
+ result = compare(pdf_lines(doc, read_head(path)), md_lines(body))
+ return result, verdict(result)
+
+
+def check(path: Path) -> int:
+ """검사하고 머리 「기계검사」 칸에 적음 · 상태 `확정` 이고 통과인데 확정지문이 비었으면 지문도 적음.
+
+ ⚠ 있는 확정지문은 안 고침 — 확정 뒤 바뀐 파일을 지문을 다시 적어 덮으면 시험이 못 잡음.
+ """
+ result, line = check_file(path)
+ print(f"{path.name}: {line}")
+ for token, (count, at) in sorted(result["num_missing"].items()):
+ print(f" ① 결손 {token} ×{count} (PDF {', '.join(map(str, at))}쪽)")
+ for token, (count, at) in sorted(result["num_extra"].items()):
+ print(f" ① 허구 {token} ×{count} (md 몸 {', '.join(map(str, at))}줄)")
+ for spot in result["spots"][:40]:
+ print(
+ f" ② PDF {spot['pdf']}쪽 · md 몸 {spot['md']}줄 · …{spot['context']}"
+ f" [PDF만 「{spot['pdf_only']}」 · md만 「{spot['md_only']}」]"
+ )
+ text = set_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"), "기계검사", line)
+ head = read_head(path)
+ if head.get("상태") == "확정" and line.startswith("통과") and not head.get("확정지문"):
+ text = set_head(text, "확정지문", fingerprint(text))
+ path.write_text(text, encoding="utf-8", newline="\n")
+ return 0 if line.startswith("통과") else 1
+
+
+# ── pages · status ─────────────────────────────────────────────────────
+PAGES_DIR = ROOT / "tmp/pum_pages"
+
+
+def pages(book_name: str, span: str = "") -> list[Path]:
+ """쪽 그림 150dpi PNG — `tmp/pum_pages/<책>/p0135.png`(git 밖)."""
+ import pymupdf
+
+ out = PAGES_DIR / book_name
+ out.mkdir(parents=True, exist_ok=True)
+ made = []
+ with pymupdf.open(BOOKS[book_name]["pdf"]) as doc:
+ first, _, last = (span or f"1~{doc.page_count}").replace("-", "~").partition("~")
+ for number in range(int(first), int(last or first) + 1):
+ path = out / f"p{number:04d}.png"
+ doc[number - 1].get_pixmap(dpi=150).save(path)
+ made.append(path)
+ return made
+
+
+def status() -> str:
+ """장별 상태 표(md) — 머리 「상태」 칸을 모음."""
+ rows = [
+ "| 책 | 장 | " + " | ".join(STATES) + " | 계 |",
+ "|---|---|" + "---|" * (len(STATES) + 1),
+ ]
+ for name, book in BOOKS.items():
+ by_chapter: dict[str, collections.Counter] = {}
+ for path in sorted(book["out"].rglob("*.md")):
+ chapter = path.relative_to(book["out"]).parts[0]
+ 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:
- if len(argv) >= 2 and argv[0] == "plan":
- made = plan(argv[1])
- print(f"새 파일 {len(made)}")
+ 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
diff --git a/resources/tester/test_pum_md_locked.py b/resources/tester/test_pum_md_locked.py
new file mode 100644
index 00000000..e9a07743
--- /dev/null
+++ b/resources/tester/test_pum_md_locked.py
@@ -0,0 +1,42 @@
+"""품셈 md 잠금 — 상태 `확정` 파일은 확정지문이 맞고 기계검사 ①② 가 0 (규칙 6장).
+
+`확정` 뒤 파일이 바뀌면 빨강. 고치려면 상태를 `작성중` 으로 되돌리고 ①②③ 다시.
+"""
+
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
+
+import pum_md_tool as tool # noqa: E402
+
+LOCKED = [
+ path
+ for book in tool.BOOKS.values()
+ for path in sorted(book["out"].rglob("*.md"))
+ if tool.read_head(path).get("상태") == "확정"
+]
+
+
+@pytest.mark.parametrize("path", LOCKED, ids=lambda p: p.name)
+def test_locked_file_unchanged_and_clean(path):
+ head = tool.read_head(path)
+ assert head["확정지문"], "확정인데 지문이 없음 — check 를 돌려 적을 것"
+ assert tool.fingerprint(path.read_text(encoding="utf-8")) == head["확정지문"], (
+ "확정 뒤 파일이 바뀜 — 상태를 작성중으로 되돌리고 ①②③ 다시"
+ )
+ pytest.importorskip("pymupdf")
+ _, line = tool.check_file(path)
+ assert line.startswith("통과"), line
+
+
+def test_heads_are_well_formed():
+ """모든 새 md 머리 — 칸이 다 있고 상태가 넷 중 하나."""
+ for book in tool.BOOKS.values():
+ for path in book["out"].rglob("*.md"):
+ head = tool.read_head(path)
+ assert set(tool.HEAD_KEYS) <= set(head), path
+ assert head["상태"] in tool.STATES, f"{path}: {head['상태']}"
diff --git a/resources/tester/test_pum_md_tool.py b/resources/tester/test_pum_md_tool.py
index c1560815..8832e50f 100644
--- a/resources/tester/test_pum_md_tool.py
+++ b/resources/tester/test_pum_md_tool.py
@@ -78,3 +78,46 @@ def test_forest_headings_follow_page_spacing():
assert tool.find_heading(doc, "제1장", 13)[2] == "제1장 적용기준"
page, row, _ = tool.find_heading(doc, "9-6", 136)
assert (page, row) == (136, 0) # 쪽 맨 위 — 앞 절은 135 쪽에서 끝남
+
+
+def test_md_lines_drop_markup_keep_escaped_text():
+ body = "\n".join(
+ [
+ "## 9-5. 발파암",
+ "",
+ "(단위: ㎥당)",
+ "",
+ "| 구분 | 수량 |",
+ "|---|---|",
+ "| 보통인부 | 0.50 |",
+ r"| a\|b | 1,000
2 |",
+ "",
+ "> 【예시】 10^-7",
+ r"\- 원문 줄표",
+ "",
+ ]
+ )
+ text = " ".join(line for _, line in tool.md_lines(body))
+ assert "#" not in text and "---" not in text and "
" not in text and "병합" not in text
+ assert "a|b" in text # 막음표 뗀 원문 글자
+ assert "- 원문 줄표" in text and "그림" not in text and "pic" not in text
+ assert "10 -7" in text # 첨자 표시 ^ 는 뺌
+
+
+def test_compare_counts_numbers_and_chars_both_ways():
+ pdf = [(135, "보통인부 0.50 1,000"), (135, "비고")]
+ same = tool.compare(pdf, tool.md_lines("| 보통인부 | 0.50 | 1,000 |\n비고"))
+ assert tool.verdict(same).startswith("통과")
+ bad = tool.compare(pdf, [(1, "보통인부 0.5 1000 비고 가")])
+ assert set(bad["num_missing"]) == {"0.50", "1,000"} # 자릿수·쉼표 바꾸면 결손
+ assert set(bad["num_extra"]) == {"0.5", "1000"}
+ assert bad["char_extra"]["가"] == 1 and bad["char_missing"][","] == 1
+ assert tool.verdict(bad).startswith("불통") and bad["spots"]
+
+
+def test_fingerprint_ignores_line_endings_and_tool_fields():
+ text = "---\n상태: 확정\n기계검사: 통과\n확정지문:\n---\n본문\n"
+ same = text.replace("\n", "\r\n").replace("기계검사: 통과", "기계검사: 다시")
+ assert tool.fingerprint(text) == tool.fingerprint(same)
+ assert tool.fingerprint(text) != tool.fingerprint(text.replace("본문", "본문!"))
+ assert "확정지문: abc" in tool.set_head(text, "확정지문", "abc")