Files
Aislo/resources/knowledge/original/_pipeline/hwpx_text.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

200 lines
6.2 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 -*-
"""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} → 재추출")