Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.0] — 2026-06-04

MINOR release. parse 한 `Document` 를 다시 HWPX 로 저장하는 첫 역방향 표면을 추가한다 — v0.2.0 ~ v0.6.0 의 모든 산출물 (IR / SVG / PDF / PNG) 이 read-only 였던 것에 writeback 을 연다. 직렬화는 상류 `serialize_hwpx` 에 위임한다. 추가만 있고 기존 IR / 렌더 / MCP 표면은 모두 보존 (additive only) — IR schema (`"1.1"`) 변경 0.

### Added

- `Document.to_hwpx_bytes() -> bytes` — 문서를 HWPX (ZIP+XML) 바이트로 직렬화. 출력은 ZIP magic `b"PK\x03\x04"` 으로 시작하고 첫 엔트리가 STORED `mimetype` = `application/hwp+zip`. `Document` IR 이 포맷 독립이라 HWP5 로 parse 한 문서도 HWPX 로 출력된다 (HWP5 → HWPX 포맷 변환). 직렬화 실패 (참조 무결성 위반 — BinData 누락 등) 는 `ValueError`.
- `Document.export_hwpx(path) -> int` — 문서를 HWPX 파일로 저장하고 작성 바이트 수 (> 0) 를 반환. 파일 쓰기 실패 (부모 디렉토리 부재 등) 는 `OSError`. `render_pdf` / `export_pdf` 의 메모리 / 파일 분리 패턴과 대칭.
- README § "HWPX 저장 (writeback)" 신설 — `to_hwpx_bytes` / `export_hwpx` 사용 예 + HWP5 → HWPX 변환 + 보존 범위 / 에러 계약. PNG 렌더 섹션과 LangChain 통합 섹션 사이 배치.

보존 범위: 텍스트·문단은 round-trip 의미를 보존한다 (parse → 저장 → 재파싱 시 섹션 수 / 문단 수 / 문단 텍스트 동등). 표·그림·수식은 상류 serializer 의 현 보존 범위에 위임 — 의미 보존 미보장 (예외 없는 직렬화만 보장). round-trip 은 의미적 동등성 기준이며 byte 단위 동일은 보장하지 않는다. 표·그림 round-trip 의미 보존은 v0.8.0, HWP5 binary 출력 (`export_hwp`) 은 별도 minor.

### Build

- `external/rhwp` submodule pin `1899ef9b` (v0.7.12) → `ce45231c` (v0.7.12 + 394 commit, 2026-05-27 상류). spec·feat 는 `1899ef9b` 기준 작성됐고 (2026-05-20) GA 직전 재동기화 후 그 위에서 회귀를 재검증했다. **본 binding 관점 회귀 0** — `serialize_hwpx` 시그니처 불변, `maturin develop --release` clean, `pytest -m "not slow"` 599 passed / 2 skipped (IR baseline byte-equal 포함). 흡수한 상류 변경: serializer +2092 (16 commit 거의 전부 HWP5 binary writeback `serialize_hwp` 한컴 호환 — Form 컨트롤 byte-perfect / 각주 contract / 표 셀 배경 / EQEDIT errata), model +1267, rendering +1320 — 모두 직렬화·렌더 내부라 binding 이 소비하는 IR schema (`"1.1"`) / IR·렌더 출력은 불변. 상류 HWPX round-trip IrDiff 는 여전히 Stage 0 (카운트만) 라 본 baseline 의 텍스트·문단 round-trip 보장은 binding 자체 회귀 가드가 책임.
- `Cargo.toml` 의 `version` `0.6.1` → `0.7.0`. `pyproject.toml` 은 `dynamic = ["version"]` 으로 자동 추종.

## [0.6.1] — 2026-05-18

