diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc565d..2fc44c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.toml b/Cargo.toml index ffbf5ca..5032f9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 에 문서로 안내 diff --git a/README.md b/README.md index 86d706e..84ff701 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/design/v0.7.0/hwpx-writeback-baseline-research.md b/docs/design/v0.7.0/hwpx-writeback-baseline-research.md new file mode 100644 index 0000000..4f8126c --- /dev/null +++ b/docs/design/v0.7.0/hwpx-writeback-baseline-research.md @@ -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, 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/` 모듈 diff --git a/docs/implementation/v0.7.0/migration.md b/docs/implementation/v0.7.0/migration.md new file mode 100644 index 0000000..a43d567 --- /dev/null +++ b/docs/implementation/v0.7.0/migration.md @@ -0,0 +1,157 @@ +--- +status: Frozen +description: "v0.7.0 구현 로그 — HWPX writeback baseline (`to_hwpx_bytes` / `export_hwpx`). 상류 `serialize_hwpx` 위임 + 텍스트·문단 round-trip. 상류 pin `1899ef9 → ce45231c` 재동기화 후 회귀 0 재검증" +ga: v0.7.0 +last_updated: 2026-06-04 +--- + +# v0.7.0 — HWPX writeback baseline (구현 로그) + +[v0.7.0/hwpx-writeback-baseline](../../roadmap/v0.7.0/hwpx-writeback-baseline.md) (spec) + +[design/v0.7.0/hwpx-writeback-baseline-research](../../design/v0.7.0/hwpx-writeback-baseline-research.md) +(ADR) 의 구현 결과 로그. 결정의 근거·옵션 비교는 ADR 가 보유 — 본 문서는 +*산출물 / 검증 결과 / 호환성 / 이월 사항* + *상류 재동기화 영향 분석* 만 기록한다. + +MINOR release. 단일 세션 규모 (Rust 2 메서드 + Python wrapper 2 + 테스트 7) 로 +단일 `migration.md` 채택. + +본 release 의 특수성: spec·feat 는 상류 pin `1899ef9` (v0.7.12) 기준으로 작성됐고 +(2026-05-20), GA 직전 상류 pin 을 `ce45231c` (v0.7.12 + 394 commit) 로 재동기화한 +뒤 그 위에서 회귀를 재검증했다. § 3 이 그 분석을 보유한다. + +## 1. 산출물 + +### Rust 신규 + +| 파일 / 위치 | 변경 | +|---|---| +| [src/document.rs](../../../src/document.rs) | `PyDocument::to_hwpx_bytes(&self) -> PyBytes` / `export_hwpx(&self, output_path) -> usize` 2 #[pymethods] 신규. 본체는 `rhwp::serializer::serialize_hwpx(self.inner.document())` 위임 + `SerializeError → PyValueError`, 파일 쓰기 실패 → `PyIOError`. GIL 보유 (§ 2 결정 3) | + +### Python 신규 + +| 파일 / 위치 | 변경 | +|---|---| +| [python/rhwp/document.py](../../../python/rhwp/document.py) | `Document.to_hwpx_bytes()` / `export_hwpx(output_path)` wrapper 메서드 — Rust getter 위임. § "HWPX writeback" docstring 에 보존 범위 / 에러 계약 명시 | +| [python/rhwp/_rhwp.pyi](../../../python/rhwp/_rhwp.pyi) | `_Document.to_hwpx_bytes` / `export_hwpx` 2 stub | + +### 테스트 + +| 파일 | 변동 | 책임 | +|---|---|---| +| [tests/test_hwpx_writeback.py](../../../tests/test_hwpx_writeback.py) | 신규 (+119 lines) | 4 테스트 클래스 7 테스트 — `TestRoundtripPreservation` (AC-1) / `TestContainerShape` (AC-2~4) / `TestExportHwpx` (AC-5) / `TestAdditiveNoSideEffects` (AC-6). per-test `pytest.mark.spec("v0.7.0/hwpx-writeback-baseline#AC-N")` 마커. 상류 `serialize_hwpx` 위임이라 extra 의존 없음 — 항상 실행 (test-without-extras skip count 무관) | + +### 문서 + +| 파일 | 변경 | +|---|---| +| [README.md](../../../README.md) | § "HWPX 저장 (writeback)" 신설 — `to_hwpx_bytes` / `export_hwpx` 사용 예 + HWP5 → HWPX 변환 + 보존 범위 / 에러 계약. PNG 렌더 섹션과 LangChain 통합 섹션 사이 배치 | +| [CHANGELOG.md](../../../CHANGELOG.md) | `[0.7.0]` 섹션 신설 — Added (2 메서드) / Build (상류 pin `1899ef9 → ce45231c` + sync disclosure) | +| [docs/roadmap/v0.7.0/hwpx-writeback-baseline.md](../../roadmap/v0.7.0/hwpx-writeback-baseline.md) (spec) | frontmatter `Draft → Frozen`, `target → ga: v0.7.0` | +| [docs/design/v0.7.0/hwpx-writeback-baseline-research.md](../../design/v0.7.0/hwpx-writeback-baseline-research.md) (ADR) | frontmatter 동일 전환 | +| [docs/traces/coverage.md](../../traces/coverage.md) | spec_trace 자동 갱신 — 7 새 v0.7.0/hwpx-writeback-baseline#AC-N row | +| [docs/roadmap/README.md](../../roadmap/README.md) | 활성 spec 인덱스 v0.7.0 row 를 Frozen 으로 표시 + 구현 / 검증 로그 표에 v0.7.0 row 추가 + 현재 상태 / v0.8.0 narrative 갱신 | + +### Build + +| 파일 / 위치 | 변경 | +|---|---| +| [Cargo.toml](../../../Cargo.toml) | version 0.6.1 → 0.7.0 | +| [.gitmodules](../../../.gitmodules) / `external/rhwp` | submodule pin `1899ef9` (v0.7.12) → `ce45231c` (v0.7.12 + 394 commit, 2026-05-27 상류) | + +## 2. 결정 사항 (spec 결정 5 항목 ↔ 구현 매핑) + +| spec 결정 | 구현 위치 | +|---|---| +| 1 — 직렬화 source (상류 `serialize_hwpx` 위임) | `src/document.rs` — `rhwp::serializer::serialize_hwpx(self.inner.document())`. 자체 직렬화 구현 0 | +| 2 — API 표면·명명 (`to_hwpx_bytes` + `export_hwpx`) | `to_*` = 데이터 변환 (메모리) / `export_*` = 파일 저장. 기존 `to_ir` / `export_pdf` 패턴과 정합 | +| 3 — GIL 전략 (baseline 은 GIL 보유) | `serialize_hwpx(self.inner.document())` 가 `&self.inner` 캡처 — `DocumentCore` 가 RefCell 로 `!Sync` 라 `py.detach` 클로저 이동 불가 (`to_ir` 와 동일 제약). clone-후-detach 는 측정 후 후속 patch | +| 4 — 입력 포맷 (HWP5 / HWPX 모두) | 입력 포맷 분기 없음 — `Document` IR 포맷 독립. AC-3 (HWP5 → HWPX) 회귀 가드 | +| 5 — 보존 boundary (텍스트·문단 보장, 표·그림 위임) | AC-1 (텍스트·문단 round-trip) 가 회귀 보장. AC-4 (표·그림 실문서 crash-free) 는 상류 위임 범위. 참조 무결성 위반은 `ValueError` hard-error | + +## 3. 상류 재동기화 영향 분석 (`1899ef9 → ce45231c`) + +spec·feat 작성 시점 pin (`1899ef9`, v0.7.12, 2026-05-18 상류) 과 GA pin +(`ce45231c`, 2026-05-27 상류) 사이 **394 commit**. 변경 규모: + +| 영역 | 변경 | +|---|---| +| `src/serializer/` | +2092 / −447 (23 파일) — 16 commit 거의 전부 **HWP5 binary writeback (hwpx2hwp) 한컴 호환** (`serialize_hwp` 성숙: Form 컨트롤 byte-perfect / 각주 contract 정합 / 표 셀 배경 / EQEDIT errata) | +| `src/model/` | +1267 / −428 — event / style / table / paragraph 구조체 확장 (직렬화·렌더 내부) | +| `src/document_core/queries/rendering.rs` | +1320 — 렌더 파이프라인 | + +본 baseline 이 위임하는 표면의 안정성: + +- **`serialize_hwpx` 시그니처 불변** — `1899ef9` ↔ `ce45231c` 모두 `serialize_hwpx(doc: &Document) -> Result, SerializeError>`. `SerializeError` enum 구조도 동일 +- **HWPX IrDiff 검증은 Stage 0 그대로** — 상류 `serializer/hwpx/roundtrip.rs` 가 여전히 카운트만 비교 (섹션 / 문단 / doc_info 리소스 / bin_data). 문단 텍스트 내용은 미비교. 즉 본 baseline 의 "텍스트·문단 round-trip 보장" 은 상류 IrDiff 가 아니라 binding 자체 AC-1 이 책임 +- **상류 자원 방향** — serializer 16 commit 이 HWPX writeback 검증 확장이 아니라 HWP5 binary writeback 한컴 호환에 집중. 표·그림 HWPX 의미 보존 (v0.8.0 선행조건) 은 아직 미성숙 + +재검증 (§ 4): 빌드 통과 + 회귀 599 passed + IR baseline byte-equal → **binding 관점 회귀 0**. model / rendering 대규모 변경은 직렬화·렌더 내부에 집중됐고 binding 이 소비하는 IR schema (`"1.1"`) 와 IR / 렌더 출력은 불변. + +## 4. 호환성 + +| 시나리오 | 결과 | +|---|---| +| **기존 사용자 (`render_pdf` / `render_png` / `to_ir` 등)** | 변경 없음. v0.6.x 표면 모두 보존 (additive only) | +| **새 사용자 (`to_hwpx_bytes` / `export_hwpx`)** | extra 없이 즉시 사용 — 상류 `serialize_hwpx` 위임 | +| **IR schema** | `"1.1"` 불변 — writeback 은 read-only IR 표면과 독립 (AC-6) | +| **`tests/type_check_errors.py` 의 4 intentional pyright errors** | 변경 없음 | +| **test-without-extras CI skip count** | 6 유지 — `test_hwpx_writeback.py` 는 extra 무관, 항상 실행 | + +**SemVer**: MINOR (0.6.1 → 0.7.0). additive only — 외부 wire format / wheel 의존성 / schema (`"1.1"`) / abi3-py310 정책 보존. + +## 5. 검증 + +| 검사 | 결과 | +|---|---| +| `uv run maturin develop --release` (pin `ce45231c`) | OK — release 빌드 35.85s, abi3 wheel (Apple silicon) | +| `uv run pytest -m "not slow"` | **599 passed, 2 skipped, 6 deselected** — IR baseline byte-equal 포함 | +| `uv run pytest tests/test_hwpx_writeback.py` | 7 passed — AC-1 ~ AC-6 그린 | + +2 skipped 는 fixture 한계 (`aift.hwp` 에 미주 / 수식 컨트롤 없음) — writeback 무관. + +### AC ↔ 테스트 매핑 + +| AC | 테스트 | +|---|---| +| AC-1 (텍스트·문단 round-trip) | `TestRoundtripPreservation::test_text_paragraph_roundtrip` (`business_overview.hwpx`) | +| AC-2 (valid HWPX 컨테이너) | `TestContainerShape::test_to_hwpx_bytes_is_valid_container` | +| AC-3 (HWP5 → HWPX 변환) | `TestContainerShape::test_hwp5_input_converts_to_hwpx_container` | +| AC-4 (표·그림 실문서 crash-free) | `TestContainerShape::test_table_document_serializes_without_crash` (`table-vpos-01.hwpx`) | +| AC-5 (`export_hwpx` 파일 + 바이트 수 / 부모 부재 OSError) | `TestExportHwpx::test_export_writes_file_and_returns_byte_count` + `test_export_to_missing_parent_raises_oserror` | +| AC-6 (additive, 부작용 없음) | `TestAdditiveNoSideEffects::test_writeback_does_not_mutate_existing_surfaces` | + +6/6 AC 충족. + +## 6. 알려진 한계 / 이월 사항 + +| 항목 | 상태 | 후속 | +|---|---|---| +| HWP5 binary writeback (`to_hwp_bytes` / `export_hwp`) | 본 baseline 미노출. 상류 `serialize_hwp` 는 공개 API 이며 본 sync 에서 한컴 호환 byte-perfect 수준으로 성숙 (§ 3) | spec § 영구 비목표대로 별도 minor. 상류 성숙도 반영하여 v0.9.0 앞당김은 별도 검토 | +| 표·그림·수식 round-trip 의미 보존 | baseline 은 crash-free 만 보장 (AC-4). 상류 HWPX IrDiff Stage 0 | v0.8.0 — 상류 IrDiff 확장에 lock-step | +| GIL clone-후-detach 최적화 | baseline 은 GIL 보유 (정확성 우선) | `benches/bench_gil.py` 측정이 순이득 보이면 후속 patch (v0.7.x) | +| 순수 텍스트 HWPX fixture 부재 | AC-1 은 `business_overview.hwpx` (텍스트 풍부, 표 무) 사용 — samples 에 머리말/꼬리말 도형 없는 순수 텍스트 HWPX 없음 | 검증 대상은 최상위 문단 수·텍스트 의미 보존이라 표 컨트롤 유무와 독립 | + +## 7. v0.7.0 GA 절차 (인계) + +본 step 이후 GA 까지의 release 절차 (CONVENTIONS § 버전 GA 후): + +1. **`Cargo.toml` version bump** — 0.6.1 → 0.7.0 (완료) +2. **spec / ADR frontmatter flip** — `Draft → Frozen`, `target → ga: v0.7.0` (완료) +3. **본 `migration.md`** — 작성 즉시 Frozen + ga: v0.7.0 (CONVENTIONS § Implementation log) +4. **`docs/roadmap/README.md` 인덱스 갱신** — v0.7.0 row Frozen + 구현 로그 표 추가 (완료) +5. **`CHANGELOG.md` [0.7.0] 섹션** — 완료 +6. **git tag `v0.7.0`** + GitHub Release 생성 — `publish.yml` 트리거 (Trusted Publisher OIDC) — *사용자 진행* + +## 8. 참조 + +### 짝 페어 + +- spec: [docs/roadmap/v0.7.0/hwpx-writeback-baseline.md](../../roadmap/v0.7.0/hwpx-writeback-baseline.md) +- ADR: [docs/design/v0.7.0/hwpx-writeback-baseline-research.md](../../design/v0.7.0/hwpx-writeback-baseline-research.md) + +### 상류 + +- 상류 `serialize_hwpx`: `external/rhwp/src/serializer/hwpx/mod.rs` +- 상류 직렬화 trait / 에러: `external/rhwp/src/serializer/mod.rs` (`DocumentSerializer` / `SerializeError`) +- 상류 PR #170 (HWPX Serializer — Document IR → HWPX 저장) +- submodule pin: `1899ef9` (v0.7.12) → `ce45231c` (GA pin) diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 2b99a94..1ff4da7 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -4,7 +4,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe 본 문서는 Living — 자유 갱신. -## 현재 상태 (2026-05-10) +## 현재 상태 (2026-06-04) - **v0.1.0 / v0.1.1** — Frozen, PyPI 배포 완료 - **v0.2.0** — Frozen, Document IR v1 GA (2026-04-25) @@ -15,7 +15,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe - **v0.5.0** — Frozen, MCP server (`rhwp-mcp`) GA (2026-05-06) - **v0.5.1** — Frozen, MCP tool 출력 schema 강타입화 GA (2026-05-07) - **v0.6.0** — Frozen, 페이지 PNG 렌더링 (VLM 입력) GA (2026-05-10) -- **v0.7.0** — 미착수, HWPX writeback baseline (단순 문서 round-trip — 상류 Stage 2.3 GA 기반) +- **v0.7.0** — Frozen, HWPX writeback baseline (parse → HWPX round-trip, 텍스트·문단 보존) GA (2026-06-04) ## 활성 spec 인덱스 @@ -33,16 +33,17 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | v0.5.0 (MCP server) | Frozen | [v0.5.0/mcp.md](v0.5.0/mcp.md) | [design/v0.5.0/mcp-research.md](../design/v0.5.0/mcp-research.md) | | v0.5.1 (MCP typed output) | Frozen | [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) | [design/v0.5.1/mcp-typed-output-research.md](../design/v0.5.1/mcp-typed-output-research.md) | | v0.6.0 (png-vlm-render) | Frozen | [v0.6.0/png-vlm-render.md](v0.6.0/png-vlm-render.md) | [design/v0.6.0/png-vlm-render-research.md](../design/v0.6.0/png-vlm-render-research.md) | +| v0.7.0 (hwpx-writeback-baseline) | Frozen | [v0.7.0/hwpx-writeback-baseline.md](v0.7.0/hwpx-writeback-baseline.md) | [design/v0.7.0/hwpx-writeback-baseline-research.md](../design/v0.7.0/hwpx-writeback-baseline-research.md) | ## 미착수 작업 계획 본 섹션은 결정 미정 narrative — `vX.Y.Z` 디렉토리가 아직 없는 minor 들의 의도/스코프. 작업 시점이 가까워지면 `/new-spec ` 으로 정식 spec 으로 promote. -### v0.7.0 ~ v1.0.0 — JSON IR → HWP 역생성 +### v0.8.0 ~ v1.0.0 — JSON IR → HWP 역생성 (확장) -선행 조건: v0.6.0 GA 완료 (✓ 2026-05-10). 상류 `edwardkim/rhwp` v0.7.12 시점 HWPX serializer 가 **Stage 2.3 (텍스트·문단·탭·소프트 브레이크 round-trip)** 까지 공개 API 로 GA — `serialize_hwp` / `serialize_hwpx` / `serialize_document` (`external/rhwp/src/serializer/`). 표·이미지·스타일은 Stage 2.5+, 수식 등 고급 요소는 후속 Stage 진행 중 — 본 라인은 상류 Stage 진척과 minor 단위로 lock-step. +선행 조건: v0.7.0 (HWPX writeback baseline) GA 완료 (2026-06-04) — Frozen spec [v0.7.0/hwpx-writeback-baseline.md](v0.7.0/hwpx-writeback-baseline.md) 가 텍스트·문단 round-trip + HWP5 → HWPX 변환을 연다. 본 라인은 그 위에 표·이미지·수식 의미 보존과 HWP5 binary 출력을 쌓는 확장 단계. -IR 을 축으로 한 역방향 변환 — parse 결과를 다시 HWP/HWPX 로 저장. v0.7.0 진입 시점에 상류 baseline 이 GA 된 상태이므로 본 minor 는 PyO3 표면 노출 + 보존 회귀 테스트 + 보존 boundary 문서화가 본체. 사용자가 IR 을 편집해 새 HWP/HWPX 를 생성하는 mutable IR 빌더 API 는 v1.0 API 안정 선언 시점에 통합 검토. +상류 `edwardkim/rhwp` 는 표·그림까지 직렬화 코드를 보유하나 보존 검증의 성숙도가 포맷별로 비대칭이다. HWPX round-trip 의미 보존 검증 (IrDiff) 은 아직 골격 카운트 수준 (섹션·문단·리소스 카운트 비교, 문단 텍스트 내용 미비교) 이고, 반대로 HWP5 binary writeback (`serialize_hwp`) 은 한컴 호환 byte-perfect 수준으로 성숙했다 (v0.7.0 GA 직전 상류 sync `1899ef9 → ce45231c` 에서 확인). 따라서 v0.8.0 (HWPX 표·그림 의미 보존) 은 상류 IrDiff 확장에 lock-step 으로 묶이고, v0.9.0 (HWP5 binary writeback) 은 상류 성숙도상 앞당김 여지가 있다 — 분할 순서는 착수 시점 상류 진척으로 재확인. 사용자가 IR 을 편집해 새 HWP/HWPX 를 생성하는 mutable IR 빌더 API 는 v1.0 API 안정 선언 시점에 통합 검토. 범위: - IR → **HWPX** 역직렬화 (HWPX 가 XML 기반이라 먼저) @@ -54,7 +55,6 @@ IR 을 축으로 한 역방향 변환 — parse 결과를 다시 HWP/HWPX 로 | 버전 | 범위 | |---|---| -| v0.7.0 | HWPX writeback baseline (단순 문서 왕복) | | v0.8.0 | HWPX writeback 확장 (표·이미지·수식) | | v0.9.0 | HWP5 writeback baseline | | v1.0.0 | HWP5 writeback 확장 + API 안정 선언 | @@ -87,6 +87,7 @@ SemVer 0.x.y 단계에서 minor 는 단조 증가. v1.0.0 은 API 안정 선언 | v0.5.0 | [implementation/v0.5.0/stages/](../implementation/v0.5.0/stages/) (S1~S5) | — | | v0.5.1 | [implementation/v0.5.1/migration.md](../implementation/v0.5.1/migration.md) | — | | v0.6.0 | [implementation/v0.6.0/migration.md](../implementation/v0.6.0/migration.md) | — | +| v0.7.0 | [implementation/v0.7.0/migration.md](../implementation/v0.7.0/migration.md) | — | ## 원칙 diff --git a/docs/roadmap/v0.7.0/hwpx-writeback-baseline.md b/docs/roadmap/v0.7.0/hwpx-writeback-baseline.md new file mode 100644 index 0000000..7547fb7 --- /dev/null +++ b/docs/roadmap/v0.7.0/hwpx-writeback-baseline.md @@ -0,0 +1,53 @@ +--- +status: Frozen +description: "v0.7.0 — HWPX writeback baseline. parse 결과를 'export_hwpx' / 'to_hwpx_bytes' 로 HWPX 저장 — 상류 'serialize_hwpx' 위임 + 텍스트·문단 round-trip 보장" +ga: v0.7.0 +last_updated: 2026-06-04 +--- + +# v0.7.0 — HWPX writeback baseline + +parse 한 `Document` 를 다시 HWPX 파일로 저장하는 첫 역방향 표면을 추가한다. v0.2.0 ~ v0.6.0 의 IR / SVG / PDF / PNG 산출물은 모두 read-only 출력이었고, v0.7.0 은 상류 `edwardkim/rhwp` 의 `serialize_hwpx` 를 `Document.to_hwpx_bytes()` / `Document.export_hwpx(path)` 로 노출하여 "parse → 저장" round-trip 을 연다. 추가만 있고 기존 IR / 렌더 / MCP 표면은 모두 보존 (additive only) — IR SchemaVersion 영향 없음. + +주요 결정의 근거·대안·실패 시나리오는 짝 페어: [hwpx-writeback-baseline-research.md](../../design/v0.7.0/hwpx-writeback-baseline-research.md). + +## 배경 + +v0.4.0 view 렌더러 / v0.5.0 MCP / v0.6.0 PNG 까지 모든 표면은 HWP/HWPX 를 *읽어* 텍스트·이미지·IR 로 평탄화하는 단방향이었다. 상류 `serializer/hwpx/` 모듈이 `Document` IR → HWPX(ZIP+XML) 직렬화를 공개 API (`serialize_hwpx`) 로 제공하면서, binding 측에서 PyO3 표면만 노출하면 역방향 round-trip 이 성립하는 상태가 됐다. + +`Document` IR 은 포맷 독립이다 — HWP5 / HWPX / HWP3 파서가 모두 동일한 `model::document::Document` 로 변환하므로, HWP5 로 parse 한 문서도 `serialize_hwpx` 로 HWPX 출력이 가능하다 (HWP5 → HWPX 포맷 변환이 부수 효과로 성립). + +보존 범위는 상류 serializer 의 현 보존 범위에 위임한다. 상류는 텍스트·문단·표·그림·도형·수식의 직렬화 코드를 보유하되 round-trip 의미 보존의 검증 (IrDiff) 은 요소별로 점진 확장 중이다. 본 baseline 이 회귀로 보장하는 것은 **텍스트·문단 round-trip** 이며, 표·그림 등은 crash-free + 상류 보존 범위를 그대로 따른다. 표·그림의 의미 보존 보장은 v0.8.0 으로 분리. + +## 결정 사항 + +| 항목 | 값 | 근거 | +|---|---|---| +| 1 — 직렬화 source | 상류 `serialize_hwpx` 위임 | 자체 직렬화 구현 금지 (upstream-first). HWPX writer 결함/누락은 상류 이슈로 보고. 자세한 본체 비교는 ADR §1 | +| 2 — API 표면·명명 | `to_hwpx_bytes() -> bytes` + `export_hwpx(path) -> int` | `render_pdf` / `export_pdf` 의 메모리/파일 분리 패턴과 대칭. `render_*` (raster 전용) / `save_*` (mutable 연상) 회피. 자세한 본체 비교는 ADR §2 | +| 3 — GIL 전략 | baseline 은 GIL 보유 | `serialize_hwpx(self.inner.document())` 가 `&self.inner` 캡처 — `DocumentCore` 가 RefCell 로 `!Sync` 라 `py.detach` 클로저 이동 불가. clone 후 detach 는 측정 후 (ADR §3) | +| 4 — 입력 포맷 | HWP5 / HWPX 모두 수용 | `Document` IR 포맷 독립. HWP5 → HWPX 변환 허용. 입력 포맷에 따른 분기 없음 | +| 5 — 보존 boundary | 텍스트·문단 round-trip 보장, 표·그림은 상류 위임 (crash-free) | 상류 IrDiff 검증 범위가 점진 확장 중. 표·그림 의미 보존 보장은 v0.8.0. 자세한 본체 비교는 ADR §4 | + +## 인수조건 + +- **AC-1** — 텍스트·문단만 있는 HWPX 를 parse → `export_hwpx(out)` → `parse(out)` 했을 때, 재파싱 결과의 섹션 수 / 문단 수 / 각 문단 `text` 가 원본과 동등하다 (round-trip 의미 보존) +- **AC-2** — `to_hwpx_bytes()` 출력은 valid HWPX 컨테이너다: ZIP magic `b"PK\x03\x04"` 으로 시작하고, 첫 ZIP 엔트리가 STORED 방식 `mimetype` = `application/hwp+zip` +- **AC-3** — HWP5 파일 (`aift.hwp`) 을 parse → `to_hwpx_bytes()` 가 AC-2 를 만족하는 bytes 를 반환한다 (HWP5 → HWPX 포맷 변환) +- **AC-4** — 표·그림을 포함한 실문서 (`table-vpos-01.hwpx`) 를 parse → `to_hwpx_bytes()` 가 예외 없이 bytes 를 반환한다 (해당 fixture 경험적 검증, 의미 보존 미보장). 단 무조건 crash-free 는 아니다 — 컨트롤 직렬화 실패는 상류가 per-control graceful 처리하나, 참조 무결성 실패 (BinData 누락 등) 는 `ValueError` 로 전파된다 +- **AC-5** — `export_hwpx(path)` 는 `path` 에 파일을 생성하고 작성 바이트 수 (> 0) 를 반환한다. 존재하지 않는 부모 디렉토리 등 쓰기 실패 시 `OSError` +- **AC-6** — `to_hwpx_bytes()` / `export_hwpx()` 는 `Document` 의 기존 IR / 렌더 메서드 (`to_ir` / `render_pdf` / `render_png` 등) 와 독립적으로 동작하며, 호출 후에도 기존 메서드 결과가 변하지 않는다 (additive, 부작용 없음) + +## 영구 비목표 + +- **IR mutable 편집 후 재저장** — `Document` 는 parse 결과 read-only 이고 Python IR (`HwpDocument`) → Rust `Document` 역매퍼가 없다. IR 을 편집해 새 문서를 생성하는 빌더 API 는 v1.0 API 안정 선언 시점에 통합 검토 +- **HWP5 binary 출력 (`export_hwp`)** — 상류 `serialize_hwp` 는 존재하나 본 baseline 은 HWPX 출력만. HWP5 binary writeback 은 v0.9.0 +- **표·그림·수식 round-trip 의미 보존 보장** — 상류 IrDiff 검증 범위 확장에 의존. baseline 은 crash-free 만 보장. 의미 보존은 v0.8.0 +- **bytewise 동일성** — round-trip 은 의미적 동등성 기준. ZIP 압축 / 타임스탬프 / canonical default 주입 등으로 원본과 byte 단위 동일은 보장하지 않는다 +- **CLI / MCP 노출** — SDK 표면 (`Document` 메서드) 만. `rhwp-py` CLI 의 변환 서브명령 / MCP 도구 노출은 별도 demand 시 후속 + +## 참조 + +- 짝 페어 (ADR): [hwpx-writeback-baseline-research.md](../../design/v0.7.0/hwpx-writeback-baseline-research.md) +- 상류 HWPX serializer: `external/rhwp/src/serializer/hwpx/` (`serialize_hwpx`) +- 상류 직렬화 trait: `external/rhwp/src/serializer/mod.rs` (`DocumentSerializer` / `SerializeError`) diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md index d186ec4..ccb6224 100644 --- a/docs/traces/coverage.md +++ b/docs/traces/coverage.md @@ -635,3 +635,10 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | v0.6.0/png-vlm-render | AC-5 | `tests/test_render_png.py::TestMcpRenderPagePng::test_returns_image_content` | | v0.6.0/png-vlm-render | AC-6 | `tests/test_render_png.py::TestArenderPng::test_async_returns_png_without_panic` | | v0.6.0/png-vlm-render | AC-7 | `tests/test_render_png.py::TestExportPng::test_writes_files_with_png_magic` | +| v0.7.0/hwpx-writeback-baseline | AC-1 | `tests/test_hwpx_writeback.py::TestRoundtripPreservation::test_text_paragraph_roundtrip` | +| v0.7.0/hwpx-writeback-baseline | AC-2 | `tests/test_hwpx_writeback.py::TestContainerShape::test_to_hwpx_bytes_is_valid_container` | +| v0.7.0/hwpx-writeback-baseline | AC-3 | `tests/test_hwpx_writeback.py::TestContainerShape::test_hwp5_input_converts_to_hwpx_container` | +| v0.7.0/hwpx-writeback-baseline | AC-4 | `tests/test_hwpx_writeback.py::TestContainerShape::test_table_document_serializes_without_crash` | +| v0.7.0/hwpx-writeback-baseline | AC-5 | `tests/test_hwpx_writeback.py::TestExportHwpx::test_export_to_missing_parent_raises_oserror` | +| v0.7.0/hwpx-writeback-baseline | AC-5 | `tests/test_hwpx_writeback.py::TestExportHwpx::test_export_writes_file_and_returns_byte_count` | +| v0.7.0/hwpx-writeback-baseline | AC-6 | `tests/test_hwpx_writeback.py::TestAdditiveNoSideEffects::test_writeback_does_not_mutate_existing_surfaces` | diff --git a/python/rhwp/_rhwp.pyi b/python/rhwp/_rhwp.pyi index 204422d..7703699 100644 --- a/python/rhwp/_rhwp.pyi +++ b/python/rhwp/_rhwp.pyi @@ -52,4 +52,6 @@ class _Document: def to_ir(self) -> HwpDocument: ... def to_ir_json(self, *, indent: int | None = None) -> str: ... def bytes_for_image_id(self, bin_data_id: int) -> bytes | None: ... + def to_hwpx_bytes(self) -> bytes: ... + def export_hwpx(self, output_path: str) -> int: ... def __repr__(self) -> str: ... diff --git a/python/rhwp/document.py b/python/rhwp/document.py index 973bd3f..368e620 100644 --- a/python/rhwp/document.py +++ b/python/rhwp/document.py @@ -316,6 +316,40 @@ def export_png(self, output_dir: str, *, prefix: str | None = None) -> list[str] """ return self._inner.export_png(output_dir, prefix=prefix) + # * HWPX writeback + + def to_hwpx_bytes(self) -> bytes: + """문서를 HWPX (ZIP+XML) 바이트로 직렬화한다 (상류 serializer 위임). + + ``Document`` IR 은 포맷 독립이므로 HWP5 로 parse 한 문서도 HWPX 로 + 출력된다 (HWP5 → HWPX 포맷 변환). 텍스트·문단은 round-trip 의미를 + 보존하고, 표·그림은 상류 보존 범위에 위임한다 (의미 보존 미보장). + 출력은 ZIP magic ``b"PK\\x03\\x04"`` 으로 시작하고 첫 엔트리가 STORED + 방식 ``mimetype`` = ``application/hwp+zip``. + + Returns: + HWPX 컨테이너 바이트. + + Raises: + ValueError: 직렬화 실패 — 참조 무결성 위반 (BinData 누락 등). + """ + return self._inner.to_hwpx_bytes() + + def export_hwpx(self, output_path: str) -> int: + """문서를 HWPX 파일로 저장한다. + + Args: + output_path: 출력 파일 경로. + + Returns: + 저장된 바이트 수 (> 0). + + Raises: + OSError: 파일 쓰기 실패 (부모 디렉토리 부재 등). + ValueError: 직렬화 실패 — 참조 무결성 위반 (BinData 누락 등). + """ + return self._inner.export_hwpx(output_path) + def __repr__(self) -> str: return repr(self._inner) diff --git a/src/document.rs b/src/document.rs index 9a0b07d..2652f9d 100644 --- a/src/document.rs +++ b/src/document.rs @@ -293,6 +293,33 @@ impl PyDocument { result.extract::() } + /// 문서를 HWPX (ZIP+XML) bytes 로 직렬화한다 (상류 `serialize_hwpx` 위임). + /// + /// `Document` IR 은 포맷 독립이라 HWP5 로 parse 한 문서도 HWPX 로 출력된다 + /// (HWP5 → HWPX 포맷 변환). 출력은 ZIP magic 으로 시작하고 첫 엔트리가 + /// STORED `mimetype` = `application/hwp+zip`. 직렬화 실패 (참조 무결성 + /// 위반 등) 는 `ValueError`. + fn to_hwpx_bytes<'py>(&self, py: Python<'py>) -> PyResult> { + // ^ GIL 보유: serialize_hwpx(self.inner.document()) 가 &self.inner 캡처 — + // DocumentCore 가 RefCell 캐시로 !Sync 라 py.detach 클로저 이동 불가 + // (to_ir 와 동일 제약). clone 후 detach 는 측정 후 후속 patch. + let bytes = rhwp::serializer::serialize_hwpx(self.inner.document()) + .map_err(|e| PyValueError::new_err(format!("HWPX serialization failed: {e}")))?; + Ok(PyBytes::new(py, &bytes)) + } + + /// 문서를 HWPX 파일로 저장하고 작성 바이트 수를 반환한다. + /// + /// 직렬화 실패는 `ValueError`, 파일 쓰기 실패 (부모 디렉토리 부재 등) 는 + /// `OSError`. GIL 은 `export_svg` 와 동일하게 보유 — serialize 가 `&self.inner` + /// 를 캡처하므로 detach 불가. + fn export_hwpx(&self, output_path: &str) -> PyResult { + let bytes = rhwp::serializer::serialize_hwpx(self.inner.document()) + .map_err(|e| PyValueError::new_err(format!("HWPX serialization failed: {e}")))?; + std::fs::write(output_path, &bytes).map_err(|e| PyIOError::new_err(e.to_string()))?; + Ok(bytes.len()) + } + fn __repr__(&self) -> String { format!( "Document(sections={}, paragraphs={}, pages={})", diff --git a/tests/test_hwpx_writeback.py b/tests/test_hwpx_writeback.py new file mode 100644 index 0000000..8c92491 --- /dev/null +++ b/tests/test_hwpx_writeback.py @@ -0,0 +1,119 @@ +"""v0.7.0 HWPX writeback baseline 회귀 가드 — to_hwpx_bytes / export_hwpx 검증. + +AC-1 ~ AC-6 매핑은 ``docs/roadmap/v0.7.0/hwpx-writeback-baseline.md`` § 인수조건. +상류 ``serialize_hwpx`` 위임 표면이라 외부 extra 의존이 없다 — 본 파일은 항상 +실행되며 extras-gated 가 아니다 (test-without-extras CI skip count 무관). + +AC-1 의 round-trip fixture 로는 텍스트 문단이 풍부한 ``business_overview.hwpx`` +를 쓴다. samples 에 표·도형이 전혀 없는 순수 텍스트 HWPX 는 존재하지 않으나 +(실문서는 머리말/꼬리말 도형을 동반), 본 검증 대상은 최상위 문단 수·텍스트의 +의미 보존이며 표 컨트롤 유무와 독립이다. +""" + +import io +import zipfile +from pathlib import Path + +import pytest + +import rhwp + +ZIP_MAGIC = b"PK\x03\x04" +HWPX_MIMETYPE = b"application/hwp+zip" + + +def _assert_valid_hwpx_container(data: bytes) -> None: + """AC-2 컨테이너 형태 단언 — ZIP magic + 첫 엔트리가 STORED ``mimetype``.""" + assert data[:4] == ZIP_MAGIC, "HWPX must start with ZIP local-file-header magic" + zf = zipfile.ZipFile(io.BytesIO(data)) + first = zf.infolist()[0] + assert first.filename == "mimetype", f"first entry must be 'mimetype', got {first.filename!r}" + assert first.compress_type == zipfile.ZIP_STORED, "mimetype entry must be STORED (uncompressed)" + assert zf.read("mimetype") == HWPX_MIMETYPE + + +class TestRoundtripPreservation: + @pytest.mark.spec("v0.7.0/hwpx-writeback-baseline#AC-1") + def test_text_paragraph_roundtrip(self, samples_dir: Path, tmp_path: Path) -> None: + original = rhwp.parse(str(samples_dir / "hwpx" / "business_overview.hwpx")) + # ^ 가드: 실제 텍스트가 있는 fixture 여야 round-trip 검증이 유의미 + assert any(p.strip() for p in original.paragraphs()) + + out = tmp_path / "roundtrip.hwpx" + original.export_hwpx(str(out)) + reparsed = rhwp.parse(str(out)) + + assert reparsed.section_count == original.section_count + assert reparsed.paragraph_count == original.paragraph_count + assert reparsed.paragraphs() == original.paragraphs() + + +class TestContainerShape: + @pytest.mark.spec("v0.7.0/hwpx-writeback-baseline#AC-2") + def test_to_hwpx_bytes_is_valid_container(self, parsed_hwpx: rhwp.Document) -> None: + data = parsed_hwpx.to_hwpx_bytes() + assert isinstance(data, bytes) + _assert_valid_hwpx_container(data) + + @pytest.mark.spec("v0.7.0/hwpx-writeback-baseline#AC-3") + def test_hwp5_input_converts_to_hwpx_container(self, parsed_hwp: rhwp.Document) -> None: + # ^ HWP5 입력도 Document IR 포맷 독립 → HWPX 컨테이너로 출력 (HWP5 → HWPX 변환) + data = parsed_hwp.to_hwpx_bytes() + assert isinstance(data, bytes) + _assert_valid_hwpx_container(data) + + @pytest.mark.spec("v0.7.0/hwpx-writeback-baseline#AC-4") + def test_table_document_serializes_without_crash(self, parsed_hwpx: rhwp.Document) -> None: + # ^ table-vpos-01.hwpx (표·그림 포함 실문서) — 경험적 crash-free 검증. + # 의미 보존은 미보장 (상류 위임), 예외 없이 유효 컨테이너 bytes 만 확인. + data = parsed_hwpx.to_hwpx_bytes() + assert isinstance(data, bytes) + assert len(data) > 0 + _assert_valid_hwpx_container(data) + + +class TestExportHwpx: + @pytest.mark.spec("v0.7.0/hwpx-writeback-baseline#AC-5") + def test_export_writes_file_and_returns_byte_count( + self, parsed_hwpx: rhwp.Document, tmp_path: Path + ) -> None: + out = tmp_path / "out.hwpx" + written = parsed_hwpx.export_hwpx(str(out)) + assert written > 0 + assert out.exists() + assert out.stat().st_size == written + assert out.read_bytes()[:4] == ZIP_MAGIC + + @pytest.mark.spec("v0.7.0/hwpx-writeback-baseline#AC-5") + def test_export_to_missing_parent_raises_oserror( + self, parsed_hwpx: rhwp.Document, tmp_path: Path + ) -> None: + bad = tmp_path / "does_not_exist" / "out.hwpx" + with pytest.raises(OSError): + parsed_hwpx.export_hwpx(str(bad)) + + +class TestAdditiveNoSideEffects: + @pytest.mark.spec("v0.7.0/hwpx-writeback-baseline#AC-6") + def test_writeback_does_not_mutate_existing_surfaces( + self, samples_dir: Path, tmp_path: Path + ) -> None: + # ^ 독립 parse — 공유 session fixture 캐시 간섭 없이 before/after 비교. + # paragraphs / extract_text / render_svg / count getter 는 매 호출 self.inner + # 에서 재계산되므로, 동등성은 writeback 이 문서를 변형하지 않았음을 입증한다. + doc = rhwp.parse(str(samples_dir / "hwpx" / "business_overview.hwpx")) + + ir_before = doc.to_ir_json() + paras_before = doc.paragraphs() + text_before = doc.extract_text() + svg_before = doc.render_svg(0) + counts_before = (doc.section_count, doc.paragraph_count, doc.page_count) + + doc.to_hwpx_bytes() + doc.export_hwpx(str(tmp_path / "side_effect_check.hwpx")) + + assert doc.to_ir_json() == ir_before + assert doc.paragraphs() == paras_before + assert doc.extract_text() == text_before + assert doc.render_svg(0) == svg_before + assert (doc.section_count, doc.paragraph_count, doc.page_count) == counts_before