Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HwpForge

한글 문서(HWP/HWPX)를 프로그래밍으로 제어하는 Rust 라이브러리

crates.io docs.rs License: MIT OR Apache-2.0


HwpForge란?

HwpForge는 한컴 한글의 HWP/HWPX 문서를 Rust로 읽고, 쓰고, 변환할 수 있는 라이브러리입니다. public guide와 umbrella crate surface는 여전히 HWPX/Markdown 중심이지만, HWP5는 전용 crate와 CLI를 통해 decode, audit, re-emission 경로를 제공합니다.

주요 기능

  • HWPX 풀 코덱 — HWPX 파일 디코딩/인코딩 + 무손실 라운드트립
  • HWP5 읽기/점검 경로 — legacy .hwp decode, audit, HWPX re-emission
  • Markdown 브릿지 — GFM Markdown ↔ HWPX 변환
  • YAML 스타일 템플릿 — 재사용 가능한 디자인 토큰 (Figma 패턴)
  • 타입 안전 API — 브랜드 인덱스, 타입스테이트 검증, unsafe 코드 0

지원 콘텐츠

카테고리요소
텍스트런, 문자 모양, 문단 모양, 스타일 (한컴 기본 22종)
구조표 (중첩), 이미지, 글상자, 캡션
레이아웃다단, 페이지 설정, 가로/세로, 여백, 마스터페이지
머리글/바닥글머리글, 바닥글, 쪽번호 (autoNum)
주석각주, 미주
도형선, 타원, 다각형, 호, 곡선, 연결선 (채움/회전/화살표)
수식HancomEQN 스크립트
차트18종 차트 (OOXML 호환)
참조책갈피, 상호참조, 필드, 메모, 찾아보기
MarkdownGFM 디코드, 손실/무손실 인코드, 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기본 포함설명
hwpxHWPX 인코더/디코더 (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 &section.paragraphs {
            for run in &paragraph.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 (대장간)형식별 인코더/디코더용광로와 망치
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 코덱]
    B --> S5[hwpforge-smithy-hwp5<br/>HWP5 decode/projection]
    SH --> U[hwpforge<br/>umbrella crate]
    SM --> U
    S5 --> U
    U --> PY[hwpforge-bindings-py<br/>Python (stub)]
    U --> CLI[hwpforge-bindings-cli<br/>CLI (shipped)]

규칙: 의존성은 위에서 아래로만 흐릅니다. 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>: 팬텀 타입을 이용한 브랜드 인덱스. CharShapeIndexParaShapeIndex를 혼용하면 컴파일 에러

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 프론트매터 → Core
  • MdEncoder — 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 (OOXML xmlns: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 — 임베드된 이미지 바이너리 데이터
}
필드타입설명
documentDocument<Draft>섹션, 문단, 런 트리
style_storeHwpxStyleStore폰트/글자모양/문단모양/스타일
image_storeImageStore이미지 바이너리 저장소

메타데이터 접근

디코딩된 문서에서 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(&registry).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>를 반환합니다. HwpxErrorHwpxErrorCode와 메시지를 포함합니다.

#![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(&registry)커스텀 템플릿 적용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 필드설명
titletitle문서 제목
authorauthor작성자
datecreated작성일 (ISO 8601)
subjectsubject주제/설명
keywordskeywords검색 키워드 (배열)
modifiedmodified수정일 (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/modifiedOption<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 필드설명
titletitle문서 제목
authorauthor작성자
datecreated작성일 (ISO 8601)
subjectsubject주제/설명
keywordskeywords검색 키워드 (YAML 배열)
modifiedmodified수정일 (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.tomlmd 기능을 활성화합니다:

[dependencies]
hwpforge = { version = "0.1", features = ["md"] }

Lossy vs Lossless 모드 선택

기준Lossy (encode_lossy)Lossless (encode_lossless)
출력 형식표준 GFM MarkdownYAML 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(&registry).unwrap();
let style_store = bridge.style_store();
}

한컴 스타일셋: Classic / Modern / Latest

한글 프로그램은 버전에 따라 다른 기본 스타일 구성을 사용합니다.

스타일셋스타일 수설명
Classic18개한글 구버전 호환
Modern22개기본값 (한글 2018~)
Latest23개최신 버전

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(&registry).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 / Column3D3D 막대/세로 막대
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],
};
}

차트를 문단에 삽입하는 패턴

차트 ControlRun::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 &section.paragraphs {
        for run in &paragraph.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) = &section.header {
        print!("[머리글] ");
        extract_paragraphs(&header.paragraphs);
    }

    // 본문 문단
    for paragraph in &section.paragraphs {
        extract_paragraph(paragraph, 0);
    }

    // 바닥글 텍스트
    if let Some(footer) = &section.footer {
        print!("[바닥글] ");
        extract_paragraphs(&footer.paragraphs);
    }
}

/// 단일 문단에서 텍스트 추출 (들여쓰기 레벨 지원)
fn extract_paragraph(para: &Paragraph, indent: usize) {
    let prefix = "  ".repeat(indent);
    print!("{}", prefix);

    for run in &para.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 &para.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 쪽입니다.

현재 선택지는 이렇습니다.

  1. CLI workflow 사용: convert-hwp5, audit-hwp5, census-hwp5
  2. 전용 crate 사용: hwpforge-smithy-hwp5Hwp5Decoder
  3. 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 &section.paragraphs {
        for run in &paragraph.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/Section0mimetype, 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-blueprintYAML 스타일 템플릿없음
hwpforge-smithy-hwpxHWPX ↔ Core 코덱HWPX (ZIP+XML)
hwpforge-smithy-hwp5HWP5 decode/projection + audit helpersHWP5 (OLE/CFB)
hwpforge-smithy-mdMarkdown ↔ Core 코덱Markdown (텍스트)

핵심 원칙: Core 이하 계층은 어떤 파일 포맷도 모릅니다. 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)가 아닙니다.

현재 지원 상태

기능HWPXHWP5Markdown
읽기 (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 CLIconvert-hwp5, audit-hwp5, census-hwp5로 decode/점검/재출력완전 자동
전용 cratehwpforge-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();
}

관련 문서

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.

[0.4.0] - 2026-03-20

Changed

  • Promote the workspace release line to 0.4.0 for the breaking tab semantics contract in hwpforge-core and hwpforge-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::TabDef now includes explicit stops; downstream struct literals must initialize the new field.
  • hwpforge_blueprint::Template, ParaShape, and PartialParaShape now carry tab definition references/collections directly.
  • Consumers matching on BlueprintErrorCode should handle the new tab-related error codes explicitly.

[0.3.0] - 2026-03-19

Changed

  • Promote the workspace release line to 0.3.0 for the breaking HWPX section editing contract update.
  • Preserve-first section editing now requires preservation metadata on ExportedSection and rejects stale or legacy section exports explicitly.

[0.2.0] - 2026-03-17

Changed

  • Adopt the hwpforge-core v0.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, and Image are now #[non_exhaustive] and should be constructed via new/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 한글