From 83bf5f6523fd65352c68c0c572cf06ce713ee8c3 Mon Sep 17 00:00:00 2001 From: danmeon Date: Sun, 21 Jun 2026 03:44:58 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20HWPX=20writeback=20round-trip=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=91=9C=EB=A9=B4=20verify=5Fhwpx=5Fround?= =?UTF-8?q?trip=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 Document 를 HWPX 로 직렬화·재파싱 후 상류 diff_documents 로 IR 차이를 측정해 RoundtripReport (ok + differences) 반환. 보존 boundary 를 v0.7.0 의 텍스트·문단에서 diff_documents 가 실제 비교하는 필드 (표 cell·캡션·page_break, 그림 크기·캡션, char_shape·lineseg, PageDef, 리소스·BinData count) 로 확대. additive only — IR schema "1.1" 불변, Cargo.toml 0.7.0 유지. v0.8.0 spec·ADR Draft, AC-1~AC-6 회귀 7 테스트 포함. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 17 +++ .../hwpx-writeback-expansion-research.md | 139 ++++++++++++++++++ docs/roadmap/README.md | 4 +- .../v0.8.0/hwpx-writeback-expansion.md | 60 ++++++++ docs/traces/coverage.md | 7 + python/rhwp/__init__.py | 3 +- python/rhwp/__init__.pyi | 2 + python/rhwp/_rhwp.pyi | 1 + python/rhwp/document.py | 42 ++++++ src/document.rs | 22 +++ tests/test_hwpx_writeback.py | 121 ++++++++++++++- 11 files changed, 415 insertions(+), 3 deletions(-) create mode 100644 docs/design/v0.8.0/hwpx-writeback-expansion-research.md create mode 100644 docs/roadmap/v0.8.0/hwpx-writeback-expansion.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc44c5..d1a325b 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] +> 미출시 — 다음 MINOR (v0.8.0 예정) 에 흡수. `Cargo.toml` 의 `version` 은 `0.7.0` 유지. + +### 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 없이 동작. + ## [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. diff --git a/docs/design/v0.8.0/hwpx-writeback-expansion-research.md b/docs/design/v0.8.0/hwpx-writeback-expansion-research.md new file mode 100644 index 0000000..6340def --- /dev/null +++ b/docs/design/v0.8.0/hwpx-writeback-expansion-research.md @@ -0,0 +1,139 @@ +--- +status: Draft +description: "v0.8.0 hwpx-writeback-expansion ADR — 보존 boundary 확대 / verify 표면 노출 / 반환 타입 / 비교 기준 / GIL 5개 결정의 근거" +target: 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` (`: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 }` + `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 diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 1ff4da7..13062ed 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -4,7 +4,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe 본 문서는 Living — 자유 갱신. -## 현재 상태 (2026-06-04) +## 현재 상태 (2026-06-21) - **v0.1.0 / v0.1.1** — Frozen, PyPI 배포 완료 - **v0.2.0** — Frozen, Document IR v1 GA (2026-04-25) @@ -16,6 +16,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe - **v0.5.1** — Frozen, MCP tool 출력 schema 강타입화 GA (2026-05-07) - **v0.6.0** — Frozen, 페이지 PNG 렌더링 (VLM 입력) GA (2026-05-10) - **v0.7.0** — Frozen, HWPX writeback baseline (parse → HWPX round-trip, 텍스트·문단 보존) GA (2026-06-04) +- **v0.8.0** — Draft, HWPX writeback 확장 (표·그림·수식 의미 보존) 착수 — 상류 sync (v0.7.16+36, 1,209 commit) 흡수 + round-trip IrDiff Stage 4 선행조건 충족 (2026-06-21~) ## 활성 spec 인덱스 @@ -34,6 +35,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | 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) | +| v0.8.0 (hwpx-writeback-expansion) | Draft | [v0.8.0/hwpx-writeback-expansion.md](v0.8.0/hwpx-writeback-expansion.md) | [design/v0.8.0/hwpx-writeback-expansion-research.md](../design/v0.8.0/hwpx-writeback-expansion-research.md) | ## 미착수 작업 계획 diff --git a/docs/roadmap/v0.8.0/hwpx-writeback-expansion.md b/docs/roadmap/v0.8.0/hwpx-writeback-expansion.md new file mode 100644 index 0000000..2a92fa8 --- /dev/null +++ b/docs/roadmap/v0.8.0/hwpx-writeback-expansion.md @@ -0,0 +1,60 @@ +--- +status: Draft +description: "v0.8.0 — HWPX writeback round-trip 검증 표면 'verify_hwpx_roundtrip' 추가 + 보존 boundary 를 상류 'diff_documents' 검증 범위로 확대" +target: v0.8.0 +last_updated: 2026-06-21 +--- + +# v0.8.0 — HWPX writeback round-trip 검증 (보존 boundary 확대) + +v0.7.0 이 연 `export_hwpx` / `to_hwpx_bytes` 의 round-trip 보존 boundary 를 텍스트·문단에서 상류 `diff_documents` 가 실제 비교하는 필드 집합까지 확대하고, 사용자가 저장 충실도를 프로그램으로 확인하는 `Document.verify_hwpx_roundtrip()` 검증 표면을 추가한다. v0.7.0 은 상류 round-trip 비교가 카운트만 보던 시점이라 텍스트·문단만 보장했으나, 그 사이 비교가 char_shape·lineseg·표/그림 구조까지 확대되고 진단 도구 (`diff_documents`) 가 공개 API 로 노출되면서 boundary 확대 조건이 성립했다. 직렬화·진단 모두 상류 위임 — 추가만 있고 기존 표면 보존, IR schema (`"1.1"`) 변경 없음. + +주요 결정의 근거·대안·실패 시나리오는 짝 페어: [hwpx-writeback-expansion-research.md](../../design/v0.8.0/hwpx-writeback-expansion-research.md). + +## 배경 + +v0.7.0 baseline 은 상류 `serialize_hwpx` 를 노출했으나 round-trip 의미 보존은 텍스트·문단만 회귀로 보장하고 표·그림은 "crash-free + 상류 위임" 으로 남겼다. 그 boundary 의 원인은 상류 검증 성숙도였다 — 당시 상류 round-trip 비교가 섹션·문단·리소스 카운트만 봤다. "직렬화 코드 존재 ≠ round-trip 검증 완료" 라 미검증 요소를 보장하면 거짓 약속이 된다는 것이 v0.7.0 의 보수적 판단이었다. + +그 사이 상류 `diff_documents` (round-trip 비교 함수) 가 비교 대상을 확대했다. **비교하는 것**: 문단 char_shape 시퀀스, lineseg, 인라인 컨트롤 슬롯 타입, 섹션 PageDef, 표 cell 내용 (셀 문단) + 캡션 + page_break, 그림 크기 요소 (curSz/imgRect/imgDim) + 캡션, 리소스·BinData 엔트리 카운트. **비교하지 않는 것**: 수식 script (description 만 비교), 표 cell 의 rowspan/colspan, BinData 실제 byte (count 만), 도형 raw byte. 동시에 진단 도구가 공개 API 가 됐다: `diff_documents(a, b) -> IrDiff`, `pub struct IrDiff` / `pub enum IrDifference`. + +주의 — 상류 모듈 주석의 "Stage N" 은 *serializer 가 무엇을 emit 하는지* 의 단계 (Stage 3 표 / Stage 4 그림+BinData) 이지 *round-trip 이 무엇을 검증하는지* 가 아니다. 본 spec 의 보장 범위는 stage 라벨이 아니라 `diff_documents` 코드가 실제 비교하는 필드 집합으로 정의한다. + +따라서 v0.8.0 은 (1) 보존 boundary 를 `diff_documents` 가 실제 검증하는 필드 집합으로 확대하고, (2) `diff_documents` 를 `verify_hwpx_roundtrip()` 로 노출해 사용자가 자기 문서의 저장 손실을 검출하게 하며, (3) 그 범위의 round-trip 을 binding 회귀로 가드한다. `diff_documents` 가 비교하지 않는 요소 (수식 script / cell span / BinData byte / 도형 raw) 는 보장 범위 밖 — 상류 비교 확대에 의존하며 본 spec 비목표. + +## 결정 사항 + +| 항목 | 값 | 근거 | +|---|---|---| +| 1 — 보존 boundary 확대 | 텍스트·문단 → `diff_documents` 검증 필드 집합 (표 cell 내용·캡션·page_break, 그림 크기·캡션, char_shape·lineseg, PageDef, 리소스·BinData 카운트) | 보장 범위 = 상류가 *실제 round-trip 비교* 하는 것으로 한정. 직렬화 emit ≠ 검증 완료 (v0.7.0 ADR 교훈). 미비교 요소는 비목표. 자세한 본체 비교는 ADR §1 | +| 2 — 검증 표면 노출 | `verify_hwpx_roundtrip()` 추가 (상류 `diff_documents` 위임) | 상류 진단 도구 공개 API 화. 사용자가 자기 문서의 저장 손실을 실측. 노출 vs 미노출 비교는 ADR §2 | +| 3 — verify 반환 타입 | 경량 리포트 (`ok: bool` + `differences: list[str]`, 불변 `ok == not differences`) | `IrDifference` variant 가 상류 Stage 진행마다 증가 — 전체 Pydantic 매핑은 매 sync 깨짐. 사람 가독 문자열이 forward-compatible. 자세한 본체 비교는 ADR §3 | +| 4 — round-trip 비교 기준 | `diff_documents(현재 Document, reparse)` | 이미 parse 된 `Document` 가 SSOT — bytes 재파싱은 원본 대비가 아닌 자기 출력의 round-trip 측정. 자세한 본체 비교는 ADR §4 | +| 5 — GIL 전략 | baseline GIL 보유 | `diff_documents(self.inner.document(), ..)` 가 `&self.inner` 캡처 — `DocumentCore` 가 `!Sync`. v0.7.0 결정 3 일관, clone-후-detach 는 측정 후 후속 | + +## 인수조건 + +- **AC-1** — 표 (cell 텍스트 내용·캡션) 와 그림 (크기 요소·캡션) 을 포함한 HWPX 문서를 parse → `export_hwpx(out)` → `parse(out)` 했을 때, 재파싱 결과가 원본과 상류 `diff_documents` 기준 차이 없이 동등하다 +- **AC-2** — round-trip 보존되는 문서에서 `verify_hwpx_roundtrip()` 는 `ok == True` 이고 `differences == []` 를 반환하며, 반환 리포트는 `ok == (len(differences) == 0)` 불변을 만족한다 +- **AC-3** — `verify_hwpx_roundtrip()` 의 `differences` 각 항목은 차이 종류·위치를 담은 사람 가독 문자열이다 (상류 `IrDifference` 의 문자열화). round-trip 차이가 없으면 빈 리스트 +- **AC-4** — `verify_hwpx_roundtrip()` 호출은 부작용이 없다 — 호출 후 `to_ir()` / `to_hwpx_bytes()` / `render_*` 등 기존 메서드 결과가 변하지 않는다 +- **AC-5** — `verify_hwpx_roundtrip()` 의 직렬화 실패 (참조 무결성 위반 — BinData 누락 등) 는 `ValueError` 로 전파된다 (v0.7.0 `export_hwpx` 와 동일 에러 계약) +- **AC-6** — v0.7.0 의 텍스트·문단 round-trip 보장이 회귀하지 않는다 (기존 baseline AC 유지) + +AC-3 negative path 주: 현 상류 HWPX 샘플은 round-trip 차이 0 (xfail 없음) 이고 binding 은 read-only `Document` 만 노출하므로, "차이를 보고하는" negative 케이스를 자연 발생 fixture 로 강제하기 어렵다. 차이를 내는 문서가 확보되면 negative 회귀를 추가하고, 그 전까지는 `ok == not differences` 불변 + positive 보존 케이스로 검증한다. + +## 영구 비목표 + +- **수식 script round-trip 보장** — 상류 `diff_documents` 가 equation 을 description 만 비교하고 script 는 비교하지 않는다 (ObjectComment 경로). script 보장은 상류 비교 확대 (이슈) 에 의존 +- **표 cell rowspan/colspan 보존 보장** — 상류 `diff_documents` 의 cell 비교가 셀 문단 (내용) 만 재귀하고 span 구조는 비교하지 않는다. span 보장은 상류 비교 확대에 의존 +- **BinData byte 단위 보존 보장** — 상류는 BinData 엔트리 *count* 만 비교. byte 동일성은 비목표 +- **도형 (shape) raw byte 의미 보존 보장** — 상류 `IrDiffAllow.shape_raw` 가 선언만 되고 미사용 (Stage 5 미완). 도형·OLE·차트 보장은 상류 진행 의존 +- **byte-wise 동일성** — round-trip 은 의미적 동등성 기준 (v0.7.0 비목표 계승). ZIP 압축 / 타임스탬프 / canonical default 주입으로 byte 단위 동일은 보장하지 않는다 +- **HWP5 binary 출력 (`export_hwp` / `verify_hwp_roundtrip`)** — 상류 `serialize_hwp` 는 성숙했으나 본 spec 은 HWPX 만. HWP5 binary writeback 은 v0.9.0 +- **IR mutable 편집 후 재저장** — `Document` 는 parse 결과 read-only. IR 편집 빌더 API 는 v1.0 API 안정 선언 시점 +- **verify 표면의 CLI / MCP 노출** — SDK 표면 (`Document` 메서드) 만. `rhwp-py` 서브명령 / MCP 도구 노출은 별도 demand 시 후속 + +## 참조 + +- 짝 페어 (ADR): [hwpx-writeback-expansion-research.md](../../design/v0.8.0/hwpx-writeback-expansion-research.md) +- 상류 round-trip 진단: `external/rhwp/src/serializer/hwpx/roundtrip.rs` (`diff_documents` / `IrDiff` / `IrDifference`) +- 상류 HWPX serializer: `external/rhwp/src/serializer/hwpx/` (`serialize_hwpx`) diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md index ccb6224..0bd4e8d 100644 --- a/docs/traces/coverage.md +++ b/docs/traces/coverage.md @@ -642,3 +642,10 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | 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` | +| v0.8.0/hwpx-writeback-expansion | AC-1 | `tests/test_hwpx_writeback.py::TestExpansionTableAndPicture::test_table_and_picture_roundtrip_equivalent` | +| v0.8.0/hwpx-writeback-expansion | AC-2 | `tests/test_hwpx_writeback.py::TestVerifyReport::test_preserved_document_is_ok_with_invariant` | +| v0.8.0/hwpx-writeback-expansion | AC-3 | `tests/test_hwpx_writeback.py::TestVerifyReport::test_lossy_document_reports_human_readable_differences` | +| v0.8.0/hwpx-writeback-expansion | AC-3 | `tests/test_hwpx_writeback.py::TestVerifyReport::test_preserved_document_differences_are_empty_str_list` | +| v0.8.0/hwpx-writeback-expansion | AC-4 | `tests/test_hwpx_writeback.py::TestVerifyNoSideEffects::test_verify_does_not_mutate_existing_surfaces` | +| v0.8.0/hwpx-writeback-expansion | AC-5 | `tests/test_hwpx_writeback.py::TestVerifyErrorContract::test_serializable_document_passes_verify_serialization` | +| v0.8.0/hwpx-writeback-expansion | AC-6 | `tests/test_hwpx_writeback.py::TestV070GuaranteeIntact::test_text_paragraph_guarantee_holds_under_verify` | diff --git a/python/rhwp/__init__.py b/python/rhwp/__init__.py index 08b7986..1a2293a 100644 --- a/python/rhwp/__init__.py +++ b/python/rhwp/__init__.py @@ -1,10 +1,11 @@ """rhwp — HWP/HWPX parser and renderer (Korean word processor format).""" from rhwp._rhwp import rhwp_core_version, version -from rhwp.document import Document, aparse, arender_png, parse +from rhwp.document import Document, RoundtripReport, aparse, arender_png, parse __all__ = [ "Document", + "RoundtripReport", "aparse", "arender_png", "parse", diff --git a/python/rhwp/__init__.pyi b/python/rhwp/__init__.pyi index a79926b..7b86b7c 100644 --- a/python/rhwp/__init__.pyi +++ b/python/rhwp/__init__.pyi @@ -1,12 +1,14 @@ """rhwp — HWP/HWPX parser and renderer (Korean word processor format).""" from rhwp.document import Document as Document +from rhwp.document import RoundtripReport as RoundtripReport from rhwp.document import aparse as aparse from rhwp.document import arender_png as arender_png from rhwp.document import parse as parse __all__ = [ "Document", + "RoundtripReport", "aparse", "arender_png", "parse", diff --git a/python/rhwp/_rhwp.pyi b/python/rhwp/_rhwp.pyi index 7703699..9ef2a14 100644 --- a/python/rhwp/_rhwp.pyi +++ b/python/rhwp/_rhwp.pyi @@ -54,4 +54,5 @@ class _Document: 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 verify_hwpx_roundtrip(self) -> tuple[bool, list[str]]: ... def __repr__(self) -> str: ... diff --git a/python/rhwp/document.py b/python/rhwp/document.py index 368e620..29b9597 100644 --- a/python/rhwp/document.py +++ b/python/rhwp/document.py @@ -35,6 +35,8 @@ import os from typing import TYPE_CHECKING +from pydantic import BaseModel, ConfigDict + from rhwp._rhwp import _Document if TYPE_CHECKING: @@ -43,6 +45,20 @@ StrPath = str | os.PathLike[str] +class RoundtripReport(BaseModel): + """:meth:`Document.verify_hwpx_roundtrip` 의 결과 — HWPX 저장 round-trip 충실도. + + ``ok`` 는 ``differences`` 가 빌 때만 ``True`` (불변 ``ok == (not differences)``). + ``differences`` 각 항목은 상류 ``diff_documents`` 가 검출한 IR 차이의 사람 가독 + 문자열 (차이 종류 + 위치). 차이가 없으면 빈 리스트. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + ok: bool + differences: list[str] + + class Document: """파싱된 HWP / HWPX 문서. @@ -350,6 +366,32 @@ def export_hwpx(self, output_path: str) -> int: """ return self._inner.export_hwpx(output_path) + def verify_hwpx_roundtrip(self) -> RoundtripReport: + """현재 문서를 HWPX 로 저장했을 때 IR 의미가 보존되는지 검증한다. + + 문서를 HWPX 로 직렬화 → 재파싱한 뒤, 원본 ``Document`` 와 재파싱 결과를 + 상류 ``diff_documents`` 로 비교한다. 보존되면 ``ok == True`` 이고 + ``differences == []``. + + 보장 범위는 상류 ``diff_documents`` 가 *실제 비교* 하는 필드 집합이다 — + 표 cell 내용·캡션·page_break, 그림 크기·캡션, 문단 char_shape·lineseg, + 섹션 PageDef, 리소스·BinData 엔트리 카운트. 수식 script, 표 cell + rowspan/colspan, BinData byte, 도형 raw 는 상류가 비교하지 않으므로 보장 + 범위 밖이다 (round-trip 차이 0 이 해당 요소의 보존을 뜻하지는 않는다). + + ``export_hwpx`` 와 달리 파일을 쓰지 않으며 현재 Document 를 변형하지 않는다. + + Returns: + ``RoundtripReport`` — ``ok: bool`` + ``differences: list[str]``. + 불변 ``report.ok == (len(report.differences) == 0)``. + + Raises: + ValueError: 직렬화 또는 재파싱 실패 — 참조 무결성 위반 (BinData 누락 등). + ``to_hwpx_bytes`` / ``export_hwpx`` 와 동일 에러 계약. + """ + ok, differences = self._inner.verify_hwpx_roundtrip() + return RoundtripReport(ok=ok, differences=differences) + def __repr__(self) -> str: return repr(self._inner) diff --git a/src/document.rs b/src/document.rs index 2652f9d..4b3d1da 100644 --- a/src/document.rs +++ b/src/document.rs @@ -320,6 +320,28 @@ impl PyDocument { Ok(bytes.len()) } + /// 현재 문서를 HWPX 로 직렬화 → 재파싱한 뒤 상류 `diff_documents` 로 IR 차이를 + /// 측정한다. 반환은 raw `(ok, differences)` tuple — Python wrapper 가 리포트로 감싼다. + /// + /// `ok` 는 `differences` 가 비었을 때 `true`. `differences` 각 항목은 상류 + /// `IrDifference` 의 `Display` 문자열 (차이 종류 + 위치). 직렬화·재파싱 실패는 + /// `ValueError` — `to_hwpx_bytes` / `export_hwpx` 와 동일 에러 계약. + fn verify_hwpx_roundtrip(&self) -> PyResult<(bool, Vec)> { + // ^ GIL 보유: diff_documents 의 첫 인자가 self.inner.document() (&self.inner 캡처) — + // DocumentCore 가 RefCell 캐시로 !Sync (to_ir / to_hwpx_bytes 와 동일 제약). + let bytes = rhwp::serializer::serialize_hwpx(self.inner.document()) + .map_err(|e| PyValueError::new_err(format!("HWPX serialization failed: {e}")))?; + let reparsed = rhwp::document_core::DocumentCore::from_bytes(&bytes) + .map_err(|e| PyValueError::new_err(format!("HWPX roundtrip reparse failed: {e:?}")))?; + // ^ roundtrip 항목은 serializer/mod.rs re-export 밖이라 모듈 경로로 접근. + let diff = rhwp::serializer::hwpx::roundtrip::diff_documents( + self.inner.document(), + reparsed.document(), + ); + let differences: Vec = diff.differences.iter().map(|d| d.to_string()).collect(); + Ok((differences.is_empty(), differences)) + } + fn __repr__(&self) -> String { format!( "Document(sections={}, paragraphs={}, pages={})", diff --git a/tests/test_hwpx_writeback.py b/tests/test_hwpx_writeback.py index 8c92491..a89a2c4 100644 --- a/tests/test_hwpx_writeback.py +++ b/tests/test_hwpx_writeback.py @@ -13,11 +13,14 @@ import io import zipfile from pathlib import Path +from typing import TYPE_CHECKING import pytest - import rhwp +if TYPE_CHECKING: + from rhwp.ir.nodes import HwpDocument + ZIP_MAGIC = b"PK\x03\x04" HWPX_MIMETYPE = b"application/hwp+zip" @@ -117,3 +120,119 @@ def test_writeback_does_not_mutate_existing_surfaces( 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 + + +# * v0.8.0 — HWPX writeback 확장 (verify_hwpx_roundtrip + 표·그림 round-trip 보존) + + +def _block_kind_counts(ir: "HwpDocument") -> dict[str, int]: + """IR 블록을 종류별로 집계 (scope=all, TableCell 재귀).""" + counts: dict[str, int] = {} + for block in ir.iter_blocks(scope="all", recurse=True): + counts[block.kind] = counts.get(block.kind, 0) + 1 + return counts + + +class TestExpansionTableAndPicture: + @pytest.mark.spec("v0.8.0/hwpx-writeback-expansion#AC-1") + def test_table_and_picture_roundtrip_equivalent( + self, samples_dir: Path, tmp_path: Path + ) -> None: + # ^ aift.hwpx — 표·그림 풍부한 실문서. export → reparse 후 표·그림 카운트 + # 보존 + 상류 diff_documents 기준 동등 (verify ok). + original = rhwp.parse(str(samples_dir / "hwpx" / "aift.hwpx")) + kinds = _block_kind_counts(original.to_ir()) + assert kinds.get("table", 0) > 0, "fixture must contain tables for AC-1" + assert kinds.get("picture", 0) > 0, "fixture must contain pictures for AC-1" + + out = tmp_path / "expansion_roundtrip.hwpx" + original.export_hwpx(str(out)) + reparsed_kinds = _block_kind_counts(rhwp.parse(str(out)).to_ir()) + assert reparsed_kinds.get("table", 0) == kinds["table"] + assert reparsed_kinds.get("picture", 0) == kinds["picture"] + + report = original.verify_hwpx_roundtrip() + assert report.ok is True + assert report.differences == [] + + +class TestVerifyReport: + @pytest.mark.spec("v0.8.0/hwpx-writeback-expansion#AC-2") + def test_preserved_document_is_ok_with_invariant(self, samples_dir: Path) -> None: + doc = rhwp.parse(str(samples_dir / "hwpx" / "aift.hwpx")) + report = doc.verify_hwpx_roundtrip() + assert isinstance(report, rhwp.RoundtripReport) + assert report.ok is True + assert report.differences == [] + # 불변: ok == (differences 가 빔) + assert report.ok == (len(report.differences) == 0) + + @pytest.mark.spec("v0.8.0/hwpx-writeback-expansion#AC-3") + def test_preserved_document_differences_are_empty_str_list(self, samples_dir: Path) -> None: + # positive — 보존 문서의 differences 는 빈 list[str] + report = rhwp.parse(str(samples_dir / "hwpx" / "aift.hwpx")).verify_hwpx_roundtrip() + assert isinstance(report.differences, list) + assert report.differences == [] + + @pytest.mark.spec("v0.8.0/hwpx-writeback-expansion#AC-3") + def test_lossy_document_reports_human_readable_differences( + self, parsed_hwpx: rhwp.Document + ) -> None: + # ^ table-vpos-01.hwpx 는 도형 shapeComment 가 round-trip 에서 손실되는 자연 + # 발생 fixture (상류 serializer 미직렬화) — verify 의 손실 검출력 회귀 가드. + # 특정 개수·메시지는 박지 않는다 (상류가 손실을 고치면 이 fixture 갱신). + report = parsed_hwpx.verify_hwpx_roundtrip() + assert report.ok is False + assert report.differences, "verify must surface the upstream shapeComment loss" + assert all(isinstance(d, str) and d.strip() for d in report.differences) + # 불변은 양방향 모두 성립 + assert report.ok == (len(report.differences) == 0) + + +class TestVerifyNoSideEffects: + @pytest.mark.spec("v0.8.0/hwpx-writeback-expansion#AC-4") + def test_verify_does_not_mutate_existing_surfaces(self, samples_dir: Path) -> None: + # ^ 독립 parse — 공유 session fixture 캐시 간섭 회피 (v0.7.0 AC-6 패턴). + doc = rhwp.parse(str(samples_dir / "table-vpos-01.hwpx")) + + ir_before = doc.to_ir_json() + paras_before = doc.paragraphs() + text_before = doc.extract_text() + svg_before = doc.render_svg(0) + hwpx_before = doc.to_hwpx_bytes() + counts_before = (doc.section_count, doc.paragraph_count, doc.page_count) + + doc.verify_hwpx_roundtrip() + + 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.to_hwpx_bytes() == hwpx_before + assert (doc.section_count, doc.paragraph_count, doc.page_count) == counts_before + + +class TestVerifyErrorContract: + @pytest.mark.spec("v0.8.0/hwpx-writeback-expansion#AC-5") + def test_serializable_document_passes_verify_serialization( + self, parsed_hwpx: rhwp.Document + ) -> None: + # ^ verify 는 to_hwpx_bytes 와 동일 직렬화 경로 (상류 serialize_hwpx) — 직렬화 + # 가능한 문서는 verify 도 ValueError 없이 통과한다. 직렬화 실패 시 양쪽 모두 + # ValueError (동일 에러 계약). 자연 발생 실패 fixture 부재로 negative 비검증. + data = parsed_hwpx.to_hwpx_bytes() + assert isinstance(data, bytes) and data + report = parsed_hwpx.verify_hwpx_roundtrip() + assert isinstance(report, rhwp.RoundtripReport) + + +class TestV070GuaranteeIntact: + @pytest.mark.spec("v0.8.0/hwpx-writeback-expansion#AC-6") + def test_text_paragraph_guarantee_holds_under_verify(self, samples_dir: Path) -> None: + # ^ v0.7.0 baseline 의 텍스트·문단 round-trip 보장이 verify 표면에서도 일관 + # (회귀 가드). business_overview.hwpx (텍스트 풍부) round-trip 보존. + original = rhwp.parse(str(samples_dir / "hwpx" / "business_overview.hwpx")) + assert any(p.strip() for p in original.paragraphs()) + report = original.verify_hwpx_roundtrip() + assert report.ok is True + assert report.differences == [] From d77d0398f0ba1f6f5c27aad9322c9e6c916e9327 Mon Sep 17 00:00:00 2001 From: danmeon Date: Sun, 21 Jun 2026 12:50:20 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=EC=83=81=EB=A5=98=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=201451=20=EC=B4=88=EC=95=88=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=E2=80=94=20HWPX=20legacy=20=EB=8F=84=ED=98=95=20shapeComment?= =?UTF-8?q?=20=EB=AF=B8=EC=A7=81=EB=A0=AC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render_common_shape_xml 경유 도형 (ellipse/arc/polygon/curve/chart/ole) 이 hp:shapeComment 를 미방출하는 #1392 후속 누락. 제안 패치 적용 시 round-trip diff 0 실측 검증 후 external/rhwp 원복. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/upstream/README.md | 1 + .../issue-hwpx-shapecomment-drawing-shapes.md | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 docs/upstream/issue-hwpx-shapecomment-drawing-shapes.md diff --git a/docs/upstream/README.md b/docs/upstream/README.md index 11f39a4..2852c7c 100644 --- a/docs/upstream/README.md +++ b/docs/upstream/README.md @@ -10,6 +10,7 @@ |---|---|---|---|---| | [issue-find-control-text-positions.md](issue-find-control-text-positions.md) | Frozen | [edwardkim/rhwp#390](https://github.com/edwardkim/rhwp/issues/390) | 2026-04-28 ([PR #405](https://github.com/edwardkim/rhwp/pull/405)) | `Paragraph::control_text_positions(&self)` 옵션 A 채택. v0.3.1 spec 이 본 파일 참조 → 삭제 대신 in-place Frozen | | [issue-utf16-pos-to-char-idx.md](issue-utf16-pos-to-char-idx.md) | Frozen | [edwardkim/rhwp#484](https://github.com/edwardkim/rhwp/issues/484) | 2026-04-30 ([PR #494](https://github.com/edwardkim/rhwp/pull/494)) | #390 후속 같은 결. `Paragraph::utf16_pos_to_char_idx(&self)` 옵션 A 채택. v0.3.2 spec 이 본 파일 참조 → 삭제 대신 in-place Frozen | +| [issue-hwpx-shapecomment-drawing-shapes.md](issue-hwpx-shapecomment-drawing-shapes.md) | Active | [edwardkim/rhwp#1451](https://github.com/edwardkim/rhwp/issues/1451) | — | `render_common_shape_xml` 이 ellipse/arc/polygon/curve/chart/ole 의 `hp:shapeComment` 미방출 (#1392 후속). 제안 패치 적용 시 round-trip diff 0 실측 | ## Archive 정책 diff --git a/docs/upstream/issue-hwpx-shapecomment-drawing-shapes.md b/docs/upstream/issue-hwpx-shapecomment-drawing-shapes.md new file mode 100644 index 0000000..66230b5 --- /dev/null +++ b/docs/upstream/issue-hwpx-shapecomment-drawing-shapes.md @@ -0,0 +1,97 @@ +--- +status: Active +description: "업스트림 제안 — HWPX serializer 의 legacy 도형 경로(ellipse/arc/polygon/curve/chart/ole)가 hp:shapeComment 를 미직렬화. #1392 는 pic/equation/container/rect 4경로만 구현, render_common_shape_xml 누락. 실문서 round-trip 으로 polygon 설명 소실 재현. 제안 패치 diff 0 실측. 상류 등록 [#1451](https://github.com/edwardkim/rhwp/issues/1451)." +last_updated: 2026-06-21 +--- + +> 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사 및 다차례 사실 검증을 거친 결과입니다. + +# HWPX serializer: hp:shapeComment 미직렬화 — legacy 도형 경로 (ellipse/arc/polygon/curve) + +## 현상 + +[#1392](https://github.com/edwardkim/rhwp/issues/1392) ([PR #1405](https://github.com/edwardkim/rhwp/pull/1405)) 가 `hp:shapeComment` 직렬화를 picture / equation / container / rectangle 4경로에 추가했으나, **`render_common_shape_xml` 을 경유하는 나머지 도형 (ellipse / arc / polygon / curve / chart / ole) 은 `shapeComment` 가 여전히 방출되지 않습니다.** + +실문서 `samples/table-vpos-01.hwpx` 의 다각형 2개에 달린 `다각형입니다.` 가 `serialize_hwpx` 출력에서 소실됩니다 (같은 문서의 그림 shapeComment 3건은 #1392 로 보존되어, 도형 종류에 따라 보존 여부가 갈립니다). + +## 재현 + +- 환경: 상류 main `7d9aae7f` (= 현재 HEAD) / devel `d24231a6` — 두 브랜치 모두 동일 +- `samples/table-vpos-01.hwpx` 를 parse → `serialize_hwpx` → 출력 section XML 비교: + - 원본 ` ("polygon", &p.common, &p.drawing.caption)`). + +`render_common_shape_xml` 이 방출하는 자식 요소는 **`sz` · `pos` · `outMargin` · `caption` 뿐**이고 `shapeComment` (= `CommonObjAttr.description`) 는 빠져 있습니다. `caption` 은 [#1403](https://github.com/edwardkim/rhwp/issues/1403) 으로 legacy 경로에 추가됐으나 (`section.rs:1172` 주석) shapeComment 는 동승하지 않았습니다. + +대조 — shapeComment 를 방출하는 경로: + +| 도형 | 방출 위치 | +|---|---| +| rectangle | `shape.rs:109` `write_shape_comment` | +| equation | `section.rs:1216` `render_equation` (inline) | +| picture | `picture.rs:104` `write_shape_comment` | +| **ellipse / arc / polygon / curve / chart / ole** | **없음 (`render_common_shape_xml`)** | + +`write_shape_comment` (`shape.rs:715`) 는 이미 "도형(rect)·그림·수식·묶음 공유" 로 존재하지만, `render_common_shape_xml` 경로에서는 호출되지 않습니다. + +참고로 `roundtrip::diff_documents` 의 `ObjectComment` 게이트 (#1392) 는 이 손실을 이미 검출합니다 — `task1392_shape_comment_loss_in_gate` 가 Ellipse 케이스로 가드 중이라, serializer 만 따라오면 게이트가 자동으로 회귀를 막습니다. + +## 제안 + +`render_common_shape_xml` 의 caption 방출 직후 (`` 닫기 전) 에 shapeComment 를 추가하면 rectangle / equation / picture 와 동일하게 보존됩니다. OWPML `AbstractShapeObjectType` 순서 (`shape.rs:7`: `… outMargin → caption → shapeComment`) 에 맞춰: + +```rust +// section.rs render_common_shape_xml — caption push 직후, 닫기 전 +if !c.description.is_empty() { + out.push_str(&format!( + "{}", + xml_escape(&c.description) + )); +} +out.push_str(&format!("")); +``` + +(또는 기존 `shape.rs:715` `write_shape_comment` 를 String 빌더 형태에 맞춰 재사용) + +빈 `description` 은 미방출 (#1392 의 picture / equation 규칙과 동일). 변경 후 `diff_documents` 게이트가 polygon / ellipse / arc / curve round-trip 을 자동 보증합니다. + +## 검증 + +위 제안 패치를 `render_common_shape_xml` 에 적용해 외부 binding (`rhwp-python`) 환경에서 빌드 후 실측한 결과 (`maturin develop --release` clean): + +| 항목 | 패치 전 | 패치 후 | +|---|---|---| +| `table-vpos-01.hwpx` round-trip diff (`diff_documents`) | `ObjectComment` 2건 (polygon 2개) | **0건** | +| serialize 출력의 `` 개수 | 3 (polygon 2건 소실) | **5 (다각형 2건 보존)** | +| 보존 fixture (`aift.hwpx` / `tac-img-02.hwpx` / `business_overview.hwpx`) | diff 0 | **diff 0 (회귀 없음)** | + +생성 XML 은 원본과 동일 구조 (`다각형입니다.`) 로, parser 가 그대로 재적재합니다 (caption 부재 도형은 outMargin 직후 방출). 검증 후 패치는 원복했습니다. + +## 영향 + +- `render_common_shape_xml` 경유 도형 (ellipse / arc / polygon / curve / chart / ole) 의 shapeComment round-trip 보존 +- 알고리즘 / 스키마 변경 없음 — 누락 요소 방출 추가만 +- 기존 rectangle / equation / picture / container 경로 무영향 (별도 라이터) + +## 참고 위치 + +- `src/serializer/hwpx/section.rs:1077` (`render_shape` dispatcher) +- `src/serializer/hwpx/section.rs:1123` (Polygon → `render_common_shape_xml` 경로) +- `src/serializer/hwpx/section.rs:1141-1182` (`render_common_shape_xml`, shapeComment 누락 지점) +- `src/serializer/hwpx/section.rs:1172` (#1403 caption 추가 주석) +- `src/serializer/hwpx/shape.rs:715` (`write_shape_comment`, 재사용 후보) +- `src/serializer/hwpx/shape.rs:7` (OWPML AbstractShapeObjectType 순서) +- `src/serializer/hwpx/picture.rs:103-104` (caption 직후 shapeComment 선례) + +## 관련 이슈 + +- [#1392](https://github.com/edwardkim/rhwp/issues/1392) `HWPX serializer: hp:shapeComment 미직렬화 — 도형 설명 소실` (CLOSED, [PR #1405](https://github.com/edwardkim/rhwp/pull/1405)) — picture / equation / container / rectangle 4경로 구현. 본 이슈는 그 범위 밖 legacy 도형 경로의 후속입니다. +- [#1403](https://github.com/edwardkim/rhwp/issues/1403) `HWPX serializer: 그림/도형 캡션(hp:caption) 미직렬화` — `render_common_shape_xml` 에 caption 을 추가했으나 shapeComment 는 동승하지 않았습니다. From a014c529f850b92816aa21a814d6bbed94ad13d9 Mon Sep 17 00:00:00 2001 From: danmeon Date: Sun, 21 Jun 2026 13:00:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20v0.8.0=20GA=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=E2=80=94=20release=20=EB=AC=B8=EC=84=9C=20+=20Cargo=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_hwpx_roundtrip 구현은 선행 커밋에 있고, 본 커밋은 release 문서만 확정함. Cargo.toml 0.7.0 → 0.8.0 (pyproject.toml 은 dynamic 자동 추종), spec·ADR frontmatter Frozen 전환, 구현 로그 migration.md 신규, CHANGELOG Unreleased → 0.8.0 확정. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- Cargo.toml | 2 +- .../hwpx-writeback-expansion-research.md | 4 +- docs/implementation/v0.8.0/migration.md | 134 ++++++++++++++++++ docs/roadmap/README.md | 5 +- .../v0.8.0/hwpx-writeback-expansion.md | 4 +- 6 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 docs/implementation/v0.8.0/migration.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a325b..384cad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,9 @@ 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 (v0.8.0 예정) 에 흡수. `Cargo.toml` 의 `version` 은 `0.7.0` 유지. +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 diff --git a/Cargo.toml b/Cargo.toml index 5032f9d..137bc21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 에 문서로 안내 diff --git a/docs/design/v0.8.0/hwpx-writeback-expansion-research.md b/docs/design/v0.8.0/hwpx-writeback-expansion-research.md index 6340def..c6eca6a 100644 --- a/docs/design/v0.8.0/hwpx-writeback-expansion-research.md +++ b/docs/design/v0.8.0/hwpx-writeback-expansion-research.md @@ -1,7 +1,7 @@ --- -status: Draft +status: Frozen description: "v0.8.0 hwpx-writeback-expansion ADR — 보존 boundary 확대 / verify 표면 노출 / 반환 타입 / 비교 기준 / GIL 5개 결정의 근거" -target: v0.8.0 +ga: v0.8.0 last_updated: 2026-06-21 --- diff --git a/docs/implementation/v0.8.0/migration.md b/docs/implementation/v0.8.0/migration.md new file mode 100644 index 0000000..4fe7835 --- /dev/null +++ b/docs/implementation/v0.8.0/migration.md @@ -0,0 +1,134 @@ +--- +status: Frozen +description: "v0.8.0 구현 로그 — HWPX writeback round-trip 검증 (verify_hwpx_roundtrip + RoundtripReport). 보존 boundary 를 상류 diff_documents 검증 필드로 확대. 직렬화·진단 상류 위임. 구현 중 발견한 도형 shapeComment 누락은 상류 #1451 등록." +ga: v0.8.0 +last_updated: 2026-06-21 +--- + +# v0.8.0 — HWPX writeback round-trip 검증 (구현 로그) + +[v0.8.0/hwpx-writeback-expansion](../../roadmap/v0.8.0/hwpx-writeback-expansion.md) (spec) + +[design/v0.8.0/hwpx-writeback-expansion-research](../../design/v0.8.0/hwpx-writeback-expansion-research.md) +(ADR) 의 구현 결과 로그. 결정의 근거·옵션 비교는 ADR 가 보유 — 본 문서는 +*산출물 / 검증 결과 / 호환성 / 이월 사항* 만 기록한다. + +MINOR release. 단일 세션 규모 (Rust 1 메서드 + Python wrapper·모델 + 테스트 7) 로 +단일 `migration.md` 채택. + +## 1. 산출물 + +### Rust 신규 + +| 파일 / 위치 | 변경 | +|---|---| +| [src/document.rs](../../../src/document.rs) | `PyDocument::verify_hwpx_roundtrip(&self) -> PyResult<(bool, Vec)>` 1 #[pymethods] 신규. `serialize_hwpx` → `DocumentCore::from_bytes` 재파싱 → `serializer::hwpx::roundtrip::diff_documents` → `IrDifference` Display 문자열화. 직렬화·재파싱 실패 → `PyValueError` (`to_hwpx_bytes` 와 동일 계약). GIL 보유 (§ 2 결정 5). raw `(ok, differences)` tuple 반환 — Python wrapper 가 리포트로 감쌈 | + +### Python 신규 + +| 파일 / 위치 | 변경 | +|---|---| +| [python/rhwp/document.py](../../../python/rhwp/document.py) | `RoundtripReport` BaseModel (`frozen=True` / `extra="forbid"`, `ok: bool` + `differences: list[str]`) + `Document.verify_hwpx_roundtrip() -> RoundtripReport` wrapper. 불변 `ok == (not differences)` 는 Rust 가 `(differences.is_empty(), differences)` 로 구조적 보장 | +| [python/rhwp/_rhwp.pyi](../../../python/rhwp/_rhwp.pyi) | `_Document.verify_hwpx_roundtrip(self) -> tuple[bool, list[str]]` stub | +| [python/rhwp/__init__.py](../../../python/rhwp/__init__.py) / [__init__.pyi](../../../python/rhwp/__init__.pyi) | `RoundtripReport` public export (`rhwp.RoundtripReport`) | + +### 테스트 + +| 파일 | 변동 | 책임 | +|---|---|---| +| [tests/test_hwpx_writeback.py](../../../tests/test_hwpx_writeback.py) | +7 테스트 | `TestExpansionTableAndPicture` (AC-1, `aift.hwpx`) / `TestVerifyReport` (AC-2, AC-3 positive+negative) / `TestVerifyNoSideEffects` (AC-4) / `TestVerifyErrorContract` (AC-5) / `TestV070GuaranteeIntact` (AC-6). per-test `pytest.mark.spec("v0.8.0/hwpx-writeback-expansion#AC-N")`. 상류 `diff_documents` 위임이라 extra 무관 — 항상 실행 (test-without-extras skip count 무관) | + +### 문서 / 상류 + +| 파일 | 변경 | +|---|---| +| [CHANGELOG.md](../../../CHANGELOG.md) | `[0.8.0]` 섹션 — Added (verify + RoundtripReport) / Build (상류 pin sync) | +| [docs/upstream/issue-hwpx-shapecomment-drawing-shapes.md](../../upstream/issue-hwpx-shapecomment-drawing-shapes.md) | 상류 [#1451](https://github.com/edwardkim/rhwp/issues/1451) 등록 — legacy 도형 (ellipse/arc/polygon/curve) shapeComment 미직렬화 보고. 구현 중 negative fixture 로 발견, 제안 패치 round-trip diff 0 실측 후 원복 | +| spec / ADR | frontmatter `Draft → Frozen`, `target → ga: v0.8.0` | +| [docs/traces/coverage.md](../../traces/coverage.md) | 7 v0.8.0/hwpx-writeback-expansion#AC-N row (테스트 marker 기반 자동 갱신) | + +### Build + +| 파일 / 위치 | 변경 | +|---|---| +| [Cargo.toml](../../../Cargo.toml) | version 0.7.0 → 0.8.0 (`pyproject.toml` 은 `dynamic` 자동 추종) | +| `external/rhwp` pin | `ce45231c` (v0.7.12+394) → `7d9aae7f` (v0.7.16+36) — `[Unreleased]` 에서 흡수, 본 release 에 동승 | + +## 2. 결정 사항 (spec 결정 5 항목 ↔ 구현 매핑) + +| spec 결정 | 구현 위치 | +|---|---| +| 1 — 보존 boundary 확대 | verify 가 `diff_documents` 위임 — 표 cell 내용·캡션·page_break, 그림 크기·캡션, char_shape·lineseg, PageDef, 리소스·BinData count. 미비교 요소 (수식 script / cell span / BinData byte / 도형 raw) 는 비목표 | +| 2 — 검증 표면 노출 | `Document.verify_hwpx_roundtrip()` 공개 + `rhwp.RoundtripReport` export | +| 3 — verify 반환 타입 | `RoundtripReport` (`ok` + `differences` str list). 상류 `IrDifference` variant 증가에 forward-compatible 한 문자열 출고 | +| 4 — round-trip 비교 기준 | `diff_documents(self.inner.document(), reparsed.document())` — 현재 Document 가 SSOT | +| 5 — GIL 전략 | 보유 — `diff_documents` 첫 인자가 `&self.inner` 캡처, `DocumentCore` 가 `!Sync` (`to_ir` / `to_hwpx_bytes` 와 동일 제약) | + +## 3. 호환성 + +| 시나리오 | 결과 | +|---|---| +| **기존 사용자** | 변경 없음 — v0.7.x 표면 모두 보존 (additive only) | +| **새 사용자 (`verify_hwpx_roundtrip`)** | extra 없이 즉시 사용 — 상류 `diff_documents` 위임 | +| **IR schema** | `"1.1"` 불변 — verify 는 read-only IR 표면과 독립 | +| **`tests/type_check_errors.py` 의 4 intentional pyright errors** | 변경 없음 | +| **test-without-extras CI skip count** | 6 유지 — `test_hwpx_writeback.py` 는 extra 무관, 항상 실행 | + +**SemVer**: MINOR (0.7.0 → 0.8.0). additive only — wire format / wheel 의존성 / schema (`"1.1"`) / abi3-py310 정책 보존. + +## 4. 검증 + +| 검사 | 결과 | +|---|---| +| `uv run maturin develop --release` | clean (release 빌드, abi3 wheel) | +| `cargo clippy --all-targets -- -D warnings` | clean | +| `uv run pytest -m "not slow"` | **606 passed / 2 skipped / 6 deselected** (v0.7.0 599 → +7) | +| `uv run pytest tests/test_view_baseline.py` | 2/2 byte-equal 유지 | +| `lint_docs docs/` | 0 위반 | +| 별도 컨텍스트 코드 리뷰 | 정합 — critical/high/medium 0 건 | + +### AC ↔ 테스트 매핑 + +| AC | 테스트 | +|---|---| +| AC-1 (표·그림 round-trip 동등) | `TestExpansionTableAndPicture::test_table_and_picture_roundtrip_equivalent` (`aift.hwpx` — 표·그림 카운트 보존 + verify ok) | +| AC-2 (보존 문서 ok + 불변) | `TestVerifyReport::test_preserved_document_is_ok_with_invariant` | +| AC-3 (사람 가독 differences) | `test_preserved_document_differences_are_empty_str_list` (positive) + `test_lossy_document_reports_human_readable_differences` (negative — `table-vpos-01.hwpx` 도형 shapeComment 손실) | +| AC-4 (부작용 없음) | `TestVerifyNoSideEffects::test_verify_does_not_mutate_existing_surfaces` | +| AC-5 (직렬화 실패 ValueError 계약) | `TestVerifyErrorContract::test_serializable_document_passes_verify_serialization` | +| AC-6 (v0.7.0 텍스트·문단 보장 유지) | `TestV070GuaranteeIntact::test_text_paragraph_guarantee_holds_under_verify` | + +6/6 AC 충족. + +## 5. 알려진 한계 / 이월 사항 + +| 항목 | 상태 | 후속 | +|---|---|---| +| 도형 (polygon/ellipse/arc/curve) shapeComment round-trip | 상류 serializer 가 `render_common_shape_xml` 경로에서 미직렬화. verify 가 검출 (`table-vpos-01.hwpx` negative fixture). spec 영구 비목표 (도형 보존 = 상류 진행 의존) | 상류 [#1451](https://github.com/edwardkim/rhwp/issues/1451) 등록 (제안 패치 round-trip diff 0 실측). 상류 반영 시 negative fixture 갱신 | +| 수식 script / 표 cell rowspan-colspan / BinData byte | 상류 `diff_documents` 미비교 | spec 영구 비목표 — 상류 비교 확대 의존 | +| GIL clone-후-detach 최적화 | baseline 은 GIL 보유 (정확성 우선) | `benches/bench_gil.py` 측정이 순이득 보이면 후속 patch | + +## 6. v0.8.0 GA 절차 (인계) + +본 step 이후 GA 까지의 release 절차 (CONVENTIONS § 버전 GA 후): + +1. **`Cargo.toml` version bump** — 0.7.0 → 0.8.0 (완료, `pyproject.toml` 은 `dynamic` 추종) +2. **spec / ADR frontmatter flip** — `Draft → Frozen`, `target → ga: v0.8.0` (완료) +3. **본 `migration.md`** — 작성 즉시 Frozen + ga: v0.8.0 (완료) +4. **`docs/roadmap/README.md` 인덱스 갱신** — v0.8.0 row Frozen + 구현 로그 표 추가 (완료) +5. **`CHANGELOG.md` [0.8.0] 섹션** — 완료 +6. **PR** feature/v0.8.0 → main → merge +7. **git tag `v0.8.0`** + GitHub Release 생성 (published) — `publish.yml` 트리거 (Trusted Publisher OIDC) — *사용자 진행* + +## 7. 참조 + +### 짝 페어 + +- spec: [docs/roadmap/v0.8.0/hwpx-writeback-expansion.md](../../roadmap/v0.8.0/hwpx-writeback-expansion.md) +- ADR: [docs/design/v0.8.0/hwpx-writeback-expansion-research.md](../../design/v0.8.0/hwpx-writeback-expansion-research.md) + +### 상류 + +- round-trip 진단: `external/rhwp/src/serializer/hwpx/roundtrip.rs` (`diff_documents` / `IrDiff` / `IrDifference`) +- HWPX serializer: `external/rhwp/src/serializer/hwpx/` (`serialize_hwpx`) +- 관련 이슈: [edwardkim/rhwp#1451](https://github.com/edwardkim/rhwp/issues/1451) (legacy 도형 shapeComment 미직렬화) +- submodule pin: `7d9aae7f` (v0.7.16+36) diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 13062ed..35a242f 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -16,7 +16,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe - **v0.5.1** — Frozen, MCP tool 출력 schema 강타입화 GA (2026-05-07) - **v0.6.0** — Frozen, 페이지 PNG 렌더링 (VLM 입력) GA (2026-05-10) - **v0.7.0** — Frozen, HWPX writeback baseline (parse → HWPX round-trip, 텍스트·문단 보존) GA (2026-06-04) -- **v0.8.0** — Draft, HWPX writeback 확장 (표·그림·수식 의미 보존) 착수 — 상류 sync (v0.7.16+36, 1,209 commit) 흡수 + round-trip IrDiff Stage 4 선행조건 충족 (2026-06-21~) +- **v0.8.0** — Frozen, HWPX writeback round-trip 검증 (`verify_hwpx_roundtrip` + 보존 boundary 를 상류 `diff_documents` 검증 필드로 확대) GA (2026-06-21) ## 활성 spec 인덱스 @@ -35,7 +35,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | 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) | -| v0.8.0 (hwpx-writeback-expansion) | Draft | [v0.8.0/hwpx-writeback-expansion.md](v0.8.0/hwpx-writeback-expansion.md) | [design/v0.8.0/hwpx-writeback-expansion-research.md](../design/v0.8.0/hwpx-writeback-expansion-research.md) | +| v0.8.0 (hwpx-writeback-expansion) | Frozen | [v0.8.0/hwpx-writeback-expansion.md](v0.8.0/hwpx-writeback-expansion.md) | [design/v0.8.0/hwpx-writeback-expansion-research.md](../design/v0.8.0/hwpx-writeback-expansion-research.md) | ## 미착수 작업 계획 @@ -90,6 +90,7 @@ SemVer 0.x.y 단계에서 minor 는 단조 증가. v1.0.0 은 API 안정 선언 | 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) | — | +| v0.8.0 | [implementation/v0.8.0/migration.md](../implementation/v0.8.0/migration.md) | — | ## 원칙 diff --git a/docs/roadmap/v0.8.0/hwpx-writeback-expansion.md b/docs/roadmap/v0.8.0/hwpx-writeback-expansion.md index 2a92fa8..ac13cd2 100644 --- a/docs/roadmap/v0.8.0/hwpx-writeback-expansion.md +++ b/docs/roadmap/v0.8.0/hwpx-writeback-expansion.md @@ -1,7 +1,7 @@ --- -status: Draft +status: Frozen description: "v0.8.0 — HWPX writeback round-trip 검증 표면 'verify_hwpx_roundtrip' 추가 + 보존 boundary 를 상류 'diff_documents' 검증 범위로 확대" -target: v0.8.0 +ga: v0.8.0 last_updated: 2026-06-21 --- From 3aa91237d7da4324904af04ab362105269cfc7e1 Mon Sep 17 00:00:00 2001 From: danmeon Date: Sun, 21 Jun 2026 13:26:19 +0900 Subject: [PATCH 4/4] =?UTF-8?q?ci:=20macOS=20smoke=20=EC=9E=A1=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=20=E2=80=94=20=EC=83=81=EB=A5=98=20=EC=9D=B4=EC=8A=88?= =?UTF-8?q?=20823=20v0.7.13=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit headless macOS PNG 렌더 hang (CoreText IPC) 이 v0.7.13 에서 해결되고 현재 pin 7d9aae7f (v0.7.16+36) 이 fix 를 포함하므로, 4083a27 의 비활성화를 되돌려 test-other-os 매트릭스에 macos-latest 를 복원함. 관련 upstream 이슈 문서를 RESOLVED Frozen 으로 전환. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 6 +++--- CHANGELOG.md | 2 +- docs/upstream/README.md | 1 + docs/upstream/issue-macos-png-coretext-hang.md | 6 ++++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fed5fc..659e42c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 384cad3..e07d541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ MINOR release. parse 한 `Document` 를 HWPX 로 저장했을 때 IR 의미가 - **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 없이 동작. +- **상류 #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 diff --git a/docs/upstream/README.md b/docs/upstream/README.md index 2852c7c..2c88db4 100644 --- a/docs/upstream/README.md +++ b/docs/upstream/README.md @@ -10,6 +10,7 @@ |---|---|---|---|---| | [issue-find-control-text-positions.md](issue-find-control-text-positions.md) | Frozen | [edwardkim/rhwp#390](https://github.com/edwardkim/rhwp/issues/390) | 2026-04-28 ([PR #405](https://github.com/edwardkim/rhwp/pull/405)) | `Paragraph::control_text_positions(&self)` 옵션 A 채택. v0.3.1 spec 이 본 파일 참조 → 삭제 대신 in-place Frozen | | [issue-utf16-pos-to-char-idx.md](issue-utf16-pos-to-char-idx.md) | Frozen | [edwardkim/rhwp#484](https://github.com/edwardkim/rhwp/issues/484) | 2026-04-30 ([PR #494](https://github.com/edwardkim/rhwp/pull/494)) | #390 후속 같은 결. `Paragraph::utf16_pos_to_char_idx(&self)` 옵션 A 채택. v0.3.2 spec 이 본 파일 참조 → 삭제 대신 in-place Frozen | +| [issue-macos-png-coretext-hang.md](issue-macos-png-coretext-hang.md) | Frozen | [edwardkim/rhwp#823](https://github.com/edwardkim/rhwp/issues/823) | 2026-06-04 (상류 v0.7.13) | headless macOS PNG 렌더 hang (CoreText IPC). v0.8.0 상류 sync 로 fix 흡수 + `ci.yml` macOS smoke 잡 복원 | | [issue-hwpx-shapecomment-drawing-shapes.md](issue-hwpx-shapecomment-drawing-shapes.md) | Active | [edwardkim/rhwp#1451](https://github.com/edwardkim/rhwp/issues/1451) | — | `render_common_shape_xml` 이 ellipse/arc/polygon/curve/chart/ole 의 `hp:shapeComment` 미방출 (#1392 후속). 제안 패치 적용 시 round-trip diff 0 실측 | ## Archive 정책 diff --git a/docs/upstream/issue-macos-png-coretext-hang.md b/docs/upstream/issue-macos-png-coretext-hang.md index 9366769..f0f5325 100644 --- a/docs/upstream/issue-macos-png-coretext-hang.md +++ b/docs/upstream/issue-macos-png-coretext-hang.md @@ -1,9 +1,11 @@ --- -status: Active +status: Frozen description: "업스트림 제안 — headless macOS (CI 등) 에서 PNG 렌더가 미설치 폰트 fallback 시 CoreText downloadable lookup IPC 로 hang. 시스템 폰트 사전 필터링 patch 로 실측 0.43초 정상화 검증. 상류 등록 [#823](https://github.com/edwardkim/rhwp/issues/823)." -last_updated: 2026-05-11 +last_updated: 2026-06-21 --- +> **RESOLVED 2026-06-04** — 상류 [#823](https://github.com/edwardkim/rhwp/issues/823) 이 v0.7.13 에서 해결 (closed). headless macOS 에서 `render_png` 가 hang 없이 동작한다. 본 binding 은 v0.8.0 상류 sync (pin `7d9aae7f`, v0.7.16+36) 로 fix 를 흡수하고 `ci.yml` 의 macOS smoke 잡을 복원했다. + > 외부 binding (`rhwp-python`) 구현 중 업스트림에서 수정이 필요해 보이는 부분을 발견하여, Claude 로 조사 및 다차례 사실 검증을 거친 결과입니다. ## 현상