# -*- 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