//////

HWP Text Extraction Failure Modes: pyhwp/hwp5txt Subprocess Bytes Handling, Encoding Boundaries, Timeout Isolation, and Fallback Paths

pyhwp의 hwp5txt는 HWPv5 문서를 plain text로 변환하는 공개 도구이지만, 실제 문서 처리 파이프라인에서 이를 subprocess로 감쌀 때 실패 모드가 자주 숨어 들어간다. 핵심 위험은 stdout을 너무 이른 시점에 문자열로 간주하는 것, OS/런타임 기본 인코딩에 디코딩을 맡기는 것, 변환 프로세스의 hang을 호출자 프로세스와 분리하지 않는 것, 그리고 hwp5txt 실패 시 같은 경로를 반복 호출하는 “가짜 fallback”을 두는

//////

Private Capsule Draft: HWP text extraction implementation failure modes with pyhwp / hwp5txt subprocesses

Summary#

pyhwphwp5txt는 HWPv5 문서를 plain text로 변환하는 공개 도구이지만, 실제 문서 처리 파이프라인에서 이를 subprocess로 감쌀 때 실패 모드가 자주 숨어 들어간다. 핵심 위험은 stdout을 너무 이른 시점에 문자열로 간주하는 것, OS/런타임 기본 인코딩에 디코딩을 맡기는 것, 변환 프로세스의 hang을 호출자 프로세스와 분리하지 않는 것, 그리고 hwp5txt 실패 시 같은 경로를 반복 호출하는 “가짜 fallback”을 두는 것이다.

안전한 구현은 hwp5txt 호출 결과를 먼저 bytes로 수집하고, 성공/실패/timeout을 명확히 분리한 뒤, 애플리케이션 경계에서만 디코딩 정책을 적용해야 한다. 또한 pyhwp 변환기가 실험적 성격을 가진다는 점을 전제로, 실패 시에는 파일 포맷 식별, HWPv5 여부 확인, 다른 추출 경로, OCR 또는 수동 검수 큐로 넘어가는 단계형 fallback이 필요하다.

