파일마다 포맷 폭이 달라(≈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>
341 lines
11 KiB
Python
341 lines
11 KiB
Python
# -*- 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""))
|
||
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}건")
|