Files
Aislo/resources/knowledge/original/_pipeline/pdf2md.py
T
eomsangdonandClaude Opus 5 4cb9b15939 style: 저장소 전체 포맷터 일괄 적용 (prettier·biome·ruff)
파일마다 포맷 폭이 달라(≈80 대 100) 한 줄만 고쳐도 포맷터가 무관한 줄을 대량
재포맷했음. 사용자 지시로 전체를 한 번에 맞춤. 코드 동작 변경 없음 — 포맷만.

- 프론트엔드 `.ts/.css/.html` → 저장소 prettier (`.prettierrc`, printWidth 100)
- `B07_DesignDetail/openwebcad/**` → 자체 biome (tab 들여쓰기·single quote·lineWidth 100).
  `biome format` 만 사용 — `biome lint --write` 는 포맷 아닌 코드 수정까지 하므로 제외
- 파이썬 → `ruff format` (엔진 코드는 이미 정합, resources·scratch 스크립트 24개만 변경)

두 포맷터가 서로 되돌리지 않도록 `.prettierignore` 신규 — openwebcad 와 빌드·산출물
폴더를 prettier 대상에서 뺌. `.prettierrc` 에 `endOfLine: "auto"` 추가 — 기본값 `lf` 가
`core.autocrlf=true` 로 받은 CRLF 파일을 매번 전부 다시 써서 `--list-different` 가
실제 포맷 차이를 가리고 있었음.

검증: `tsc --noEmit` 통과(루트·openwebcad 둘 다), pytest 349 passed / 17 skipped /
0 failed, CAD vitest 87건 중 81 passed / 6 failed(laptop-sub 기준선과 동일, 회귀 없음).
포맷터 재실행 시 prettier·biome 모두 변경 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:08:24 +09:00

341 lines
11 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):
"""페이지 이미지를 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", "<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}건")