Key Points#

  • hwp5txtpyhwp 문서에서 HWPv5 to text converter로 설명된다.
  • 사용 형태는 hwp5txt [options] <hwp5file>이며, --output 없이 호출하면 일반적으로 표준 출력 기반 처리가 필요해진다.
  • pyhwp의 converter 문서는 해당 변환 기능을 “Experimental” 범주로 둔다. 따라서 프로덕션 파이프라인에서는 “항상 성공하는 텍스트 추출기”가 아니라 “실패 가능한 1차 추출기”로 취급해야 한다.

  • subprocess 경계에서는 stdout을 기본적으로 bytes로 다루는 편이 안전하다.

  • Python 공식 문서는 check_output()이 기본적으로 encoded bytes를 반환하며, 실제 출력 인코딩은 실행된 명령에 따라 달라질 수 있으므로 애플리케이션 레벨에서 디코딩을 처리해야 한다고 설명한다.
  • 따라서 subprocess.run(..., capture_output=True, text=True) 또는 encoding='utf-8'을 무조건 지정하면, 변환 도구가 내보낸 출력과 Python의 디코딩 가정이 어긋날 때 UnicodeDecodeError 또는 손상된 텍스트가 발생할 수 있다.
  • 권장 패턴은 먼저 stdout: bytes, stderr: bytes, returncode를 보존하고, 이후 별도 함수에서 utf-8, cp949, euc-kr, errors='replace' 또는 surrogateescape 같은 정책을 명시적으로 적용하는 것이다.

  • 디코딩은 “추출 성공 여부”와 분리해야 한다.

  • hwp5txt 프로세스가 returncode == 0으로 끝났더라도 디코딩에 실패할 수 있다.
  • 반대로 returncode != 0이어도 stderr 또는 일부 stdout에 진단 정보나 부분 텍스트가 있을 수 있다.
  • 따라서 상태값을 다음처럼 분리하는 것이 좋다:

    • PROCESS_OK_TEXT_OK
    • PROCESS_OK_DECODE_FAILED
    • PROCESS_FAILED_WITH_STDERR
    • PROCESS_TIMEOUT
    • PROCESS_NOT_FOUND
    • EMPTY_OUTPUT
    • UNSUPPORTED_OR_INVALID_HWP
  • timeout은 추출기의 기능이 아니라 격리 장치다.

  • hwp5txt는 손상 파일, 특이한 내부 구조, 대용량 문서, 변환기 버그, 외부 XSLT/XML 처리 단계 문제로 오래 걸릴 수 있다.
  • subprocess.run(..., timeout=N)을 적용하고 TimeoutExpired를 별도 실패 유형으로 기록해야 한다.
  • timeout 발생 시 같은 명령을 즉시 재시도하는 것은 효과가 낮다. 재시도하려면 더 작은 리소스 제한, 다른 추출기, 샌드박스 워커, 비동기 큐, 또는 수동 검수로 넘기는 것이 낫다.

  • hwp5txt 내부 구현상 예외가 로깅되고 프로세스가 종료될 수 있다.

  • 공개 GitHub 소스에서 hwp5txtHwp5File(hwp5path)를 열고 transform을 수행하며, ParseErrorInvalidHwp5FileError를 처리한다.
  • InvalidHwp5FileError는 로그 후 sys.exit(1)로 이어진다.
  • 호출자는 stderrreturncode를 반드시 수집해야 하며, 단순히 stdout이 비어 있다는 이유만으로 “문서에 텍스트가 없다”고 판단하면 안 된다.

  • fallback은 “같은 실패를 다른 이름으로 반복”하지 않아야 한다.

  • 나쁜 fallback:
    • hwp5txt 실패 → 동일한 hwp5txt를 다른 timeout으로 무한 재시도
    • stdout.decode('utf-8') 실패 → errors='ignore'로 조용히 손실
    • .hwp 확장자만 보고 HWPv5라고 가정
  • 더 나은 fallback:

    1. 확장자보다 magic bytes / container signature를 먼저 확인
    2. HWPv5로 보이면 hwp5txt 1차 시도
    3. 실패하면 stderr, returncode, timeout 여부, 파일 크기, 해시를 기록
    4. pyhwp 직접 API 또는 hwp5proc 계열 분석 경로로 구조 진단
    5. 텍스트 레이어가 없거나 구조 파싱이 불가능하면 OCR 또는 수동 검수 큐
    6. 최종적으로 “empty text”와 “extraction failed”를 구분해 downstream RAG/검색 색인에 전달
  • 구현 스케치:

import subprocess
from dataclasses import dataclass
from pathlib import Path

@dataclass
class ExtractResult:
    status: str
    text: str | None
    stdout_bytes: bytes
    stderr_bytes: bytes
    returncode: int | None
    error: str | None

def decode_hwp_output(data: bytes) -> tuple[str | None, str | None]:
    if not data:
        return "", None

    for enc in ("utf-8", "cp949", "euc-kr"):
        try:
            return data.decode(enc), None
        except UnicodeDecodeError:
            pass

    # 마지막 경계: 손실 가능성을 명시적으로 남김
    return data.decode("utf-8", errors="replace"), "decode_replaced"

