파일마다 포맷 폭이 달라(≈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>
200 lines
6.2 KiB
Python
200 lines
6.2 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""HWPX(zip+XML) → Markdown. 공백·표·박스·계층 구조를 보존한다.
|
||
|
||
HWP→PDF 변환에서 공백이 소실된 첨부 md를 이 원본에서 재생성하는 용도.
|
||
- 문단 <hp:p>, 텍스트 <hp:t>, 표 <hp:tbl>/<hp:tr>/<hp:tc> 를 문서 순서대로 처리
|
||
- 단일셀 표(글상자) → 인용블록(줄바꿈 보존)
|
||
- 번호체계(제N장 / N-N-N. / 1. / 가. / (1) / ①) → 헤딩·중첩 리스트
|
||
"""
|
||
|
||
import re, sys, zipfile
|
||
from pathlib import Path
|
||
import xml.etree.ElementTree as ET
|
||
|
||
NS = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
|
||
|
||
|
||
def _local(tag):
|
||
return tag.split("}")[-1]
|
||
|
||
|
||
def para_text(p):
|
||
buf = []
|
||
for el in p.iter():
|
||
t = _local(el.tag)
|
||
if t == "t":
|
||
buf.append("".join(el.itertext()))
|
||
elif t == "tab":
|
||
buf.append("\t")
|
||
elif t == "lineBreak":
|
||
buf.append("\n")
|
||
return "".join(buf)
|
||
|
||
|
||
def cell_paras(tc):
|
||
parts = []
|
||
for sub in tc.iter(f"{NS}p"):
|
||
s = para_text(sub).strip()
|
||
if s:
|
||
parts.append(s)
|
||
return parts
|
||
|
||
|
||
def cell_text(tc):
|
||
return " ".join(cell_paras(tc)).replace("|", "/")
|
||
|
||
|
||
def table_md(tbl):
|
||
"""(kind, value) 반환. kind = 'table' | 'box' | 'text'."""
|
||
rows = [tr.findall(f"{NS}tc") for tr in tbl.findall(f"{NS}tr")]
|
||
rows = [r for r in rows if r]
|
||
if not rows:
|
||
return ("text", "")
|
||
# 단일셀(1행 1열) = 글상자 → 줄바꿈 보존 박스
|
||
if len(rows) == 1 and len(rows[0]) == 1:
|
||
return ("box", cell_paras(rows[0][0]))
|
||
txt = [[cell_text(tc) for tc in r] for r in rows]
|
||
if len(txt) < 2 or max(len(r) for r in txt) < 2:
|
||
return ("text", "\n".join(" ".join(r) for r in txt))
|
||
w = max(len(r) for r in txt)
|
||
txt = [r + [""] * (w - len(r)) for r in txt]
|
||
out = ["| " + " | ".join(txt[0]) + " |", "|" + "|".join(["---"] * w) + "|"]
|
||
for r in txt[1:]:
|
||
out.append("| " + " | ".join(c.replace("\n", "<br>") for c in r) + " |")
|
||
return ("table", "\n".join(out))
|
||
|
||
|
||
def walk(container, out):
|
||
for child in container:
|
||
if _local(child.tag) != "p":
|
||
continue
|
||
tbls = child.findall(f".//{NS}tbl")
|
||
if tbls:
|
||
for tb in tbls:
|
||
out.append(table_md(tb))
|
||
else:
|
||
out.append(("text", para_text(child)))
|
||
|
||
|
||
def extract(path):
|
||
z = zipfile.ZipFile(path)
|
||
secs = sorted(n for n in z.namelist() if re.search(r"Contents/section\d+\.xml$", n))
|
||
out = []
|
||
for sec in secs:
|
||
walk(ET.fromstring(z.read(sec)), out)
|
||
return out
|
||
|
||
|
||
# ── 계층 마커: (정규식, 종류) — 리스트 깊이는 등장 순서 스택으로 결정 ──
|
||
CHAP = re.compile(r"^제\d+\s*장(\s|$)")
|
||
SECN = re.compile(r"^\d+-\d+(-\d+)?\.?(\s|$)") # 품셈 절/항 번호 (1-2, 1-2-3.)
|
||
JO = re.compile(r"^제\d+조(의\d+)?\s*\(")
|
||
MARKERS = [
|
||
("num", re.compile(r"^(\d{1,2}\.)\s*(.*)$")),
|
||
("kor", re.compile(r"^([가-힣]\.)\s*(.*)$")),
|
||
("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")),
|
||
("circ", re.compile(r"^([①-⑳])\s*(.*)$")),
|
||
("dash", re.compile(r"^([-∙·○])\s+(.*)$")),
|
||
]
|
||
|
||
|
||
def marker(s):
|
||
for k, rx in MARKERS:
|
||
m = rx.match(s)
|
||
if m:
|
||
return k, m.group(1), m.group(2)
|
||
return None, "", s
|
||
|
||
|
||
def structure(items, header):
|
||
"""(kind, val) 아이템 리스트 → 구조화 md 라인. hwpx/hwp5 공용."""
|
||
lines = list(header)
|
||
stack = [] # 리스트 마커 종류 스택
|
||
for kind, val in items:
|
||
if kind == "table":
|
||
lines += ["", val, ""]
|
||
stack = []
|
||
continue
|
||
if kind == "box":
|
||
# 글상자 → 인용블록, 문단 줄바꿈 보존
|
||
lines.append("")
|
||
for p in val:
|
||
lines.append(f"> {p}")
|
||
lines.append("")
|
||
stack = []
|
||
continue
|
||
st = (val or "").strip()
|
||
if not st:
|
||
continue
|
||
# 헤딩류
|
||
if CHAP.match(st):
|
||
lines += ["", f"## {st}", ""]
|
||
stack = []
|
||
continue
|
||
if SECN.match(st):
|
||
lines += ["", f"### {st}", ""]
|
||
stack = []
|
||
continue
|
||
if JO.match(st):
|
||
m = re.match(r"^(제\d+조(?:의\d+)?\s*\([^)]*\))\s*(.*)$", st, re.S)
|
||
lines += ["", f"### {m.group(1)}", ""]
|
||
if m.group(2).strip():
|
||
lines.append(m.group(2).strip())
|
||
stack = []
|
||
continue
|
||
# 리스트 마커
|
||
k, mk, rest = marker(st)
|
||
if k:
|
||
if k in stack:
|
||
depth = stack.index(k)
|
||
del stack[depth + 1 :]
|
||
else:
|
||
stack.append(k)
|
||
depth = len(stack) - 1
|
||
ind = " " * depth
|
||
lines.append(f"{ind}- {mk} {rest}".rstrip())
|
||
else:
|
||
# 리스트 진행 중이면 현재 깊이의 본문(연속 문단)으로 들여쓰기
|
||
if stack:
|
||
lines.append(" " * len(stack) + st)
|
||
else:
|
||
lines.append(st)
|
||
# 빈 줄 정리
|
||
md, blank = [], 0
|
||
for l in lines:
|
||
if l.strip() == "":
|
||
blank += 1
|
||
if blank > 1:
|
||
continue
|
||
else:
|
||
blank = 0
|
||
md.append(l)
|
||
out = "\n".join(md).strip() + "\n"
|
||
# HWP 사설영역·서로게이트 등 UTF-8 인코딩 불가 문자 제거(깨진 글리프)
|
||
out = "".join(c for c in out if not ("\ud800" <= c <= "\udfff"))
|
||
out = out.encode("utf-8", "ignore").decode("utf-8")
|
||
return out
|
||
|
||
|
||
def to_md(path):
|
||
items = extract(path)
|
||
header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWPX 재추출)", ""]
|
||
return structure(items, header)
|
||
|
||
|
||
def hwp5_to_md(path, header=None):
|
||
"""구형 HWP5(OLE) → 구조화 md. 표를 복원(extract_items)해 계층·표 보존."""
|
||
import hwp5_text
|
||
|
||
items = hwp5_text.extract_items(str(path))
|
||
if header is None:
|
||
header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWP 재추출)", ""]
|
||
return structure(items, header)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
for f in sys.argv[1:]:
|
||
p = Path(f)
|
||
p.with_suffix(".md").write_text(to_md(p), encoding="utf-8")
|
||
print(f"{p.name} → 재추출")
|