Files
Aislo/resources/knowledge/original/_pipeline/pdf2md.py
T
eomsangdonandClaude Opus 5 6fe7004946 knowledge(교본): 산림과임업기술(임도) 그림 파일명 의미화
페이지 번호식 이름(`..._p101_1.png`)을 내용이 드러나는 이름으로 바꿨다.
예: `노선측량_그림5-2-27_중심선과영선의위치비교.png`

- 그림 120개 이름 변경(122개 신규 등록), 본문 md 12개의 링크를 새 이름으로.
- 추출 파이프라인(`_pipeline/pdf2md.py`)과 QC 리포트 갱신.

확인: 그림 127개 · md 이미지 링크 127개 · 깨진 링크 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 16:17:10 +09:00

347 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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):
"""PDF 화면에 보이는 이미지 영역을 렌더링하고 (rect, ref) 리스트 반환.
원시 xref를 직접 저장하면 이미지 마스크가 검게 나오거나 이미지 위에 별도로
그려진 문자·선이 빠질 수 있다. 페이지의 실제 배치 사각형을 렌더링하여 화면과
같은 합성 결과를 보존한다.
"""
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)
if r.is_empty or r.is_infinite:
continue
try:
source = pymupdf.Pixmap(doc, xref)
except Exception:
continue
if source.width < MIN_IMG or source.height < MIN_IMG:
continue
idx += 1
picdir.mkdir(parents=True, exist_ok=True)
fn = f"{stem}_p{pno + 1}_{idx}.png"
try:
px = page.get_pixmap(matrix=pymupdf.Matrix(2, 2), clip=r, alpha=False)
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", "<br>")
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}건")