From d0994a1bce59347ac1725d678638e5dc55711e0b Mon Sep 17 00:00:00 2001 From: hyungi Date: Tue, 9 Jun 2026 04:19:37 +0000 Subject: [PATCH] =?UTF-8?q?fix(markdown):=20hwp=20=EB=B3=80=ED=99=98=20lib?= =?UTF-8?q?hwplo=E2=86=92pyhwp=20=EA=B5=90=EC=B2=B4=20+=20xml=20=ED=94=84?= =?UTF-8?q?=EB=A1=A4=EB=A1=9C=EA=B7=B8=20strip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LibreOffice 번들 libhwplo 필터가 실제 한컴 HWP5 binary 를 못 읽어(rc=0 + "source file could not be loaded") HWP 전건 실패(0/4). 순수 Python HWP5 전용 변환기 pyhwp(CLI hwp5html)로 교체. - office_md.py: .hwp → _via_pyhwp_html(hwp5html→index.xhtml→markdownify). hwp5html xhtml 의 선언이 markdownify PI 파싱으로 md 본문에 새고, ~34자가 _MIN_BODY_CHARS(16) 빈출력 게이트를 무력화(빈 변환 false-success, 모듈 불변식 위반) → markdownify 전 프롤로그 re.sub strip. - .hwpx 는 pyhwp 미지원 → LibreOffice 폴백 유지. - marker_worker.py: 엔진 라벨 .hwp→pyhwp / .hwpx→libreoffice_hwp / else→markitdown. - requirements.txt: pyhwp + six(pyhwp 미선언 런타임 의존성). 검증: HWP5 4건(용접 WPS/PQR·산업안전기사 1·2과목·원칙요약) 4/4 success, 한글 무결·표 GFM 보존·xml 아티팩트 0. 기존 포맷 경로(docx/xlsx/pptx·pdf· passthrough·hwpx) 회귀 없음(적대 리뷰 2렌즈 확인). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/requirements.txt | 6 +++- app/workers/marker_worker.py | 9 ++++-- app/workers/office_md.py | 61 +++++++++++++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/app/requirements.txt b/app/requirements.txt index 3ccc782..8451495 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -21,6 +21,10 @@ pymupdf>=1.24.0 trafilatura>=1.12.0 readability-lxml>=0.8.1 markdownify>=0.13.1 -# office OOXML(docx/xlsx/pptx) → md (plan ds-s1-backend-1 C-1). hwp 는 LibreOffice+markdownify 경로. +# office OOXML(docx/xlsx/pptx) → md (plan ds-s1-backend-1 C-1). # 정확한 핀은 E-1 markitdown OOXML PoC(devsbx/버전핀 컨텍스트)에서 확정. markitdown[docx,xlsx,pptx]>=0.1.0 +# .hwp(HWP5 binary) → md: 순수 Python HWP5 전용 변환기(CLI hwp5html). LibreOffice 번들 libhwplo +# 필터가 실제 한컴 HWP5 를 못 읽어 전건 실패 → pyhwp 로 교체(2026-06-09). six = pyhwp 의 미선언 런타임 의존성. +pyhwp>=0.1b15 +six>=1.16.0 diff --git a/app/workers/marker_worker.py b/app/workers/marker_worker.py index c9b5634..60cb273 100644 --- a/app/workers/marker_worker.py +++ b/app/workers/marker_worker.py @@ -396,8 +396,13 @@ async def _process_office( """ from workers.office_md import OfficeMdError, convert_office_to_md - is_hwp = Path(container_path).suffix.lower() in (".hwp", ".hwpx") - engine = "libreoffice_hwp" if is_hwp else "markitdown" + suffix = Path(container_path).suffix.lower() + if suffix == ".hwp": + engine = "pyhwp" # HWP5 binary: libhwplo 못 읽어 pyhwp 로 교체(2026-06-09) + elif suffix == ".hwpx": + engine = "libreoffice_hwp" # HWPX 는 pyhwp 미지원 → LibreOffice 폴백 + else: + engine = "markitdown" try: # 동기 subprocess(LibreOffice)/markitdown — 스레드로 빼서 이벤트 루프 비차단. md_content = await asyncio.to_thread(convert_office_to_md, container_path) diff --git a/app/workers/office_md.py b/app/workers/office_md.py index 32c5728..4345955 100644 --- a/app/workers/office_md.py +++ b/app/workers/office_md.py @@ -5,9 +5,11 @@ 전략 (하이브리드): - OOXML(.docx/.xlsx/.pptx) → markitdown ← 신규 의존성(pip install markitdown). lazy import. - - .hwp/.hwpx → LibreOffice(headless) → HTML → markdownify ← markdownify 기존 의존성. - (LibreOffice 가 hwp import 필터 보유. .hwpx 는 .hwp 와 다른 필터·버전 의존 → E-1: prod LibreOffice - 버전핀 안전컨텍스트에서 PoC 실행. 표 fidelity 가 진짜 리스크 — 하니스가 측정.) + - .hwp(HWP5 binary) → pyhwp hwp5html → HTML → markdownify ← pyhwp+six 의존성. + (2026-06-09: LibreOffice 번들 libhwplo 필터가 실제 한컴 HWP5 파일을 못 읽어 rc=0 + 'source file + could not be loaded' 로 전건 실패 → 순수 Python HWP5 전용 변환기 pyhwp 로 교체.) + - .hwpx → LibreOffice(headless) → HTML → markdownify ← markdownify 기존 의존성. + (HWPX(zip)는 pyhwp 미지원 → LibreOffice 폴백 유지. 현재 코퍼스는 전부 HWP5 binary.) 실패 계약 (C-5 postcondition 의 backend 절반): 변환 실패·빈 출력·타임아웃·의존성 부재 → OfficeMdError 를 raise 한다. @@ -18,6 +20,7 @@ from __future__ import annotations import os +import re import shutil import subprocess import tempfile @@ -34,6 +37,9 @@ _MIN_BODY_CHARS = 16 # 이름) → 기본값 정합. soffice 만 있는 환경은 LIBREOFFICE_BIN 으로 override. _SOFFICE_BIN = os.environ.get("LIBREOFFICE_BIN", "libreoffice") +# pyhwp 콘솔 스크립트(pip install pyhwp 시 PATH 등록). HWP5 binary(.hwp) 전용. +_HWP5HTML_BIN = os.environ.get("HWP5HTML_BIN", "hwp5html") + class OfficeMdError(Exception): """office/hwp → md 변환 실패 신호. 호출부는 md_status='failed' 로 라우팅.""" @@ -50,7 +56,9 @@ def convert_office_to_md(path: str | Path, *, timeout: int = 90) -> str: if suffix in OOXML_FORMATS: md = _via_markitdown(p) - else: # .hwp / .hwpx + elif suffix == ".hwp": + md = _via_pyhwp_html(p, timeout=timeout) + else: # .hwpx (pyhwp 미지원 → LibreOffice 폴백) md = _via_libreoffice_html(p, timeout=timeout) md = (md or "").strip() @@ -74,8 +82,51 @@ def _via_markitdown(path: Path) -> str: return getattr(result, "text_content", "") or "" +def _via_pyhwp_html(path: Path, *, timeout: int) -> str: + """HWP5 binary(.hwp) → pyhwp hwp5html → markdownify. + + LibreOffice 번들 libhwplo 필터가 실제 한컴 HWP5 파일을 못 읽어(rc=0 + 'source file could + not be loaded') 전건 실패 → 순수 Python HWP5 전용 변환기 pyhwp(CLI hwp5html)로 교체. + `_via_libreoffice_html` 와 동일한 실패 계약(rc≠0 또는 출력 부재 → OfficeMdError raise). + """ + try: + from markdownify import markdownify # 기존 의존성 + except ImportError as e: # noqa: BLE001 + raise OfficeMdError("markdownify 미설치(기존 의존성이어야 함)") from e + + with tempfile.TemporaryDirectory(prefix="office_md_hwp_") as tmp: + outdir = Path(tmp) + # hwp5html --output /index.xhtml + styles.css + cmd = [_HWP5HTML_BIN, "--output", str(outdir), str(path)] + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, check=False + ) + except FileNotFoundError as e: + raise OfficeMdError( + f"pyhwp(hwp5html) 바이너리 부재({_HWP5HTML_BIN}) — `pip install pyhwp six` 필요" + ) from e + except subprocess.TimeoutExpired as e: + raise OfficeMdError(f"pyhwp 변환 타임아웃({timeout}s): {path.name}") from e + + index_path = outdir / "index.xhtml" + if proc.returncode != 0 or not index_path.exists(): + raise OfficeMdError( + f"pyhwp html 변환 실패: {path.name} (rc={proc.returncode}): " + f"{(proc.stderr or proc.stdout or '').strip()[:300]}" + ) + html = index_path.read_text(encoding="utf-8", errors="replace") + # hwp5html 의 xhtml 은 최상단 선언을 가짐(LibreOffice 의 .html 경로엔 없음). + # markdownify 의 html.parser 가 이를 PI 텍스트('xml version="1.0" encoding="utf-8"?')로 + # 본문에 흘려 (1) md 최상단 잡음·검색/청크 오염, (2) 빈 body 셸일 때 그 ~34자가 + # _MIN_BODY_CHARS(16) 빈출력 게이트를 무력화(빈 변환의 false-success) → markdownify 전에 제거. + html = re.sub(r"^\s*<\?xml[^>]*\?>\s*", "", html) + # 표 보존 위해 markdownify 가 table 을 GFM 으로 — heading_style ATX (libreoffice 경로와 동일). + return markdownify(html, heading_style="ATX", strip=["span", "font"]) + + def _via_libreoffice_html(path: Path, *, timeout: int) -> str: - """LibreOffice headless 로 HTML 변환 후 markdownify. hwp/hwpx 용.""" + """LibreOffice headless 로 HTML 변환 후 markdownify. hwpx 용(.hwp 는 pyhwp).""" try: from markdownify import markdownify # 기존 의존성 except ImportError as e: # noqa: BLE001