HWP5와 HWPX: 이중 포맷 파이프라인
HwpForge는 한국의 두 가지 주요 문서 포맷 — 바이너리 OLE 기반 HWP5와 XML 기반 HWPX — 을 하나의 통합 파이프라인으로 처리할 수 있도록 설계되었습니다.
포맷 비교
| 특성 | HWP5 (.hwp) | HWPX (.hwpx) |
|---|---|---|
| 컨테이너 | OLE2/CFB (Compound File Binary) | ZIP |
| 내부 데이터 | 바이너리 레코드 스트림 | XML 파일 (KS X 6101 OWPML) |
| 표준 | 한컴 독자 포맷 (공개 스펙) | 국가 표준 KS X 6101 |
| 역사 | 1990년대~현재 (레거시) | 2014년~ (현대) |
| 파일 시그니처 | D0 CF 11 E0 A1 B1 1A E1 (OLE) | 50 4B 03 04 (ZIP/PK) |
| 스트림 구조 | FileHeader, DocInfo, BodyText/Section0 등 | mimetype, Contents/header.xml, Contents/section0.xml 등 |
| 압축 | zlib (스트림 단위) | ZIP deflate (파일 단위) |
| 암호화 | 지원 (스트림 암호화) | 지원 (ZIP 암호화) |
| 한글 호환성 | 한글 97~최신 | 한글 2014~최신 |
Core DOM: 포맷 독립 중간 표현 (IR)
HwpForge의 핵심 설계 원칙은 Core DOM이 포맷에 독립적이라는 것입니다. Document<Draft>는 HWP5든 HWPX든 Markdown이든 동일한 구조체로 표현됩니다.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ HWP5 파일 │ │ HWPX 파일 │ │ Markdown │
│ (OLE/CFB) │ │ (ZIP/XML) │ │ (GFM+YAML) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ decode │ decode │ decode
▼ ▼ ▼
┌──────────────────────────────────────────────────────┐
│ Document<Draft> (Core DOM) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Sections → Paragraphs → Runs → Text/Control │ │
│ │ + Metadata (title, author, created, ...) │ │
│ │ + Tables, Images, Shapes, Charts, ... │ │
│ └──────────────────────────────────────────────┘ │
│ 포맷 독립 중간 표현 (IR) │
└──────────┬───────────────┬───────────────┬───────────┘
│ encode │ encode │ encode
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ HWP5 │ │ HWPX │ │ Markdown │
│ CLI/전용 │ │ ✅ 구현 │ │ ✅ 구현 │
└──────────┘ └──────────┘ └──────────┘
이 설계 덕분에:
- 하나의 문서 모델로 모든 포맷을 처리합니다
- 포맷 간 변환이 Core DOM을 경유하여 자연스럽게 이루어집니다
- 새 포맷 추가 시 기존 코드 수정 없이 Smithy 크레이트만 추가하면 됩니다
- 비즈니스 로직은 Core DOM에만 의존하므로 포맷 변경에 영향을 받지 않습니다
포맷 감지
파일의 첫 바이트(매직 바이트)로 포맷을 판별합니다.
#![allow(unused)] fn main() { /// 파일 포맷 감지 enum DocumentFormat { Hwp5, // OLE2/CFB 바이너리 Hwpx, // ZIP + XML Markdown, // 텍스트 Unknown, } fn detect_format(bytes: &[u8]) -> DocumentFormat { if bytes.len() < 4 { return DocumentFormat::Unknown; } // OLE2 Compound File Binary: D0 CF 11 E0 if bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0]) { return DocumentFormat::Hwp5; } // ZIP (PK\x03\x04) if bytes.starts_with(&[0x50, 0x4B, 0x03, 0x04]) { return DocumentFormat::Hwpx; } // UTF-8 텍스트로 시작하면 Markdown 후보 if std::str::from_utf8(bytes).is_ok() { return DocumentFormat::Markdown; } DocumentFormat::Unknown } }
포맷 독립 문서 처리
Core DOM을 활용하면 입력 포맷에 관계없이 동일한 코드로 문서를 처리할 수 있습니다.
현재 지원되는 파이프라인
#![allow(unused)] fn main() { use hwpforge::hwpx::{HwpxDecoder, HwpxEncoder, HwpxRegistryBridge}; use hwpforge::md::{MdDecoder, MdDocument, MdEncoder}; use hwpforge::core::{Document, Draft, ImageStore}; // === 1. HWPX → Core DOM === let hwpx_result = HwpxDecoder::decode_file("input.hwpx").unwrap(); let doc_from_hwpx: Document<Draft> = hwpx_result.document; // === 2. Markdown → Core DOM === let markdown = "# 제목\n\n본문 내용입니다."; let MdDocument { document: doc_from_md, style_registry } = MdDecoder::decode_with_default(markdown).unwrap(); // === 3. 포맷 독립 처리 (어느 소스에서 왔든 동일) === fn process_document(doc: &Document<Draft>) { // 메타데이터 접근 let meta = doc.metadata(); println!("제목: {:?}", meta.title); println!("작성자: {:?}", meta.author); // 섹션/문단 순회 for section in doc.sections() { println!("문단 수: {}", section.paragraphs.len()); let counts = section.content_counts(); println!("표: {}, 이미지: {}", counts.tables, counts.images); } } process_document(&doc_from_hwpx); process_document(&doc_from_md); // === 4. Core DOM → 다른 포맷으로 출력 === // HWPX로 저장 let bridge = HwpxRegistryBridge::from_registry(&style_registry).unwrap(); let rebound = bridge.rebind_draft_document(doc_from_md).unwrap(); let validated = rebound.validate().unwrap(); let bytes = HwpxEncoder::encode(&validated, bridge.style_store(), &ImageStore::new()).unwrap(); std::fs::write("output.hwpx", &bytes).unwrap(); // Markdown으로 저장 let markdown_out = MdEncoder::encode_lossy(&validated).unwrap(); std::fs::write("output.md", &markdown_out).unwrap(); }
현재: HWP5 전용 crate / CLI 경로
#![allow(unused)] fn main() { use hwpforge_smithy_hwp5::Hwp5Decoder; use hwpforge::hwpx::{HwpxEncoder, HwpxStyleStore}; use hwpforge::core::ImageStore; let hwp5_result = Hwp5Decoder::decode_file("legacy.hwp").unwrap(); let doc: Document<Draft> = hwp5_result.document; // Core DOM을 경유하여 HWP5 → HWPX 변환 let validated = doc.validate().unwrap(); let style_store = HwpxStyleStore::with_default_fonts("함초롬바탕"); let bytes = HwpxEncoder::encode(&validated, &style_store, &ImageStore::new()).unwrap(); std::fs::write("converted.hwpx", &bytes).unwrap(); }
CLI만 필요하다면 전용 명령도 이미 있습니다.
hwpforge convert-hwp5 legacy.hwp -o converted.hwpx
hwpforge audit-hwp5 legacy.hwp converted.hwpx
hwpforge census-hwp5 legacy.hwp --json
CLI에서 포맷 처리
현재 CLI는 HWPX와 Markdown을 지원합니다.
# Markdown → HWPX 변환
hwpforge convert report.md -o report.hwpx
# HWPX 문서 검사 (메타데이터 포함)
hwpforge inspect report.hwpx --json
# HWPX → JSON → 편집 → HWPX 라운드트립
hwpforge to-json report.hwpx -o report.json
# (AI 에이전트가 JSON 편집)
hwpforge from-json report.json -o updated.hwpx
# HWPX → Markdown (읽기용)
# Rust API: MdEncoder::encode_lossy(&validated)
크레이트 역할 분담
| 크레이트 | 역할 | 포맷 의존성 |
|---|---|---|
hwpforge-foundation | 원시 타입 (HwpUnit, Color, Index) | 없음 |
hwpforge-core | 포맷 독립 문서 모델 (IR) | 없음 |
hwpforge-blueprint | YAML 스타일 템플릿 | 없음 |
hwpforge-smithy-hwpx | HWPX ↔ Core 코덱 | HWPX (ZIP+XML) |
hwpforge-smithy-hwp5 | HWP5 decode/projection | HWP5 (OLE/CFB) |
hwpforge-smithy-md | Markdown ↔ Core 코덱 | Markdown (텍스트) |
hwpforge-convert | HWP5 → HWPX 변환 오케스트레이터 + audit helpers | HWP5 + HWPX (smithy 경유) |
핵심 원칙: Core 이하 계층은 어떤 파일 포맷도 모릅니다. Smithy 계층만 특정 포맷을 이해하고, convert는 두 Smithy를 엮어 포맷 간 변환을 지휘합니다(자체 포맷 파싱 없음).
HWP5 포맷 구조 (참고)
HWP5 파일은 OLE2 Compound File Binary (CFB) 컨테이너 안에 바이너리 레코드 스트림을 저장합니다.
HWP5 파일 (OLE2 CFB)
├── FileHeader — 파일 인식 정보, 버전, 플래그
├── DocInfo — 문서 설정 (스타일, 폰트, 탭, 번호)
├── BodyText/
│ ├── Section0 — 첫 번째 섹션 (바이너리 레코드)
│ ├── Section1 — 두 번째 섹션
│ └── ...
├── BinData/ — 이미지 등 바이너리 데이터
├── DocOptions/ — 추가 옵션
├── Scripts/ — 매크로 스크립트
└── PrvText — 미리보기 텍스트
각 섹션은 Tag-Length-Value (TLV) 구조의 레코드 체인으로 구성됩니다:
레코드 = TagID (10bit) + Level (10bit) + Size (12bit) + Data (Size bytes)
주의: HWP5의 TagID에는 +16 오프셋이 있습니다.
PARA_HEADER= 0x42 (66), 공식 스펙의 0x32 (50)가 아닙니다.
현재 지원 상태
| 기능 | HWPX | HWP5 | Markdown |
|---|---|---|---|
| 읽기 (Decode) | ✅ 완전 지원 | 🟡 전용 crate/CLI 경로 | ✅ 완전 지원 |
| 쓰기 (Encode) | ✅ 완전 지원 | — | ✅ 완전 지원 |
| 메타데이터 추출 | ✅ Core DOM | 🟡 inspect/census summary | ✅ YAML Frontmatter |
| 이미지 추출 | ✅ ImageStore | 🟡 decode/projection path | — |
| 스타일 보존 | ✅ HwpxStyleStore | 🟡 warning-first projection + HWPX re-emission | ✅ StyleRegistry |
| JSON 라운드트립 | ✅ to-json/from-json | — | — |
HWP5 읽기 자체는 더 이상 future tense가 아니다. 지금도 hwpforge-smithy-hwp5와 CLI를 통해 decode / inspect / convert / audit 경로를 사용할 수 있다. 다만 umbrella crate와 일부 top-level guide는 여전히 HWPX/Markdown 중심이며, HWP5 parity는 warning-first로 점진적으로 넓혀 가는 중이다.