From 56343d80881d164ff555d2b13623e611dc3d6663 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 19 Sep 2026 01:27:15 +0900 Subject: [PATCH] =?UTF-8?q?knowledge(=ED=92=88=EC=85=88md):=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20check=20=C2=B7=20pages=20=C2=B7=20status=20+=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check: ① 수(갯수까지) ② 글자(공백 뗌) 양방향 대조 · 어긋난 자리를 쪽·줄로 출력 · 머리 기계검사 칸에 적음 - md 쪽은 머리 · 주석 · 그림 · 표 문법 · 제목·인용 표시 · 첨자 표시를 빼고, 막음표(\) 뒤 글자는 원문으로 셈 - 확정 파일은 통과할 때 확정지문(LF 로 센 sha256)을 한 번만 적음 — 있는 지문은 안 덮음 - pages: 쪽 그림 150dpi → tmp/pum_pages(git 밖) · status: 장별 상태 표 - test_pum_md_locked.py: 확정 파일 지문·①② 0 · 머리 칸 다 있음 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- .../original/_pipeline/pum_md_tool.py | 238 +++++++++++++++++- resources/tester/test_pum_md_locked.py | 42 ++++ resources/tester/test_pum_md_tool.py | 43 ++++ 3 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_pum_md_locked.py 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"\- 원문 줄표", + "![그림 3](pic/9-05_1.png)", + ] + ) + 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")