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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ jobs:
fail-fast: false
matrix:
# ^ macos-latest 는 상류 edwardkim/rhwp#823 (headless macOS 에서 PNG 렌더
# hang — CoreText downloadable lookup IPC 영구 대기) 해결 시 복귀.
# 현재 GHA macos runner 에서 30분+ hang 으로 wheel 검증이 불가.
os: [windows-latest]
# hang — CoreText downloadable lookup IPC 영구 대기) 이 v0.7.13 에서 해결되어
# 복원. 현재 pin 7d9aae7f (v0.7.16+36) 이 fix 포함 — render_png 가 hang 없이 동작.
os: [macos-latest, windows-latest]
defaults:
run:
shell: bash
Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,24 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.8.0] — 2026-06-21

MINOR release. parse 한 `Document` 를 HWPX 로 저장했을 때 IR 의미가 보존되는지 검증하는 `Document.verify_hwpx_roundtrip()` 표면을 추가하고, 보존 boundary 를 v0.7.0 의 텍스트·문단에서 상류 `diff_documents` 가 실제 비교하는 필드 집합 (표 cell·캡션·page_break, 그림 크기·캡션, char_shape·lineseg, PageDef, 리소스·BinData count) 으로 확대한다. 직렬화·진단 모두 상류 위임 — 추가만 있고 기존 표면 보존, IR schema (`"1.1"`) 변경 0. 동시에 상류 v0.7.13 ~ v0.7.16 sync 를 흡수한다.

### Added

- `Document.verify_hwpx_roundtrip() -> RoundtripReport` — 현재 문서를 HWPX 로 직렬화 → 재파싱한 뒤 상류 `diff_documents` 로 원본 대비 IR 차이를 측정. 반환 `RoundtripReport` 는 `ok: bool` + `differences: list[str]` 경량 리포트 (불변 `ok == (not differences)`). `differences` 각 항목은 상류 `IrDifference` 의 사람 가독 문자열 (차이 종류 + 위치). 직렬화·재파싱 실패는 `ValueError` — `to_hwpx_bytes` / `export_hwpx` 와 동일 에러 계약. GIL 보유 — `diff_documents` 가 `self.inner.document()` (`&self.inner`) 를 캡처하고 `DocumentCore` 가 `!Sync` (v0.7.0 결정 3 일관).
- `rhwp.RoundtripReport` — verify 결과 모델 (`frozen=True` / `extra="forbid"`) 을 public 노출. `report.ok` 로 프로그램 분기, `report.differences` 로 사람 가독 진단. 상류 `IrDifference` variant 가 게이트 진행마다 증가하므로 강타입 mirror 대신 forward-compatible 한 문자열 리스트로 출고.

보존 boundary 확대: v0.7.0 의 텍스트·문단 → 상류 `diff_documents` 가 *실제 round-trip 비교* 하는 필드 집합 (표 cell 내용·캡션·page_break, 그림 크기·캡션, 문단 char_shape·lineseg, 섹션 PageDef, 리소스·BinData 엔트리 카운트). 미비교 요소 (수식 script, 표 cell rowspan/colspan, BinData byte, 도형 raw) 는 보장 범위 밖 — 상류 비교 확대에 의존. 직렬화·진단 모두 상류 위임 — 추가만 있고 기존 표면 보존, IR schema (`"1.1"`) 변경 0. 회귀 가드: `tests/test_hwpx_writeback.py` 에 AC-1 ~ AC-6 (7 테스트) 추가 — 표·그림 round-trip 동등 (`aift.hwpx`) + verify positive + 자연 발생 negative 검출 (`table-vpos-01.hwpx` 의 그리기 도형 shapeComment 상류 미직렬화) + 부작용 없음 + v0.7.0 텍스트·문단 보장 유지. spec / ADR: [docs/roadmap/v0.8.0/hwpx-writeback-expansion.md](docs/roadmap/v0.8.0/hwpx-writeback-expansion.md) / [docs/design/v0.8.0/hwpx-writeback-expansion-research.md](docs/design/v0.8.0/hwpx-writeback-expansion-research.md) (둘 다 Draft — GA 전환은 릴리스 시점).

### Build

