HwpForge
한글 문서(HWP/HWPX)를 프로그래밍으로 제어하는 Rust 라이브러리
HwpForge란?
HwpForge는 한컴 한글의 HWP/HWPX 문서를 Rust로 읽고, 쓰고, 변환할 수 있는 라이브러리입니다. public guide와 umbrella crate surface는 여전히 HWPX/Markdown 중심이지만, HWP5는 전용 crate와 CLI를 통해 decode, audit, re-emission 경로를 제공합니다.
주요 기능
- HWPX 풀 코덱 — HWPX 파일 디코딩/인코딩 + 무손실 라운드트립
- HWP5 읽기/점검 경로 — legacy
.hwpdecode, audit, HWPX re-emission - Markdown 브릿지 — GFM Markdown ↔ HWPX 변환
- YAML 스타일 템플릿 — 재사용 가능한 디자인 토큰 (Figma 패턴)
- 타입 안전 API — 브랜드 인덱스, 타입스테이트 검증, unsafe 코드 0
지원 콘텐츠
| 카테고리 | 요소 |
|---|---|
| 텍스트 | 런, 문자 모양, 문단 모양, 스타일 (한컴 기본 22종) |
| 구조 | 표 (중첩), 이미지, 글상자, 캡션 |
| 레이아웃 | 다단, 페이지 설정, 가로/세로, 여백, 마스터페이지 |
| 머리글/바닥글 | 머리글, 바닥글, 쪽번호 (autoNum) |
| 주석 | 각주, 미주 |
| 도형 | 선, 타원, 다각형, 호, 곡선, 연결선 (채움/회전/화살표) |
| 수식 | HancomEQN 스크립트 |
| 차트 | 18종 차트 (OOXML 호환) |
| 참조 | 책갈피, 상호참조, 필드, 메모, 찾아보기 |
| Markdown | GFM 디코드, 손실/무손실 인코드, YAML 프론트매터 |
누구를 위한 라이브러리인가?
- LLM/AI 에이전트 — 자연어로 한글 문서 자동 생성
- 백엔드 개발자 — 서버에서 한글 문서 프로그래밍 생성
- 자동화 도구 — CI/CD 파이프라인에서 보고서 자동 생성
- 데이터 파이프라인 — HWPX 문서에서 텍스트/표 추출
빠른 맛보기
#![allow(unused)] fn main() { use hwpforge::core::{Document, Draft, Paragraph, Run, Section, PageSettings}; use hwpforge::foundation::{CharShapeIndex, ParaShapeIndex}; use hwpforge::hwpx::{HwpxEncoder, HwpxStyleStore}; use hwpforge::core::ImageStore; // 1. 문서 생성 let mut doc = Document::<Draft>::new(); doc.add_section(Section::with_paragraphs( vec![Paragraph::with_runs( vec![Run::text("안녕하세요, HwpForge!", CharShapeIndex::new(0))], ParaShapeIndex::new(0), )], PageSettings::a4(), )); // 2. 검증 + 인코딩 let validated = doc.validate().unwrap(); let style_store = HwpxStyleStore::with_default_fonts("함초롬바탕"); let image_store = ImageStore::new(); let bytes = HwpxEncoder::encode(&validated, &style_store, &image_store).unwrap(); // 3. 파일 저장 std::fs::write("output.hwpx", &bytes).unwrap(); }
다음 단계
설치
HwpForge는 순수 Rust로 작성된 라이브러리입니다. 별도의 시스템 의존성 없이 Cargo.toml에 추가하는 것만으로 사용할 수 있습니다.
최소 지원 Rust 버전 (MSRV)
Rust 1.88 이상이 필요합니다. 현재 버전을 확인하려면:
rustc --version
버전이 낮다면 rustup으로 업데이트합니다:
rustup update stable
의존성 추가
Cargo.toml의 [dependencies] 섹션에 추가합니다:
[dependencies]
hwpforge = "0.1"
기본 설치에는 HWPX 인코더/디코더가 포함됩니다.
Feature Flags
HwpForge는 필요한 기능만 선택적으로 활성화할 수 있습니다.
| Feature | 기본 포함 | 설명 |
|---|---|---|
hwpx | 예 | HWPX 인코더/디코더 (ZIP + XML, KS X 6101) |
md | 아니오 | Markdown(GFM) ↔ HWPX 변환 |
full | 아니오 | 모든 기능 활성화 |
HWPX만 사용 (기본)
[dependencies]
hwpforge = "0.1"
Markdown 변환 포함
[dependencies]
hwpforge = { version = "0.1", features = ["md"] }
모든 기능 활성화
[dependencies]
hwpforge = { version = "0.1", features = ["full"] }
빌드 확인
의존성을 추가한 후 빌드가 정상적으로 되는지 확인합니다:
cargo build
다음과 같이 컴파일이 성공하면 설치가 완료된 것입니다:
Compiling hwpforge v0.1.0
Finished `dev` profile [unoptimized + debuginfo] target(s) in ...
다음 단계
설치가 완료되었습니다. 빠른 시작으로 이동하여 첫 번째 HWPX 문서를 생성해 보세요.
빠른 시작
이 페이지에서는 HwpForge의 세 가지 핵심 사용 패턴을 코드 예제와 함께 설명합니다.
예제 1: 텍스트 문서 생성 후 HWPX로 저장
가장 기본적인 사용 패턴입니다. 문서 구조를 직접 조립하고 HWPX 파일로 출력합니다.
use hwpforge::core::{Document, Draft, ImageStore, PageSettings, Paragraph, Run, Section}; use hwpforge::foundation::{CharShapeIndex, ParaShapeIndex}; use hwpforge::hwpx::{HwpxEncoder, HwpxStyleStore}; fn main() -> anyhow::Result<()> { // 1. Draft 상태의 문서 생성 let mut doc = Document::<Draft>::new(); // 2. 텍스트 Run 구성 — CharShapeIndex(0)은 기본 글자 스타일을 참조 let run = Run::text("안녕하세요, HwpForge입니다!", CharShapeIndex::new(0)); // 3. 문단 생성 — ParaShapeIndex(0)은 기본 문단 스타일을 참조 let paragraph = Paragraph::with_runs(vec![run], ParaShapeIndex::new(0)); // 4. 섹션(= 쪽 단위 컨테이너)에 문단 추가, A4 용지 설정 let section = Section::with_paragraphs(vec![paragraph], PageSettings::a4()); doc.add_section(section); // 5. 유효성 검증 — Draft → Validated 상태 전이 (타입스테이트) let validated = doc.validate()?; // 6. 스타일 스토어: 한컴 Modern(22종) 기본 스타일 사용 let style_store = HwpxStyleStore::with_default_fonts("함초롬바탕"); // 7. 이미지 스토어: 이미지가 없으므로 빈 스토어 사용 let image_store = ImageStore::new(); // 8. HWPX 바이트 인코딩 후 파일 저장 let bytes = HwpxEncoder::encode(&validated, &style_store, &image_store)?; std::fs::write("output.hwpx", &bytes)?; println!("output.hwpx 저장 완료 ({} bytes)", bytes.len()); Ok(()) }
참고:
CharShapeIndex::new(0)과ParaShapeIndex::new(0)은HwpxStyleStore::with_default_fonts()이 제공하는 기본 스타일(본문)을 가리킵니다. 커스텀 스타일을 사용하려면 스타일 템플릿 문서를 참고하세요.
예제 2: HWPX 파일 디코딩
기존 HWPX 파일을 읽어서 Core 문서 모델로 변환합니다.
use hwpforge::hwpx::HwpxDecoder; use hwpforge::core::run::RunContent; fn main() -> anyhow::Result<()> { // 파일 경로를 받아 HWPX를 디코딩 let result = HwpxDecoder::decode_file("input.hwpx")?; let doc = &result.document; // 섹션 수 출력 println!("섹션 수: {}", doc.sections().len()); // 메타데이터 접근 (제목, 작성자, 작성일 등) let meta = doc.metadata(); if let Some(title) = &meta.title { println!("제목: {}", title); } if let Some(author) = &meta.author { println!("작성자: {}", author); } // 각 섹션의 문단과 텍스트 출력 for (sec_idx, section) in doc.sections().iter().enumerate() { println!("--- 섹션 {} ---", sec_idx + 1); for paragraph in §ion.paragraphs { for run in ¶graph.runs { if let RunContent::Text(ref text) = run.content { print!("{}", text); } } println!(); // 문단 끝 줄바꿈 } } Ok(()) }
HwpxDecoder::decode_file은 경로를 받아 ZIP을 열고 XML을 파싱합니다.
반환값에는 document(문서 구조), style_store(글꼴/문단 스타일), image_store(이미지)가 포함됩니다.
document.metadata()로 제목, 작성자 등의 메타데이터에 접근할 수 있습니다.
예제 3: Markdown → HWPX 변환
GFM(GitHub Flavored Markdown) 텍스트를 HWPX 파일로 변환합니다.
features = ["md"] 또는 features = ["full"]이 필요합니다.
use hwpforge::core::ImageStore; use hwpforge::hwpx::{HwpxEncoder, HwpxRegistryBridge}; use hwpforge::md::MdDecoder; fn main() -> anyhow::Result<()> { // 1. GFM Markdown 텍스트 (YAML 프론트매터 지원) let markdown = r#"--- title: 보고서 제목 author: 홍길동 date: 2026-03-06 --- 1장. 서론 본 보고서는 HwpForge를 이용한 **자동 문서 생성** 예시입니다. # 1.1 배경 - 항목 A - 항목 B - 항목 C # 1.2 결론 > HwpForge는 LLM 에이전트가 한글 문서를 생성할 때 사용할 수 있습니다. "#; // 2. Markdown → Core 문서 모델 변환 // MdDecoder::decode_with_default는 document + style_registry(스타일 매핑)를 반환 let md_doc = MdDecoder::decode_with_default(markdown)?; // 3. registry-local 스타일 인덱스를 HWPX store-local 인덱스로 rebinding let bridge = HwpxRegistryBridge::from_registry(&md_doc.style_registry)?; let rebound = bridge.rebind_draft_document(md_doc.document)?; // 4. Draft → Validated 상태 전이 let validated = rebound.validate()?; // 5. 이미지 없음 let image_store = ImageStore::new(); // 6. HWPX 인코딩 후 저장 let bytes = HwpxEncoder::encode(&validated, bridge.style_store(), &image_store)?; std::fs::write("report.hwpx", &bytes)?; println!("report.hwpx 저장 완료"); Ok(()) }
Markdown 변환 시 자동으로 처리되는 항목:
| Markdown 요소 | 변환 결과 |
|---|---|
# H1 ~ ###### H6 | 한컴 개요 1 ~ 6 스타일 |
**굵게** | 글자 진하게 |
*기울임* | 글자 기울임 |
`코드` | 고정폭 글꼴 |
> 인용문 | 들여쓰기 문단 |
- 목록 | 글머리 기호 목록 |
| YAML 프론트매터 | 문서 메타데이터 |
다음 단계
아키텍처 개요
HwpForge는 대장간(Forge) 메타포를 기반으로 설계된 계층형 크레이트 구조를 갖습니다. 각 계층은 명확한 역할을 가지며, 상위 계층은 하위 계층에만 의존합니다.
Forge 메타포
| 계층 | 역할 | 비유 |
|---|---|---|
| Foundation (기반) | 원시 타입, 단위, 인덱스 | 쇠못과 금속 소재 |
| Core (핵심) | 형식 독립 문서 모델 | 도면 위의 설계도 |
| Blueprint (청사진) | YAML 스타일 템플릿 | 피그마 디자인 토큰 |
| Smithy (대장간) | 형식별 인코더/디코더 | 용광로와 망치 |
| Convert (변환) | 포맷 간 변환 오케스트레이터 | 단조 작업 지휘 |
| Bindings (바인딩) | Python, CLI 인터페이스 | 완성된 제품 포장 |
크레이트 의존성 그래프
graph TD
F[hwpforge-foundation<br/>원시 타입] --> C[hwpforge-core<br/>문서 모델]
C --> B[hwpforge-blueprint<br/>스타일 템플릿]
B --> SH[hwpforge-smithy-hwpx<br/>HWPX 코덱]
B --> SM[hwpforge-smithy-md<br/>Markdown 코덱]
C --> S5[hwpforge-smithy-hwp5<br/>HWP5 decode/projection]
C --> CONV[hwpforge-convert<br/>HWP5 → HWPX 오케스트레이터]
SH --> CONV
S5 --> CONV
SH --> U[hwpforge<br/>umbrella crate]
SM --> U
CONV --> CLI[hwpforge-bindings-cli<br/>CLI (shipped)]
S5 --> CLI
SH --> CLI
SM --> CLI
SH --> MCP[hwpforge-bindings-mcp<br/>MCP (shipped)]
SM --> MCP
SH --> PY[hwpforge-bindings-py<br/>Python (stub)]
규칙: 의존성은 위에서 아래로만 흐릅니다.
foundation을 수정하면 모든 크레이트가 재빌드됩니다. 따라서foundation은 최소한으로 유지합니다.
핵심 원칙: 구조와 스타일의 분리
HwpForge는 HTML + CSS의 관계처럼 문서 구조와 스타일 정의를 완전히 분리합니다.
Core (구조) Blueprint (스타일)
───────────── ──────────────────
Paragraph font: "맑은 고딕"
style_id: 2 ──▶ size: 10pt
runs: [...] color: #000000
- Core는 스타일 ID(인덱스)만 보유합니다. 실제 글꼴 이름이나 크기를 모릅니다.
- Blueprint는 스타일 정의를 YAML 템플릿으로 관리합니다.
- Smithy 컴파일러가 Core + Blueprint를 조합해 최종 형식을 생성합니다.
이 구조 덕분에 하나의 YAML 템플릿을 여러 문서에 재사용하거나, 동일한 문서를 HWPX/Markdown 등 다른 형식으로 내보낼 수 있습니다.
타입스테이트 패턴: Document → Document
Document는 컴파일 타임에 상태를 추적하는 타입스테이트 패턴을 사용합니다.
#![allow(unused)] fn main() { use hwpforge::core::{Document, Draft}; // Draft 상태: 편집 가능, 저장 불가 let mut doc = Document::<Draft>::new(); doc.add_section(/* ... */); // validate()를 호출해야만 Validated 상태로 전이 let validated = doc.validate().unwrap(); // Validated 상태에서만 인코딩 가능 // doc.validate()를 건너뛰면 컴파일 에러 발생 let bytes = hwpforge::hwpx::HwpxEncoder::encode( &validated, &hwpforge::hwpx::HwpxStyleStore::with_default_fonts("함초롬바탕"), &hwpforge::core::ImageStore::new(), ).unwrap(); }
잘못된 상태에서 저장을 시도하면 런타임 에러가 아닌 컴파일 에러가 발생합니다.
이중 포맷 설계: HWP5 + HWPX
한국에는 두 가지 주요 문서 포맷이 있습니다:
- HWP5 (
.hwp): OLE2/CFB 바이너리 컨테이너 + TLV 레코드 (1990년대~현재, 레거시) - HWPX (
.hwpx): ZIP 컨테이너 + XML 파일 (KS X 6101 국가 표준, 2014년~현재)
HwpForge는 Core DOM이 포맷에 독립적이도록 설계하여 두 포맷을 통합 처리합니다:
HWP5 (.hwp) ──decode──▶ ┌────────────────────┐ ◀──decode── Markdown (.md)
│ Document<Draft> │
HWPX (.hwpx) ──decode──▶ │ (포맷 독립 IR) │ ──encode──▶ HWPX / Markdown
└────────────────────┘
모든 Smithy 크레이트는 Core DOM으로/에서 변환만 수행합니다. 비즈니스 로직은 Core에만 의존하므로, 새 포맷(예: smithy-odt)을 추가해도 기존 코드를 수정할 필요가 없습니다.
현재 HWP5 경로는 hwpforge-smithy-hwp5와 CLI surface에서 실사용 가능하며, umbrella crate 중심 예제는 여전히 HWPX/Markdown 쪽이 먼저 소개됩니다.
자세한 내용은 HWP5와 HWPX: 이중 포맷 파이프라인을 참고하세요.
각 크레이트 설명
hwpforge-foundation
의존성이 없는 루트 크레이트입니다. 모든 크레이트가 공유하는 원시 타입을 정의합니다.
HwpUnit: 정수 기반 HWP 단위 (1pt = 100 HWPUNIT). 부동소수점 오차 없음Color: BGR 바이트 순서 색상 타입.Color::from_rgb(r, g, b)로 생성Index<T>: 팬텀 타입을 이용한 브랜드 인덱스.CharShapeIndex와ParaShapeIndex를 혼용하면 컴파일 에러
hwpforge-core
형식에 독립적인 문서 모델입니다. 한글/Markdown/PDF 어디에도 종속되지 않습니다.
Document<S>,Section,Paragraph,Run— 기본 문서 구조Table,Control,Shape— 복합 요소PageSettings— 용지 크기, 여백, 가로/세로 방향
hwpforge-blueprint
YAML로 작성하는 스타일 템플릿 시스템입니다. 피그마의 디자인 토큰 개념과 유사합니다.
- 상속(
extends)과 병합(merge)을 지원하는PartialCharShape/CharShape두 타입 구조 StyleRegistry— 파싱 후 인덱스를 할당한 최종 스타일 집합
hwpforge-smithy-hwpx
HWPX ↔ Core 변환을 담당하는 핵심 코덱입니다. KS X 6101(OWPML) 국가 표준을 구현합니다.
HwpxDecoder— ZIP + XML 파싱 → Core 문서 모델HwpxEncoder— Core 문서 모델 → ZIP + XML 바이트HwpxStyleStore— 한컴 기본 스타일 22종(Modern) 내장
hwpforge-smithy-md
GFM(GitHub Flavored Markdown) ↔ Core 변환을 담당합니다.
MdDecoder— Markdown + YAML 프론트매터 → CoreMdEncoder— Core → Markdown (손실/무손실 모드)
hwpforge (umbrella crate)
모든 공개 크레이트를 재내보내기(re-export)하는 진입점 크레이트입니다. 사용자는 이 크레이트 하나만 의존성에 추가하면 됩니다.
다음 단계
HWPX 인코딩/디코딩
HWPX 포맷 소개
HWPX는 한글과컴퓨터의 공개 문서 표준(KS X 6101, OWPML)입니다. 내부 구조는 ZIP 컨테이너 안에 XML 파일들이 담긴 형태로, Microsoft DOCX와 유사합니다.
주요 구성 파일:
mimetype— 포맷 식별자Contents/header.xml— 스타일 정의 (폰트, 문단 모양, 글자 모양)Contents/section0.xml,section1.xml, … — 본문 내용BinData/— 이미지 등 바이너리 파일들Chart/— 차트 XML (OOXMLxmlns:c형식)
hwpforge-smithy-hwpx 크레이트가 이 포맷의 인코드/디코드를 담당합니다.
디코딩: HWPX 파일 읽기
HwpxDecoder::decode_file()로 .hwpx 파일을 HwpxDocument로 읽습니다.
#![allow(unused)] fn main() { use hwpforge_smithy_hwpx::HwpxDecoder; let result = HwpxDecoder::decode_file("document.hwpx").unwrap(); // 섹션 수 확인 println!("섹션 수: {}", result.document.sections().len()); // 첫 번째 섹션의 문단 수 let section = &result.document.sections()[0]; println!("문단 수: {}", section.paragraphs.len()); }
HwpxDocument 결과 구조
HwpxDecoder::decode_file()은 HwpxDocument를 반환합니다. 세 가지 필드로 구성됩니다.
#![allow(unused)] fn main() { use hwpforge_smithy_hwpx::{HwpxDecoder, HwpxDocument}; let HwpxDocument { document, style_store, image_store } = HwpxDecoder::decode_file("document.hwpx").unwrap(); // document: Document<Draft> — 문서 DOM (섹션/문단/런 트리) // style_store: HwpxStyleStore — 폰트, 글자 모양, 문단 모양, 스타일 // image_store: ImageStore — 임베드된 이미지 바이너리 데이터 }
| 필드 | 타입 | 설명 |
|---|---|---|
document | Document<Draft> | 섹션, 문단, 런 트리 |
style_store | HwpxStyleStore | 폰트/글자모양/문단모양/스타일 |
image_store | ImageStore | 이미지 바이너리 저장소 |
메타데이터 접근
디코딩된 문서에서 metadata()로 제목, 작성자 등의 메타데이터에 접근합니다.
#![allow(unused)] fn main() { use hwpforge_smithy_hwpx::HwpxDecoder; let result = HwpxDecoder::decode_file("document.hwpx").unwrap(); let meta = result.document.metadata(); if let Some(title) = &meta.title { println!("제목: {}", title); } if let Some(author) = &meta.author { println!("작성자: {}", author); } if let Some(created) = &meta.created { println!("작성일: {}", created); } }
전체 메타데이터 필드 목록과 사용법은 메타데이터 가이드를 참고하세요.
인코딩: Core → HWPX
HwpxEncoder::encode()로 Document<Validated>를 HWPX 바이트 벡터로 직렬화합니다.
#![allow(unused)] fn main() { use hwpforge_smithy_hwpx::{HwpxDecoder, HwpxEncoder, HwpxStyleStore}; use hwpforge_core::{Document, Section, Paragraph, PageSettings}; use hwpforge_core::run::Run; use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex}; // 새 문서 생성 let mut doc = Document::new(); doc.add_section(Section::with_paragraphs( vec![Paragraph::with_runs( vec![Run::text("안녕하세요, HwpForge!", CharShapeIndex::new(0))], ParaShapeIndex::new(0), )], PageSettings::a4(), )); let validated = doc.validate().unwrap(); let style_store = HwpxStyleStore::with_default_fonts("함초롬바탕"); let image_store = Default::default(); let bytes = HwpxEncoder::encode(&validated, &style_store, &image_store).unwrap(); std::fs::write("output.hwpx", &bytes).unwrap(); }
HwpxStyleStore 생성 방법
with_default_fonts() — 간단한 기본 스타일
단일 글꼴 이름으로 빠르게 스타일 스토어를 생성합니다. 가장 간단한 방법입니다.
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxStyleStore; let style_store = HwpxStyleStore::with_default_fonts("함초롬바탕"); }
from_registry() — Blueprint 템플릿에서 변환
커스텀 YAML 스타일 템플릿을 적용할 때 사용합니다. 자세한 내용은 스타일 템플릿 참조.
#![allow(unused)] fn main() { use hwpforge::blueprint::builtins::builtin_default; use hwpforge::blueprint::registry::StyleRegistry; use hwpforge::hwpx::HwpxRegistryBridge; let template = builtin_default().unwrap(); let registry = StyleRegistry::from_template(&template).unwrap(); let bridge = HwpxRegistryBridge::from_registry(®istry).unwrap(); let style_store = bridge.style_store(); }
HwpxStyleStore::from_registry() 자체는 HWPX style table만 만듭니다.
Blueprint/Markdown 경로에서 만든 문서는 registry-local CharShapeIndex / ParaShapeIndex
를 들고 있으므로, encode 직전에는 HwpxRegistryBridge로 rebinding 해야 합니다.
라운드트립 예제 (decode → modify → encode)
기존 HWPX 파일을 읽어서 수정한 뒤 다시 저장하는 전형적인 패턴입니다.
#![allow(unused)] fn main() { use hwpforge_smithy_hwpx::{HwpxDecoder, HwpxEncoder}; use hwpforge_core::run::Run; use hwpforge_core::paragraph::Paragraph; use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex}; // 1. 기존 파일 디코딩 let mut result = HwpxDecoder::decode_file("original.hwpx").unwrap(); // 2. 문서 수정: 새 문단 추가 let new_para = Paragraph::with_runs( vec![Run::text("추가된 문단입니다.", CharShapeIndex::new(0))], ParaShapeIndex::new(0), ); // Draft 상태이므로 sections 직접 접근 가능 result.document.sections_mut()[0].paragraphs.push(new_para); // 3. 검증 후 인코딩 let validated = result.document.validate().unwrap(); let bytes = HwpxEncoder::encode( &validated, &result.style_store, &result.image_store, ).unwrap(); std::fs::write("modified.hwpx", &bytes).unwrap(); }
기존 텍스트 찾기 및 수정
특정 텍스트를 찾아 수정하려면 sections_mut()으로 가변 접근 후 RunContent::Text를 패턴 매칭합니다.
텍스트 치환 (find & replace)
#![allow(unused)] fn main() { use hwpforge_smithy_hwpx::{HwpxDecoder, HwpxEncoder}; use hwpforge_core::run::RunContent; // 1. 디코딩 let mut result = HwpxDecoder::decode_file("template.hwpx").unwrap(); // 2. 모든 섹션의 모든 문단을 순회하며 텍스트 치환 for section in result.document.sections_mut() { for paragraph in &mut section.paragraphs { for run in &mut paragraph.runs { if let RunContent::Text(ref mut text) = run.content { if text.contains("{{회사명}}") { *text = text.replace("{{회사명}}", "한국테크"); } if text.contains("{{날짜}}") { *text = text.replace("{{날짜}}", "2026년 3월 11일"); } } } } } // 3. 검증 후 저장 let validated = result.document.validate().unwrap(); let bytes = HwpxEncoder::encode(&validated, &result.style_store, &result.image_store).unwrap(); std::fs::write("output.hwpx", &bytes).unwrap(); }
재사용 가능한 치환 함수
#![allow(unused)] fn main() { use hwpforge_core::document::{Document, Draft}; use hwpforge_core::run::RunContent; /// 문서 내 모든 텍스트에서 `from`을 `to`로 치환합니다. /// 치환된 횟수를 반환합니다. fn replace_text(doc: &mut Document<Draft>, from: &str, to: &str) -> usize { let mut count = 0; for section in doc.sections_mut() { for paragraph in &mut section.paragraphs { for run in &mut paragraph.runs { if let RunContent::Text(ref mut text) = run.content { if text.contains(from) { *text = text.replace(from, to); count += 1; } } } } } count } }
완전한 읽기 → 수정 → 저장 예제
#![allow(unused)] fn main() { use hwpforge_smithy_hwpx::{HwpxDecoder, HwpxEncoder}; use hwpforge_core::run::{Run, RunContent}; use hwpforge_core::paragraph::Paragraph; use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex}; fn modify_document( input: &str, output: &str, ) -> Result<(), Box<dyn std::error::Error>> { // 읽기 let mut result = HwpxDecoder::decode_file(input) .map_err(|e| format!("디코딩 실패: {e}"))?; let sections = result.document.sections_mut(); // 기존 텍스트 수정 for section in sections.iter_mut() { for paragraph in &mut section.paragraphs { for run in &mut paragraph.runs { if let RunContent::Text(ref mut text) = run.content { *text = text.replace("초안", "최종본"); } } } } // 새 문단 추가 if let Some(first_section) = result.document.sections_mut().first_mut() { first_section.paragraphs.push(Paragraph::with_runs( vec![Run::text("— 이 문서는 자동으로 수정되었습니다.", CharShapeIndex::new(0))], ParaShapeIndex::new(0), )); } // 저장 let validated = result.document.validate() .map_err(|e| format!("검증 실패: {e}"))?; let bytes = HwpxEncoder::encode(&validated, &result.style_store, &result.image_store) .map_err(|e| format!("인코딩 실패: {e}"))?; std::fs::write(output, &bytes)?; Ok(()) } }
오류 처리
모든 함수는 HwpxResult<T>를 반환합니다. HwpxError는 HwpxErrorCode와 메시지를 포함합니다.
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxDecoder; match HwpxDecoder::decode_file("missing.hwpx") { Ok(_result) => println!("디코딩 성공"), Err(e) => eprintln!("디코딩 실패: {e}"), } }
엣지 케이스 및 주의사항
빈 문서
Document는 최소 1개의 섹션이 있어야 validate()를 통과합니다.
#![allow(unused)] fn main() { use hwpforge::core::{Document, Draft, PageSettings, Paragraph, Section}; use hwpforge::foundation::ParaShapeIndex; let mut doc = Document::<Draft>::new(); // ❌ 빈 문서 — validate() 실패 // let validated = doc.validate(); // Err: 섹션 없음 // ✅ 빈 문단이라도 하나 추가 doc.add_section(Section::with_paragraphs( vec![Paragraph::new(ParaShapeIndex::new(0))], PageSettings::a4(), )); let validated = doc.validate().unwrap(); // OK }
한국어/특수 문자
HwpForge는 내부적으로 UTF-8을 사용합니다. 한국어, 이모지, 특수 기호를 포함한 모든 유니코드 문자를 지원합니다.
#![allow(unused)] fn main() { use hwpforge::core::run::Run; use hwpforge::foundation::CharShapeIndex; // 모두 정상 동작 let run1 = Run::text("한글 텍스트 테스트", CharShapeIndex::new(0)); let run2 = Run::text("특수문자: ©®™ §¶ ±×÷", CharShapeIndex::new(0)); let run3 = Run::text("수학 기호: α β γ δ ∑ ∫", CharShapeIndex::new(0)); }
스타일 스토어 선택
| 생성 방법 | 용도 | 특징 |
|---|---|---|
with_default_fonts("글꼴명") | 빠른 프로토타이핑 | 한컴 Modern 22종 기본 스타일 |
from_registry(®istry) | 커스텀 템플릿 적용 | YAML로 정의한 스타일 사용 |
디코딩된 result.style_store | 기존 문서 수정 | 원본 스타일 보존 |
메타데이터 (Metadata)
HwpForge의 모든 문서는 Metadata 구조체를 통해 제목, 작성자, 작성일 등의 메타데이터를 관리합니다.
Metadata 구조체
#![allow(unused)] fn main() { pub struct Metadata { pub title: Option<String>, // 문서 제목 pub author: Option<String>, // 작성자 pub subject: Option<String>, // 주제/설명 pub keywords: Vec<String>, // 검색 키워드 pub created: Option<String>, // 작성일 (ISO 8601, 예: "2026-03-06") pub modified: Option<String>, // 수정일 (ISO 8601) } }
모든 필드는 선택적입니다. Metadata::default()는 모든 필드가 비어 있는 상태를 반환합니다.
기존 HWPX 파일에서 메타데이터 읽기
HwpxDecoder로 HWPX 파일을 디코딩한 후 document.metadata()로 접근합니다.
use hwpforge::hwpx::HwpxDecoder; fn main() -> anyhow::Result<()> { let result = HwpxDecoder::decode_file("document.hwpx")?; let meta = result.document.metadata(); // 개별 필드 접근 if let Some(title) = &meta.title { println!("제목: {}", title); } if let Some(author) = &meta.author { println!("작성자: {}", author); } if let Some(created) = &meta.created { println!("작성일: {}", created); } if let Some(subject) = &meta.subject { println!("주제: {}", subject); } if !meta.keywords.is_empty() { println!("키워드: {}", meta.keywords.join(", ")); } Ok(()) }
Markdown에서 메타데이터 설정
YAML Frontmatter로 메타데이터를 지정하면 MdDecoder가 자동으로 Metadata 필드에 매핑합니다.
#![allow(unused)] fn main() { use hwpforge::md::{MdDecoder, MdDocument}; let markdown = r#"--- title: 분기 보고서 author: 김철수 date: 2026-03-06 subject: 2026년 1분기 경영실적 보고 keywords: - 분기실적 - 경영보고 modified: 2026-03-10 --- 보고서 본문 내용이 여기에 들어갑니다. "#; let MdDocument { document, style_registry } = MdDecoder::decode_with_default(markdown).unwrap(); let meta = document.metadata(); assert_eq!(meta.title.as_deref(), Some("분기 보고서")); assert_eq!(meta.author.as_deref(), Some("김철수")); assert_eq!(meta.created.as_deref(), Some("2026-03-06")); assert_eq!(meta.subject.as_deref(), Some("2026년 1분기 경영실적 보고")); assert_eq!(meta.keywords, vec!["분기실적", "경영보고"]); assert_eq!(meta.modified.as_deref(), Some("2026-03-10")); }
Frontmatter 필드 매핑
| YAML 필드 | Metadata 필드 | 설명 |
|---|---|---|
title | title | 문서 제목 |
author | author | 작성자 |
date | created | 작성일 (ISO 8601) |
subject | subject | 주제/설명 |
keywords | keywords | 검색 키워드 (배열) |
modified | modified | 수정일 (ISO 8601) |
template | (스타일 선택) | 스타일 템플릿 이름 |
template은 메타데이터가 아닌 스타일 선택에 사용됩니다.
프로그래밍으로 메타데이터 설정
Document<Draft> 상태에서 metadata_mut()으로 직접 설정할 수 있습니다.
#![allow(unused)] fn main() { use hwpforge::core::{Document, Draft, Metadata, PageSettings, Paragraph, Run, Section}; use hwpforge::foundation::{CharShapeIndex, ParaShapeIndex}; let mut doc = Document::<Draft>::new(); // 메타데이터 설정 doc.metadata_mut().title = Some("제안서".to_string()); doc.metadata_mut().author = Some("홍길동".to_string()); doc.metadata_mut().created = Some("2026-03-06".to_string()); doc.metadata_mut().subject = Some("신규 사업 제안".to_string()); doc.metadata_mut().keywords = vec!["사업".to_string(), "제안".to_string()]; // 또는 Metadata 구조체를 직접 생성하여 설정 let meta = Metadata { title: Some("제안서".to_string()), author: Some("홍길동".to_string()), created: Some("2026-03-06".to_string()), ..Metadata::default() }; doc.set_metadata(meta); // 섹션 추가 후 검증/인코딩 doc.add_section(Section::with_paragraphs( vec![Paragraph::with_runs( vec![Run::text("본문 내용", CharShapeIndex::new(0))], ParaShapeIndex::new(0), )], PageSettings::a4(), )); let validated = doc.validate().unwrap(); }
CLI에서 메타데이터 확인
hwpforge inspect 명령으로 HWPX 파일의 메타데이터를 확인합니다.
# 사람이 읽기 좋은 출력
hwpforge inspect document.hwpx
# 출력 예시:
# Document: document.hwpx
# Title: 분기 보고서
# Author: 김철수
# Sections: 1
# [0] 5 paras, 1 tables, 0 images, 0 charts | header=false footer=false pagenum=false
# JSON 출력 (AI 에이전트용)
hwpforge inspect document.hwpx --json
# 출력 예시:
# {
# "status": "ok",
# "metadata": {
# "title": "분기 보고서",
# "author": "김철수"
# },
# "sections": [...]
# }
JSON 라운드트립에서 메타데이터
to-json으로 내보내면 메타데이터가 JSON에 포함됩니다.
hwpforge to-json document.hwpx -o doc.json
{
"document": {
"sections": [...],
"metadata": {
"title": "분기 보고서",
"author": "김철수",
"subject": null,
"keywords": [],
"created": "2026-03-06",
"modified": null
}
},
"styles": {...}
}
AI 에이전트가 JSON에서 메타데이터를 수정한 후 from-json으로 HWPX를 재생성할 수 있습니다.
# JSON 편집 후 HWPX로 변환
hwpforge from-json doc.json -o updated.hwpx
MCP 도구에서 메타데이터 확인
hwpforge_inspect MCP 도구로 메타데이터를 포함한 문서 구조를 확인합니다.
{
"tool": "hwpforge_inspect",
"arguments": {
"file_path": "/path/to/document.hwpx"
}
}
현재 제한사항
- HWPX 네이티브 메타데이터: 한글 프로그램으로 작성된 HWPX 파일의
META-INF/내 네이티브 메타데이터 추출은 아직 지원하지 않습니다. Markdown Frontmatter로 설정된 메타데이터와to-json/from-json라운드트립을 통한 메타데이터만 보존됩니다. - 타임스탬프 형식:
created/modified는Option<String>(ISO 8601 문자열)입니다.chrono등 날짜 라이브러리와 연동 시 직접 파싱이 필요합니다.
Markdown에서 HWPX로
HwpForge는 Markdown을 HWPX로 변환하는 완전한 파이프라인을 제공합니다. LLM이 Markdown을 생성하면 HwpForge가 이를 한글 문서로 자동 변환합니다.
MD → Core → HWPX 파이프라인
Markdown 문자열
|
v (MdDecoder::decode)
Document<Draft> + StyleRegistry
|
v (doc.validate())
Document<Validated>
|
v (HwpxEncoder::encode)
HWPX 바이트 → .hwpx 파일
각 단계는 독립적이므로, 중간 Core DOM을 직접 조작하거나 검사할 수 있습니다.
MdDecoder::decode() 사용법
#![allow(unused)] fn main() { use hwpforge::md::{MdDecoder, MdDocument}; let markdown = r#" --- title: 사업 제안서 author: 홍길동 date: 2026-03-06 --- 개요 본 제안서는 신규 사업 기회를 설명합니다. # 배경 시장 분석에 따르면 성장 가능성이 높습니다. "#; let MdDocument { document, style_registry } = MdDecoder::decode_with_default(markdown).unwrap(); println!("섹션 수: {}", document.sections().len()); }
MdDocument에는 document: Document<Draft>와 style_registry: StyleRegistry가 포함됩니다.
YAML Frontmatter
Markdown 파일 상단에 --- 블록으로 문서 메타데이터를 지정합니다.
---
title: 문서 제목 # Metadata.title
author: 작성자 이름 # Metadata.author
date: 2026-03-06 # Metadata.date (ISO 8601)
template: government # 사용할 스타일 템플릿 이름 (옵션)
---
지원 필드:
| 필드 | Metadata 필드 | 설명 |
|---|---|---|
title | title | 문서 제목 |
author | author | 작성자 |
date | created | 작성일 (ISO 8601) |
subject | subject | 주제/설명 |
keywords | keywords | 검색 키워드 (YAML 배열) |
modified | modified | 수정일 (ISO 8601) |
template | (스타일) | 스타일 템플릿 이름 (옵션) |
Frontmatter 없이도 디코딩이 가능하며, 메타데이터 필드는 빈 값으로 처리됩니다.
디코딩 후 메타데이터 확인
#![allow(unused)] fn main() { use hwpforge::md::{MdDecoder, MdDocument}; let markdown = "---\ntitle: 보고서\nauthor: 홍길동\ndate: 2026-03-06\n---\n\n# 본문\n"; let MdDocument { document, .. } = MdDecoder::decode_with_default(markdown).unwrap(); let meta = document.metadata(); assert_eq!(meta.title.as_deref(), Some("보고서")); assert_eq!(meta.author.as_deref(), Some("홍길동")); assert_eq!(meta.created.as_deref(), Some("2026-03-06")); }
전체 메타데이터 필드와 프로그래밍 설정 방법은 메타데이터 가이드를 참고하세요.
섹션 마커
<!-- hwpforge:section --> 주석으로 HWPX 섹션을 분리합니다. 한 Markdown 파일에서 여러 섹션(페이지 설정이 다른 구역)을 만들 때 유용합니다.
# 1장 개요
첫 번째 섹션 내용.
<!-- hwpforge:section -->
# 2장 본론
두 번째 섹션 — 다른 페이지 설정 가능.
H1-H6 → 개요 1-6 자동 매핑
Markdown 헤딩은 한글의 개요 스타일로 자동 변환됩니다.
| Markdown | 한글 스타일 |
|---|---|
# H1 | 개요 1 (style ID 2) |
## H2 | 개요 2 (style ID 3) |
### H3 | 개요 3 (style ID 4) |
#### H4 | 개요 4 (style ID 5) |
##### H5 | 개요 5 (style ID 6) |
###### H6 | 개요 6 (style ID 7) |
| 일반 문단 | 본문 (style ID 0) |
MdEncoder — Core → Markdown
반대 방향(HWPX → Markdown) 변환도 지원합니다. 두 가지 모드가 있습니다.
#![allow(unused)] fn main() { use hwpforge::md::MdEncoder; use hwpforge::hwpx::HwpxDecoder; let result = HwpxDecoder::decode_file("document.hwpx").unwrap(); let validated = result.document.validate().unwrap(); // Lossy 모드: 읽기 좋은 GFM (표, 이미지 등 일부 정보 손실) let gfm = MdEncoder::encode_lossy(&validated).unwrap(); // Lossless 모드: YAML frontmatter + HTML-like 마크업 (정보 보존) let lossless = MdEncoder::encode_lossless(&validated).unwrap(); }
| 모드 | 특징 | 용도 |
|---|---|---|
encode_lossy | 읽기 좋은 GFM | 사람이 읽는 문서 미리보기 |
encode_lossless | 구조 완전 보존 | 라운드트립, 백업 |
전체 파이프라인 예제 (MD string → HWPX file)
use hwpforge::md::{MdDecoder, MdDocument}; use hwpforge::hwpx::{HwpxEncoder, HwpxStyleStore}; fn markdown_to_hwpx(markdown: &str, output_path: &str) { // 1. Markdown 파싱 → Core DOM let MdDocument { document, .. } = MdDecoder::decode_with_default(markdown).unwrap(); // 2. 문서 검증 let validated = document.validate().unwrap(); // 3. 한컴 기본 스타일 적용 후 HWPX 인코딩 let style_store = HwpxStyleStore::with_default_fonts("함초롬바탕"); let image_store = Default::default(); let bytes = HwpxEncoder::encode(&validated, &style_store, &image_store).unwrap(); // 4. 파일 저장 std::fs::write(output_path, &bytes).unwrap(); println!("저장 완료: {output_path}"); } fn main() { let md = r#" --- title: AI 활용 정책 제안서 author: 정책팀 date: 2026-03-06 --- 제안 배경 인공지능 기술의 급속한 발전에 대응하여 정책 수립이 필요합니다. # 현황 분석 국내외 AI 활용 사례를 분석하였습니다. # 정책 방향 단계적 도입과 윤리적 기준 마련을 제안합니다. "#; markdown_to_hwpx(md, "proposal.hwpx"); }
HWPX → Markdown 변환 (RAG/LLM 활용)
HWPX 문서를 Markdown으로 변환하면 LLM이나 RAG(Retrieval-Augmented Generation) 시스템에서 직접 활용할 수 있습니다.
의존성 설정
Cargo.toml에 md 기능을 활성화합니다:
[dependencies]
hwpforge = { version = "0.1", features = ["md"] }
Lossy vs Lossless 모드 선택
| 기준 | Lossy (encode_lossy) | Lossless (encode_lossless) |
|---|---|---|
| 출력 형식 | 표준 GFM Markdown | YAML frontmatter + HTML 마크업 |
| 가독성 | 높음 (사람/LLM 모두) | 낮음 (기계 파싱용) |
| 정보 손실 | 스타일/레이아웃 일부 손실 | 구조 완전 보존 |
| RAG 추천 | 추천 — 청크 분할에 적합 | 원본 복원이 필요할 때만 |
| LLM 추천 | 추천 — 토큰 효율적 | 라운드트립 편집 시 |
RAG 시스템에서는 encode_lossy를 권장합니다. 표준 GFM으로 출력되어 청크 분할기(text splitter)와 호환성이 높고, 불필요한 마크업이 없어 토큰을 절약합니다.
완전한 HWPX → Markdown 예제 (에러 처리 포함)
use hwpforge::hwpx::HwpxDecoder; use hwpforge::md::MdEncoder; use std::path::Path; fn hwpx_to_markdown(input_path: &str) -> Result<String, Box<dyn std::error::Error>> { // 1. 파일 존재 여부 확인 let path = Path::new(input_path); if !path.exists() { return Err(format!("파일을 찾을 수 없습니다: {}", input_path).into()); } // 2. HWPX 디코딩 let result = HwpxDecoder::decode_file(input_path) .map_err(|e| format!("HWPX 디코딩 실패: {e}"))?; // 3. 메타데이터 확인 (선택) let meta = result.document.metadata(); if let Some(title) = &meta.title { eprintln!("문서 제목: {}", title); } // 4. Draft → Validated 상태 전이 let validated = result.document.validate() .map_err(|e| format!("문서 검증 실패: {e}"))?; // 5. Markdown 변환 (RAG용 lossy 모드) let markdown = MdEncoder::encode_lossy(&validated) .map_err(|e| format!("Markdown 인코딩 실패: {e}"))?; Ok(markdown) } fn main() { match hwpx_to_markdown("document.hwpx") { Ok(md) => { std::fs::write("output.md", &md).expect("파일 저장 실패"); println!("변환 완료: {} bytes", md.len()); } Err(e) => eprintln!("오류: {e}"), } }
대량 파일 변환
여러 HWPX 파일을 Markdown으로 일괄 변환합니다:
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxDecoder; use hwpforge::md::MdEncoder; use std::path::Path; fn batch_convert(input_dir: &str, output_dir: &str) -> Result<usize, Box<dyn std::error::Error>> { std::fs::create_dir_all(output_dir)?; let mut count = 0; for entry in std::fs::read_dir(input_dir)? { let entry = entry?; let path = entry.path(); if path.extension().is_some_and(|ext| ext == "hwpx") { let result = HwpxDecoder::decode_file(&path)?; let validated = result.document.validate()?; let markdown = MdEncoder::encode_lossy(&validated)?; let out_name = path.file_stem().unwrap().to_string_lossy(); let out_path = Path::new(output_dir).join(format!("{}.md", out_name)); std::fs::write(&out_path, &markdown)?; eprintln!("변환: {} → {}", path.display(), out_path.display()); count += 1; } } Ok(count) } }
CLI로 변환
# Markdown → HWPX
hwpforge convert report.md -o report.hwpx
# HWPX 구조 확인 후 JSON으로 추출 (Markdown 변환 대안)
hwpforge inspect document.hwpx --json
hwpforge to-json document.hwpx -o document.json
참고: CLI의
convert명령은 현재 Markdown → HWPX 방향만 지원합니다. HWPX → Markdown 변환은 Rust API(MdEncoder)를 사용하세요.
스타일 템플릿 (YAML)
Blueprint 개념: 구조와 스타일 분리
HwpForge는 HTML+CSS와 동일한 철학으로 **구조(Core)**와 **스타일(Blueprint)**을 분리합니다.
Core (Document, Section, Paragraph, Run)
= HTML — "무엇이 있는가"
Blueprint (Template, StyleRegistry, CharShape, ParaShape)
= CSS — "어떻게 보이는가"
Core의 문단과 런은 스타일 인덱스(ParaShapeIndex, CharShapeIndex)만 참조합니다. 실제 폰트 이름이나 크기는 Blueprint에 정의됩니다. 덕분에 동일한 문서 구조에 다른 템플릿을 적용해 전혀 다른 외관의 HWPX를 생성할 수 있습니다.
Template YAML 구조
meta:
name: my-template
version: "1.0"
description: "커스텀 스타일 템플릿"
styles:
body:
font: "한컴바탕"
size: 10pt
line_spacing: 160%
alignment: justify
heading1:
inherits: body # body에서 상속
font: "한컴고딕"
size: 16pt
bold: true
heading2:
inherits: heading1
size: 14pt
상속 (Inheritance)
inherits 키로 다른 스타일을 상속받습니다. 상속 체인은 DFS로 해결되며, 자식 스타일의 값이 부모를 덮어씁니다. Option 필드(PartialCharShape, PartialParaShape)를 병합하는 two-type 패턴으로 구현됩니다.
StyleRegistry: from_template() 사용법
#![allow(unused)] fn main() { use hwpforge_blueprint::template::Template; use hwpforge_blueprint::registry::StyleRegistry; let yaml = r#" meta: name: custom version: "1.0" styles: body: font: "나눔명조" size: 11pt "#; // YAML → Template → StyleRegistry let template = Template::from_yaml(yaml).unwrap(); let registry = StyleRegistry::from_template(&template).unwrap(); // 인덱스 기반 접근 (브랜드 타입으로 혼용 방지) let body_entry = registry.get_style("body").unwrap(); let char_shape = registry.char_shape(body_entry.char_shape_id).unwrap(); println!("폰트: {}", char_shape.font); // "나눔명조" println!("크기: {:?}", char_shape.size); // HwpUnit }
내장 템플릿: builtin_default()
별도 YAML 없이 즉시 사용 가능한 기본 템플릿입니다.
#![allow(unused)] fn main() { use hwpforge_blueprint::builtins::builtin_default; use hwpforge_blueprint::registry::StyleRegistry; let template = builtin_default().unwrap(); assert_eq!(template.meta.name, "default"); let registry = StyleRegistry::from_template(&template).unwrap(); let body = registry.get_style("body").unwrap(); let cs = registry.char_shape(body.char_shape_id).unwrap(); assert_eq!(cs.font, "한컴바탕"); }
HwpxRegistryBridge 변환: from_registry()
Blueprint의 StyleRegistry를 HWPX 인코더 경계에서 안전하게 쓰기 위한 bridge를 만듭니다.
이 bridge는 두 가지를 함께 맡습니다.
HwpxStyleStore생성- registry-local
CharShapeIndex/ParaShapeIndex를 store-local HWPX id로 rebinding
#![allow(unused)] fn main() { use hwpforge_blueprint::builtins::builtin_default; use hwpforge_blueprint::registry::StyleRegistry; use hwpforge_smithy_hwpx::HwpxRegistryBridge; let template = builtin_default().unwrap(); let registry = StyleRegistry::from_template(&template).unwrap(); // Blueprint StyleRegistry → HWPX encode bridge let bridge = HwpxRegistryBridge::from_registry(®istry).unwrap(); let style_store = bridge.style_store(); }
한컴 스타일셋: Classic / Modern / Latest
한글 프로그램은 버전에 따라 다른 기본 스타일 구성을 사용합니다.
| 스타일셋 | 스타일 수 | 설명 |
|---|---|---|
Classic | 18개 | 한글 구버전 호환 |
Modern | 22개 | 기본값 (한글 2018~) |
Latest | 23개 | 최신 버전 |
Modern은 개요 8/9/10을 스타일 ID 9-11에 삽입하므로 인덱스가 Classic과 다릅니다.
#![allow(unused)] fn main() { use hwpforge_smithy_hwpx::{HwpxStyleStore, HancomStyleSet}; // 기본값 (간단한 방법) let modern = HwpxStyleStore::with_default_fonts("함초롬바탕"); // 특정 스타일셋 지정 // from_registry_with()로 커스텀 레지스트리 + 스타일셋 조합 가능 }
예제: 커스텀 스타일로 문서 생성
#![allow(unused)] fn main() { use hwpforge_blueprint::template::Template; use hwpforge_blueprint::registry::StyleRegistry; use hwpforge_smithy_hwpx::{HwpxEncoder, HwpxRegistryBridge}; use hwpforge_core::{Document, Section, Paragraph, PageSettings}; use hwpforge_core::run::Run; use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex}; let yaml = r#" meta: name: report version: "1.0" styles: body: font: "맑은 고딕" size: 10pt line_spacing: 150% title: inherits: body font: "맑은 고딕" size: 20pt bold: true alignment: center "#; // 스타일 빌드 let template = Template::from_yaml(yaml).unwrap(); let registry = StyleRegistry::from_template(&template).unwrap(); let bridge = HwpxRegistryBridge::from_registry(®istry).unwrap(); // 문서 구성 (스타일 인덱스는 레지스트리에서 조회) let mut doc = Document::new(); doc.add_section(Section::with_paragraphs( vec![ // 제목 문단 (ParaShapeIndex 0 = title) Paragraph::with_runs( vec![Run::text("분기 보고서", CharShapeIndex::new(0))], ParaShapeIndex::new(0), ), // 본문 문단 (ParaShapeIndex 1 = body) Paragraph::with_runs( vec![Run::text("1분기 실적은 목표를 초과 달성했습니다.", CharShapeIndex::new(1))], ParaShapeIndex::new(1), ), ], PageSettings::a4(), )); let rebound = bridge.rebind_draft_document(doc).unwrap(); let validated = rebound.validate().unwrap(); let image_store = Default::default(); let bytes = HwpxEncoder::encode(&validated, bridge.style_store(), &image_store).unwrap(); std::fs::write("report.hwpx", &bytes).unwrap(); }
차트 생성
HwpForge는 OOXML 차트 형식(xmlns:c)을 사용해 18종의 차트를 HWPX 문서에 삽입할 수 있습니다.
지원 차트 종류 (18종)
| 변형 | 설명 |
|---|---|
Bar | 가로 막대 차트 |
Column | 세로 막대 차트 |
Bar3D / Column3D | 3D 막대/세로 막대 |
Line / Line3D | 꺾은선 / 3D 꺾은선 |
Pie / Pie3D | 원형 / 3D 원형 |
Doughnut | 도넛 차트 |
OfPie | 원형-of-원형 / 막대-of-원형 |
Area / Area3D | 영역 / 3D 영역 |
Scatter | 분산형 (XY) |
Bubble | 버블 차트 |
Radar | 방사형 차트 |
Surface / Surface3D | 표면 / 3D 표면 |
Stock | 주식 차트 (HLC/OHLC/VHLC/VOHLC) |
Control::Chart 생성 방법
차트는 Control::Chart 변형으로 표현됩니다. Run::control()로 런에 삽입하고, 그 런을 문단에 넣습니다.
#![allow(unused)] fn main() { use hwpforge_core::control::Control; use hwpforge_core::chart::{ChartType, ChartData, ChartGrouping, LegendPosition}; use hwpforge_foundation::HwpUnit; let chart = Control::Chart { chart_type: ChartType::Column, data: ChartData::category( &["1월", "2월", "3월", "4월"], &[("매출", &[1200.0, 1500.0, 1350.0, 1800.0])], ), title: Some("월별 매출".to_string()), legend: LegendPosition::Bottom, grouping: ChartGrouping::Clustered, width: HwpUnit::from_mm(120.0).unwrap(), height: HwpUnit::from_mm(80.0).unwrap(), }; }
ChartData: Category vs Xy 방식
Category 방식 (막대, 꺾은선, 원형, 영역, 방사형 등)
카테고리 레이블(X축)과 여러 시리즈로 구성됩니다. 대부분의 차트 종류에 사용합니다.
#![allow(unused)] fn main() { use hwpforge_core::chart::ChartData; // 편의 생성자: cats 슬라이스 + (이름, 값 슬라이스) 튜플 배열 let data = ChartData::category( &["1분기", "2분기", "3분기", "4분기"], &[ ("매출액", &[4200.0, 5100.0, 4800.0, 6200.0]), ("비용", &[3100.0, 3400.0, 3200.0, 3900.0]), ], ); }
Xy 방식 (분산형, 버블)
X값과 Y값 쌍으로 구성됩니다. 두 변수 간의 관계를 나타낼 때 사용합니다.
#![allow(unused)] fn main() { use hwpforge_core::chart::ChartData; // (이름, x값 슬라이스, y값 슬라이스) 튜플 배열 let data = ChartData::xy(&[ ("데이터셋 A", &[1.0, 2.0, 3.0, 4.0], &[2.1, 3.9, 6.2, 7.8]), ("데이터셋 B", &[1.0, 2.0, 3.0, 4.0], &[1.5, 3.0, 5.0, 6.5]), ]); }
ChartSeries, XySeries 구조
시리즈를 직접 구성할 때는 구조체를 사용합니다.
#![allow(unused)] fn main() { use hwpforge_core::chart::{ChartData, ChartSeries, XySeries}; // Category용 시리즈 let series = ChartSeries { name: "판매량".to_string(), values: vec![100.0, 150.0, 200.0], }; let data = ChartData::Category { categories: vec!["A".to_string(), "B".to_string(), "C".to_string()], series: vec![series], }; // XY용 시리즈 let xy_series = XySeries { name: "측정값".to_string(), x_values: vec![0.0, 1.0, 2.0], y_values: vec![0.0, 1.0, 4.0], }; }
차트를 문단에 삽입하는 패턴
차트 Control을 Run::control()로 감싼 뒤, Paragraph::with_runs()에 포함시킵니다.
#![allow(unused)] fn main() { use hwpforge_core::control::Control; use hwpforge_core::chart::{ChartType, ChartData, ChartGrouping, LegendPosition}; use hwpforge_core::run::Run; use hwpforge_core::paragraph::Paragraph; use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex, HwpUnit}; let chart_control = Control::Chart { chart_type: ChartType::Column, data: ChartData::category( &["A", "B", "C"], &[("값", &[10.0, 20.0, 30.0])], ), title: None, legend: LegendPosition::Right, grouping: ChartGrouping::Clustered, width: HwpUnit::from_mm(100.0).unwrap(), height: HwpUnit::from_mm(70.0).unwrap(), }; let para = Paragraph::with_runs( vec![Run::control(chart_control, CharShapeIndex::new(0))], ParaShapeIndex::new(0), ); }
예제: 막대 차트 (Column)
#![allow(unused)] fn main() { use hwpforge_core::control::Control; use hwpforge_core::chart::{ChartType, ChartData, ChartGrouping, LegendPosition}; use hwpforge_core::run::Run; use hwpforge_core::paragraph::Paragraph; use hwpforge_core::{Document, Section, PageSettings}; use hwpforge_smithy_hwpx::{HwpxEncoder, HwpxStyleStore}; use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex, HwpUnit}; let data = ChartData::category( &["2022", "2023", "2024", "2025"], &[ ("국내 매출", &[3200.0, 4100.0, 5300.0, 6800.0]), ("해외 매출", &[1100.0, 1800.0, 2700.0, 3900.0]), ], ); let chart = Control::Chart { chart_type: ChartType::Column, data, title: Some("연도별 매출 현황 (단위: 백만원)".to_string()), legend: LegendPosition::Bottom, grouping: ChartGrouping::Clustered, width: HwpUnit::from_mm(140.0).unwrap(), height: HwpUnit::from_mm(90.0).unwrap(), }; let mut doc = Document::new(); doc.add_section(Section::with_paragraphs( vec![Paragraph::with_runs( vec![Run::control(chart, CharShapeIndex::new(0))], ParaShapeIndex::new(0), )], PageSettings::a4(), )); let validated = doc.validate().unwrap(); let bytes = HwpxEncoder::encode( &validated, &HwpxStyleStore::with_default_fonts("함초롬바탕"), &Default::default(), ).unwrap(); std::fs::write("bar_chart.hwpx", &bytes).unwrap(); }
예제: 원형 차트 (Pie)
#![allow(unused)] fn main() { use hwpforge_core::control::Control; use hwpforge_core::chart::{ChartType, ChartData, ChartGrouping, LegendPosition}; use hwpforge_foundation::{HwpUnit}; // 원형 차트는 단일 시리즈 사용 let chart = Control::Chart { chart_type: ChartType::Pie, data: ChartData::category( &["서울", "경기", "부산", "기타"], &[("비율", &[38.5, 25.2, 12.8, 23.5])], ), title: Some("지역별 매출 비중".to_string()), legend: LegendPosition::Right, grouping: ChartGrouping::Standard, // Pie는 Standard 사용 width: HwpUnit::from_mm(100.0).unwrap(), height: HwpUnit::from_mm(80.0).unwrap(), }; }
주의: 차트 XML은 ZIP에 포함되지만
content.hpf매니페스트에는 등록하지 않습니다. 매니페스트에 등록하면 한글이 크래시합니다.
텍스트 추출 (Text Extraction)
HwpForge의 Core DOM을 활용하여 문서에서 텍스트를 추출하고, 문서 구조(섹션, 문단, 표, 각주 등)를 보존하는 방법을 설명합니다.
포맷 지원 현황: 현재 HWPX(
.hwpx)와 Markdown(.md)는 이 가이드의 예제대로 바로 텍스트 추출할 수 있습니다. 레거시 HWP5(.hwp)는 전용 crate/CLI 경로가 이미 존재하지만, top-level guide는 아직 HWPX/Markdown 중심으로 설명합니다. 자세한 내용은 이중 포맷 파이프라인을 참고하세요.
문서 구조 개요
HwpForge 문서는 다음과 같은 트리 구조를 가집니다:
Document
├── Metadata (title, author, created, ...)
├── Section 0
│ ├── PageSettings (용지 크기, 여백)
│ ├── Header / Footer / PageNumber (선택)
│ ├── Paragraph 0
│ │ ├── para_shape (문단 스타일 인덱스)
│ │ └── Run[]
│ │ ├── Run { content: Text("본문 텍스트"), char_shape }
│ │ ├── Run { content: Table(...), char_shape }
│ │ ├── Run { content: Image(...), char_shape }
│ │ └── Run { content: Control(Footnote/TextBox/...), char_shape }
│ ├── Paragraph 1
│ │ └── ...
│ └── ...
├── Section 1
│ └── ...
└── ...
핵심 타입:
| 타입 | 설명 |
|---|---|
RunContent::Text(String) | 일반 텍스트 |
RunContent::Table(Box<Table>) | 인라인 표 |
RunContent::Image(Image) | 인라인 이미지 |
RunContent::Control(Box<Control>) | 컨트롤 (글상자, 하이퍼링크, 각주, 도형 등) |
기본 텍스트 추출
가장 간단한 패턴: 모든 섹션의 모든 문단에서 텍스트만 추출합니다.
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxDecoder; use hwpforge::core::run::RunContent; let result = HwpxDecoder::decode_file("document.hwpx").unwrap(); let doc = &result.document; for section in doc.sections() { for paragraph in §ion.paragraphs { for run in ¶graph.runs { if let RunContent::Text(ref text) = run.content { print!("{}", text); } } println!(); // 문단 끝 줄바꿈 } } }
구조 보존 텍스트 추출
문서 구조(섹션, 문단, 표, 각주 등)를 보존하면서 텍스트를 추출합니다.
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxDecoder; use hwpforge::core::run::RunContent; use hwpforge::core::control::Control; use hwpforge::core::paragraph::Paragraph; let result = HwpxDecoder::decode_file("document.hwpx").unwrap(); let doc = &result.document; // 메타데이터 출력 let meta = doc.metadata(); if let Some(title) = &meta.title { println!("=== {} ===", title); } for (sec_idx, section) in doc.sections().iter().enumerate() { println!("\n--- 섹션 {} ---", sec_idx + 1); // 머리글 텍스트 if let Some(header) = §ion.header { print!("[머리글] "); extract_paragraphs(&header.paragraphs); } // 본문 문단 for paragraph in §ion.paragraphs { extract_paragraph(paragraph, 0); } // 바닥글 텍스트 if let Some(footer) = §ion.footer { print!("[바닥글] "); extract_paragraphs(&footer.paragraphs); } } /// 단일 문단에서 텍스트 추출 (들여쓰기 레벨 지원) fn extract_paragraph(para: &Paragraph, indent: usize) { let prefix = " ".repeat(indent); print!("{}", prefix); for run in ¶.runs { match &run.content { RunContent::Text(text) => print!("{}", text), RunContent::Table(table) => { println!("\n{}[표 {}x{}]", prefix, table.row_count(), table.col_count()); for (r, row) in table.rows.iter().enumerate() { for (c, cell) in row.cells.iter().enumerate() { print!("{} [{},{}] ", prefix, r, c); extract_paragraphs(&cell.paragraphs); } } } RunContent::Image(img) => { print!("[이미지: {}]", img.source_path); } RunContent::Control(ctrl) => { extract_control(ctrl, indent); } } } println!(); } /// 컨트롤 요소에서 텍스트 추출 fn extract_control(ctrl: &Control, indent: usize) { match ctrl.as_ref() { Control::TextBox { paragraphs, .. } => { print!("[글상자] "); extract_paragraphs(paragraphs); } Control::Hyperlink { text, url, .. } => { print!("[링크: {} → {}]", text, url); } Control::Footnote { paragraphs, .. } => { print!("[각주: "); extract_paragraphs(paragraphs); print!("]"); } Control::Endnote { paragraphs, .. } => { print!("[미주: "); extract_paragraphs(paragraphs); print!("]"); } // 도형 (Line, Ellipse, Polygon 등)은 텍스트 없음 — 건너뜀 _ => {} } } /// 문단 목록에서 텍스트 추출 (헬퍼) fn extract_paragraphs(paragraphs: &[Paragraph]) { for para in paragraphs { for run in ¶.runs { if let RunContent::Text(ref text) = run.content { print!("{}", text); } } } } }
콘텐츠 요약 (빠른 분석)
문서의 구조적 특성을 빠르게 파악하려면 content_counts()를 사용합니다.
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxDecoder; let result = HwpxDecoder::decode_file("document.hwpx").unwrap(); for (i, section) in result.document.sections().iter().enumerate() { let counts = section.content_counts(); println!( "섹션 {}: {} 문단, {} 표, {} 이미지, {} 차트", i, section.paragraphs.len(), counts.tables, counts.images, counts.charts ); println!( " 머리글={} 바닥글={} 쪽번호={}", section.header.is_some(), section.footer.is_some(), section.page_number.is_some() ); } }
CLI로 텍스트 추출
inspect — 구조 요약
hwpforge inspect document.hwpx --json
to-json — 전체 DOM을 JSON으로 내보내기
JSON 출력에는 모든 텍스트와 구조 정보가 포함됩니다.
hwpforge to-json document.hwpx -o doc.json
Markdown 변환 — 읽기 쉬운 텍스트 추출
Rust API를 통해 HWPX를 Markdown으로 변환하면 구조를 보존한 텍스트를 얻을 수 있습니다.
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxDecoder; use hwpforge::md::MdEncoder; let result = HwpxDecoder::decode_file("document.hwpx").unwrap(); let validated = result.document.validate().unwrap(); // 사람이 읽기 좋은 GFM (헤딩, 표, 목록 구조 보존) let markdown = MdEncoder::encode_lossy(&validated).unwrap(); println!("{}", markdown); }
이 방법은 문서 구조(헤딩 계층, 표, 목록, 인용 등)를 Markdown 형식으로 자연스럽게 보존합니다.
레거시 HWP5 파일 처리
레거시 HWP5(.hwp) 파일은 현재도 다룰 수 있습니다. 다만 public guide의 중심 경로는 아직 HWPX/Markdown 쪽입니다.
현재 선택지는 이렇습니다.
- CLI workflow 사용:
convert-hwp5,audit-hwp5,census-hwp5 - 전용 crate 사용:
hwpforge-smithy-hwp5의Hwp5Decoder - HWPX로 재출력 후 기존 guide 재사용: 변환 결과를 HWPX guide와 같은 방식으로 처리
전용 crate 경로 예시는 다음과 같습니다.
#![allow(unused)] fn main() { use hwpforge_smithy_hwp5::Hwp5Decoder; use hwpforge_core::run::RunContent; let result = Hwp5Decoder::decode_file("legacy.hwp").unwrap(); let doc = &result.document; for section in doc.sections() { for paragraph in §ion.paragraphs { for run in ¶graph.runs { if let RunContent::Text(ref text) = run.content { print!("{}", text); } } println!(); } } }
주의:
- HWP5 경로는 warning-first가 기본입니다.
- visual parity나 layout fidelity는 HWPX path보다 더 까다롭습니다.
- stable top-level facade는 여전히 HWPX/Markdown 중심이므로, HWP5는 전용 crate 또는 CLI를 우선 보십시오.
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로 점진적으로 넓혀 가는 중이다.
대규모 HWP 아카이브 마이그레이션
레거시 HWP/HWPX 문서 아카이브를 검색 가능한 Markdown으로 마이그레이션하는 전략을 설명합니다.
마이그레이션 파이프라인 개요
┌────────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ 1. 스캔 │ ──▶ │ 2. 분류 │ ──▶ │ 3. 변환 │ ──▶ │ 4. 검증 │
│ 파일 목록 수집 │ │ 포맷 감지 │ │ Core → MD │ │ 무결성 확인 │
│ │ │ HWP5/HWPX │ │ lossy 모드 │ │ 결과 기록 │
└────────────────┘ └──────────────┘ └─────────────┘ └──────────────┘
1단계: 파일 스캔 및 포맷 분류
파일 확장자와 매직 바이트로 포맷을 감지합니다.
#![allow(unused)] fn main() { use std::path::{Path, PathBuf}; #[derive(Debug)] enum DocFormat { Hwpx, // ZIP + XML (PK 시그니처) Hwp5, // OLE2/CFB (D0 CF 시그니처) Unknown(String), } #[derive(Debug)] struct ScanResult { path: PathBuf, format: DocFormat, size_bytes: u64, } fn scan_archive(dir: &Path) -> Vec<ScanResult> { let mut results = Vec::new(); let walker = walkdir::WalkDir::new(dir) .into_iter() .filter_map(|e| e.ok()); for entry in walker { let path = entry.path(); let ext = path.extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); if ext != "hwp" && ext != "hwpx" { continue; } let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0); let format = detect_format(path); results.push(ScanResult { path: path.to_path_buf(), format, size_bytes, }); } results } fn detect_format(path: &Path) -> DocFormat { let Ok(bytes) = std::fs::read(path) else { return DocFormat::Unknown("읽기 실패".into()); }; if bytes.len() < 4 { return DocFormat::Unknown("파일이 너무 작음".into()); } // ZIP (HWPX): PK\x03\x04 if bytes.starts_with(&[0x50, 0x4B, 0x03, 0x04]) { return DocFormat::Hwpx; } // OLE2/CFB (HWP5): D0 CF 11 E0 if bytes.starts_with(&[0xD0, 0xCF, 0x11, 0xE0]) { return DocFormat::Hwp5; } DocFormat::Unknown(format!("알 수 없는 시그니처: {:02X} {:02X}", bytes[0], bytes[1])) } }
2단계: 변환 (HWPX 중심 + HWP5 별도 경로)
기본 migration sample은 HWPX를 중심으로 설명합니다. 다만 현재는 HWP5도 전용 crate와 CLI로 decode, audit, HWPX re-emission 경로를 사용할 수 있습니다.
HWPX 파일 변환
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxDecoder; use hwpforge::md::MdEncoder; use std::path::Path; #[derive(Debug)] struct ConvertResult { source: String, status: ConvertStatus, markdown_len: usize, title: Option<String>, } #[derive(Debug)] enum ConvertStatus { Success, DecodeError(String), ValidationError(String), EncodeError(String), } fn convert_hwpx(input: &Path) -> ConvertResult { let source = input.display().to_string(); // 1. 디코딩 (손상 파일 처리) let result = match HwpxDecoder::decode_file(input) { Ok(r) => r, Err(e) => { return ConvertResult { source, status: ConvertStatus::DecodeError(e.to_string()), markdown_len: 0, title: None, }; } }; let title = result.document.metadata().title.clone(); // 2. 검증 let validated = match result.document.validate() { Ok(v) => v, Err(e) => { return ConvertResult { source, status: ConvertStatus::ValidationError(e.to_string()), markdown_len: 0, title, }; } }; // 3. Markdown 변환 (RAG/검색용 lossy 모드) match MdEncoder::encode_lossy(&validated) { Ok(md) => ConvertResult { source, status: ConvertStatus::Success, markdown_len: md.len(), title, }, Err(e) => ConvertResult { source, status: ConvertStatus::EncodeError(e.to_string()), markdown_len: 0, title, }, } } }
HWP5 파일 처리
레거시 HWP5(.hwp) 파일도 현재 다룰 수 있습니다. 다만 대규모 migration에서는 HWPX 중심 파이프라인과 HWP5 전용 파이프라인을 분리하는 편이 운영이 쉽습니다.
| 전략 | 설명 | 자동화 |
|---|---|---|
| HwpForge CLI | convert-hwp5, audit-hwp5, census-hwp5로 decode/점검/재출력 | 완전 자동 |
| 전용 crate | hwpforge-smithy-hwp5로 HWP5 decode 후 Core/HWPX 경로 재사용 | 자동 |
| 별도 분류 | HWP5 파일만 분리해 별도 queue로 처리 | 수동 |
// 현재 — HWP5 직접 decode 후 기존 pipeline에 연결
// use hwpforge_smithy_hwp5::Hwp5Decoder;
//
// let result = Hwp5Decoder::decode_file("legacy.hwp")?;
// let validated = result.document.validate()?;
// let markdown = MdEncoder::encode_lossy(&validated)?;
3단계: 배치 처리 아키텍처
대규모 아카이브(수천~수만 파일)를 안정적으로 처리하는 패턴입니다.
#![allow(unused)] fn main() { use std::path::{Path, PathBuf}; use std::fs; use hwpforge::hwpx::HwpxDecoder; use hwpforge::md::MdEncoder; struct MigrationConfig { input_dir: PathBuf, output_dir: PathBuf, error_dir: PathBuf, /// 개별 파일 처리 제한 시간 (초) timeout_secs: u64, /// 최대 파일 크기 (바이트, 기본 100MB) max_file_size: u64, } struct MigrationReport { total: usize, success: usize, failed: usize, skipped_hwp5: usize, skipped_too_large: usize, errors: Vec<(String, String)>, } fn run_migration(config: &MigrationConfig) -> MigrationReport { fs::create_dir_all(&config.output_dir).expect("출력 디렉토리 생성 실패"); fs::create_dir_all(&config.error_dir).expect("오류 디렉토리 생성 실패"); let mut report = MigrationReport { total: 0, success: 0, failed: 0, skipped_hwp5: 0, skipped_too_large: 0, errors: Vec::new(), }; let files: Vec<_> = walkdir::WalkDir::new(&config.input_dir) .into_iter() .filter_map(|e| e.ok()) .filter(|e| { e.path().extension() .is_some_and(|ext| ext == "hwpx" || ext == "hwp") }) .collect(); report.total = files.len(); eprintln!("총 {} 파일 발견", report.total); for (i, entry) in files.iter().enumerate() { let path = entry.path(); let rel_path = path.strip_prefix(&config.input_dir).unwrap_or(path); // 진행률 표시 if (i + 1) % 100 == 0 || i + 1 == report.total { eprintln!("[{}/{}] 처리 중...", i + 1, report.total); } // 예시 단순화를 위해 HWP5는 별도 queue로 분리 if path.extension().is_some_and(|ext| ext == "hwp") { report.skipped_hwp5 += 1; continue; } // 파일 크기 제한 let size = entry.metadata().map(|m| m.len()).unwrap_or(0); if size > config.max_file_size { report.skipped_too_large += 1; continue; } // 변환 시도 match convert_single(path, &config.output_dir, rel_path) { Ok(_) => report.success += 1, Err(e) => { report.failed += 1; report.errors.push((path.display().to_string(), e.clone())); // 실패 파일을 오류 디렉토리에 복사 let err_dest = config.error_dir.join(rel_path); if let Some(parent) = err_dest.parent() { let _ = fs::create_dir_all(parent); } let _ = fs::copy(path, err_dest); } } } report } fn convert_single( input: &Path, output_dir: &Path, rel_path: &Path, ) -> Result<(), String> { let result = HwpxDecoder::decode_file(input) .map_err(|e| format!("디코딩 실패: {e}"))?; let validated = result.document.validate() .map_err(|e| format!("검증 실패: {e}"))?; let markdown = MdEncoder::encode_lossy(&validated) .map_err(|e| format!("MD 인코딩 실패: {e}"))?; // 출력 경로: .hwpx → .md let out_name = rel_path.with_extension("md"); let out_path = output_dir.join(out_name); if let Some(parent) = out_path.parent() { fs::create_dir_all(parent).map_err(|e| format!("디렉토리 생성 실패: {e}"))?; } fs::write(&out_path, &markdown).map_err(|e| format!("파일 쓰기 실패: {e}"))?; Ok(()) } }
4단계: 무결성 검증
변환 결과의 품질을 검증합니다.
기본 검증 항목
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxDecoder; use hwpforge::md::MdEncoder; use std::path::Path; struct IntegrityCheck { source_sections: usize, source_paragraphs: usize, source_tables: usize, markdown_lines: usize, markdown_bytes: usize, has_title: bool, } fn verify_conversion(hwpx_path: &Path, markdown: &str) -> Option<IntegrityCheck> { let result = HwpxDecoder::decode_file(hwpx_path).ok()?; let doc = &result.document; let mut total_paragraphs = 0; let mut total_tables = 0; for section in doc.sections() { total_paragraphs += section.paragraphs.len(); total_tables += section.content_counts().tables; } Some(IntegrityCheck { source_sections: doc.sections().len(), source_paragraphs: total_paragraphs, source_tables: total_tables, markdown_lines: markdown.lines().count(), markdown_bytes: markdown.len(), has_title: doc.metadata().title.is_some(), }) } }
검증 체크리스트
| 항목 | 방법 | 허용 기준 |
|---|---|---|
| 텍스트 보존 | 원본 문단 수 vs Markdown 비어있지 않은 줄 수 | 손실 < 10% |
| 표 구조 | 원본 표 수 vs Markdown | 테이블 수 | 동일 |
| 메타데이터 | YAML frontmatter에 title/author 존재 | 원본과 일치 |
| 파일 크기 | Markdown 바이트 > 0 | 빈 파일 없음 |
| 인코딩 | UTF-8 유효성 | 깨진 문자 없음 |
손상 파일 처리 전략
대규모 아카이브에서는 손상되거나 비표준 파일이 불가피합니다.
일반적인 오류 유형과 대응
| 오류 | 원인 | 대응 |
|---|---|---|
| ZIP 파싱 실패 | 파일 손상, 불완전 다운로드 | 오류 목록에 기록, 원본 보존 |
| XML 파싱 실패 | 비표준 네임스페이스, 잘못된 인코딩 | 오류 목록에 기록, 수동 검토 |
| 검증 실패 | 빈 섹션, 유효하지 않은 인덱스 | 경고 후 계속 진행 |
| OOM (메모리 부족) | 매우 큰 임베디드 이미지 | 파일 크기 제한으로 사전 필터링 |
| 암호화된 파일 | 비밀번호 보호 | 별도 목록으로 분류 |
에러 리포트 생성
#![allow(unused)] fn main() { use std::fs; struct MigrationReport { total: usize, success: usize, failed: usize, skipped_hwp5: usize, skipped_too_large: usize, errors: Vec<(String, String)>, } fn write_report(report: &MigrationReport, path: &str) { let mut lines = Vec::new(); lines.push(format!("# 마이그레이션 리포트\n")); lines.push(format!("- 총 파일: {}", report.total)); lines.push(format!("- 성공: {}", report.success)); lines.push(format!("- 실패: {}", report.failed)); lines.push(format!("- HWP5 별도 처리: {}", report.skipped_hwp5)); lines.push(format!("- 크기 초과: {}", report.skipped_too_large)); if !report.errors.is_empty() { lines.push(format!("\n## 실패 목록\n")); lines.push(format!("| 파일 | 오류 |")); lines.push(format!("| --- | --- |")); for (file, err) in &report.errors { lines.push(format!("| `{}` | {} |", file, err)); } } fs::write(path, lines.join("\n")).expect("리포트 저장 실패"); } }
Lossy vs Lossless 모드 선택
| 목적 | 권장 모드 | 이유 |
|---|---|---|
| RAG/검색 인덱싱 | encode_lossy | 표준 GFM, 청크 분할 호환, 토큰 절약 |
| 아카이브 백업 | encode_lossless | 구조 완전 보존, 원본 복원 가능 |
| 하이브리드 | 둘 다 생성 | lossy는 검색용, lossless는 백업용 |
하이브리드 전략이 이상적입니다:
#![allow(unused)] fn main() { use hwpforge::hwpx::HwpxDecoder; use hwpforge::md::MdEncoder; let result = HwpxDecoder::decode_file("document.hwpx").unwrap(); let validated = result.document.validate().unwrap(); // 검색/RAG용 let lossy = MdEncoder::encode_lossy(&validated).unwrap(); std::fs::write("output/search/document.md", &lossy).unwrap(); // 아카이브 백업용 let lossless = MdEncoder::encode_lossless(&validated).unwrap(); std::fs::write("output/archive/document.lossless.md", &lossless).unwrap(); }
관련 문서
- HWPX 인코딩/디코딩 — 기본 HWPX 처리
- Markdown에서 HWPX로 — Markdown 변환 상세 (RAG 가이드 포함)
- 텍스트 추출 — 구조 보존 텍스트 추출
- HWP5와 HWPX: 이중 포맷 파이프라인 — 포맷 비교 및 감지
API 레퍼런스 (rustdoc)
HwpForge의 전체 공개 API 문서는 docs.rs에서 확인할 수 있습니다.
참고
- 이 mdBook는 개념 설명과 사용 가이드 중심입니다.
- trait, struct, enum, function의 상세 시그니처는 rustdoc이 진실입니다.
- docs.rs와 저장소 문서가 어긋나면, 공개 API 계약은 rustdoc 쪽을 먼저 확인하십시오.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased] — targeted as 0.10.0
Added — 다단 구분선 colLine carry (BREAKING)
다단(ColumnSettings)에 단 사이 구분선(<hp:colLine>)을 carry. 한컴 native wire
(type="DOUBLE_SLIM" width="0.7 mm" color="#CA56A7")를 byte-exact 재현 + HWPX 양방향
round-trip + 한컴 시각 게이트(2단+구분선 렌더) 통과.
- 신규 public 타입
hwpforge_core::column::ColumnLine { line_type: BorderLineType, width: HwpUnit, color: Color }(OWPML 기본값SOLID/0.12 mm/#000000). ColumnSettings.col_line: Option<ColumnLine>필드 +ColumnSettings::with_separator()빌더.- 구분선 없는 다단은 byte-중립 (
<hp:colPr>self-closing 유지). 구분선 있을 때만 container colPr 로 emit (OWPML 순서: colLine → colSz). - HWPX encoder/decoder 양방향. HWP5→HWPX leg(ColDef 구분선 비트 디코드)는 후속 슬라이스.
BREAKING CHANGE: hwpforge_core::column::ColumnSettings 에 col_line: Option<ColumnLine>
필드 추가 — struct-literal 로 ColumnSettings 를 직접 생성하던 외부 코드는 col_line 을
명시해야 함 (equal_columns/custom 생성자 사용 시 무영향). 신규 public 타입 ColumnLine.
JSON: 구분선 없으면 col_line 키 미직렬화(기존 byte 불변).
[0.6.0 – 0.9.0] — 2026-05-29 … 2026-06-28 (released)
아래 항목들은 이미 crates.io 로 릴리스된
v0.6.0~v0.9.0누적분이다 (루트 CHANGELOG 를 버전별 섹션으로 컷하지 못해 오랫동안[Unreleased]에 쌓여 있던 것 — E6 IR 와이어-누출 상환 A/B/C/M2, Wave 11/12 HWP5→HWPX carry, 문서 메타데이터 등). 정확한 버전별 대응은 release-plz 가 소유하는 per-crate CHANGELOG (crates/*/CHANGELOG.md) 를 참조.
Changed — BREAKING — cross-ref inst_id 누출을 ObjectId 로 (E6/M2, ADR-010)
크로스레퍼런스 링크가 두 무관한 정수 필드(타깃 inst_id: Option<u64/u32> ↔ 참조자
RefTarget::SystemId(u64))의 값-우연-일치였던 것을, 공유 newtype
hwpforge_core::ObjectId(u64) 로 묶어 타입 수준 링크로 격상 (ADR-010, ADR-005
supersede). 리팩토링이 링크를 조용히 깨면 byte-diff 로도 안 잡히던 취약성 제거 +
notes(u32)/shapes(u64) 폭 불일치 해소.
- 신규 public 타입
hwpforge_core::ObjectId(#[serde(transparent)]→ JSON/YAML 에서 bare integer). - 타깃
inst_id필드 타입 변경 (필드명 유지):Image/Table/Control::{Equation,Group,TextArt,Footnote,Endnote}의inst_id→Option<ObjectId>. Footnote/Endnote 는u32→u64승격. 패턴 매칭에서 값을 꺼내 쓰면ObjectId로 받게 됨(Some(42)→Some(ObjectId::new(42))). RefTarget::SystemId(u64)→RefTarget::Object(ObjectId)(variant 리네임- payload 타입).
FromStr/Display 출력 wire 문자열(#<id>)은 불변.
- payload 타입).
Control::{footnote,endnote}_with_id인자u32→u64(정수 리터럴 호출은 무변경).- JSON 영향: 타깃
inst_id값은 정수 그대로(byte-동일).RefTarget직렬화 태그가"SystemId"→"Object"로 변경 (CLI to-json/from-json, MCP). wire(HWPX) 출력은 전(全) 구간 byte-중립. - 와이어 스키마
HxFootNote.inst_idu32→u64(in-range byte-중립, truncation 제거).
Fixed — Markdown(GFM) 표 헤더 행 손실 (사용자 흐름 점검)
convert(Markdown → HWPX) 에서 GFM 표의 헤더 행이 통째로 사라지던 data-loss
버그. | A | B | 헤더 + | 1 | 2 | 데이터 → HWPX 에 1|2 1행만 남고 A|B 소실.
- 원인: md 디코더가 pulldown-cmark 의
Tag::TableHead/TagEnd::TableHead를 no-op({}) 처리 → 헤더 셀이 어느 행에도 안 붙고 드롭. 본문 행(TableRow)만 캡처. - 수정:
TableHead를TableRow처럼 처리해 헤더 셀을 표 행 0 으로 캡처 (Core/HWPX 는 행 0 을 헤더로 렌더 —to-md가 행 0 을 md 헤더로 출력하는 것과 정합). - 검증:
convert→ HWPX 에 2행(A,B / 1,2) +to-md라운드트립이 헤더 완전 복원. - 테스트:
pipeline_table_roundtrip을 2행·헤더 셀 내용 단언으로 강화(기존엔 버그를 주석으로 박제하고 있었음), 표 셀 image/link 디코더 테스트 2건 행 인덱스 정정.
Changed — BREAKING (이전 wave 누락분 명시, API audit)
코드 audit 에서 발견한, 이미 발생했으나 Breaking 으로 명시되지 않았던 public API 변경 (downstream 마이그레이션 누락 방지):
Control::memo(content, author, date)→Control::memo(content)로 시그니처 축소. anchor run 이 필요하면Control::memo_with_anchor(content, anchor_runs)사용 (Wave 12e/f).Control::Memo에서author: String/date: String필드 제거 →anchor_runs: Vec<Run>+metadata: MemoMetadata로 대체. 패턴 매칭에서 해당 필드 바인딩 제거 필요 (Wave 12e/f).RefType: TryFrom<u8>public trait impl 제거 (Wave 12m Phase 2). raw HWP5 바이트 → RefType 변환은 smithy-hwp5 경계 함수로 이동. downstream 의 직접 byte 변환 코드는 깨짐.blueprint::style::{CharShape, PartialCharShape}에#[non_exhaustive]추가 (B3). 외부 크레이트의 struct-literal 생성 차단 —CharShape는PartialCharShape::resolve(),PartialCharShape는default()+ 필드 설정으로 생성. 향후 필드 추가가 더는 breaking 이 되지 않도록 한 번에 고정 (underline_shape추가가 이미 깨뜨린 김에).blueprint::style::{ParaShape, PartialParaShape, PartialStyle}에도#[non_exhaustive]추가 (B 일관화) — CharShape 쌍과 동일한 style/shape 패밀리.ParaShape는#[derive(Default)]추가(가산적, 합리적 기본값) +resolve()또는default()+필드설정 으로 생성. (성장형이나 외부 구성이 많은core::{Section, Paragraph}등은 builder/Default 선행이 필요해 후속 슬라이스로 보류 — backlog 기록.)
Fixed — HWP5→HWPX 채우기 “색 없음” → faceColor="none" (P1-5, 옵션 전수조사)
HWP5 는 “배경색 없음” 을 None fill 이 아니라 Color fill + background_color = 0xFFFFFFFF (Windows COLORREF null sentinel)로 인코딩한다. colorref_to_hwpx_color
가 (raw>>24)!=0 검사를 먼저 통과시켜 faceColor="#FFFFFFFF" (흰색)을 emit했고,
한컴 native 는 faceColor="none" 을 쓴다.
- 신규 fill 전용 헬퍼
colorref_to_hwpx_fill_color:0xFFFFFFFF → "none", 그 외는 기존 매핑. face/hatch 색에만 적용 — 테두리 선 색은 native 가 실제#RRGGBB를 쓰므로 공유 함수 그대로 둠 (검증 안 된 거동 도입 방지). - 검증: 기존 native
sample-cell-diagonal(borderFill id=2 background=0xFFFFFFFF) 변환 결과가faceColor="none"으로 일치, 출력에서#FFFFFFFF완전 제거. - 테스트:
fill_color_maps_no_color_sentinel_to_none.
Changed — 미상 무늬/그러데이션 type warning-first (P1-3/4, 옵션 전수조사)
border_fill 의 채우기 무늬·그러데이션 type 이 알 수 없는 raw 값일 때 조용히
기본값(무늬→솔리드, 그러데이션→LINEAR)으로 떨어지던 것을 ProjectionFallback
경고로 노출. (알려진 6개 무늬·4개 그러데이션 type 은 정상 carry 되며, 무늬
None=무늬 없음 은 경고 대상 아님.)
- 신규 경고 subject:
style.border_fill.fill_pattern,style.border_fill.gradation_type(각각border_fill_id+ raw 값 포함). - 정상 저작으론 도달하지 않는 방어적 경로라 native fixture 불필요.
- 테스트:
unknown_hatch_pattern_warns_but_known_and_none_stay_silent,unknown_gradation_type_warns_but_known_stays_silent.
Fixed — HWP5→HWPX 이미지 채우기 모드 12종 (P1-2, 옵션 전수조사)
배경 그림 채우기(<hc:imgBrush mode>)의 16개 모드 중 4개(TILE/TOTAL/CENTER/
ZOOM)만 매핑되고 나머지 12개가 무음으로 투명 처리(fill 드롭) 되던 문제.
바둑판 가로/세로, 가운데 위/아래, 좌/우 정렬 채우기가 전부 사라졌다.
hwp5_image_fill_mode_to_hwpx를 16개 모드 전부로 완성. HWP5 raw 0-15 는 OWPMLImageBrushModeenum 과 동일 순서 1:1 (raw 1=TILE_HORZ_TOP, 7= CENTER_TOP, 10=LEFT_TOP, 14=RIGHT_BOTTOM,*Middle→*_CENTER등).- 검증: 사용자 작성 native
sample-cell-image-fill(4 family 대표 모드 TILE_HORZ_TOP/CENTER_TOP/LEFT_TOP/RIGHT_BOTTOM) 변환 결과가 한컴 native 와 일치, 변환 경고 4→0. - 테스트:
image_fill_mode_maps_all_16_ks_x_6101_modes(전 모드 단위).
Fixed — HWP5→HWPX 쪽 번호 형식 carry (P0-3, 옵션 전수조사)
pgnp(쪽 번호 위치) 컨트롤의 번호 모양 바이트를 전혀 읽지 않아 모든 쪽
번호가 formatType="DIGIT" 로만 나가던 문제. 로마자/한글/알파벳 쪽 번호가
조용히 아라비아 숫자로 변환됨.
parse_page_number_control이 property bits 0-7 (header_data[4]) = 번호 모양(HWPNumberShape)을 읽어NumberFormatType::try_from으로 매핑 (위치는 기존대로 bits 8-11 =header_data[5]). HWP5 shape 코드는NumberFormatType과 1:1 (0=Digit, 2=RomanCapital, …).- 검증: 사용자 작성 native
sample-pagenu-roman(로마자 대문자) 변환 결과가 한컴 native 와 byte-identical —<hp:pageNum pos="INSIDE_TOP" formatType="ROMAN_CAPITAL" sideChar="-"/>. - 테스트:
parse_page_number_control_reads_number_shape_from_property(단위),..._user_sample_page_number_format_matches_native(native e2e).
Fixed — HWP5→HWPX 문단 번호매기기 형식 5종 (P0-1, 옵션 전수조사)
문단 번호 형식(<hh:paraHead numFormat>) 디코딩 매핑이 KS X 6101 OWPML
NumberType1 enum 과 어긋나 일부 형식이 손실/오변환됐다. 옵션 단위 전수조사
중 발견.
- code 11:
"HANJA_DIGIT"(OWPML 에 없는 무효 문자열) →CIRCLED_HANGUL_JAMO(원 ㄱ,ㄴ,ㄷ) 로 정정. 한자 숫자는 실제로 code 13 =IDEOGRAPH. - code 6 (
CIRCLED_LATIN_CAPTION, 원 알파벳 대문자 Ⓐ), code 12 (HANGUL_PHONETIC일,이,삼), code 13 (IDEOGRAPH한자), code 14 (CIRCLED_IDEOGRAPH) 추가 — 이전엔 전부 무음으로DIGIT로 붕괴. - code 0~10 은 사용자 작성 native fixture
sample-numbering-hangul(10수준, 9형식)로 byte 단위 일치 확인 — 가/나/다(HANGUL_SYLLABLE), ㄱ/ㄴ/ㄷ (HANGUL_JAMO) 등 정상 carry 회귀 잠금. (audit 의 “가/나/다가 아라비아로 나간다” 주장은 거짓이었음 — 코드 정독 한계, fixture 로 반증.) - 테스트:
numbering_num_format_covers_full_ks_x_6101_enum(전 코드 단위),..._user_sample_numbering_formats_match_native(native e2e 게이트).
Verified — HWP5→HWPX 빗금무늬(hatch fill) carry + gotcha #21 방향 스왑
빗금/역빗금 등 무늬 채우기(<hc:winBrush hatchStyle>)의 HWP5→HWPX carry 를
native 한컴 fixture 로 실측 검증. 코드 변경 없음 — 디코드/projection/인코더
경로는 이미 완성돼 있었고, 한컴 HWPX writer 의 빗금↔역빗금 문자열 스왑
(gotcha #21: 시각 빗금(/) → "BACK_SLASH", 시각 역빗금() → "SLASH") 도
이미 올바르게 반영돼 있었음.
- fixture: 사용자 작성 native
sample-charbg-hatch-{slash,backslash}.{hwp,hwpx}(글자 배경 무늬 — hatch fill 은 page/char/table border fill 에 공유되므로 동일 코드 경로를 검증). 우리 출력 winBrush 가 한컴 native 와 byte-identical (faceColor="#E5E5E5" hatchColor="#CA56A7" hatchStyle="BACK_SLASH"/"SLASH"). - 회귀 게이트:
..._charbg_hatch_carries_swapped_pattern_direction. - Windows-deferred: 쪽 테두리/배경(페이지) 무늬 fixture 는 macOS 한글 [쪽] 메뉴에 항목 자체가 없어 작성 불가 → Windows 한글 fixture 대기 (masterPage gap C / non-chart OLE 와 동일 차단). 단 hatch fill 자체는 위에서 공유 검증됨.
Added — HWP5→HWPX 다단(multi-column / <hp:colPr>) carry
한 구역을 2단/3단 신문형으로 나눈 다단을 HWP5→HWPX 변환에서 보존; 이전엔
cold ctrl 을 마커로만 필터하고 단 개수를 항상 colCount="1" 로
하드코딩해 단 정보가 손실됐다.
- HWP5 디코더:
cold(CTRL_ID_COLUMN_DEF) ctrl payload 파싱 —[4..6]u16 property (bits 2-9 = 단 개수),[6..8]u16 단 간격(HWPUNIT).secd사이드카 캡처 패턴 미러로SectionResult.column_def에 저장. - Projection:
col_count >= 2면Section.column_settings = ColumnSettings::equal_columns(count, gap). 단일 단은None유지(인코더 기본값). - 인코더/Core: 변경 없음 —
Section.column_settings와build_col_pr_xml가 이미 multi-column emit + HWPX round-trip 지원. HWP5 leg 만 비어 있던 것. - 검증: 사용자 작성
sample-multicolumn.hwp(2단) 변환 결과 colPr 가 native 와 byte-identical (colCount="2" sameSz="1" sameGap="2268"), 0 warnings. 디코더 단위 테스트(ctrl_header_cold_captures_column_def) 추가. nextest/clippy clean, 한컴 시각 게이트 PASS.
Fixed — Blueprint 저작 시 밑줄 선종류(underline shape) 손실
Blueprint(YAML 템플릿 / 빌더 API)로 만든 문서의 밑줄이 선종류와 무관하게
항상 SOLID 로 나가던 문제. Core breaking (additive): CharShape /
PartialCharShape 에 underline_shape 필드 추가 (strikeout_shape 는
이미 있었으나 underline_shape 만 누락돼 있었음).
- 근본 원인:
style_store.rs의 Blueprint→HwpxCharShape브리지가underline_shape: UnderlineShape::Solid를 하드코딩 — Blueprint 에 읽을 필드가 없었기 때문. - 수정:
blueprint/style.rs에underline_shape추가 (PartialCharShape은Option,CharShape는UnderlineShape기본Solid) +merge()/resolve()thread, 하드코딩을cs.underline_shape로 교체. - 범위 메모: HWP5→HWPX 변환과 HWPX→HWPX round-trip 경로는 이미
밑줄/취소선 12종 선종류를 완전히 carry 하고 있었음 (Foundation
UnderlineShape/StrikeoutShape+ HWP5 decoder + HWPX encoder/decoder 모두 완비). 이번 수정은 Blueprint 저작 경로 전용. - 검증: nextest 통과 (+ 비-Solid 밑줄 carry 회귀 테스트 +
serde round-trip 테스트). 생성기
examples/underline_shapes.rs산출물이 SOLID/DASH/DOT/DASH_DOT/DASH_DOT_DOT/LONG_DASH/DOUBLE_SLIM/WAVE 밑줄 + SOLID/DASH/DOUBLE_SLIM/WAVE 취소선을 distinct 하게 emit, 한컴 시각 게이트 PASS (이중선 2줄, 물결 곡선 정상 렌더링).
Added — HWP5↔HWPX TextArt(글맵시 / <hp:textart>) carry
한컴 글맵시(워프된 장식 문자)를 HWP5→HWPX 변환에서 보존 (이전엔 통째로
drop). Core breaking (additive): Control::TextArt { text, shape, font_name, font_style, align, line_spacing, char_spacing, width, height, horz_offset, vert_offset, fill_color, inst_id } 신설 + validate.rs 가
TextArt 를 도형 family 로 검증/그룹 자식 허용.
- Wire 발견: TextArt 는
gsoShapeComponent(0x4C)의 comp_type 판별자"$tat"가ShapeTextArt(0x5A)sub-record 를 감싸는 구조 (0x5A는 dead 가 아니라 TextArt 전용으로 사용됨 — WIRE_SPEC §10 정정).0x5A레이아웃:pt0..3(32B) + BSTR text/fontName/fontStyle + u32 fontType(1=TTF)/textShape(0..54)/lineSpacing/charSpacing/align(0=LEFT) + 20B shadow tail. - textShape enum (55종): HWP5 정수 = 글맵시 모양 그리드 위치, HWPX
문자열은 native 출력에서 추출. 사용자가 작성한 56-textart fixture 로
전체 테이블 (
PARALLELOGRAM=0 …WAVE2=17 …DOUBLE_LINE_CIRCLE=54) 을 한 번에 확정 (TEXTART_SHAPE_NAMES). - HWP5 디코더:
Hwp5ShapeTextArt스키마 +$tat/0x5A를 4개 gso 컨텍스트에 ellipse 미러로 thread,classify_gso_control에서Hwp5Control::TextArt조기 분류. - Projection →
Control::TextArt(textart_shape_name/textart_align_name으로 정수→문자열, 범위 밖이면 경고+fallback). - HWPX 인코더:
<hp:textart>직접 emit (renderingInfo scaMatrix = curSz/orgSz 계산, lineShape NONE, fillBrush, pt0-3, textartPr, shapeComment). - HWPX 디코더:
<hp:textart>→Control::TextArtround-trip (HxTextArt). - 검증: workspace nextest 통과(+신규 textart 스키마/인코더/테이블 테스트),
clippy
-D warningsclean. 사용자 작성sample-gso-textart-all.hwp(56 글맵시, 55 distinct shape) 변환 결과 textShape 55종 전부 보존, 0 drop, HWP5→HWPX→JSON round-trip 56개 유지. scaMatrix/orgSz/curSz native 일치.
Added — HWP5↔HWPX 묶음 객체(group / <hp:container>) carry (Wave A, flat groups)
한컴 “개체 묶기“로 묶인 그리기 객체(group)를 HWP5→HWPX 변환에서 보존
(이전엔 통째로 drop). Core breaking (additive): 재귀
Control::Group { children: Vec<Control>, width, height, horz_offset, vert_offset, inst_id } 신설 + validate.rs 가 비-도형 자식을
ValidationError::InvalidGroupChild 로 거부.
- Wire 발견: container 는 별도 TagId(0x56) 가 아니라
gsoShapeComponent(0x4C)의 comp_type 판별자"$con"(line"$col"/ rect"$rec"/ ellipse"$ell"와 동일 메커니즘). 자식은 한 단계 깊은ShapeComponent들이고 각자 기존 shape sub-record(0x4F/0x50/…) 보유. - HWP5 디코더: gso 스코프를 단일-
Optionflat 상태머신에서GsoGroupBuilder/GsoChildBuilder스택으로 재구성 (기존table_stack패턴, architect 리뷰 P0). 자식 geometry 는 자식 ShapeComponent common header 에서 파싱 (Hwp5ShapeComponentGeometry::parse_from_shape_component: x=[4..8], y=[8..12], w=[16..20], h=[20..24] — native 바이트 대조로 도출). Wave A 는 flat 만; 중첩$con은 depth cap(GSO_GROUP_MAX_DEPTH) 으로 warn+degrade. - HWPX 인코더:
<hp:container>재귀 emit. 자식 위치는 한컴이<hc:transMatrix>translation(e3=x, e6=y)으로 잡으므로 그것을 설정 (identity matrix 면 전부 원점에 겹침 — 시각 검증으로 확인);<hp:offset>도 동일 값으로 mirror, top-level<hp:sz>/<hp:pos>는 자식에서 제거,<hp:curSz>는 0×0 (native 일치). - HWPX 디코더:
<hp:container>→Control::Groupround-trip. - 검증: workspace nextest 통과, clippy clean, 변환 산출물 geometry 가
native
sample-gso-group.hwpx와 byte 일치 (offset/transMatrix/orgSz/ curSz/container sz·pos), 한컴 시각 게이트 PASS (직사각형+타원 나란히, 텍스트 “사각형”/“타원” 보존, 겹침 없음). 중첩 그룹 재귀는 Wave B.
Added — HWP5↔HWPX 중첩 묶음 객체($con-in-$con) 재귀 carry (Wave B)
Wave A 가 flat 그룹만 보존한 데 이어, 그룹 안의 그룹($con 이 $con 을
다시 품는 구조)을 재귀적으로 보존. Core 변경 없음(Control::Group 가
이미 재귀형 Vec<Control>); 4개 레이어가 재귀를 타도록 확장.
- HWP5 디코더:
GsoGroupBuilder.current_child를Option<GsoChildBuilder>→Option<GsoActiveChild>로 교체.GsoActiveChild::{Leaf(GsoChildBuilder), Nested(Box<GsoGroupBuilder>)}로 중첩$con은 자식GsoGroupBuilder를 열어 재귀 (Box 로GsoGroupBuilder → GsoActiveChild → GsoGroupBuilder타입 cycle 차단).GSO_GROUP_MAX_DEPTH초과 시에만 leaf 로 degrade → Unknown(경고). - Projection:
project_group_child에Hwp5Control::Group(nested) => project_group_run(...)arm 추가 (중첩 그룹 자식 재귀). - 레이아웃 힌트:
collect_group_child_layout_hints가 자식이 중첩 그룹이면 재귀 — 그러지 않으면 inner 그룹의 텍스트 자식<hp:p>가 hint 없이 emit 되어paragraph layout hint count underflow발생. - HWPX 인코더:
encode_group_child_xml에Control::Groupearly-return 추가 —encode_group_to_xml로 재귀 후 transMatrix/offset/curSz/sz·pos 를 다른 자식과 동일하게 처리 (groupLevel 은 재귀 내부에서 +1 baked). - HWPX 디코더:
HxContainer.containers: Vec<HxContainer>필드 +decode_container가 중첩 container 재귀,MAX_NESTING_DEPTH(32) depth guard. - 검증: workspace nextest 통과, clippy clean. 변환 산출물이 native
sample-gso-group-nested.hwpx와 구조·geometry byte 일치 (groupLevel 0/1/2/2/1, inner container identity matrix, ellipse e3=1164/e6=6512, line e3=525/e6=13422, 전 자식 curSz 0×0 + top-level sz/pos 없음). 한컴 시각 게이트 PASS.
Fixed — SUMMERY/PATH 필드 빈 본문 → 한컴 “낮은 보안 수준 복구” 경고 (#120/#136)
HWP5 → HWPX 변환 시 문서정보(SUMMERY: $author/$title/$createtime/
$modifiedtime/$lastsaveby)·경로(PATH: $P$F) 필드의
<hp:fieldBegin>…<hp:fieldEnd> 본문이 빈 <hp:t/> 로 나가
한컴이 필드를 미해결로 보고 “낮은 보안 수준으로 복구” 경고 + 첫 열기
빈 placeholder 를 남기던 문제.
- 진단: byte-diff 로 빈 본문이 유일 구조 차이임을 확인하고, 우리
출력에 native 값만 주입한 격리 파일로 한컴 실측 (경고 없음) 하여
본문 값이 원인임을 확정. 한컴 native HWPX 는 본문에 resolved 값을
캐싱하며 그 값은 HWP5 원본
BodyText/Section0ParaText 의 FieldBegin..FieldEnd span 에 이미 존재. Wave 12n Step 6.6 이 “빈 본문이 경고를 회피한다” 던 주석은 틀렸음 — 당시 거부된 건 합성 ISO 날짜 (locale 불일치) 였지 verbatim 값이 아니었다. - Core (breaking, additive):
Control::Field/UnknownSummery/DateCodeField/PathField에display_text: String추가 (CrossRef::display_text와 동일 형태/의미, 빈 문자열 = 없음). ClickHere 는 빈 값 유지 (visible placeholder 는hint_text). - HWP5 projection: FieldBegin..FieldEnd span 을
display_text로 누적 (Hyperlink/CrossRef 와 동일 경로) + DoS cap (MAX_FIELD_DISPLAY_TEXT_UNITS = 4096, body 가 BSTR command cap 우회). - HWPX encoder/decoder: 본문에 캐싱값 emit + round-trip 복원
(디코더가 run body 텍스트를
display_text로 환원). - 검증: workspace nextest 2,469 통과, 변환 산출물 field body 가 native 안정값과 일치 (날짜/경로는 출처 파일 차이만), 한컴 시각 게이트 (경고 없음 + 첫 열기 값 표시) PASS.
Fixed — HWP5→HWPX 차트 편집 시나리오 (크기 + custom title)
HWP5 → HWPX 변환본의 차트를 to-json → 값 편집 → from-json 으로
재생성하는 체인을 end-to-end 검증하며 발견된 2건:
- 차트 표시 크기: projection 이
ShapeComponentOleextent (내부 캔버스, 상수 7200×7200) 를hp:sz로 사용해 변환 차트가 ≈2.5cm 로 축소되던 버그. 표시 프레임은gsoCtrlHeader geometry[16..24](HWPUNIT) — 한컴 native HWPX 쌍의hp:sz 32250×18750과 byte 일치 (probe_chart_geometry로 확정). geometry 우선 + extent fallback 으로 수정; encoder 의orgSz/extent7200 hardcode 는 native 와 일치라 유지. e2e 게이트에 native 크기 단언 추가. - custom title 형태:
write_title의 최소형 (<a:bodyPr/><a:lstStyle/>+ 빈<a:rPr lang="ko-KR"/>) 은 표준 OOXML 로는 유효하지만 한컴 native 형태와 다름. 사용자 작성sample-chart-title.hwpxfixture 에서 한컴의 실제 custom title 형태를 probe 해 byte-convention 미러링으로 교체: fully-attributedbodyPr,lstStyle제거,pPr/defRPr+rPr에sz=1400+함초롬돋움 4종, trailingendParaRPr. 단위 게이트write_title_mirrors_hancom_native_form추가.
시각 검증: 변환 → 판매 값 [10,3.5,1.5,1.2]→[1,2,3,4] 편집 + 제목
추가 → 재생성본이 한컴에서 정상 크기 프레임 + 제목 + 분리형 파이
(explosion 25% 보존) 로 렌더링 확인.
Refactor (tasks #88/#89/#90/#91/#92/#94/#95) — Wave 12 누적 부채 정리
기능 영향 0 (모든 step 에서 workspace nextest 전체 통과로 검증), 코드 구조만 개선:
- #94 CTRL_ID 상수 33개 선언 (4개 파일, 9개 중복 그룹) →
smithy-hwp5/src/ctrl_ids.rs단일 모듈 25개 canonical 상수. drift 이름 정리:SECTION_DEF→SECD,CROSSREF→FIELD_CROSSREF,FIELD_INLINE_PAGE/INLINE_AUTONUM→ATNO,INDEXMARK_INLINE→INDEXMARK. - #95 Wave 12i/12k 공유 split-leader / length-prefixed UTF-16 BSTR
파서를
parse_split_leader_utf16/parse_length_prefixed_utf16helper 로 통합 (Dutmal main/sub, IndexMark primary/secondary). Compose (12j) 는 length prefix 가 없는 다른 wire 라 의도적으로 제외. - #88
parse_name_subrecord의Option<Option<String>>→ClickHereNameSubrecord { Named / Unnamed / Malformed }named enum. - #89
Hwp5ClickHereControl::parse→Result<_, ClickHereParseError>(TruncatedHeader/CommandTooLong/TruncatedCommand/CommandSyntax) — DroppedControl warning 에 구체 사유 표기. - #90
handle_top_level_record~425줄 → 71줄 dispatcher + 4 helper (try_intercept_memo_cluster,try_attach_clickhere_name,handle_ctrl_header,handle_eqedit_record). - #91
project_paragraph_with_images_structural~225줄 → 135줄 + 3 helper (project_visible_text_segment,start_field_from_marker,drain_unconsumed_paragraph_queues). - #92
smithy-hwpx/encoder/section.rs5,481줄 → 3,765줄 (잔여 중 ~2,760줄은 root 테스트 모듈). 기존section/table.rs선례 패턴 (mod X;+use super::*;+pub(super) fn) 으로 8개 family 모듈 신설:field(694) /section_pr(280) /header_footer(245) /memo(169) /chart(152) /picture(139) /equation(66) /typography(64). root 재수입으로 호출처/테스트 무변경, 본문 verbatim 이동. 같은 crate 내 모듈 분리라 runtime 성능 영향 0.
Docs (task #72) — HWP5 wire spec (HwpForge-internal)
Added crates/hwpforge-smithy-hwp5/HWP5_WIRE_SPEC.md — code-grounded
HWP5 binary format documentation capturing Wave 12 series discoveries
that the public KS X 6101 / Hancom spec omits:
- ParaHeader
[18..22]instance_idfield (Wave 12p Step 1) - Family-aware CtrlHeader
instance_idoffsets (fn/en at 16, gso/tbl/eqed at 36, Wave 12p Step 5) - ParaShape
property1bits 25-27 are zero-based ordinal cap=6 (Wave 12p #121) - Style “개요 N” / “Outline N” outline-level override for levels 7~9 (Wave 12q #122)
- Default
linesegarraysynthesis values for paragraphs lackingParaLineSeg(Wave 12p #123) editableattribute perFieldType(Wave 12p #124)- BSTR length-prefix allocation caps (
MAX_*_UNITSfamily, Wave 12 + task #86) - CTRL_ID magic constant inventory across 24 entries (3 source files, prelude to task #94 consolidation)
- SummaryInformation OLE2 PropertySet layout incl. Hancom custom PIDs
0x14/0x15(Wave 12o) - Cross-reference Command 8-parameter Hancom-canonical wire (Wave 12m, ADR-004)
Each topic links back to the canonical source file/symbol so future contributors can grep this file before re-running probes.
Linked from crates/hwpforge-smithy-hwp5/AGENTS.md for discoverability.
Wave 12q (task #122) — outline level 7~9 recovery via Style “개요 N” linkage
HWP5 ParaShape.property1 bit 25-27 은 3 bits 만 표현 가능 (cap=6).
한컴 native 는 outline level 7~9 를 Style record 의 한국어 이름
“개요 N” (N-1 = HWPX level) 으로 표현. 변환 후 paraPr 의 heading_level
을 Style “개요 N” 매핑으로 override 하여 native parity 달성.
Fixed
hwpforge-smithy-hwp5:apply_outline_style_level_overrides()— ParaShape 변환 후 Style 테이블에서 “개요 N” / “Outline N” 패턴 매칭 → para_shape_id 가 가리키는 paraPr 의 heading_level 을 N-1 로 override. 한컴이 wire cap=6 으로 저장한 level 7/8/9 가 정확히 emit.- override 는 silent (warning 없음) — semantic loss 가 아닌 lossless level recovery 이므로 audit warning_count 에 영향 없음.
Added — hwpforge-smithy-hwpx
HwpxStyleStore::para_shape_mut()— Wave 12q level override 용 mutable accessor.
검증
sample-outline-9levels.hwp(사용자 작성 native fixture) 변환 결과:- paraPr id 2~8 level 0~6: native parity ✅
- paraPr id 18/16/17 level 7/8/9: hp10 namespace switch wrap 없이도 한컴이 정상 인식 ✅
- 한컴 시각 검증: 1~7수준
1./가./1)/가)/(1)/(가)/①, 8수준㉠, 9~10수준 marker 없음 (native 와 byte-identical 매칭). - Codex(architect) 검토 반영: paraPr id 자체에 의존 안 함, heading_type 이 Outline 인 paraPr 만 override.
ADR 참조: .docs/architecture/adr/ADR-006-wave12p-task122-outline-level-7-9-diagnostics.md
Wave 12p tasks #121/#123/#124 — outline/linesegarray/editable fidelity
Fixed (#121) — hwpforge-smithy-hwp5
Hwp5RawParaShape::heading_level()이saturating_sub(1)로 wire 를 1-based 로 잘못 가정 → 0-based 그대로 사용. HWP5 spec bit 25-27 (3 bits) 가 zero-based ordinal 0~7. HWPX<hh:heading level>도 zero-based. Codex(architect) 검토 확인.- 검증: native
sample-outline-9levels.hwpx의 paraPr id 2~8 level 0~6 가 우리 emit 와 완전 일치.
Fixed (#123) — hwpforge-smithy-hwp5
layout_hint_patch::write_linesegarray(): HWP5 source 의ParaLineSeg(tag 0x45) record 가 없는 paragraph 도 default lineseg 로<hp:linesegarray>항상 emit. 이전엔 누락 → 한컴이 “낮은 보안 수준 복구” 경고.- Default 값 (native fixture 도출):
vertsize=1000,textheight=1000,baseline=850,spacing=600,horzsize=42520(A4 content width),flags=393216.vertpos=0— 한컴이 paragraph 순서 따라 재계산.
Fixed (#124) — hwpforge-foundation + hwpforge-smithy-hwpx
FieldType::hwpx_editable()신규 method —Author/Title→false,LastSavedBy/CreatedTime/ModifiedTime→true(한컴 native fixture 도출). 이전엔 모든 SUMMERY field 가editable="1"강제됨.build_summery_run_xml_raw()시그니처 확장 (editable: bool파라미터).- 검증:
sample-field-docsummary.hwp변환 결과 8/8 SUMMERY field 가 native editable 값과 일치.
Wave 12p — cross-ref target element instance ID carry (BREAKING, partial)
Wave 12m 시각 검증의 잔존 이슈 (HWP5 변환본의 footnote/endnote/figure/
table/equation cross-ref 가 한컴에서 ? 표시) 해소 작업. Steps 1-4
완료, Step 5 (HWP5 → HWPX target ID 매칭 알고리즘) 는 후속 작업.
ADR 참조: .docs/architecture/adr/ADR-005-wave12p-target-element-instance-id-carry.md
Breaking — hwpforge-foundation
RefContentType::BookmarkNamevariant 부활 (Wave 12m fixup regression revert). Bookmark N2 매핑 native 일치 보정:- N2=0 → Page, N2=1 → Number (책갈피 본문/번호)
- N2=2 → BookmarkName (책갈피 이름), N2=3 → UpDownPos
Display는Contents와BookmarkName모두OBJECT_TYPE_CONTENTSemit (한컴 wire 일치, 의미는 RefType + N2 wire code 에서 결정).
Breaking — hwpforge-core
Image에pub inst_id: Option<u64>신규.Table에pub inst_id: Option<u64>신규.Control::Equationvariant 에inst_id: Option<u64>신규.- 모두
#[non_exhaustive]+Default::default()호환이라 builder 사용 시 caller 코드 변경 불필요.
Added — hwpforge-smithy-hwp5
Hwp5ParaHeader.instance_id: u32(Step 1a) — HWP5 ParaHeader[18..22]의 u32 LE 추출. outline cross-ref target ID source.Hwp5NestedSubtree.instance_id: u32(Step 1b) — Header/Footer/Footnote/ Endnote CtrlHeader trailer.Hwp5Table.instance_id: u32(Step 1c-1) — Table CtrlHeader trailer.Hwp5EquationControl.instance_id: u32(Step 1c-2) —eqedCtrlHeader trailer.Hwp5ImageControl.instance_id: u32(Step 1c-3) —gsoCtrlHeader trailer (via NestedSubtreeContext + InlineGsoContext).- Helper
extract_ctrl_header_trailer_instance_id(&[u8]) -> u32— 마지막 8 bytes 중 first 4 의 u32 LE.
Changed — projection / encoder
- HWP5 projection: 6종 target element 의 instance_id 를 Core inst_id
로 통과 (
0은 unset 으로None정규화). - HWPX encoder:
<hp:footNote instId="N">,<hp:endNote instId="N">,<hp:equation id="N">,<hp:tbl id="N">,<hp:pic id="N">모두 Core inst_id 가 있으면 사용, 없으면 sequentialgenerate_instidfallback.
Deferred (Step 5, 후속 작업)
Probe 결과 (crates/hwpforge-smithy-hwp5/examples/probe_instance_id.rs):
HWP5 binary 의 footnote/endnote CtrlHeader payload 는 자기 자신의
instance ID 를 carry 하지 않음 (한컴이 save 시 session counter 로
synthesize). cross-ref Command 는 target ID 를 carry 하지만 target
element 의 ID 는 wire 에 없음.
결과:
- Forged HWPX path (caller 가
Image::new(...).inst_id = Some(N)설정): ✅ 정상 동작 (encoder 가 wire 에 emit) - HWP5 → HWPX 변환 path: ❌ cross-ref 가 여전히
?표시 (target element inst_id = None, encoder skip)
Step 5 알고리즘 (ADR-005 §“Step 5 Algorithm” 에 plan 상세):
- Section 별 cross-ref Command target_id 들을 pre-scan + RefType 별 grouping
- Doc 순서대로 target element 에 그 IDs 할당 (1:N dedup 처리)
- cross-ref Command 는 as-is (HWP5 wire 그대로)
Step 5 land 시 HWP5 conversion path 도 cross-ref 정상 동작.
신규 examples / probe
crates/hwpforge-smithy-hwp5/examples/probe_instance_id.rs— debug: HWP5 fixture 의 projection inst_id 확인
검증
cargo nextest run --workspace --all-features: 2,463 passed, 2 skippedcargo clippy --workspace --all-features --all-targets -D warnings: clean- 한컴 시각 검증: forged path 만 동작 확인 (HWP5 conversion path 는 Step 5 대기)
Wave 12m fixup — fieldid %xrf magic constant + RefContentType::BookmarkName 폐기 (BREAKING)
Wave 12m Phase 2 시각 검증 (한컴오피스 한글) 에서 cross-ref body 가 ?
로 표시되는 회귀 발견 → 2단계 research (1차 + 재검증) 후 두 결정적
차이 fix.
Breaking — hwpforge-foundation
RefContentType::BookmarkNamevariant 폐기. OWPML 표 156 은 ContentType 을 4종 (PAGE / NUMBER / CONTENTS / UPDOWNPOS) 만 정의 하며, 책갈피의 경우Contents가 “책갈피 내용/이름” 의미를 내포함 (spec 명시: “참조 대상의 제시 내용 또는 책갈피의 경우, 책갈피 내용”). Wave 12m Phase 2 Step 3 의BookmarkNameenum +OBJECT_TYPE_BOOKMARK_NAMEemit 은 spec 외 invented string 으로 한컴 인식 실패. HWP5 N2 code 2 for Bookmark →RefContentType::Contents로 매핑.
Fixed — hwpforge-smithy-hwpx
build_crossref_run_xml의fieldid가 sequential counter (1_828_000_000 + N) 가 아니라 Hancom%xrfASCII magic constant (0x25787266=628_650_598) 로 emit. native HanCom-authored HWPX 11 sample 전수 분석 (n=11) 에서 모든 CROSSREF 가 동일 magic constant 사용 확인. per-instance identity 는id(begin_id) 가 carry. HwpForge 다른 native byte-identical 검증된 field 와 일관: ClickHere=%clk, SummeryField=%smr, PathField=%pat.- HWP5 projection boundary
decode_hwp5_crossref_content_type: Bookmark- N2=2 →
Contents(이전BookmarkName). 의미는 RefType-상대적 (“책갈피 이름”).
- N2=2 →
- HWPX encoder reverse boundary
ref_content_type_wire_code: Contents → 모든 RefType 에서 N2=2 (Bookmark=“책갈피 이름”, Figure/Table/Eq/ Outline=“캡션 내용”).
Regression gates
crossref_builders_emit_nonzero_matching_fieldid: fieldid 기댓값1828000000→628650598변경. assertion 메시지 “non-zero derived” → “%xrfmagic constant” 로 강화.
시각 검증 결과 (한컴오피스 한글 직접 열기)
| 케이스 | 이전 | 현재 |
|---|---|---|
| Bookmark+Page (Point) | ? | 1 ✅ |
| Bookmark+Page+Hyperlink (Point) | ? | 1 ✅ |
| Bookmark+Contents (Span) | (미검증) | 본문 표시 ✅ |
| Bookmark+Contents (Point) | ? | ? (정상 — 본문 없음) |
Point bookmark 의 Contents reference 는 본문이 없어 한컴이 ? 표시 —
우리 wire 형식은 정상. caller 가 SpanStart/SpanEnd 책갈피를 만들면 한컴
이 범위 안 본문을 표시.
추가 발견 (별도 task)
<hp:endNote>/<hp:footNote>/<hp:figure>/<hp:table>등 cross-ref target 이 될 수 있는 elements 에instIdattribute 가 emit 되지 않음. 한컴 cross-ref Command 의?#<id>target lookup 이 실패하여 endnote/footnote/caption cross-ref 가?표시. Wave 12m 범위 밖 — task #134 로 분리.
새 examples (시각 검증용)
crossref_matrix.rs— RefType × ContentType 8-permutation HWPXcrossref_with_target.rs— Point bookmark + cross-ref 4종crossref_span_bookmark.rs— Span bookmark + Contents reference 검증
검증
cargo nextest run --workspace --all-features: 2,463 passed, 2 skippedcargo clippy --workspace --all-features --all-targets -D warnings: clean
Wave 12m Phase 2 — Control::CrossRef HWP5 leg structured upgrade (BREAKING)
HWP5 %xrf 상호참조 필드를 Control::Unknown(tag="hwp5.crossref")
surrogate (Wave 12 이전 lossy 경로) 대신 typed Hwp5CrossRefControl
schema → boundary functions → native Control::CrossRef 로 end-to-end
carry. 사용자 시각 검증 가능한 12 한컴 native fixture
(tests/fixtures/hwp5/crossref/) e2e 회귀 게이트 동반.
ADR 참조: .docs/architecture/adr/ADR-004-wave12m-crossref-structured-upgrade.md
(내부 문서).
Breaking — hwpforge-foundation
RefType(enum) —#[repr(u8)]제거 +#[non_exhaustive]추가. 신규 variants:Footnote,Endnote,Outline,Unknown(u8). 기존TryFrom<u8>impl 제거 (boundary 책임을 smithy-hwp5 로 이동).RefType크기 1 byte → 2 bytes (variant + discriminant).RefContentType(enum) —#[repr(u8)]제거 +#[non_exhaustive]추가. 신규 variants:BookmarkName,Unknown(u8). 동일 크기 변화.
Breaking — hwpforge-core
Control::CrossRef—target_name: String필드를 type-safetarget: RefTarget로 교체. 신규RefTarget열거형 (Name(String)for Bookmark,SystemId(u64)for#<id>refs,Raw(String)for unparseable fallback).Control::cross_ref(...)생성자 시그니처도 연동 변경.Control::CrossRef에display_text: String필드 추가. HWPX wire 는<hp:fieldBegin>/<hp:fieldEnd>사이 visible run 으로 display text 를 embedding 한다. HWP5%xrfwire 는 display text 를 직접 carry 하지 않고 ParaText 본문에 풀어 두지만, projection 이 FieldBegin..FieldEnd span 을 읽어 이 필드에 채워 넣는다. 빈 문자열은 “display text 없음” 의미.- JSON 호환성: 의도된 clean break. 기존
target_name/ surrogate JSON 도 마이그레이션 없이 변경.
Added — hwpforge-smithy-hwp5
schema::section::Hwp5CrossRefControl— 9-field wire-fidelity 구조 (ctrl_id,command_raw,target_raw,ref_type_code,content_type_code,hyperlink_code,param4_raw,header_flag_raw,trailer_begin_id,trailer_field_id).MAX_CROSSREF_COMMAND_UNITS = 1024cap 으로 할당 전 차단. 13 unit tests (security gates 포함).decoder::section::Hwp5Control::CrossRef(Hwp5CrossRefControl)variantCTRL_ID_FIELD_CROSSREF = 0x2578_7266(“%xrf”) dispatch. malformed payload 는Hwp5Warning::DroppedControl{control: "crossref"}로 떨어진다 (silent skip 금지).
projection::Boundary functions:decode_hwp5_crossref_ref_type(0~6 +Unknown(u8)),decode_hwp5_crossref_content_type(RefType-relative: Bookmark=1→Contents/2→BookmarkName, 그 외는 1→Number/2→Contents, 3→UpDownPos),decode_hwp5_crossref_target(Bookmark→Name,#<u64>→SystemId, 실패→Raw).semantic::Hwp5SemanticControlKind::CrossRef신규 variant +semantic_adapter매핑.
Added — hwpforge-smithy-hwpx
- Reverse boundary helpers:
ref_type_wire_code(&RefType) -> u8,ref_content_type_wire_code(&RefType, &RefContentType) -> u8,crossref_target_for_command(&RefTarget, &RefType) -> String. build_crossref_run_xml가 Hancom-canonical 8-parameter form (Fiexde=1/Prop=0/Command=?{target};N1;N2;N3;0;/RefPath=?{target};/RefType/RefContentType/RefHyperLink/RefOpenType=HWPHYPERLINK_JUMP_CURRENTTAB) 직접 emit. 시그니처: 1번 인자target_name: &str→target: &RefTarget(target 종류별 정확한 emit).
Changed — hwpforge-smithy-md
- Markdown encoder 의
Control::CrossRefarm 이display_text가 비어 있지 않으면 그대로 emit (사용자가 본 visible 문자열). 비어 있을 때만target.as_display()로 fallback (Name → “bookmark1”, SystemId → “#5”) 하고[...]anchor brackets 로 wrap.
Removed (clean break)
hwpforge-smithy-hwp5::projection::HWP5_CROSSREF_UNKNOWN_TAGconstant ("hwp5.crossref")parse_crossref_target_name,encode_hwp5_crossref_unknown_data(smithy-hwp5 projection)parse_hwp5_crossref_unknown_data,Hwp5CrossRefUnknownPayload(smithy-hwpx encoder),HWP5_CROSSREF_UNKNOWN_TAGconst- HWPX encoder 의
Control::Unknown { tag == HWP5_CROSSREF_UNKNOWN_TAG }branch — projection 이 더 이상 emit 하지 않음. build_hwp5_crossref_run_xml는 fieldid 회귀 게이트 목적으로#[cfg(test)]유지.
Decoder — HWPX (Wave 12m Phase 2 Step 4)
<hp:fieldBegin type="CROSSREF">가RefPath의#<u64>를RefTarget::SystemId로 정규화, Bookmark refs (ref_type == Bookmark) 는RefTarget::Name, 그 외는RefTarget::Rawfallback.display_textcapture 는 후속 wave (FieldBegin/FieldEnd 사이 sibling run 누적).
Regression gates
- 신규 (Step 2): 12 fixture parse 회귀 게이트
(
schema::section::tests::crossref_parse_*) — 모든 wire 변형의 Command 파싱 검증. - 신규 (Step 6):
hwp5_to_hwpx_crossref_12_fixture_matrix_emits_typed_control_and_canonical_wire— 12 한컴 native fixture 의 end-to-end 변환 검증 (RefType / Hancom-canonical 8-param form / fieldid 양수).
검증
cargo nextest run --workspace --all-features: 2,463 passed, 2 skipped (Wave 12n Step 6 baseline 2,445 → 2,463, Wave 12m Phase 2 +18 신규 게이트).cargo clippy --workspace --all-features --all-targets -D warnings: clean.
Wave 12n Step 6 — %pat PATH 필드 lossless HWPX carry
Wave 12n Step 3 에서 placeholder 로 남겨두었던 Control::PathField 의
HWPX wire 매핑을 native 형식으로 완성. #120 (한컴 “낮은 보안수준 복구”
경고의 가장 강한 트리거) 해소.
Fixed — HWPX encoder
Control::PathField { command }arm 이 더 이상 LOSSY SUMMERY surrogate 를 emit 하지 않고<hp:fieldBegin type="PATH" name="" editable="0" dirty="0" zorder="-1" fieldid="628121972" metaTag=""><hp:stringParam name="Format">$P|$F|$P$F</hp:stringParam>를 직접 emit. 한컴 native 와 byte-identical (단id카운터 제외). Body 는 empty — 한컴이 저장 시$P/$F/$P$F를 실제 on-disk 경로 / 파일명으로 재평가.
Added — HWPX decoder
<hp:fieldBegin type="PATH">인식 분기 신설.Formatparam 우선, 없으면Command로 fallback.PathFieldCommand::from_wire(&cmd)로 typed variant (Path/FileName/PathAndFileName) 또는Unknown(s)로 carry.
Regression gates (+2, -1)
- 신규:
pathfield_emits_native_path_wire— encoder wire 형식 검증 (type="PATH"/fieldid="628121972"/editable="0"/Format사용 /Property미사용) - 신규:
pathfield_roundtrip_preserves_command_lossless—PathAndFileName/Path/FileName세 variant 모두 lossless round-trip 검증 - 신규:
pathfield_unknown_command_roundtrips_as_unknown— 비표준$XCommand 도PathFieldCommand::Unknown("$X")로 carry - 제거: 기존
lossy_roundtrip_pathfield_becomes_unknown_summery— 이제 lossless 이므로 의도 상충. lossy 사실을 확정하던 단언이 모두 lossless 단언으로 대체됨. - 제거: 기존
lossy_pathfield_emits_summery_with_raw_command— 마찬가지 이유.
검증:
- workspace nextest: 2,444 → 2,445 passed + 2 skipped (+3 신규, -2 폐기)
- end-to-end:
sample-field-docsummary.hwp변환 결과의 PATH 필드 wire 가 한컴 nativesample-field-docsummary.hwpx와 byte-identical (single field id 카운터 차이만 존재).
Note — #120 보안 경고 보조 트리거 잔존
PATH 필드 오분류는 #120 의 가장 강한 트리거이나 #121 (outline level off-by-1), #123 (linesegarray 누락), #124 (editable bit 강제) 등 다른 트리거가 잔존. Step 6 만으로 보안 경고가 완전 사라지는지는 사용자 시각 검증 (한컴 열기 후 확인) 에 의존.
Known Issues — Wave 12p/12q 종료 후 잔존
#120 — “낮은 보안 수준 복구” 경고 (HWP5 → HWPX SUMMERY 필드)
증상 (2026-06-09 시각 검증, converted-field-docsummary-wave12p-final.hwpx):
- 한컴에서 변환 결과 파일을 열 때 “현재의 낮은 보안 수준으로 문서에 손상을 줄 수 있는 내용을 복구하였습니다” 경고 dialog 표시.
- 처음 열 때 모든 SUMMERY 필드 (
$author,$title,$modifiedtime,$lastsaveby,$createtime) 의 body 가 빈 placeholder ([문서 정보 시작][문서 정보 끝]) 로 표시. - 사용자가 저장하면 한컴이 자체적으로 metadata 를 evaluate 해서
hanyul/ 날짜 등 실제 값으로 채워 다시 표시.
Native fixture (sample-field-docsummary.hwpx) 와의 차이:
- native: 경고 없음 + 처음 열 때부터 SUMMERY 값 표시.
- ours: 경고 + 처음 열 때 빈 표시 → 저장 후 update.
Wire 비교 결과 (/tmp/docsum_diff 의 grep):
native 와 우리 emit 모두 <hp:fieldBegin> 와 <hp:fieldEnd> 사이의
<hp:t> body 에 실제 값을 채움 (예: native field #2 hanyul, ours
field #2 hanyul). body 자체에는 차이 없음. 그럼에도 한컴이 우리
파일은 빈 placeholder 로 표시 — 한컴이 다른 구조적 차이를 트리거로
잡는 듯.
알려지지 않은 추가 트리거 (Wave 12p/12q 후 잔존):
<hp:fieldBegin>의id/fieldid/metaTagattribute 의 값/조합이 native 와 미세하게 다른가? (예: hardcodedfieldid="628321650"vs native 의 paragraph-별 다른 값)- ParaText (paragraph 본문 text) 의 byte sequence 가 다른가?
dirty="0"이지만 한컴이 dirty 로 인식하는 다른 signal?- 단일 paragraph 안에 여러 SUMMERY field 가 연속될 때 우리 emit 의
paragraph 구조가 다른가? (native field #5 는 paragraph 안에 여러
<hp:t>가 split 되어 있음)
완화책 — 후속 wave 에서 진단 필요:
- native
sample-field-docsummary.hwpx와 우리 emit 의section0.xmlbyte-level diff 로 트리거 specific 식별. - 한컴 “낮은 보안 수준” warning 의 정확한 트리거 조건 reverse-engineering.
- ZIP container 의 file 순서 / mimetype 처리 차이 가능성도 검토.
user impact: 사용자가 변환 결과를 저장 한 번 하면 모든 값이 올바르게 표시되므로 functional impact 는 크지 않음. 단 첫 인상이 “빈 필드 + 경고” 라 confusing — 후속 wave 에서 trigger specific 한 fix 필요.
상태: investigation deferred — 후속 wave 에서 byte-level diff 진단 필요.
#33 — Wave 5 gap C: 별도 <hm:masterPage> element carry
상태: investigation deferred — Windows 한컴 fixture 필요.
컨텍스트 (2026-06-09 macOS 한컴 fixture 진단):
macOS 한컴은 페이지 테두리 / 머리말 / 꼬리말 / 쪽 배경 등을 모두
<hp:secPr> 안에 <hp:pageBorderFill> + <hp:header> + <hp:footer>
로 inline emit. 별도 <hm:masterPage> element 는 만들지 못함
(masterPageCnt="0", 별도 MasterPage/ 디렉토리 / Contents/masterPageN.xml
파일 없음).
진단 결과: 현재 HwpForge 변환이 이미 macOS 한컴 시나리오 (페이지
테두리 + 머리말/꼬리말) 를 정확히 처리 — <hp:pageBorderFill> 3종
(BOTH/EVEN/ODD), <hp:header applyPageType="BOTH">, <hp:footer ...>
모두 native parity (id 번호만 +1 차이).
#33 의 진짜 scope 인 별도 <hm:masterPage> element (페이지별로
다른 master template 같은 advanced 기능) 는 macOS 한컴이 만들지 못해
fixture 확보 불가. Windows 한컴 환경에서 <hm:masterPage> 정의된
.hwp + native .hwpx fixture 확보 후 진단/구현 가능.
user impact 작음: 일반 사용자가 사용하는 페이지 테두리/머리말/꼬리말
시나리오는 이미 동작. advanced <hm:masterPage> 시나리오는 한컴 자체
가 흔한 use case 가 아닌 듯 (macOS 빌드에서 누락 가능).
Wave 12o-fixup — Codex(architect) Top-5 리뷰 4건 + 종료 정직성
Codex(architect) Wave 12o post-completion 리뷰에서 발견된 P0/P1 4건의
fast-follow fix + 종료 노트 정직성 보정. Wave 12o 종료 commit (8aa47f9)
은 revert/amend 하지 않음 (감사 추적성 보존).
Security (Top-1 P0)
hwpforge-smithy-hwp5::schema::summary_info::parse_summary_information의sec_start + 8uncheckedusizeadd 가 32-bit / wasm 타깃에서u32-derivedsec_start >= 0xFFFFFFF8일 때 wrap 후 panicking 슬라이스 인덱스로 진입할 수 있었음.checked_add+bytes.get(..)패턴으로 항상Hwp5Error::RecordParse경로 보장. 악성 .hwp 만으로 트리거 가능한 DoS 봉쇄.
Fixed (Top-2 P0 — data carry)
- HWPX decoder 가 한컴 emit
<opf:meta name="date">2026년 …</opf:meta>값을extras["date"]로 carry 하지만 HWPX encoder 의 typed-collision guard 가 이를 drop 해서 HWPX → HwpForge → HWPX 1-cycle round-trip 에서 silent data loss 발생. typed-collision 리스트에서date만 예외 처리하고 9-slot canonical 위치에서extras["date"]값을 그대로 emit.creator/subject등 진짜 typed slot 은 계속 drop.
Fixed (Top-4 P1 — API 일관성)
Hwp5Decoder::decode(canonical public entry) 가decode_intermediate에서 생성한intermediate.metadata를 받았지만 projection 결과document에set_metadata하지 않아 silently drop. 한 줄 추가로decode_hwp5_with_images/hwp5_to_hwpx_bytes와 일관성 확보.
Fixed (S3 namespace guard — P1 신규 발견)
hwpforge-smithy-hwpx::decoder::metadata::enforce_namespace가if let Some(p) = name.prefix()패턴이어서 unprefixed<title>/<meta>(prefix 누락) 가 S3 (namespace confusion) 가드를 통과.match표현으로 변환해서 prefix 누락 케이스도 reject. 하스타일 metadata 슬롯 shadow 가능성 차단.
Regression gates (+3 tests)
summary_info::section_start_past_eof_rejected— Top-1decoder::metadata::date_extras_roundtrip_preserves_value— Top-2decoder::metadata::unprefixed_element_inside_metadata_rejected— S3
Workspace nextest: 2,441 → 2,444 passed + 2 skipped.
Process integrity (Top-5)
- Wave 12o 종료 commit 의 G6/G7 PASS 표기 뒤에 G2 (native byte-parity xmllint diff), G4 (HWP5→HWPX e2e 자동 게이트), G8b (저장 후 fallback 거동) 가 미평가 상태로 묻혀 있던 사실을 CLAUDE.md 종료 노트에 명시. 후속 wave 에서 자동 게이트로 승격 약속.
Visual verification example
- 신규
crates/hwpforge-smithy-hwpx/examples/probe_date_carry_wave12o_fixup.rs— 한컴 저장본 HWPX 를 HwpForge 가 decode → encode → 재decode 한 결과 metadata 가 동일한지 자동 검증 + content.hpf 비교 명령 출력.
Wave 12o Phase 3 — HWP5 SummaryInformation OLE2 PropertySet decoder
Added — HWP5 decoder
- New module
crate::schema::summary_infoparses the\x05HwpSummaryInformationOLE2 PropertySet stream (standard MS Office layout) into a populated [Metadata](Phase 0) struct. PackageReadernow reads the summary stream alongside the other OLE2 sub-streams. Missing or undecodable summary downgrades toMetadata::default()with aParserFallbackwarning (never fatal).DecodedHwp5Intermediategains ametadatafield; bothdecode_hwp5_with_imagesandhwp5_to_hwpx_bytesforward it into the projectedDocumentviaDocument::set_metadata.
Wire mapping (PropertySet PIDs → Core Metadata)
- PID 2 (TITLE) →
title - PID 3 (SUBJECT) →
subject - PID 4 (AUTHOR) →
author - PID 5 (KEYWORDS) →
keywords(semicolon-split) - PID 6 (COMMENTS) →
description - PID 8 (LASTAUTHOR) →
last_saved_by - PID 0x0C (LASTSAVEDTIME, VT_FILETIME) →
modified(ISO 8601) - PID 0x0D (CREATEDTIME, VT_FILETIME) →
created(ISO 8601) - PID 0x14 (Hancom custom date display string) →
extras["date"] - PID 0x15 (Hancom custom app name) →
extras["appname"] - Unknown PIDs →
extras["pid_<hex>"](preserves wire bytes)
Security (Wave 12o architect review §11.2 M3/M4 + §11.4 S2/S7)
- M3 — slots into Schema stage (no new pipeline stage).
- M4 — FILETIME → ISO 8601 hand-rolled (no
chronodependency added). Sub-second precision discarded to match Hancom wire (seconds). Year > 9999 and FILETIME = 0 yieldNone. - S2 — property table monotonic-offset enforcement rejects classic PropertySet payload-cycle DoS.
- S7 — explicit UTF-16LE BOM strip from VT_LPWSTR payloads.
- Allocation caps: per-property body ≤ 64 KiB, property count ≤ 256.
Wave 12o Phase 2 — HWPX decoder content.hpf metadata parser + XXE/DoS defenses
Added — HWPX decoder
- New module
crate::decoder::metadataparsesContents/content.hpf<opf:metadata>into the Core [Metadata](Phase 0) struct, symmetric with Phase 1’s encoder output. HwpxDecoder::decodenow reads metadata before constructing theDocument, so a Hancom-emitted file’s$title/$author/etc. values resolve correctly on the next encode.- Missing or malformed
content.hpfmetadata downgrades toMetadata::default()(third-party HWPX authors may omit the block).
Security (Wave 12o architect review §11.1 B3 / §11.4 S1–S3)
<!DOCTYPE>events are rejected explicitly — defeats XXE and billion-laughs vectors before quick-xml’s default parser path.- CDATA inside
<opf:metadata>is rejected. - Allocation caps: depth ≤ 16,
<opf:meta>count ≤ 256, per-text body ≤ 64 KiB, attribute value ≤ 64 KiB. - S3 namespace confusion guard: any child of
<opf:metadata>must use theopfprefix;<hp:title>and friends are rejected. - S1 illegal-XML-char sanitizer is applied to all decoded text
before it enters
Metadata.
Wave 12o Phase 1 — HWPX encoder content.hpf metadata emit
Changed (BREAKING — public API)
HwpxEncoder::encodenow consultsdocument.metadata()and forwards it to the package writer.package::generate_content_hpf/PackageWriter::write_hwpxsignatures gain a leadingmetadata: &Metadataparameter (internalpub(crate); not part of the public surface).
Added — wire format
content.hpf<opf:metadata>is always emitted with the full nine-slot Hancom byte-parity layout (title/language/creator/subject/description/lastsaveby/CreatedDate/ModifiedDate/date/keyword) plus alphabeticalextras.Noneslots self-close; populated slots use thecontent="text"attribute.datealways self-closes (Hancom recomputes on save — Wave 12o §11.3 Q2).keywordsare joined with;into a single element (§11.3 Q3).- Defense in depth: every text body flows through
sanitize_xml_text(Wave 12o §11.4 S1) beforeescape_xml.
Wave 12o Phase 0 — Document Metadata Core breaking
Changed (BREAKING — public API)
core::metadata::Metadatagains three new fields and is now annotated#[non_exhaustive]. This is ADR-003 in the 0.7.0 break window. External callers can no longer constructMetadatawith a struct literal; use the newMetadata::new()seed plus the chainable.with_title()/.with_author()/.with_subject()/.with_description()/.with_last_saved_by()/.with_keywords()/.with_created()/.with_modified()/.with_extra()builder methods.- New
Metadata::description: Option<String>— corresponds to HWPX<opf:meta name="description">. Distinct fromsubject(Hancom stores them in separate slots). - New
Metadata::last_saved_by: Option<String>— corresponds to HWPX<opf:meta name="lastsaveby">and the$lastsavebySUMMERY auto-field (Wave 12n). Distinct fromauthor(creator). - New
Metadata::extras: BTreeMap<String, String>— lossless carry slot for<opf:meta>keys not yet promoted to typed fields. Deterministic ordering for byte-stable encoder output. - Rationale: HwpForge-emitted
content.hpfhad hardcoded<opf:title/>with all other metadata fields empty, causing Hancom Office to overwrite SUMMERY auto-field values (e.g.$title) with fallback text on save. Wave 12n’sControl::Field(Author/LastSavedBy/CreatedTime/ ModifiedTime/Title) auto-fields need a populated metadata source to resolve to user-intended values. Phases 1–4 (HWPX encoder/decoder, HWP5 SummaryInformation) wire the carry end-to-end.
Phase 12l — Control::Field name carry (Core + HWPX + HWP5)
Changed (BREAKING — public API)
Control::Fieldgains aname: Option<String>field carrying the form-mode identifier (HWPX<hp:fieldBegin name="...">). Existing callers that constructControl::Fieldwith a field-record literal must addname: None. Pattern matches with..are unaffected. Required because the prior model silently dropped the form-mode identifier on every HWPX↔Core↔HWP5 roundtrip.Control::field()convenience constructor unchanged in signature; the producedControl::Fieldnow hasname: None.Displayimpl forControl::Fieldnow printsname="…"only when present.Serialize/Deserialize/JsonSchemarepresentations ofControl::Fieldgain the new field; consumers that round-trip JSON ofControl::Fieldmust accept the new key (it is optional / defaults tonull).
Fixed — HWPX encoder
build_field_run_xmlnow emits the realnameattribute on<hp:fieldBegin>for bothCLICK_HEREandSUMMERY(automatic) fields instead of the hardcodedname="".Clickhere:set:N:now uses a fixed-point computedN(self-referential UTF-16 code unit count) instead of the literal43that did not match the real command string length and was a wire-truth lie even before name carry.build_field_run_xmlsignature gainsname: &str.
Fixed — HWPX decoder
- Both
CLICK_HERE/automatic-field branches and theSUMMERYbranch indecode_field_controlnow carryfb.nameintoControl::Field.name(withSome("") -> Nonenormalisation, matching the indexmark/dutmal pattern).
Added — HWP5 leg
- HWP5
%clkCtrlHeader parser (Hwp5ClickHereControl::parse) carries the press-field’shint_text/help_text/field_unique_idfrom the wire’s UTF-16LE Command string. Parser is length-driven (no:-delimiter split) and UTF-16 code unit cursor based, so embedded colons in hint/help and surrogate-pair input do not corrupt decoding. - HWP5
0x57(TagId::CtrlData) sub-record parser (Hwp5ClickHereControl::parse_name_subrecord) carries the form-modename. The%clk→0x57pairing is held byBodyTextParserState.pending_clickhere(mirrors theeqed→EqEditpattern used in Wave 12d) and flushed at orphan boundaries with a targetedProjectionFallbackwarning. - Projection has a dedicated
clickhere_controlsqueue + newActiveField::ClickHerevariant.start_active_field/finish_active_fieldemit a singleControl::FieldRun with all four metadata fields populated; the HWPX encoder rebuilds the visible placeholder fromhint_text, so the span between theFIELD_BEGIN/FIELD_ENDmarkers is intentionally not double-emitted. - Audit semantic model gains
Hwp5SemanticControlKind::ClickHereso press-field counts attribute correctly in the audit-batch output.
Fixed — HWPX encoder Command N
Clickhere:set:43:was a wire-truth lie (the literal43did not match any real-fixture command length, even before name carry). Replaced byclickhere_command_string, which uses the empirically-derived formulaN = utf16_len(rest_of_command) - 1(verified against the seven press-field instances across five fixtures). The formula isdigits(N)-independent, so no fixed-point iteration is required.
Notes
- See
.docs/algorithms/2026-06-02_clickhere_field_carry.mdfor the full algorithm + Codex review resolution table. - See
.docs/research/2026-06-02_clickhere_wire_dump.mdfor the wire layout derivation.
Phase 12 (HWP5 drawing-object carry)
Continues the Phase 11 line: HWP5 drawing objects the decoder previously skipped now carry through Core to HWPX instead of silently emptying their host paragraph. No Core or HWPX API changes — these shape variants already existed in the shared model; only the HWP5 leg was missing.
Added — Wave 12a (GSO ellipse / arc / curve)
- Decode
gsoShapeComponentEllipse(0x50) andShapeComponentCurve(0x53) sub-records and project them toControl::Ellipse,Control::Arc, andControl::Curve. Previously these fell through toHwp5Control::Unknownand were dropped, emptying the host paragraph. - 한컴 stores arcs inside the ellipse (
0x50) record with arc fields set — it does not emit a separateShapeComponentArc(0x51). An arc is now distinguished by content and carried asControl::Arc→<hp:ellipse hasArcPr="1">. - Classify ellipse/arc/curve in the audit semantic model
(
Hwp5SemanticControlKind::{Ellipse, Arc, Curve}) so source-side control counts match converted output. - Binary layouts confirmed empirically from 한컴 truth fixtures
(
sample-gso-{ellipse,arc,curve}); golden tests assert end-to-end carry. - Known limitation: arcs carry as the
Normalarc type sized from the bounding box. Pie/chord arc types and exact arc-sweep endpoints are deferred until dedicated fixtures exist.
Added — Wave 12b (GSO connect line)
- Carry connectors as
Control::ConnectLine→<hp:connectLine>instead of demoting them to a plain<hp:line>. 한컴 stores a connector in the sameShapeComponentLine(0x4E) sub-record as a plain line; the only discriminator is theShapeComponent(0x4C) type tag"$col"(confirmed against$rec/$ell/$cur). A conservative guard upgrades only an exact"$col"match, so plain lines are never reclassified. - Classify connectors in the audit semantic model
(
Hwp5SemanticControlKind::ConnectLine). - Confirmed end to end against a natively-drawn 한컴 connector fixture
(
sample-gso-connectline-native: two rectangles + one connector). - Known limitation: only a straight connector with its endpoints is carried;
the source connector’s object-link references have no
<hp:connectLine>representation and are dropped.
Fixed — floating ellipse/arc/curve/connect-line positioning (HWPX encoder)
<hp:ellipse>,<hp:curve>, and<hp:connectLine>hardcoded inline positioning (numberingType="NONE",textWrap="TOP_AND_BOTTOM",vertRelTo/horzRelTo="PARA") instead of using the shared offset-aware helpers that<hp:line>/<hp:rect>already used. A floating shape (non-zero offset) was therefore mis-anchored to the paragraph and rendered in the wrong place in 한컴. They now route throughshape_position/shape_numbering_type/shape_text_wrap, so a floating shape anchors toPAPERasPICTURE/IN_FRONT_OF_TEXT(matching 한컴) while inline shapes (zero offset) are unchanged. Exposed by Wave 12b’s first floating connector.
Added — Wave 12d (equation)
- Carry equations as
Control::Equation→<hp:equation>with the HancomEQN script preserved. Theeqedctrl used to fall through toHwp5Control::Unknownand was dropped. The decoder now recognizes theeqedctrl, parses the script from its childHWPTAG_EQEDIT(0x58) record (UINT32property, then aUINT16WCHAR-count length prefix + UTF-16 script), and projects it toControl::Equationsized from the ctrl-header geometry. - Classify equations in the audit semantic model
(
Hwp5SemanticControlKind::Equation). - Confirmed end to end against a 한컴 equation fixture
(
sample-equation-basic:{a + b} over {c + d}); golden test asserts both<hp:equation>emission and verbatim<hp:script>carry.
Added — Wave 12e-Memo (memo annotation carry + body corruption fix)
- Carry memo annotations as
Control::Memo→ HWPX<hp:fieldBegin type="MEMO">with the body paragraphs in<hp:subList>. The HWP5%unkctrl with command"MEMO/{shapeId}/{memo_id}/{instId}"used to fall through toHwp5Control::Unknown, so the matchingHWPTAG_MEMO_LIST(0x5D) cluster’s level-2ParaTextrecords would fall into the body-paragraphParaTextarm and overwrite the body text — the visible “memo content replaces body text” corpus bug. - Decoder now recognises the
%unk MEMO/...placeholder, captures the matching cluster region at the end of the section’s last body paragraph (records at level 1/2:MemoList,ListHeader, contentParaHeader,ParaText,CharShape, …), and joins clusters back to placeholders bymemo_id(not by document position) duringBodyTextParserState::finish. Multi-memo fixtures confirmed both happy path and id-keyed matching. - Projection adds an
ActiveField::MemoAnchorso the anchor text inside theFieldBegin %unk MEMO/FieldEndspan flows intoruns(no drop) and the memoRunis emitted at the anchor’s start position. layout_hint_patchnow folds memo body paragraphs into the body scope so the HWPX patcher does not underflow the paragraph-hint queue.- Classify memos in the audit semantic model (
Hwp5SemanticControlKind::Memo). - Golden tests confirmed against two 한컴 fixtures:
sample-memo-basic(one memo, body anchor + body content preservation) andsample-memo-multiple(two memos, id-keyed cluster matching).
Changed — Wave 12e-Memo (Core API breaking, semver-deliberate)
Control::Memono longer carriesauthor/date— the variant is nowControl::Memo { content: Vec<Paragraph> }. Neither field was actually populated by any wire path: the HWPX<hp:fieldBegin type="MEMO">only exposesMemoShapeID/MemoTypeparameters, and HWP5’s%unk MEMO/...command exposes no author/date metadata. Holding the fields encouraged callers to pass dummy values that never round-tripped.Control::memo(content)helper drops the correspondingauthor/dateparameters; call sites insmithy-hwpxexamples (shapes_and_references,hwpx_complete_guide_parts/section2) and tests (smithy-hwpx/src/registry_bridge.rs,smithy-hwpx/src/encoder/section.rs) updated.smithy-mdmarkdown encoder emits<!-- memo: body -->instead of<!-- memo(author): body -->(the author segment was always blank in practice).
Fixed — Wave 12f (memo anchor position)
- HWP5 stores the memo inline
FieldBeginmarker withextra[0..4] = %%me(0x2525_6D65), which is not the same id as theCtrlHeaderctrl_id%unk(0x2575_6E6B). Wave 12e matched only the latter, so the inline anchor was never recognised and the memoRunended up drained at the end of the paragraph as a point-anchored field — 한컴 rendered it as메모 대상 문장입니다.[메모 시작][필드 끝]. projection.rs::start_active_fieldnow recognises both ids: the inline marker activatesActiveField::MemoAnchor, which positions the memoRunat the correct anchor offset (vis=2in the basic fixture).
Changed — Wave 12g (Core API breaking, semver-deliberate)
Control::Memogainsanchor_runs: Vec<Run>. The anchor body sits inside the same<hp:run>asfieldBegin/fieldEndin 한컴 truth fixtures; the previous Wave-12f layout placed the anchor as a separate Run before the memo and 한컴 mis-rendered the end marker as generic[필드 끝].- HWPX encoder now serializes memos as a flat
[fieldBegin][anchor_xml][fieldEnd]<hp:run>viabuild_memo_anchor_xml. See.docs/algorithms/2026-06-01_memo_anchor_serialization.mdfor the anchor-body collapse heuristic (single<hp:t>per anchor) and why we accept the per-run char-shape fidelity loss. Control::memo_with_anchor(content, anchor_runs)helper added.
Added / Changed — Wave 12h (full <hp:parameters> carry)
- HWPX
<hp:parameters>now emits the full 한컴-standardcnt="7"block (Prop,Command,ID,Number,Author,MemoShapeIDRef,CreateDateTime) pluseditable="1" dirty="1" zorder="1". The pre-12hcnt="2"block (MemoShapeID+MemoTypeonly) made 한컴 mis-classify the field — the memo body rendered correctly, but the end marker fell back to generic[필드 끝]in 조판부호 view. - New schema struct
Hwp5MemoCommandparses the wire’s"MEMO/{shape_id}/{memo_id}/{hancom_inst_a}/{hancom_inst_b}/{author}/{terminator}"command string. Reusable wire-string utilities (parse_ctrl_header_command_string,split_slash_command) added toschema/section.rsfor future%hlk/%xrf/%bmkwork. - New
Control::Memo { metadata: MemoMetadata, … }field (Core API breaking, semver-deliberate).MemoMetadatacarriesshape_id_ref,number,id,author,create_datetime,command— wire values flow throughprojection.rsverbatim. CreateDateTimehas no HWP5 wire source (verified via DOC_PROPERTIES0x10, TRACKCHANGE0x20, MemoShape0x5Edumps). Encoder fills it withiso8601_utc_now()(std-only Howard-Hinnant civil-from-days) at write time — matching 한컴’s own behaviour on fresh saves.build_memo_parameters_xmlextracted as a re-usable HWPX<hp:parameters>builder for future field types that need the same structure (hyperlink / cross-reference fidelity work).
Added / Changed — Wave 12i (dutmal carry + flat-path control filter)
- Carry dutmal (덧말) annotations as
Control::Dutmal→<hp:dutmal>withmain_text/sub_text/posType/option. The decoder now readsoption_rawfromtail[8..12]of thetdutctrl payload (Hwp5DutmalControl); the HWPX encoder previously hard-codedoption="0"and the HWPX decoder discarded the value, so any non-defaultoption=4한컴 fixture lost fidelity end to end. Both legs now mirror the integer verbatim. Semantics ofoptionare intentionally not pinned — the bit/enum meaning is undocumented and produces no visible rendering difference in our truth fixture; see.docs/algorithms/2026-06-01_dutmal_carry.md. - New
Control::Dutmal { metadata: DutmalMetadata, … }field (Core API breaking, semver-deliberate).DutmalMetadatacarriesoption: u32and is#[non_exhaustive]so futuresz_ratio/align/style_id_refdecode work is additive.
Fixed — Wave 12i flat-path projection control_iter filter
project_paragraph_with_images_flatused to iterate every control inHwp5Paragraph.controls(including thesecd/cold/%bmk/%hlk/%xrf/bokm/pgnpUnknown markers that lead a first-section paragraph) when matching inline\u{FFFC}ControlRefpositions to runs. Each FFFC popped the wrong control — the marker-header Unknowns returnedNonefromproject_control_runand got dropped, while the real inline controls leaked to the end-of-paragraph drain. The observable symptom on Wave 12i’s two-dutmals-with-space fixture was the body space<hp:t> </hp:t>getting pulled in front of both dutmals (한국어 韓字→한국어韓字). The structural projection path was unaffected because it already separated those controls intomarker_headersvs.object_controlsqueues. The flat path now applies the same filter so its FFFC iterator only sees object controls. Any first-section paragraph that combinessecd/coldwith any inline shape (rect, polygon, ellipse, image, table, equation, dutmal) is covered, not just dutmal. See.docs/algorithms/2026-06-01_dutmal_carry.md(companion-fix section) for the full root-cause + rationale.
Added / Changed — Wave 12j (compose carry + char_pr_ids fidelity)
- Carry compose (글자겹침) annotations end-to-end through the HWP5 leg
—
Hwp5ComposeControlschema struct,tcpsCtrlHeader decode,Hwp5Control::Composevariant, and aproject_compose_runthat emitsControl::Composewith circleType / composeType mapped from raw enum bytes to the OWPML attribute strings (14SHAPECIRCLETYPEvalues + 2COMPOSETYPEvalues, including the spec-typoSHAPE_REVERSAL_TIRANGLE). HWPX→Core and Core→HWPX both already existed prior to this wave; only HWP5→Core was missing andtcpswas silently dropped toHwp5Control::Unknown. - New
Control::Compose { char_pr_ids: Vec<u32>, … }field (Core API breaking, semver-deliberate). HWPX schema fixes<hp:compose charPrCnt>at 10, but the existing encoder hard-coded all 10 slots tou32::MAX(“no override” sentinel) and the existing decoder discarded<hp:charPr prIDRef>children entirely. The new field threads the 10 LE u32 charPr IDs verbatim through Core so aHWPX → Core → HWPXround-trip preserves which slots carry a realprIDRefoverride (e.g.7) vs. theu32::MAXplaceholder. Encoder pads / truncates to 10 slots. - Compose wire layout discriminator. The
tcpsCtrlHeader payload has two empirically-observed forms, discriminated by the low half ofproperties(data[4..6]as LE u16):0x0003(unpacked) —composeTextis fully indata[8..], body trailer carries the 4 metadata bytes + 10 × u32 charPrs. 27 of 28 round-tripped variants and every native 한컴 fixture use this layout.0x0002(packed) —composeText[0]is inproperties[2..4],composeText[1..N]is at the start of the body; the body trailer layout is unchanged. Observed exclusively on theCHAR + OVERLAPcombination when 한컴 saved an HWPX → HWP5. The parser detects this viaproperties.low == 0x0002and prepends the packed char to the body region before decoding.
- Any other
properties.lowvalue falls through toHwp5Control::Unknown. No clamp; no guessing. See.docs/algorithms/2026-06-01_compose_carry.mdfor the full discriminator table, the shape-glyph table forproperties.high, and the validation policy rationale (Codex-reviewed). - The packed variant was discovered only through a 14 × 2 = 28
circleType × composeTypecombinatorial fixture (gen_compose_variantsexample) — single-shape native fixtures never trigger it. The lesson is captured in.docs/learnings/2026-06-01_hwp5_ctrl_header_properties_overloaded.md: HWP5’s CtrlHeaderpropertiesword is not a generic bitfield — each ctrl_id can repurpose it for ctrl-specific data, and the same ctrl can switch layouts within a single document.
Added — Wave 12k (IndexMark carry + 0x16 inline marker promotion)
- Carry IndexMark (찾아보기 표시) annotations end-to-end through the
HWP5 leg —
Hwp5IndexMarkControlschema struct,idxmCtrlHeader decode,Hwp5Control::IndexMarkvariant, and aproject_indexmark_runthat emitsControl::IndexMark { primary, secondary }. The HWPX encoder + decoder + Core variant all existed prior to Wave 12k; only the HWP5 leg was missing andidxmwas silently dropped toHwp5Control::Unknown. 0x16ParaText inline-marker promotion (Codex 🟠 HIGH).Hwp5ParaText::parseused to consume the entire0x0E..=0x16byte range silently. Adding a CtrlHeader dispatch alone would have left every IndexMarkRundrained to end-of-paragraph instead of anchored at the inline marker. The fix carves0x16out of the silent-consume range and promotes it toTextSegment::ControlRefonly whenextra[0..4]matches the LE-stored ctrl_id foridxm. The discrimination keeps every unknown0x16owner falling through the silent path so we don’t alias unrelated control families.- Targeted parser warning. Malformed
idxmpayloads emitHwp5Warning::DroppedControl { control: "indexmark", … }rather than the genericUnsupportedTag(0x47)fallback — audit baselines can attribute the loss to the IndexMark code path specifically. - Trailer 4 bytes deliberately discarded. The trailer is
0xFFFF_FFFFon native 한컴 authoring and0x00000000on HWPX→ HWP5 round-trip via 한컴 save; HWPX has no corresponding attribute, so carrying it into Core would be a format leak. The 4 bytes are still required to be present — a truncated trailer means the record boundary is no longer trustworthy. - Empty-secondary normalization. The source
Some("")for one fixture entry comes back from 한컴 assecondary_units_len = 0; the decoder reflects 한컴’s intent by returningNone. HWP5 wire cannot distinguish the two states once 한컴 has saved. - Golden tests cover (a) the native two-IndexMark fixture, (b) the
8-IndexMark combinatorial fixture (every primary-only and
primary+secondary combination + the
CPU/GPUsame-paragraph order regression gate that Codex specifically called out). The schema layer adds five focused unit tests (real native bytes, real multi bytes with secondary,primary_units_len == 1edge case,primary_units_len == 0rejection, truncated-trailer rejection). See.docs/algorithms/2026-06-02_indexmark_carry.mdfor the full layout table, Codex-review rationale, and validation policy notes.
Phase 11 (HWP5 → HWPX silent-gap closure)
This release closes the largest batch of “HWP5 decoder has the bytes, but projection or shared model can’t carry them” gaps measured against truth HWPX fixtures from 한컴 Office. All work is HWP5-leg only — HWPX encode/decode paths were already verified by existing golden tests.
Added — Wave 0 (audit infrastructure)
audit-hwp5warning taxonomy (SilentGap,DroppedControl, …) and--strictmode so silently dropped semantics are surfaced as build signals instead of accumulating as invisible technical debt.
Added — Wave 1 (character style line family + word break)
- Carry
underline(1b) andstrikeout(1c) line family (DOT,DASH,DOT_DASH, etc.) throughHwpxCharShapeinstead of collapsing toSOLID. - Carry
breakLatinWord = HYPHENATION(1d) throughWordBreakTypeand HWP5 projection so HWPXbreakLatinWordis no longer alwaysKEEP_WORD. - Surface silent
shadowdecode gap as warning (1a) until carry lands.
Added — Wave 2 (paragraph layout fidelity)
- Carry
lineSpacingType = AtLeast(2a). - Verify all 6 alignment variants (2b),
indent+pageBreakBefore(2cd), andborder+shading(2e) carry against truth HWPX fixtures.
Added — Wave 3 (paragraph-level checked decode)
- HWP5 paragraph-level
paraPr.checkeddecode so the third location of checkable-bullet truth (the per-item checked state) is no longer lost. Closes the legacy R1 line.
Added — Wave 4 (Field / Object parity)
- Wave 4a: carry HWP5
Rectcontrol through Core → HWPX. - Wave 4b: carry HWP5 footnote control through Core → HWPX.
- Wave 4c: carry HWP5 chart (OLE-backed BinData) end-to-end as
Control::EmbeddedChartpassthrough — emitsChart/chartN.xml+BinData/oleN.ole+<hp:switch>block. Closes theDroppedControl:ole_objectmeasurement. - Carry HWP5 fixed-width space and non-breaking space through Core to HWPX inline text.
- Carry HWP5 checkable bullet
checkedChar+paraHead.checkablethrough bullet conversion. - Tab fidelity end-to-end: carry inline
<hp:tab width / leader / type>attributes through a newRunContent::InlineTextvariant; HWPX encoder/decoder updated symmetrically; fill-type → leader mapping rebuilt against openhwp truth. See debug doc.docs/debug/2026-05-26_tab_fidelity_bugs.mdand.docs/debug/2026-05-27_hwpx_decoder_inline_tab_attrs_lost.md. - Field controls: emit non-zero
fieldidforCROSSREFand keepfieldBegin idwithin signed 32-bit range.
Added — Wave 5 (page-level features)
- Wave 5 gap A: carry per-ctrl
applyPageType(BOTH/ODD/EVEN) through HWP5head/footctrl property word into multiple<hp:header>/<hp:footer>elements. Verified againstsample-header-footer-odd-eventruth fixture. - Wave 5 gap B: carry
secdctrl property bits (0/1/2/5/19) intoSection.visibility.hide_first_header / footer / page_num / empty_line / master_page. Verified againstsample-header-footer-hide-firsttruth fixture. Newcrates/hwpforge-smithy-hwp5/examples/probe_secd.rsreusable probe. - Wave 5 gap C (
masterPagecarry): deferred. Diagnosed as fixture-asymmetric on macOS 한컴 (truth HWPX hasmasterpage0.xmlbut the paired HWP5 has no master-page sub-records). See debug doc.docs/debug/2026-05-27_hwp5_page_features_lost.mdfor full probe results. Resume when a PC-한컴 fixture is available.
Fixed — Wave 6 (corpus-driven conversion robustness)
Measured by extending the audit-hwp5 signal source from synthetic
fixtures to the real government-document corpus and clustering the
pre-categorized conversion failures. The two real bugs recovered all
29 hwp5_convert_failed documents (plus one more from 탈락); the
remaining failures are inputs that are genuinely not HWP5.
- Schemeless hyperlink URLs (e.g.
www.motie.go.kr) are normalized tohttp://instead of aborting the whole conversion. The HWPX encoder previously rejected any URL outside thehttp:///https:///mailto:allowlist; explicit unsafe schemes (javascript:,data:,file:, …) are still rejected. (16 corpus docs) - Non-leading table header rows are demoted to normal rows (with a
warning) in HWP5 projection instead of failing Core validation with
NonLeadingTableHeaderRow. Real 한글 tables sometimes restate a header row mid-table; the leading header block is preserved and the stray header row is demoted so the document still converts. (14 corpus docs) .hwpinputs that are actually ZIP (PK.., i.e. an HWPX saved with a.hwpextension) or a Hancom secured/DRM container (SCDS..) now fail with an actionable message instead of a raw CFB byte dump.
Changed — Breaking
- ADR-002:
hwpforge_core::Section.header: Option<HeaderFooter>→Section.headers: Vec<HeaderFooter>(andfooter→footers). This changes the publicSectionshape so that multiple page-type-scoped headers (ODD/EVEN/BOTH) can coexist as required by HWPX wire format. EmptyVecmeans “no header”. JSON dump now emits a list instead of an object (or omits when empty). Patch / MD encoder / CLI / MCP consumers updated to iterate the slot. See.docs/architecture/adr/ADR-002-section-multi-header-footer-cardinality.md.
Added — Public API
hwpforge_core::RunContent::InlineText(InlineText)variant for mixed-content runs that include<hp:tab>(and is forward-compatible with<hp:lineBreak>/<hp:nbSpace>/<hp:fwSpace>).InlineText/InlineSegment/InlineTabAttrare#[non_exhaustive].Sectionconstructors continue to benew()/with_paragraphs(); push headers/footers viasection.headers.push(HeaderFooter::…).
Migration
- Replace
section.header = Some(hf)withsection.headers.push(hf). - Replace
section.header.as_ref()withsection.headers.first()(single-slot consumers) or iteratesection.headers(general). serdeshape now usesheaders/footersarrays; persisted JSON using the old keys must be migrated.- Match arms on
RunContentmay need a newInlineText(_)arm. UseRunContent::carries_text()/plain_text()for read-only consumers that don’t care about inline attribute fidelity.
[0.5.2] - 2026-05-13
Added
hwpforgeCLI gainsto-mdlossy / lossless modes for HWPX → Markdown export choice.- HWP5 → HWPX projection preserves field controls and checkable state (precursor to Phase 11 Wave 3/4 closure).
Fixed
- Dependency hygiene updates (
sha2, GitHub Actions pinning, suppressRUSTSEC-2026-0097non-applicable advisory).
[0.5.1] - 2026-04-13
Added
- HWP5 → HWPX style fidelity bridge improvements (more char/para style surface preserved end-to-end).
- HWPX char effects: preserve
emboss,engrave,superscript,subscript(also covered later under Wave 1 audit).
Fixed
- Warn on conflicting vertical-position bits instead of silently normalizing.
- HWP5 paragraph layout hints (linesegarray, safe table height) carried to HWPX so visual diff matches truth better.
convert-hwp5/audit-hwp5/inspectsummary share the same style-projection warning source.
[0.5.0] - 2026-03-20
Changed — Breaking
- Adopt shared
ordered/bullet/outlinelist semantics acrosscore,blueprint, andsmithy-hwpx. Markdown bridge integrated. - Add checkable bullet semantics (HWPX
heading(type="BULLET")+bullet.checkedChar+bullet.paraHead.checkable+paraPr.checked). Markdown task lists normalize to this shared HWP semantic; ordered task lists intentionally drop numbering on the way in. - Tighten bullet semantics: paragraph
heading_levelis no longer a catch-all for list semantics (see gotcha #7 inCLAUDE.md).
Added
- Markdown bridge: preserve task list continuation paragraphs; normalize ordered task lists.
- Fixtures: reorganized under
examples/andtests/fixtures/.
Fixed
- HWPX style id bridging for registry-local style ids.
- Outline contract hardening (golden tests).
[0.4.0] - 2026-03-20
Changed
- Promote the workspace release line to
0.4.0for the breaking tab semantics contract inhwpforge-coreandhwpforge-blueprint. - Add shared tab-stop semantics across the IR stack so HWPX/HWP5 codecs can preserve explicit tab definitions and paragraph tab references.
Migration
hwpforge_core::TabDefnow includes explicitstops; downstream struct literals must initialize the new field.hwpforge_blueprint::Template,ParaShape, andPartialParaShapenow carry tab definition references/collections directly.- Consumers matching on
BlueprintErrorCodeshould handle the new tab-related error codes explicitly.
[0.3.0] - 2026-03-19
Changed
- Promote the workspace release line to
0.3.0for the breaking HWPX section editing contract update. - Preserve-first section editing now requires preservation metadata on
ExportedSectionand rejects stale or legacy section exports explicitly.
[0.2.0] - 2026-03-17
Changed
- Adopt the
hwpforge-corev0.2.0 public DOM contract for richer table and image semantics. - Align the workspace release line and internal crate pins on
0.2.0.
Migration
Table,TableRow,TableCell, andImageare now#[non_exhaustive]and should be constructed vianew/with_*builders instead of struct literals.- Table DOM now carries page-break, repeat-header, cell-spacing, border/fill, header-row, cell margin, and vertical-alignment semantics directly in
hwpforge-core. - Image DOM now carries placement metadata directly in
hwpforge-core. - Validation now exposes
CoreErrorCode::NonLeadingTableHeaderRow; downstream code that inspects validation codes should handle it explicitly.
0.1.0 - 2026-03-06
Added
- hwpforge: Umbrella crate with feature flags (
hwpx,md,full) - hwpforge-foundation: Primitive types (HwpUnit, Color BGR, branded Index
, enums, error codes) - hwpforge-core: Format-independent document model with typestate validation (Draft/Validated)
- Document, Section, Paragraph, Run, Table, Image
- Controls: TextBox, Footnote, Endnote, Equation, Chart (18 types)
- Shapes: Line, Ellipse, Polygon, Arc, Curve, ConnectLine
- References: Bookmark, CrossRef, Field, Memo, IndexMark
- Layout: Multi-column, captions, headers/footers, page numbers, master pages
- Annotations: Dutmal, compose characters
- hwpforge-blueprint: YAML-based style template system
- Template inheritance with DFS merge
- StyleRegistry with deduplicated fonts, char shapes, para shapes
- Built-in default template (Hancom 한컴바탕)
- BorderFill support
- hwpforge-smithy-hwpx: Full HWPX codec (KS X 6101)
- Decoder: HWPX ZIP+XML -> Core Document
- Encoder: Core Document -> HWPX ZIP+XML
- Lossless roundtrip for all supported content
- HancomStyleSet support (Classic/Modern/Latest)
- 22 default styles with per-style charPr/paraPr
- ZIP bomb defense (50MB/500MB/10k limits)
- OOXML chart generation (18 chart types)
- Golden fixture tests with real Hancom 한글 files
- hwpforge-smithy-md: Markdown codec
- GFM decoder (pulldown-cmark) with YAML frontmatter
- Lossy encoder (readable GFM) and lossless encoder (HTML+YAML)
- Full pipeline: MD -> Core -> HWPX verified in Hancom 한글