# -*- coding: utf-8 -*- """HWPX(zip+XML) → Markdown. 공백·표·박스·계층 구조를 보존한다. HWP→PDF 변환에서 공백이 소실된 첨부 md를 이 원본에서 재생성하는 용도. - 문단 , 텍스트 , 표 // 를 문서 순서대로 처리 - 단일셀 표(글상자) → 인용블록(줄바꿈 보존) - 번호체계(제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", "
") 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} → 재추출")