From af465080e722fab09afe09f8a3681c584c557596 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 19:53:26 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(512-3=20master=5Fformula):=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EB=AC=B6=EC=9D=8C=EC=9D=84=20master=5Ffor?= =?UTF-8?q?mula=5Fcheck.py=20=EB=A1=9C=20=EB=82=98=EB=88=94=20=E2=80=94=20?= =?UTF-8?q?880=20=E2=86=92=20686=20=EC=A4=84=20=C2=B7=20=EB=B0=96=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9D=80=20master=5Fformula=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../master_data/scripts/master_formula.py | 210 +--------------- .../scripts/master_formula_check.py | 227 ++++++++++++++++++ 2 files changed, 235 insertions(+), 202 deletions(-) create mode 100644 resources/master_data/scripts/master_formula_check.py diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 56b7e5a34..4fdda195a 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -660,208 +660,6 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)} -# ── 검사 ────────────────────────────────────────────────────────────── -def _choices(row: dict, name: str, master: Master): - """이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None.""" - for spec in row.get("입력", []): - if spec["이름"] == name: - return spec.get("고르기") - for step in row.get("중간", []): - if step["이름"] == name: - node = parse(step["식"]) - if node[0] == "field" and node[1][0] == "ref": - out = set() - for ref in _expand(row, node[1][1], master) or []: - try: - out.add(str(master.get(ref)["값"][node[2]])) - except (FormulaError, KeyError, TypeError): - pass - return sorted(out) - return None - - -def _expand(row: dict, template: str, master: Master): - """원문번호 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None.""" - holes = _HOLE.findall(template) - options = [_choices(row, h, master) for h in holes] - if any(o is None for o in options): - return None - return [ - fill(template, {h: v for h, v in zip(holes, combo)}) - for combo in itertools.product(*options) - ] - - -def _check_node(row, node, known: set, master: Master, where: str, out: list, calls: set): - for part in walk(node): - kind = part[0] - if kind == "name" and part[1] not in known: - out.append(f"{where} · 모르는 이름 「{part[1]}」") - if kind in ("ref", "find", "logic"): - for hole in _HOLE.findall(part[1]): - if hole not in known: - out.append(f"{where} · 원문번호 {{{hole}}} 가 입력·중간 아님") - if kind == "ref": - refs = _expand(row, part[1], master) or [] - for ref in refs: - try: - link = master.get(ref).get("연결") - except FormulaError as e: - out.append(f"{where} · {e}") - continue - if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known: - out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{REGION}」 필요") - if ( - isinstance(link, dict) - and link.get("계약종별") == "입력" - and TARIFF not in known - ): - out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{TARIFF}」 필요") - if kind == "field" and part[1][0] == "ref": - for ref in _expand(row, part[1][1], master) or []: - try: - value = master.get(ref).get("값") - except FormulaError: - continue - if not isinstance(value, dict) or part[2] not in value: - out.append(f"{where} · 「{master.label(ref)}」 에 칸 「{part[2]}」 없음") - if kind == "find": - if mk.id_of(part[1]) not in TABLE_IDS: - out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님") - continue - try: - table = master.get(part[1]) - except FormulaError as e: - out.append(f"{where} · {e}") - continue - for cond, _ in part[2]: - if cond not in table.get("조건", {}): - out.append(f"{where} · 「{master.label(part[1])}」 에 조건 「{cond}」 없음") - if kind == "field" and part[1][0] == "find": - try: - table = master.get(part[1][1]) - except FormulaError: - continue - if part[2] not in table.get("값칸", {}): - out.append(f"{where} · 「{master.label(part[1][1])}」 에 값 칸 「{part[2]}」 없음") - if kind == "logic": - calls.add(part[1]) - try: - target = master.logic(part[1]) - except FormulaError as e: - out.append(f"{where} · {e}") - continue - names = {s["이름"] for s in target.get("입력", [])} - given = {k for k, _ in part[2]} - for extra in sorted(given - names): - out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음") - for lack in sorted(names - given): - out.append(f"{where} · 「{master.label(part[1])}」 입력 「{lack}」 안 줌") - if kind == "field" and part[1][0] == "logic": - try: - target = master.logic(part[1][1]) - except FormulaError: - continue - if "결과" in target: - out.append( - f"{where} · 돈 아닌 로직 「{master.label(part[1][1])}」 은 수 하나 — 칸 「{part[2]}」 못 씀" - ) - elif part[2] not in BUNDLE: - out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함") - - -def check_logic(master: Master, row: dict) -> tuple[list[str], set]: - """(문제 목록, 부르는 로직 키) — 변수 · 찾기 조건 · 로직 입력.""" - out, calls, key = [], set(), f"{row.get('키')} {row.get('원문번호')}" - known = {s["이름"] for s in row.get("입력", [])} - - def one(text, where, names): - try: - _check_node(row, parse(text), names, master, where, out, calls) - except FormulaError as e: - out.append(f"{where} · 식 못 읽음: {e}") - - for step in row.get("중간", []): - one(step["식"], f"{key} 중간 {step['이름']}", known) - known = known | {step["이름"]} - if "결과" in row: - one(row["결과"]["식"], f"{key} 결과", known) - for item in row.get("호표", []): - where = f"{key} 호표 {item.get('이름', '')}" - one(item["수량"], where + " 수량", known) - if item.get("종류") not in ("인력", "재료", "기계", "로직"): - out.append(f"{where} · 종류 「{item.get('종류')}」") - if item.get("비목") not in (*COST_ITEMS, None) or ( - item.get("종류") != "로직" and "비목" not in item - ): - out.append(f"{where} · 비목 「{item.get('비목')}」") - if isinstance(item["요소"], dict): # 재료 고르기 조건 - if item.get("종류") != "재료": - out.append(f"{where} · 고르기 조건은 종류 「재료」 만") - # 조건 칸의 {이름} 은 계산 때 채워짐 — 그 이름이 실제 입력·중간인지만 봄 - for value in item["요소"].values(): - for hole in _HOLE.findall(value) if isinstance(value, str) else []: - if hole not in known: - out.append(f"{where} 요소 · 고르기 조건 {{{hole}}} 가 입력·중간 아님") - out += check_pick(master, where + " 요소", item["요소"]) - continue - one(item["요소"], where + " 요소", known) - group = mk.GROUP_OF.get(mk.id_of(str(item["요소"]))) - if item.get("종류") != "로직" and group != item.get("종류"): - out.append(f"{where} · 요소 그룹이 종류와 다름") - names = known | set(COST_ITEMS) | {"줄"} # 「줄」 = 호표 줄 금액 묶음(덧줄에서만) - for extra in row.get("덧줄", []): - one(extra["식"], f"{key} 덧줄 {extra['이름']}", names) - names = names | {extra["이름"]} - if extra.get("비목") not in COST_ITEMS: - out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」") - out += _check_cut(row, key) - return out, calls - - -def _check_cut(row: dict, key: str) -> list[str]: - """끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함).""" - rule = row.get("끝수") - if rule is None or isinstance(rule, str): - return [] - if not isinstance(rule, dict): - return [f"{key} 끝수 · 묶음도 글자도 아님"] - out = [] - unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"} - if unknown: - out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}") - on = rule.get("대상") - if on not in ROUND_ON: - out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}") - elif ("결과" in row) != (on == "결과"): - out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음") - if rule.get("방법") not in ROUND_WAYS: - out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}") - place = rule.get("자리", 0) - if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6: - out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수") - return out - - -def find_loops(graph: dict[str, set]) -> list[list[str]]: - """로직 부르기의 돌고 도는 길.""" - loops, state = [], {} - - def visit(node, path): - state[node] = 1 - for nxt in sorted(graph.get(node, ())): - if state.get(nxt) == 1: - loops.append(path[path.index(nxt) :] + [nxt]) - elif nxt not in state: - visit(nxt, path + [nxt]) - state[node] = 2 - - for node in sorted(graph): - if node not in state: - visit(node, [node]) - return loops - - # 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음 from master_material import ( # noqa: E402, F401 LINKED, @@ -878,3 +676,11 @@ from master_material import ( # noqa: E402, F401 pick, ) import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위 +from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로 + _check_cut, + _check_node, + _choices, + _expand, + check_logic, + find_loops, +) diff --git a/resources/master_data/scripts/master_formula_check.py b/resources/master_data/scripts/master_formula_check.py new file mode 100644 index 000000000..25aec63f8 --- /dev/null +++ b/resources/master_data/scripts/master_formula_check.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +"""마스터 로직 식 — 검사 (`master_formula` 에서 나눔 · 밖에서는 `master_formula` 로 부름). + +`master_formula` 끝에서만 읽힘 — 먼저 읽으면 서로 부르다 막힘. +""" + +from __future__ import annotations + +import itertools +from decimal import Decimal + +import master_keys as mk +from master_formula import ( + _HOLE, + BUNDLE, + COST_ITEMS, + ROUND_ON, + ROUND_WAYS, + TABLE_IDS, + FormulaError, + fill, + parse, + walk, +) +from master_material import REGION, TARIFF, check_pick + + +# ── 검사 ────────────────────────────────────────────────────────────── +def _choices(row: dict, name: str, master: Master): + """이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None.""" + for spec in row.get("입력", []): + if spec["이름"] == name: + return spec.get("고르기") + for step in row.get("중간", []): + if step["이름"] == name: + node = parse(step["식"]) + if node[0] == "field" and node[1][0] == "ref": + out = set() + for ref in _expand(row, node[1][1], master) or []: + try: + out.add(str(master.get(ref)["값"][node[2]])) + except (FormulaError, KeyError, TypeError): + pass + return sorted(out) + return None + + +def _expand(row: dict, template: str, master: Master): + """원문번호 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None.""" + holes = _HOLE.findall(template) + options = [_choices(row, h, master) for h in holes] + if any(o is None for o in options): + return None + return [ + fill(template, {h: v for h, v in zip(holes, combo)}) + for combo in itertools.product(*options) + ] + + +def _check_node(row, node, known: set, master: Master, where: str, out: list, calls: set): + for part in walk(node): + kind = part[0] + if kind == "name" and part[1] not in known: + out.append(f"{where} · 모르는 이름 「{part[1]}」") + if kind in ("ref", "find", "logic"): + for hole in _HOLE.findall(part[1]): + if hole not in known: + out.append(f"{where} · 원문번호 {{{hole}}} 가 입력·중간 아님") + if kind == "ref": + refs = _expand(row, part[1], master) or [] + for ref in refs: + try: + link = master.get(ref).get("연결") + except FormulaError as e: + out.append(f"{where} · {e}") + continue + if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known: + out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{REGION}」 필요") + if ( + isinstance(link, dict) + and link.get("계약종별") == "입력" + and TARIFF not in known + ): + out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{TARIFF}」 필요") + if kind == "field" and part[1][0] == "ref": + for ref in _expand(row, part[1][1], master) or []: + try: + value = master.get(ref).get("값") + except FormulaError: + continue + if not isinstance(value, dict) or part[2] not in value: + out.append(f"{where} · 「{master.label(ref)}」 에 칸 「{part[2]}」 없음") + if kind == "find": + if mk.id_of(part[1]) not in TABLE_IDS: + out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님") + continue + try: + table = master.get(part[1]) + except FormulaError as e: + out.append(f"{where} · {e}") + continue + for cond, _ in part[2]: + if cond not in table.get("조건", {}): + out.append(f"{where} · 「{master.label(part[1])}」 에 조건 「{cond}」 없음") + if kind == "field" and part[1][0] == "find": + try: + table = master.get(part[1][1]) + except FormulaError: + continue + if part[2] not in table.get("값칸", {}): + out.append(f"{where} · 「{master.label(part[1][1])}」 에 값 칸 「{part[2]}」 없음") + if kind == "logic": + calls.add(part[1]) + try: + target = master.logic(part[1]) + except FormulaError as e: + out.append(f"{where} · {e}") + continue + names = {s["이름"] for s in target.get("입력", [])} + given = {k for k, _ in part[2]} + for extra in sorted(given - names): + out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음") + for lack in sorted(names - given): + out.append(f"{where} · 「{master.label(part[1])}」 입력 「{lack}」 안 줌") + if kind == "field" and part[1][0] == "logic": + try: + target = master.logic(part[1][1]) + except FormulaError: + continue + if "결과" in target: + out.append( + f"{where} · 돈 아닌 로직 「{master.label(part[1][1])}」 은 수 하나 — 칸 「{part[2]}」 못 씀" + ) + elif part[2] not in BUNDLE: + out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함") + + +def check_logic(master: Master, row: dict) -> tuple[list[str], set]: + """(문제 목록, 부르는 로직 키) — 변수 · 찾기 조건 · 로직 입력.""" + out, calls, key = [], set(), f"{row.get('키')} {row.get('원문번호')}" + known = {s["이름"] for s in row.get("입력", [])} + + def one(text, where, names): + try: + _check_node(row, parse(text), names, master, where, out, calls) + except FormulaError as e: + out.append(f"{where} · 식 못 읽음: {e}") + + for step in row.get("중간", []): + one(step["식"], f"{key} 중간 {step['이름']}", known) + known = known | {step["이름"]} + if "결과" in row: + one(row["결과"]["식"], f"{key} 결과", known) + for item in row.get("호표", []): + where = f"{key} 호표 {item.get('이름', '')}" + one(item["수량"], where + " 수량", known) + if item.get("종류") not in ("인력", "재료", "기계", "로직"): + out.append(f"{where} · 종류 「{item.get('종류')}」") + if item.get("비목") not in (*COST_ITEMS, None) or ( + item.get("종류") != "로직" and "비목" not in item + ): + out.append(f"{where} · 비목 「{item.get('비목')}」") + if isinstance(item["요소"], dict): # 재료 고르기 조건 + if item.get("종류") != "재료": + out.append(f"{where} · 고르기 조건은 종류 「재료」 만") + # 조건 칸의 {이름} 은 계산 때 채워짐 — 그 이름이 실제 입력·중간인지만 봄 + for value in item["요소"].values(): + for hole in _HOLE.findall(value) if isinstance(value, str) else []: + if hole not in known: + out.append(f"{where} 요소 · 고르기 조건 {{{hole}}} 가 입력·중간 아님") + out += check_pick(master, where + " 요소", item["요소"]) + continue + one(item["요소"], where + " 요소", known) + group = mk.GROUP_OF.get(mk.id_of(str(item["요소"]))) + if item.get("종류") != "로직" and group != item.get("종류"): + out.append(f"{where} · 요소 그룹이 종류와 다름") + names = known | set(COST_ITEMS) | {"줄"} # 「줄」 = 호표 줄 금액 묶음(덧줄에서만) + for extra in row.get("덧줄", []): + one(extra["식"], f"{key} 덧줄 {extra['이름']}", names) + names = names | {extra["이름"]} + if extra.get("비목") not in COST_ITEMS: + out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」") + out += _check_cut(row, key) + return out, calls + + +def _check_cut(row: dict, key: str) -> list[str]: + """끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함).""" + rule = row.get("끝수") + if rule is None or isinstance(rule, str): + return [] + if not isinstance(rule, dict): + return [f"{key} 끝수 · 묶음도 글자도 아님"] + out = [] + unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"} + if unknown: + out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}") + on = rule.get("대상") + if on not in ROUND_ON: + out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}") + elif ("결과" in row) != (on == "결과"): + out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음") + if rule.get("방법") not in ROUND_WAYS: + out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}") + place = rule.get("자리", 0) + if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6: + out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수") + return out + + +def find_loops(graph: dict[str, set]) -> list[list[str]]: + """로직 부르기의 돌고 도는 길.""" + loops, state = [], {} + + def visit(node, path): + state[node] = 1 + for nxt in sorted(graph.get(node, ())): + if state.get(nxt) == 1: + loops.append(path[path.index(nxt) :] + [nxt]) + elif nxt not in state: + visit(nxt, path + [nxt]) + state[node] = 2 + + for node in sorted(graph): + if node not in state: + visit(node, [node]) + return loops From e81e2cab9f0fe3303ebbd7e4729969845eeb304b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 19:54:20 +0900 Subject: [PATCH 2/3] =?UTF-8?q?knowledge(512-3=20gen=5Fmd):=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=95=AD=EB=AA=A9=20ENTRIES=20=EB=A5=BC=20gen=5Fmd?= =?UTF-8?q?=5Fentries.py=20=EB=A1=9C=20=EB=82=98=EB=88=94=20=E2=80=94=2079?= =?UTF-8?q?1=20=E2=86=92=20517=20=EC=A4=84=20=C2=B7=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=20=EC=9E=85=EA=B5=AC=20gen=5Fmd.py=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EC=83=89=EC=9D=B8=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EC=A0=84=ED=9B=84=20=EA=B0=99=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../knowledge/original/_pipeline/gen_md.py | 276 +---------------- .../original/_pipeline/gen_md_entries.py | 278 ++++++++++++++++++ 2 files changed, 279 insertions(+), 275 deletions(-) create mode 100644 resources/knowledge/original/_pipeline/gen_md_entries.py diff --git a/resources/knowledge/original/_pipeline/gen_md.py b/resources/knowledge/original/_pipeline/gen_md.py index ef15eee5d..c512929d9 100644 --- a/resources/knowledge/original/_pipeline/gen_md.py +++ b/resources/knowledge/original/_pipeline/gen_md.py @@ -45,281 +45,7 @@ def norm(s): return re.sub(r"[\s\u00b7\u2027]", "", s) -ENTRIES = [ - ( - "법률", - "산림청", - "산림자원의 조성 및 관리에 관한 법률", - "약칭 산림자원법", - [r"산림자원의조성및관리에관한법률(?!시행)", r"산림자원법(?!시행)"], - ), - ( - "법률", - "산림청", - "산림자원의 조성 및 관리에 관한 법률 시행령", - "", - [r"산림자원의조성및관리에관한법률시행령", r"산림자원법시행령"], - ), - ( - "법률", - "산림청", - "산림자원의 조성 및 관리에 관한 법률 시행규칙", - "별표1 타당성평가·별표2 시설기준 포함", - [r"산림자원의조성및관리에관한법률시행규칙", r"산림자원법시행규칙"], - ), - ( - "법률", - "산림청", - "산림기술 진흥 및 관리에 관한 법률", - "약칭 산림기술법", - [r"산림기술진흥및관리에관한법률(?!시행)", r"산림기술법(?!시행)"], - ), - ( - "법률", - "산림청", - "산림기술 진흥 및 관리에 관한 법률 시행령", - "", - [r"산림기술진흥및관리에관한법률시행령", r"산림기술법시행령"], - ), - ("법률", "산림청", "산림보호법", "", [r"산림보호법"]), - ("법률", "산림청", "산지관리법", "", [r"산지관리법"]), - ("법률", "환경부", "자연환경보전법", "시행령·시행규칙 포함", [r"자연환경보전법"]), - ("법률", "행정안전부", "자연재해대책법", "시행령·시행규칙 포함", [r"자연재해대책법"]), - ("법률", "환경부", "환경영향평가법", "시행령·시행규칙 포함", [r"환경영향평가법"]), - ( - "법률", - "산업통상자원부", - "국가균형발전특별법", - "시행령·시행규칙 포함", - [r"국가균형발전특별법"], - ), - ( - "법률", - "기획재정부", - "보조금 관리에 관한 법률", - "시행령·시행규칙 포함", - [r"보조금관리에관한법률", r"보조금에관한법률"], - ), - ( - "법률", - "기획재정부", - "국가를 당사자로 하는 계약에 관한 법률", - "약칭 국가계약법", - [r"국가를당사자로하는계약에관한법률(?!시행)", r"국가계약법(?!시행|률)", r"국가계약법률"], - ), - ( - "법률", - "기획재정부", - "국가를 당사자로 하는 계약에 관한 법률 시행령", - "", - [r"국가를당사자로하는계약에관한법률시행령", r"국가계약법시행령"], - ), - ( - "법률", - "행정안전부", - "지방자치단체를 당사자로 하는 계약에 관한 법률", - "약칭 지방계약법", - [r"지방자치단체를당사자로하는계약에관한법률(?!시행)", r"지방계약법(?!시행)"], - ), - ( - "법률", - "행정안전부", - "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", - "", - [r"지방자치단체를당사자로하는계약에관한법률시행령", r"지방계약법시행령"], - ), - ("법률", "국토교통부", "도로법", "", [r"(? Date: Thu, 22 Oct 2026 19:57:46 +0900 Subject: [PATCH 3/3] =?UTF-8?q?knowledge(512-3=20pum=5Fmd=5Ftool):=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=EC=9D=BD=EA=B8=B0=C2=B7=ED=8B=80=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=3D=20pum=5Fmd=5Fplan.py=20=C2=B7?= =?UTF-8?q?=20=EA=B8=B0=EA=B3=84=EA=B2=80=EC=82=AC=20=3D=20pum=5Fmd=5Fchec?= =?UTF-8?q?k.py=20=EB=A1=9C=20=EB=82=98=EB=88=94=20=E2=80=94=20766=20?= =?UTF-8?q?=E2=86=92=20164=20=EC=A4=84=20=C2=B7=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=EC=A4=84=20=EC=9E=85=EA=B5=AC=C2=B7=EB=B0=96=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=80=20pum=5Fmd=5Ftool=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EB=8F=84=EC=9B=80=EB=A7=90=C2=B7status?= =?UTF-8?q?=C2=B7check=20=EC=B6=9C=EB=A0=A5=20=EC=A0=84=ED=9B=84=20?= =?UTF-8?q?=EA=B0=99=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../original/_pipeline/pum_md_check.py | 411 ++++++++++ .../original/_pipeline/pum_md_plan.py | 307 ++++++++ .../original/_pipeline/pum_md_tool.py | 734 ++---------------- 3 files changed, 784 insertions(+), 668 deletions(-) create mode 100644 resources/knowledge/original/_pipeline/pum_md_check.py create mode 100644 resources/knowledge/original/_pipeline/pum_md_plan.py 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"