- `external/rhwp` submodule pin `ce45231c` (v0.7.12 + 394) → `7d9aae7f` (v0.7.16 + 36). 상류 v0.7.13 ~ v0.7.16 GA 흡수 (pin 간 1,209 commit). **본 binding 관점 회귀 0** — 공개 API / IR schema (`"1.1"`) / wheel 의존성 모두 불변. 검증: `maturin develop --release` clean, `pytest -m "not slow"` 599 passed / 2 skipped (v0.7.0 GA 와 동일), IR baseline byte-equal (`tests/test_view_baseline.py` 2/2 — `aift.hwp` + `table-vpos-01.hwpx`), `cargo clippy --all-targets -D warnings` clean. 우리가 소비하는 상류 심볼 (`serialize_hwpx` / `render_page_svg_native` / `build_page_layer_tree` / `renderer::pdf::svgs_to_pdf` / `RasterRenderOptions` / `get_bin_data`) 시그니처 전부 불변.
- **HWPX serializer fidelity 대폭 강화** — lossless round-trip 도달 (DocInfo / numbering paraHead / cellzoneList / useKerning / useFontSpace 무손실, 표·그림·묶음 캡션 직렬화, 그림 크기 요소 curSz/imgRect/imgDim, MEMO 필드 parameters, shapeComment, borderFill 등록, 표 pageBreak 보존). 상류 round-trip IrDiff 가 Stage 0 (섹션·문단 카운트만) → Stage 4 (표·그림·수식 의미 동등성) 로 성숙, 143 HWPX 샘플 xfail 0 — **v0.8.0 HWPX writeback 확장의 상류 선행조건 충족**.
- native PDF export API (`DocumentCore::render_*_pdf_native`, 상류 #1359) — 기존 `renderer::pdf::svgs_to_pdf` 경로와 additive 공존, 본 binding 의 PDF 표면 영향 0.
- Text IR v2 폰트 증명 게이트 / 그림 effects·shadow round-trip / 차트 샘플 코퍼스 27종 / 미주 높이 모델 정규화.
- **상류 #823 (macOS headless Skia font lookup hang) 해결** (v0.7.13). v0.6.1 Build 섹션이 미해결로 기록했던 PNG 표면 known limitation 종결 — headless macOS 에서 `render_png` 가 hang 없이 동작. 이에 맞춰 `ci.yml` 의 macOS smoke 잡 (`test-other-os` 매트릭스) 을 복원 — `4083a27` 의 비활성화를 되돌리고 `macos-latest` 를 추가 (`docs/upstream/issue-macos-png-coretext-hang.md` RESOLVED 전환).

## [0.7.0] — 2026-06-04

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.7.0"
version = "0.8.0"
edition = "2021"
# ^ rust-version 미명시 — 상위 rhwp crate 정책(stable Rust, MSRV unclaimed) 준수.
# PyO3 0.28 이 Rust 1.83+ 요구하지만, 이는 README 에 문서로 안내
Expand Down
139 changes: 139 additions & 0 deletions docs/design/v0.8.0/hwpx-writeback-expansion-research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
status: Frozen
description: "v0.8.0 hwpx-writeback-expansion ADR — 보존 boundary 확대 / verify 표면 노출 / 반환 타입 / 비교 기준 / GIL 5개 결정의 근거"
ga: v0.8.0
last_updated: 2026-06-21
---

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

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

## 결정 매트릭스

| # | 항목 | 옵션 비교 | 채택 | 1차 근거 |
|---|---|---|---|---|
| 1 | 보존 boundary 확대 | A: 텍스트·문단 유지 / B: `diff_documents` 검증 필드로 확대 / C: 직렬화되는 전체 요소 보장 | B | 보장 = 상류가 *실제 round-trip 비교* 하는 것. 직렬화 emit ≠ 검증 |
| 2 | 검증 표면 노출 | A: 미노출 (내부 회귀 테스트만) / B: `verify_hwpx_roundtrip()` 노출 | B | 상류 진단 공개 API — 사용자가 자기 문서 손실 검출 |
| 3 | verify 반환 타입 | A: `bool` / B: 경량 리포트 (ok + str list) / C: 전체 `IrDifference` Pydantic | B | bool 은 진단 정보 부족, C 는 상류 enum 증가에 fragile |
| 4 | round-trip 비교 기준 | A: `roundtrip_ir_diff(bytes)` / B: `diff_documents(현재, reparse)` | B | 이미 parse 된 `Document` 가 SSOT — bytes 재파싱은 자기 출력의 round-trip |
| 5 | GIL 전략 | A: GIL 보유 / B: clone 후 `py.detach` | A | `&self.inner` 캡처 (`!Sync`), v0.7.0 결정 3 일관, clone 비용 미측정 |

## 1. 보존 boundary 확대

### 팩트

- v0.7.0 spec 결정 5 / 영구 비목표: "표·그림·수식 round-trip 의미 보존 보장" 을 v0.8.0 으로 분리. 당시 사유는 상류 round-trip 비교가 카운트만 보던 점.
- 상류 `diff_documents` (`external/rhwp/src/serializer/hwpx/roundtrip.rs:427`) 가 **비교하는 것**: 문단 char_shape 시퀀스 (`ParagraphCharShapes`), 컨트롤 슬롯 타입 (`ParagraphControls`), lineseg (`ParagraphLinesegs`), 섹션 PageDef (`SectionPageDef`), 표 cell 내용 — 셀 문단 char_shape 재귀 (`roundtrip.rs:939`) + 표 캡션 (`TableCaption`, `roundtrip.rs:947`) + page_break (`roundtrip.rs:931`), 그림 크기 요소 (`diff_picture_size`, `roundtrip.rs:369`) + 그림 캡션, 리소스·BinData 엔트리 카운트 (`BinDataContentCount`, `roundtrip.rs:513`).
- 상류 `diff_documents` 가 **비교하지 않는 것**: 수식 script — `roundtrip.rs:1002` 주석 "equation 은 본문 텍스트 비교 대상이 아니므로 description 만 동승" (`ObjectComment` 만 push). 표 cell rowspan/colspan — 셀 루프가 `cea.paragraphs` 만 재귀 (`roundtrip.rs:939`), `col_span`/`row_span` 은 테스트 fixture 에만 등장. BinData byte — count 만. 도형 raw byte — `IrDiffAllow.shape_raw` 가 선언만 되고 미사용 (`roundtrip.rs:78`, `allowed()` 가 `_allow` 로 무시).
- 상류 모듈 주석의 "Stage N" (`external/rhwp/src/serializer/hwpx/mod.rs:4-9`) 은 serializer emit 단계 (Stage 3 표 / Stage 4 그림+BinData / Stage 5 도형) — round-trip 검증 수준이 아니다. 검증 범위는 `diff_documents` 코드가 정의한다.

### 검증자 반박

- "직렬화되는 표·그림을 왜 전부 보장하지 않나?" → 직렬화 emit ≠ round-trip 검증 완료. `diff_documents` 가 비교하는 것만 binding 회귀로 *실측* 가능하다. 미비교 요소 (수식 script / cell span / byte) 를 보장하면 v0.7.0 ADR 이 경계한 "거짓 약속" (`design/v0.7.0/hwpx-writeback-baseline-research.md:96`) 을 반복한다.
- "상류가 Stage 4 에 도달했다던데 표·그림 다 되는 것 아닌가?" → "Stage 4" 는 serializer 가 그림+BinData 를 *emit* 하는 단계지 round-trip 검증 범위가 아니다. 둘은 별개 — 검증은 `diff_documents` 코드가 비교하는 필드로만 정의된다.
- "수식·span 을 빼면 '의미 보존' 이라 부를 수 있나?" → 그래서 spec 제목을 "round-trip 검증 (boundary 확대)" 로 두고, 보장을 `diff_documents` 범위로 정직하게 한정한다. 과대 보장보다 검증 가능한 보장이 낫다.

### 최종 결정

B 채택. 보존 boundary 를 `diff_documents` 가 실제 비교하는 필드 집합 (표 cell 내용·캡션·page_break, 그림 크기·캡션, char_shape·lineseg, PageDef, 카운트) 으로 확대하고, 미비교 요소 (수식 script / cell rowspan-colspan / BinData byte / 도형 raw) 는 비목표로 둔다.

### 1차 소스

- 상류 비교 함수/항목: `external/rhwp/src/serializer/hwpx/roundtrip.rs:369` / `:427` / `:513` / `:931` / `:939` / `:947` / `:1002`
- 상류 stage taxonomy (emit 단계): `external/rhwp/src/serializer/hwpx/mod.rs:4-9`
- v0.7.0 거짓 약속 경계 선례: `design/v0.7.0/hwpx-writeback-baseline-research.md` §4

## 2. 검증 표면 노출

### 팩트

- 상류 `roundtrip.rs:427` `pub fn diff_documents(a: &Document, b: &Document) -> IrDiff`, `roundtrip.rs:414` `pub fn roundtrip_ir_diff(hwpx_bytes: &[u8]) -> Result<IrDiff, SerializeError>`. `IrDiff` (`:56`) / `IrDifference` (`:83`) 모두 `pub`.
- 재노출 경로: `external/rhwp/src/serializer/hwpx/mod.rs:20` 이 `pub mod roundtrip` — `serializer/mod.rs` 는 `serialize_hwp` / `serialize_hwpx` 만 re-export 하나, `roundtrip` 항목은 모듈 경로 (`rhwp::serializer::hwpx::roundtrip::*`) 로 접근 가능.
- 기존 binding 표면 (`src/document.rs`) 은 round-trip 검증 메서드가 없다 — v0.7.0 은 `to_hwpx_bytes` / `export_hwpx` 출력만 제공.

### 검증자 반박

- "보증만 하고 verify 는 안 노출해도 되지 않나?" → 보증은 우리 fixture 범위. 사용자 문서는 다양하고, 자기 문서의 저장 손실을 사용자가 검출하는 표면은 RAG / 포맷 변환 파이프라인의 안전장치로 실용적이다.
- "`diff_documents` 가 `serializer/mod.rs` 에서 re-export 안 된 API 인데 의존해도?" → `pub mod roundtrip` 이라 SemVer 상 공개 표면. 단 top re-export 인 `serialize_hwpx` 보다 변화 가능성이 높음 — 시그니처 변경 시 상류 이슈로 대응, 비목표에 fragility 명시.
- "verify 가 export 와 중복 아닌가?" → export 는 저장, verify 는 저장 가능성의 사전 검증. 직교.

### 최종 결정

B 채택. `Document.verify_hwpx_roundtrip()` 을 노출한다. 상류 `diff_documents` 를 위임 호출해 현재 `Document` 의 HWPX 저장 손실을 사용자가 검출한다.

### 1차 소스

- 상류 진단 API: `external/rhwp/src/serializer/hwpx/roundtrip.rs:414` / `:427` / `:56` / `:83`
- 재노출 경로: `external/rhwp/src/serializer/hwpx/mod.rs:20`

## 3. verify 반환 타입

### 팩트

- `IrDiff` 는 `{ differences: Vec<IrDifference> }` + `is_empty()` (`roundtrip.rs:56-68`).
- `IrDifference` 는 카운트 계열 (SectionCount / ParagraphCount / CharShapeCount / …) + 의미 계열 (ParagraphCharShapes / ParagraphControls / ParagraphLinesegs / SectionPageDef / TableCaption / ObjectComment / …) 의 다수 variant 로, 각 variant 가 서로 다른 필드 구조를 가진다.
- variant 집합은 상류 게이트 진행 (#1378 → #1387 → #1392 → …) 마다 증가해왔다 — 닫힌 집합이 아니다.

### 검증자 반박

- "강타입 Pydantic 매핑이 LLM / 프로그램 소비에 더 낫지 않나?" → variant 가 매 상류 sync 마다 증가할 수 있어 강타입 mirror 는 sync 마다 깨진다. 본 binding v0.2.0 IR 의 forward-compat 라우팅 (미지 kind → UnknownBlock) 과 같은 교훈 — 닫히지 않은 외부 enum 을 강타입 미러하면 부서진다.
- "문자열은 프로그램이 파싱하기 어렵지 않나?" → verify 의 1차 용도는 "보존되는가 (`ok`) + 안 되면 무엇이 (`differences`)". 프로그램 분기는 `ok` bool 로 충분하고, `differences` 는 사람이 읽는 진단. 구조화 access 수요가 구체화되면 후속.

### 최종 결정

B 채택. `verify_hwpx_roundtrip()` 은 `ok: bool` + `differences: list[str]` 경량 리포트를 반환한다 (불변 `ok == not differences`). 각 `IrDifference` 는 상류 `Debug` / `Display` 표현을 사람 가독 문자열로 출고.

### 1차 소스

- `IrDiff` / `IrDifference` 정의: `external/rhwp/src/serializer/hwpx/roundtrip.rs:56-160`
- forward-compat 선례: 본 binding v0.2.0 IR `UnknownBlock` 라우팅 (`python/rhwp/ir/nodes.py`)

## 4. round-trip 비교 기준

### 팩트

- `roundtrip_ir_diff(hwpx_bytes: &[u8])` 는 입력 bytes 를 parse 해 원본으로 삼고, 그것을 serialize → reparse 한 결과와 비교한다 (`roundtrip.rs:414`).
- `diff_documents(a: &Document, b: &Document)` 는 두 `Document` 를 직접 비교한다 (`roundtrip.rs:427`).
- `PyDocument` 는 `inner: DocumentCore` 를 보관하고 `self.inner.document()` 가 `&Document` 를 반환한다 (`src/document.rs:15`). 원본 입력 bytes 는 parse 후 보관하지 않는다.

### 검증자 반박

- "`roundtrip_ir_diff(bytes)` 가 인자 하나라 더 단순한데?" → 그 경로를 쓰려면 우리가 `to_hwpx_bytes()` 출력을 입력으로 줘야 하는데, 그러면 "우리 HWPX 출력의 round-trip 안정성" 을 재는 것이지 "원본 `Document` 대비 저장 보존" 이 아니다. 사용자가 알고 싶은 건 후자.
- "`diff_documents` 의 두 인자를 뭘로 채우나?" → a = `self.inner.document()` (현재 Document), b = `serialize_hwpx` → `parse_hwpx` 한 reparse Document. "현재 Document 를 HWPX 로 저장하면 보존되는가" 를 측정.

### 최종 결정

B 채택. `diff_documents(현재 Document, reparse Document)`. 이미 parse 된 `Document` 가 비교의 SSOT 이며, 원본 대비 저장 보존을 측정한다.

### 1차 소스

- 상류 비교 함수: `external/rhwp/src/serializer/hwpx/roundtrip.rs:414` / `:427`
- binding Document 보관: `src/document.rs:15`

## 5. GIL 전략

### 팩트

- `src/document.rs:240-243` `to_ir` 주석: `self.inner` (DocumentCore) 가 RefCell 캐시로 `!Sync` — closure 가 `&self` 를 캡처하면 `py.detach` 의 Ungil 바운드를 불만족. owned 값 (from_bytes 의 bytes, render_pdf 의 svgs) 만 detach 가능.
- `diff_documents(self.inner.document(), &reparsed)` 의 첫 인자가 `&self.inner` 를 캡처 — 위 제약에 해당.
- round-trip 1회는 serialize_hwpx + parse_hwpx + diff 로 ≥1 ms 가 확실. 프로젝트 GIL 정책: ≥1 ms Rust-side 작업은 detach 권장하되 불확실하면 `benches/bench_gil.py` 로 측정.

### 검증자 반박

- "round-trip 이 무거운데 GIL 보유면 멀티스레드 처리량 손해 아닌가?" → 맞다. 단 detach 하려면 `self.inner.document().clone()` 으로 owned `Document` 를 만들어 이동해야 하고, clone 비용은 문서 크기 비례 — 미측정. v0.7.0 결정 3 과 동일 trade-off.
- "verify 는 호출 빈도가 낮을 텐데 최적화가 의미 있나?" → 낮은 빈도면 더욱 GIL 보유의 단순·정확성이 이득. 측정 전 최적화는 YAGNI.

### 최종 결정

A 채택. baseline 은 GIL 보유로 정확성·단순성을 우선한다. clone-후-detach 는 `bench_gil.py` 측정이 순이득을 보이면 후속 patch.

### 1차 소스

- `src/document.rs:240-243` (`to_ir` GIL 주석), v0.7.0 결정 3 (GIL 보유)
- 프로젝트 GIL 정책: `AGENTS.md` § Rust + Python hybrid build

## 참조

- 짝 페어 (spec): [roadmap/v0.8.0/hwpx-writeback-expansion.md](../../roadmap/v0.8.0/hwpx-writeback-expansion.md)
- 상류 round-trip 모듈: `edwardkim/rhwp` `src/serializer/hwpx/roundtrip.rs` + 게이트 PR #1378 / #1387 / #1389 / #1392 / #1405
Loading