Files
Aislo/resources/knowledge/original/_pipeline/pdf2md.py
T
eomsangdonandClaude Fable 5 81cd7e23c3 feat(knowledge): 도메인 지식저장소 메인 통합 + resources 그룹 체계 재편
knowledge (구 Aislo-law 독립 저장소 → resources/knowledge 이관, 저장소 폐지):
- 법령·행정규칙·표준시방서·교본 원문 + 기술문서 55건 + 실무 분석·종합비교
- 루트 지침 체계: README(지도)·00_운영지침·01_수집지침·02_분석지침·
  03_미결_및_확인사항(교본 충돌 리스트 포함)·04_참조_법령기준_목록
- 기술문서 55건 원문 전수 검증 완료 (사방 설계홍수량 법정 기준 등 반영)
- 정리: CAD·오피스 잔재 142건, 중복 zip 7건(413MB), 빈 폴더 30개 제거

resources 그룹 재편 (이름순 그룹핑):
- app_branding(구 prog_icon.jpg)·app_policies(구 legal)·
  data_global_contours(구 grobal_contours)·data_rainfall_idf_cache(구 wamis_contours)·
  template_2dDrawing(구 dwg_analysis/templete — 오타 교정, 상수·경로 동기화)
- dwg_analysis(분석 완료 1.8GB)·templates(빈 폴더)·templete_calc_cost.xlsx 삭제
- 참조 코드 5파일 경로 수정 + 프론트 재빌드 (구 경로 잔존 0)
- .gitignore: resources 추적 전환, national_contours.gpkg(22GB) 영구 제외
- .env: knowledge 수집용 API 정보 주석 통합 (KCSC·법령센터·조달청 제비율)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:14:32 +09:00

309 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}건")