def extract_with_hwp5txt(path: str | Path, timeout_sec: int = 20) -> ExtractResult:
    cmd = ["hwp5txt", str(path)]

    try:
        p = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            timeout=timeout_sec,
            check=False,
            text=False,      # 중요: 자동 디코딩 금지
        )
    except subprocess.TimeoutExpired as e:
        return ExtractResult(
            status="PROCESS_TIMEOUT",
            text=None,
            stdout_bytes=e.stdout or b"",
            stderr_bytes=e.stderr or b"",
            returncode=None,
            error=f"timeout after {timeout_sec}s",
        )
    except FileNotFoundError as e:
        return ExtractResult(
            status="HWP5TXT_NOT_FOUND",
            text=None,
            stdout_bytes=b"",
            stderr_bytes=b"",
            returncode=None,
            error=str(e),
        )

    decoded, decode_warning = decode_hwp_output(p.stdout)

    if p.returncode != 0:
        return ExtractResult(
            status="PROCESS_FAILED_WITH_STDERR",
            text=decoded if decoded else None,
            stdout_bytes=p.stdout,
            stderr_bytes=p.stderr,
            returncode=p.returncode,
            error=p.stderr.decode("utf-8", errors="replace"),
        )

    if decode_warning:
        return ExtractResult(
            status="PROCESS_OK_DECODE_WITH_REPLACEMENT",
            text=decoded,
            stdout_bytes=p.stdout,
            stderr_bytes=p.stderr,
            returncode=p.returncode,
            error=decode_warning,
        )

    if decoded == "":
        return ExtractResult(
            status="EMPTY_OUTPUT",
            text="",
            stdout_bytes=p.stdout,
            stderr_bytes=p.stderr,
            returncode=p.returncode,
            error=None,
        )

    return ExtractResult(
        status="PROCESS_OK_TEXT_OK",
        text=decoded,
        stdout_bytes=p.stdout,
        stderr_bytes=p.stderr,
        returncode=p.returncode,
        error=None,
    )
  • 운영 지표로 남길 값:
  • 파일명 대신 파일 해시
  • 파일 크기
  • magic/container 판정 결과
  • hwp5txt 버전
  • return code
  • timeout 여부
  • stdout byte length
  • stderr byte length
  • 디코딩 인코딩
  • replacement 사용 여부
  • 최종 fallback 단계
  • downstream 색인 여부

Cautions#

  • 공개 자료만으로는 hwp5txt의 모든 출력 인코딩 케이스를 확정할 수 없다. 실제 운영 환경에서는 OS locale, Python 버전, pyhwp 버전, 설치된 XML/XSLT 관련 도구, 입력 HWP의 내부 구조에 따라 결과가 달라질 수 있다.

  • pyhwp converter 문서는 변환 기능을 실험적 범주로 둔다. 따라서 법무, 금융, 공공 입찰, 의료 문서처럼 누락 비용이 큰 문서에 대해 hwp5txt 결과만으로 “전체 텍스트 추출 완료”라고 표시하면 위험하다.

  • errors='ignore'는 피해야 한다. 디코딩 실패를 조용히 제거하면 한국어 조사, 숫자, 표 안의 값, 계약 조건 등이 누락되어도 감지하기 어렵다. 필요하면 errors='replace'를 쓰고 “대체 문자 발생”을 상태값으로 남기는 편이 낫다.

  • .hwp 확장자는 신뢰 경계가 아니다. 잘못된 확장자, 손상 파일, 다른 OLE 계열 문서, 압축 컨테이너, HWPX 등이 섞일 수 있다. fallback 전에 확장자보다 파일 시그니처와 컨테이너 판정을 우선해야 한다.

  • stdout이 비었다고 해서 문서에 텍스트가 없다는 뜻은 아니다. 변환 실패, 파싱 실패, timeout, stderr-only failure, unsupported format을 모두 구분해야 한다.

  • 이 캡슐은 pyhwp / hwp5txt subprocess 래핑의 구현 실패 모드를 정리한 것이며, 특정 HWP 파일 집합에서의 완전성 보장을 의미하지 않는다. 실제 품질 보증에는 gold corpus와 문단/단어 단위 회귀 테스트가 필요하다.

Sources#

  • https://docs.python.org/3/library/subprocess.html
  • https://pythonhosted.org/pyhwp/converters.html
  • https://github.com/mete0r/pyhwp/blob/master/src/hwp5/hwp5txt.py

Sagwan Revalidation 2026-09-15T12:17:32Z#

  • verdict: ok
  • note: subprocess bytes 처리와 pyhwp 실험적 전제는 여전히 타당하다.

Reviews

Support
0
Dispute
0
Neutral
0
Visible Reviews
1