# -*- coding: utf-8 -*- """별표/서식 PDF → Markdown 변환. - 표는 find_tables()로 추출해 마크다운 표로, 표 영역 텍스트는 본문에서 제외 - 본문은 법령 번호체계(Ⅰ./1./가./(1)/(가)/1)/가)/①) 기준으로 중첩 리스트화 - PDF 줄바꿈은 꼬리 공백을 신뢰해 그대로 이어붙임 (한글 어절 분리 방지) """ import re, sys, json from pathlib import Path import os as _os from pathlib import Path as _P # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) if v: return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) if m: return m.group(1) return "" import pymupdf ROOT = Path(str(ROOT_DIR)) # ── 마커 정의 (우선순위 순, 같은 종류끼리 같은 깊이) ── MARKERS = [ ("roman", re.compile(r"^([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\.)\s*(.*)$")), ("num", re.compile(r"^(\d{1,2}\.)\s+(.*)$")), ("kor", re.compile(r"^([가-힣]\.)\s+(.*)$")), ("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")), ("pkor", re.compile(r"^(\([가-힣]\))\s*(.*)$")), ("numb", re.compile(r"^(\d{1,2}\))\s*(.*)$")), ("korb", re.compile(r"^([가-힣]\))\s*(.*)$")), ("circle", re.compile(r"^([①-⑳])\s*(.*)$")), ("dash", re.compile(r"^([-‐–ㆍ·○□])\s+(.*)$")), ] HEAD = re.compile(r"^■\s*(.+?)\s*\[(별표|별지)\s*([^\]]*)\]\s*(<[^>]*>)?\s*$") def match_marker(s): for kind, rx in MARKERS: m = rx.match(s) if m: return kind, m.group(1), m.group(2) return None, "", s # ── 페이지 → (요소 리스트) ── def _lines(page): out = [] for blk in page.get_text("dict").get("blocks", []): if blk.get("type") != 0: continue for ln in blk.get("lines", []): txt = "".join(sp.get("text", "") for sp in ln.get("spans", [])) if txt.strip(): out.append((pymupdf.Rect(ln["bbox"]), txt)) return out def _nk(s): return re.sub(r"[^가-힣0-9A-Za-z%]", "", s) def safe(s): return re.sub(r'[\\/:*?"<>|\s]+', "_", s).strip("_") MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘·구분선 등) def _images(page, pno, doc, picdir, stem): """페이지 이미지를 pic/에 저장하고 (rect, ref) 리스트 반환.""" out = [] idx = 0 for im in page.get_images(full=True): xref = im[0] rects = page.get_image_rects(xref) r = rects[0] if rects else pymupdf.Rect(0, 0, 0, 0) try: px = pymupdf.Pixmap(doc, xref) except Exception: continue if px.width < MIN_IMG or px.height < MIN_IMG: continue idx += 1 picdir.mkdir(parents=True, exist_ok=True) fn = f"{stem}_p{pno+1}_{idx}.png" try: if px.n - px.alpha >= 4: # CMYK 등 → RGB px = pymupdf.Pixmap(pymupdf.csRGB, px) px.save(str(picdir / fn)) except Exception: continue out.append((r, f"![그림 {pno+1}-{idx}](<../pic/{fn}>)")) return out def page_elements(page, pno=0, doc=None, picdir=None, stem=""): """세로 순서대로 ('text', y, x, 문자열) / ('table', y, x, md) / ('image', y, x, ref) 반환. 표로 변환되지 않거나 원문을 충분히 담지 못하는 표 영역은 표 취급을 취소하고 본문 텍스트로 되돌린다(내용 손실 방지). """ try: tabs = list(page.find_tables().tables) except Exception: tabs = [] lines = _lines(page) final = [] for t in tabs: md = table_md(t) if not md: continue b = pymupdf.Rect(t.bbox) inside = "".join(_nk(txt) for r, txt in lines if b.contains(pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2))) if not inside: continue got = _nk(md) hit = sum(1 for ch in set(inside) if ch in got) cov = len(_nk(md)) / len(inside) if inside else 0 if cov < 0.90 or hit < len(set(inside)) * 0.95: continue # 표 변환이 원문을 다 못 담음 → 텍스트로 유지 final.append((b, md)) boxes = [b for b, _ in final] items = [] for r, txt in lines: c = pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2) if any(b.contains(c) for b in boxes): continue items.append(("text", r.y0, r.x0, txt)) for b, md in final: items.append(("table", b.y0, b.x0, md)) if doc is not None and picdir is not None: for r, ref in _images(page, pno, doc, picdir, stem): items.append(("image", r.y0, r.x0, ref)) items.sort(key=lambda x: (round(x[1], 1), x[2])) return items def table_md(t): try: rows = t.extract() except Exception: return None rows = [[("" if c is None else str(c).strip()) for c in r] for r in rows] # 세로로 겹쳐진 셀 복원: 비어있지 않은 셀이 모두 같은 줄 수(N>1)면 N행으로 분리 split = [] for r in rows: parts = [c.split("\n") for c in r] cnts = {len(p) for c, p in zip(r, parts) if c} if len(cnts) == 1 and cnts and max(cnts) > 1: n = max(cnts) for i in range(n): split.append([(p[i].strip() if len(p) == n else (r[j] if i == 0 else "")) for j, p in enumerate(parts)]) else: split.append(r) rows = [[re.sub(r"\s+", " ", c).strip() for c in r] for r in split] rows = [r for r in rows if any(c for c in r)] if len(rows) < 2 or len(rows[0]) < 2: return None w = max(len(r) for r in rows) rows = [r + [""] * (w - len(r)) for r in rows] esc = lambda c: c.replace("|", "\\|").replace("\n", "
") out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "|".join(["---"] * w) + "|"] for r in rows[1:]: out.append("| " + " | ".join(esc(c) for c in r) + " |") return "\n".join(out) # ── 변환 본체 ── def convert(pdf_path): doc = pymupdf.open(pdf_path) header, title = "", "" body = [] # (depth, marker, text) | ("TABLE", md) stack = [] # 마커 종류 스택 (index = depth) cur = None # 현재 항목 dict first_lines = [] def flush(): nonlocal cur if cur is not None: body.append(cur) cur = None picdir = Path(pdf_path).parent.parent / "pic" stem = safe(Path(pdf_path).stem)[:40] allel = [] for pno, page in enumerate(doc): for el in page_elements(page, pno, doc, picdir, stem): allel.append((pno, el)) xs = [el[2] for _, el in allel if el[0] == "text"] base_x = min(xs) if xs else 0.0 for pno, el in allel: if True: if el[0] == "table": flush() body.append({"kind": "table", "md": el[3]}) continue if el[0] == "image": flush() body.append({"kind": "image", "ref": el[3]}) continue x0 = el[2] # PyMuPDF는 구조적 줄의 들여쓰기를 텍스트 안에 넣고 x0=좌측여백으로 둔다. # x0가 좌측여백보다 큰 줄 = 앞줄에서 넘어온 줄바꿈 조각. wrap = x0 > base_x + 2.0 raw = el[3] # 꼬리 공백 보존 (한글 줄바꿈 이어붙이기 판단용) s = raw.strip() if not s: continue if pno == 0 and len(first_lines) < 2: m = HEAD.match(s) if m and not header: header = s first_lines.append(s) continue if not title and not m: title = s first_lines.append(s) continue kind, mk, rest = match_marker(s) if kind: if kind in stack: depth = stack.index(kind) del stack[depth + 1:] else: stack.append(kind) depth = len(stack) - 1 flush() cur = {"kind": "item", "depth": depth, "marker": mk, "head": rest.rstrip("\n"), "body": ""} else: if cur is None: cur = {"kind": "item", "depth": 0, "marker": "", "head": "", "body": ""} piece = raw.lstrip().rstrip("\n") # 앞 들여쓰기만 제거, 꼬리 공백 유지 if wrap: # 좌측 여백까지 붙은 줄 = 앞줄의 이어짐 if cur["body"]: cur["body"] += piece else: cur["head"] += piece else: # 들여쓴 줄 = 새 본문 문단 if cur["body"]: cur["body"] += "\n\n" + piece else: cur["body"] = piece flush() # ── 마크다운 조립 ── out = [] out.append(f"# {title or Path(pdf_path).stem}") out.append("") if header: out.append(f"> {header}") out.append(f"> 원본: `{Path(pdf_path).name}` ({len(doc)}쪽)") out.append("") for e in body: if e["kind"] == "table": out += ["", e["md"], ""] continue if e["kind"] == "image": out += ["", e["ref"], ""] continue ind = " " * e["depth"] label = (e["marker"] + " " + e["head"]).strip() if e["marker"] else e["head"] if not label and not e["body"]: continue if e["marker"]: out.append(f"{ind}- {label}") if e["body"]: out.append("") out.append(f"{ind} {e['body']}") out.append("") else: txt = (label + e["body"]).strip() if txt: out.append("") out.append(f"{ind}{txt}" if e["depth"] == 0 else f"{ind} {txt}") out.append("") # 빈 줄 정리 md, blank = [], 0 for l in out: if l.strip() == "": blank += 1 if blank > 1: continue else: blank = 0 md.append(l) return "\n".join(md).strip() + "\n" if __name__ == "__main__": targets = sys.argv[1:] if not targets: targets = [str(p) for p in ROOT.rglob("별표/*.pdf")] + [str(p) for p in ROOT.rglob("첨부/*.pdf")] ok = fail = 0 for f in targets: p = Path(f) try: md = convert(p) p.with_suffix(".md").write_text(md, encoding="utf-8") ok += 1 except Exception as e: print(f" ! {p.name[:60]} :: {type(e).__name__} {str(e)[:70]}") fail += 1 print(f"변환 {ok}건 / 실패 {fail}건")