PATCH release. v0.6.0 (Frozen, 2026-05-10) 의 GitHub Release / PyPI publish 가 누락된 상태에서 발견된 release 인프라 정합화 + 후속 polish 를 한 묶음 PATCH 로 발행한다. 사용자 영향: PyPI 첫 게시 패키지가 `v0.5.1` 다음 `v0.6.1` 로 점프 — v0.6.0 의 모든 표면 (페이지 PNG 렌더링 + 문서 시스템 개편) 은 변경 없이 그대로 포함하며 `[0.6.0]` 섹션은 historical record 로 보존. 외부 공개 API / IR schema (`"1.1"`) 변경 0.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rhwp-python"
version = "0.6.1"
version = "0.7.0"
edition = "2021"
# ^ rust-version 미명시 — 상위 rhwp crate 정책(stable Rust, MSRV unclaimed) 준수.
# PyO3 0.28 이 Rust 1.83+ 요구하지만, 이는 README 에 문서로 안내
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,37 @@ print(message.content[0].text)
`ValueError("raster pixel count out of range: ...")`. 사용자가 명시 override
가능 — 예: `doc.render_png(0, max_pixels=200_000_000)`.

## HWPX 저장 (writeback)

parse 한 문서를 다시 HWPX 파일로 저장하는 역방향 표면. `Document` IR 이 포맷
독립이라 HWP5 로 parse 한 문서도 HWPX 로 출력된다 (HWP5 → HWPX 포맷 변환).
직렬화는 상류 `rhwp` 의 `serialize_hwpx` (PR #170) 에 위임한다.

```python
import rhwp

doc = rhwp.parse("report.hwp")

# 메모리 bytes — ZIP magic 으로 시작 (첫 엔트리 STORED mimetype = application/hwp+zip)
data: bytes = doc.to_hwpx_bytes()

# 디스크 저장 — 작성 바이트 수 반환
written: int = doc.export_hwpx("out.hwpx")

# HWP5 → HWPX 변환도 동일 (입력 포맷 무관)
rhwp.parse("legacy.hwp").export_hwpx("converted.hwpx")
```

**보존 범위** — 텍스트·문단은 round-trip 의미를 보존한다 (parse → 저장 →
재파싱 시 섹션 수 / 문단 수 / 문단 텍스트 동등). 표·그림·수식은 상류 serializer
의 현 보존 범위에 위임하며 의미 보존을 보장하지 않는다 (예외 없는 직렬화만
보장). 표·그림의 round-trip 의미 보존은 후속 버전 과제다. round-trip 은 의미적
동등성 기준이며 byte 단위 동일은 보장하지 않는다 (ZIP 압축 / canonical default
주입 등).

직렬화 실패 (참조 무결성 위반 — BinData 누락 등) 는 `ValueError`, 파일 쓰기
실패 (부모 디렉토리 부재 등) 는 `OSError`.

## LangChain 통합

```bash
Expand Down
110 changes: 110 additions & 0 deletions docs/design/v0.7.0/hwpx-writeback-baseline-research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
status: Frozen
description: "v0.7.0 hwpx-writeback-baseline ADR — 직렬화 source(상류 위임) / API 명명 / GIL 전략 / 보존 boundary 4개 결정의 근거"
ga: v0.7.0
last_updated: 2026-06-04
---

# v0.7.0 hwpx-writeback-baseline — 설계 의사결정 리서치 요약

[v0.7.0/hwpx-writeback-baseline.md](../../roadmap/v0.7.0/hwpx-writeback-baseline.md) §결정 사항 중 외부 독자가 "왜?" 를 던질 만한 **4**건의 업계 선례·대안·실패 시나리오를 기록한다. spec 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다.

## 결정 매트릭스

| # | 항목 | 옵션 비교 | 채택 | 1차 근거 |
|---|---|---|---|---|
| 1 | 직렬화 source | A: 자체 직렬화 구현 / B: 상류 `serialize_hwpx` 위임 | B | upstream-first — 결함은 상류 이슈, binding 은 표면만 |
| 2 | API 표면·명명 | A: `render_hwpx` / B: `to_hwpx_bytes` + `export_hwpx` / C: `save_hwpx` | B | `render_*` 는 raster 전용, `save_*` 는 mutable 시맨틱 연상 |
| 3 | GIL 전략 | A: GIL 보유 / B: `Document` clone 후 `py.detach` | A | `DocumentCore` `!Sync` — clone 비용 미측정, 정확성 우선 |
| 4 | 보존 boundary | A: 전체 요소 보존 보장 / B: 텍스트·문단만 보장 + 상류 위임 | B | 상류 IrDiff 검증이 점진 확장 중 — 미검증 요소 보장은 거짓 약속 |

## 1. 직렬화 source

### 팩트

- 상류 `external/rhwp/src/serializer/hwpx/mod.rs:40` 이 `serialize_hwpx(doc: &Document) -> Result<Vec<u8>, SerializeError>` 를 공개 API 로 export 한다.
- `PyDocument` 는 `inner: DocumentCore` 를 보관하고 (`src/document.rs:15`), `self.inner.document()` 가 `&Document` 를 반환한다 (`src/document.rs:75` 등에서 사용). 상류 시그니처에 그대로 전달 가능.
- 본 프로젝트의 운영 원칙: `external/rhwp` 는 upstream-owned, 로컬 수정 금지 — 결함/누락은 상류 GitHub 이슈로 보고 (자체 patch / 알고리즘 복사 금지).

### 검증자 반박

- "상류 serializer 에 버그가 있으면 binding 에서 못 고치나?" → 못 고친다 (의도적). 상류 이슈/PR 로 해결한다. 자체 patch 는 유지보수 부채 + 상류와 발산하는 fork 를 만든다.
- "직렬화를 위임만 하면 binding 의 가치가 없는 것 아닌가?" → binding 의 가치는 직렬화 알고리즘 재구현이 아니라 Python 표면 / 타입 스텁 / 에러 변환 (`SerializeError` → `PyValueError` / `OSError`) / GIL 관리 / round-trip 회귀 테스트다.

### 최종 결정

B 채택. 상류 `serialize_hwpx` 를 그대로 위임 호출하고 자체 직렬화 구현은 하지 않는다. HWPX writer 결함은 상류 이슈로 보고한다.

### 1차 소스

- 상류 serializer 모듈: `external/rhwp/src/serializer/hwpx/mod.rs`, `external/rhwp/src/serializer/mod.rs`
- 상류 PR #170 (HWPX Serializer 구현 — Document IR → HWPX 저장)
- HWP5-origin Document 수용 증거 (결정 사항 §4 입력 포맷 backing): 상류 test `equation_roundtrip_from_hancom_origin_hwp_sample` (`external/rhwp/src/serializer/hwpx/mod.rs`) 가 `parse_hwp` 결과를 `serialize_hwpx` 에 직접 투입. `SerializeError::UnsupportedInput` 은 enum 에 선언만 되고 `serialize_hwpx` 경로에서 생성되지 않음 (입력 포맷 게이트 부재)

## 2. API 표면·명명

### 팩트

- 기존 `Document` 표면의 동사 관용: `render_svg` / `render_pdf` / `render_png` (시각 raster·view 산출물), `export_svg` / `export_pdf` / `export_png` (파일 저장), `to_ir` / `to_ir_json` (데이터 구조 변환) — `python/rhwp/document.py`.
- 즉 `render_*` = 픽셀/뷰 렌더, `to_*` = 데이터 변환 (메모리), `export_*` = 파일 저장의 3분 패턴이 이미 확립돼 있다.

### 검증자 반박

- "`save_hwpx` 가 사용자에게 더 직관적이지 않나?" → `save` 는 "편집한 것을 저장" 하는 mutable 시맨틱을 연상시킨다. baseline 은 편집 없는 변환이라 부정확하고, v1.0 의 mutable IR 빌더 API 와 명명이 충돌한다.
- "`render_hwpx` 는?" → `render_*` 는 픽셀/뷰 산출물 전용 의미라 ZIP+XML 포맷 직렬화에 부적합. `to_hwpx_bytes` 가 `to_ir_json` 과 같은 "구조 → 직렬 형식" 결을 따른다.

### 최종 결정

B 채택. `to_hwpx_bytes() -> bytes` (메모리) + `export_hwpx(path) -> int` (파일). 기존 `to_ir` / `export_pdf` 패턴과 정합.

### 1차 소스

- 기존 API 표면: `python/rhwp/document.py` (`render_*` / `export_*` / `to_*` 메서드군)

## 3. GIL 전략

### 팩트

- `src/document.rs:240-243` `to_ir` 주석: "GIL 해제 불가: `self.inner` (DocumentCore) 가 RefCell 캐시로 `!Sync` — closure 가 `&self` 를 캡처하면 `py.detach` 의 Ungil 바운드 불만족. parse (`from_bytes` — owned bytes) 와 `render_pdf` / `export_pdf` (owned svgs) 만 GIL 해제 가능."
- `serialize_hwpx(self.inner.document())` 는 `&self.inner` 를 캡처한다 — 위 제약에 해당해 클로저 이동 불가.
- 프로젝트 GIL 가이드: ≥1 ms Rust-side 작업은 `py.detach` 권장하되, 불확실하면 `benches/bench_gil.py` 패턴으로 측정 후 결정.

### 검증자 반박

- "ZIP 압축은 ≥1 ms 일 텐데 GIL 보유면 멀티스레드 처리량 손해 아닌가?" → 맞다. 단 `detach` 하려면 `self.inner.document().clone()` 으로 owned `Document` 를 만들어 클로저로 이동해야 한다. clone 비용 vs GIL 보유 비용은 미측정.
- "clone 후 detach 가 항상 이득인가?" → 아니다. clone 비용은 `Document` 크기에 비례 — 대형 문서면 clone 이 GIL 보유보다 비쌀 수 있다. 측정 없이 단정 불가.

### 최종 결정

A 채택. baseline 은 GIL 보유로 정확성을 우선한다. clone-후-detach 최적화는 `bench_gil.py` 측정이 순이득을 보이면 후속 patch (v0.7.x) 로 분리.

### 1차 소스

- `src/document.rs` (`to_ir` GIL 주석, `render_pdf` / `export_pdf` detach 패턴)
- 프로젝트 GIL 정책 (`AGENTS.md` § Rust + Python hybrid build)

## 4. 보존 boundary

### 팩트

- 상류 `external/rhwp/src/serializer/hwpx/section.rs:292` `render_control_slot` 이 `Table` / `Picture` / `Equation` / `Shape` 컨트롤을 emit 한다 — 표 직렬화 실패 시 `eprintln!` 후 계속 진행 (graceful degradation).
- 상류 `external/rhwp/src/serializer/hwpx/roundtrip.rs:7` IrDiff 하네스 주석: "누적 확장 — Stage 0 에선 뼈대 필드 (섹션 수·문단 수·리소스 카운트) 만 비교하고, Stage 1~5 진행 시 비교 대상 필드를 누적 확장." 즉 직렬화 코드 존재와 round-trip 검증 완료는 별개.
- `serialize_hwpx` 는 per-control 만 graceful (`section.rs` 의 `eprintln!`) 하고, 참조 무결성은 hard-error 다: `BinDataContent 누락` (`hwpx/mod.rs:86-93`) / `assert_all_refs_resolved()` / `assert_bin_data_3way()` 가 `Err(SerializeError)` 를 반환 → binding 에서 `ValueError` 로 전파. 보존 boundary 는 "무조건 crash-free" 가 아니라 "per-control graceful + 무결성 hard-error" 모델.

### 검증자 반박

- "표가 직렬화되는데 왜 보존을 보장하지 않나?" → 직렬화 코드 존재 ≠ round-trip 의미 보존 검증 완료. 상류가 IrDiff 로 검증한 범위 밖을 우리가 보장하면 거짓 약속이 된다.
- "그럼 baseline 의 실용 가치가 텍스트뿐인가?" → 텍스트·문단 round-trip + HWP5 → HWPX 포맷 변환 + 표·그림 포함 실문서 crash-free 직렬화. 메타 정정 / 평문 교정 / 포맷 마이그레이션 시나리오를 커버한다.

### 최종 결정

B 채택. 텍스트·문단 round-trip 을 회귀로 보장하고, 표·그림 등은 상류 보존 범위에 위임 (crash-free 만 보장). 의미 보존 확장은 상류 IrDiff 진척에 맞춰 v0.8.0.

### 1차 소스

- 상류 직렬화/검증: `external/rhwp/src/serializer/hwpx/section.rs`, `external/rhwp/src/serializer/hwpx/roundtrip.rs`

## 참조

- 짝 페어 (spec): [roadmap/v0.7.0/hwpx-writeback-baseline.md](../../roadmap/v0.7.0/hwpx-writeback-baseline.md)
- 상류 PR #170 (HWPX Serializer) / `edwardkim/rhwp` `serializer/hwpx/` 모듈
Loading
Loading