diff --git a/mydocs/plans/task_m100_1557.md b/mydocs/plans/task_m100_1557.md new file mode 100644 index 000000000..9bc017e9a --- /dev/null +++ b/mydocs/plans/task_m100_1557.md @@ -0,0 +1,55 @@ +# Task #1557: HWPX 저장본 한글 페이지 붕괴 해소 — 수행계획서 + +## 목표 +`serialize_hwpx` 저장본을 한글 2024 가 열 때 **다중 페이지 문서가 1쪽으로 붕괴**하는 결함을 +해소한다. 저장본이 한글에서 **원본과 동일한 페이지수**로 렌더되도록 한다. + +## 배경 (V2-A 조사) +fidelity v2(fresh 바이너리, hwpdocs 실문서 1135건) 결과 발견: +- 저장본이 한글에서 페이지 붕괴. 표본 40건 중 **4건(10%)**: 8→1, 29→1, 5→1, 2→1. +- **PASS(IR diff=0) 문서도 붕괴**(36382669: 한글 8→1, PageCount 2회+PDF 일치). +- rhwp 자신은 원본·저장본을 동일 페이지수로 봄(36384160: 29=29, 텍스트 100% 동일). + → **IR 게이트도 rhwp 페이지수도 검출 불가, 한글 오라클 전용.** + +## 현황 — root-cause 위치 확정 (수행계획 선행 격리) +36382669(PASS, 8→1) 저장본 격리 실험으로 원인을 좁힘: +| 실험 | 한글 페이지 | +|------|-----------| +| 원본 | 8 | +| 저장본(rt) | 1 | +| rt + 원본 section0.xml | 1 (section0 원인 아님) | +| rt + **원본 header.xml** | **8 (복원)** | +| rt + outlineShapeIDRef 0 치환 | 1 (원인 아님) | + +→ **원인은 저장본 `header.xml`(DocInfo 방출).** 원본 대비 태그 누락(각 1개): +`` 22→21, `` 2→1, `//` 56→55. +`secPr`/`pagePr`/`pageBreak`/`colPr`/`beginNum`/`compatibleDocument` 는 동일. + +## 범위 +**포함(In)** +- header.xml 내부 bisection 으로 붕괴 유발 element 확정. +- 해당 DocInfo 직렬화(`src/serializer/hwpx/` header/doc_info) 결함 수정 → header.xml 정합. +- 대표 붕괴 케이스 한글 페이지 복원 검증 + 회귀 가드(아래). + +**제외(Out)** +- 표 셀 pic 드롭(V2-B, 별도), char_shape 8유닛 시프트(F3 #1556). +- HWP5 측(#1554). + +## 검사·판정 기준 +- **1차(필수)**: 대표 붕괴 케이스 한글 PageCount 원본==저장본 복원 + (36382669 8→8, 36384160 29→29). pyhwpx 측정. +- IR diff 불변(`hwpx-roundtrip` 해당 파일 diff 증가 없음) — 수정이 IR 의미 변경 아님. +- `cargo test --test hwpx_roundtrip_baseline` 회귀 없음(samples/hwpx 전건). +- (가능 시) 게이트 보강: 한글 PageCount 오라클 또는 header.xml DocInfo 누락 회귀 가드. + +## 위험 / 주의 +- 한글 의존 검증(자동화 한계) — 회귀 가드를 코드 레벨(누락 element 방출 단언)로도 건다. +- 누락 element 가 다수 붕괴 케이스에 공통인지 Stage 1 에서 교차 확인(36384160 등). +- HWP3 전용 분기 추가 금지(CLAUDE.md). HWPX serializer 범위 내 수정. + +## 다음 단계 +승인 시 **구현 계획서**(`task_m100_1557_impl.md`, 3~4단계): +1. header.xml 내부 bisection → 붕괴 유발 element 확정 + serializer 코드 매핑 +2. serializer 수정(누락 element 방출 정합) +3. 회귀 가드 + 대표 케이스 한글 검증 + baseline 회귀 +작성 → 재승인 후 단계별 진행. diff --git a/mydocs/plans/task_m100_1557_impl.md b/mydocs/plans/task_m100_1557_impl.md new file mode 100644 index 000000000..69eee5e84 --- /dev/null +++ b/mydocs/plans/task_m100_1557_impl.md @@ -0,0 +1,55 @@ +# Task #1557: HWPX 저장본 한글 페이지 붕괴 해소 — 구현 계획서 + +> 수행계획서 `task_m100_1557.md`(승인 완료). 본 문서는 단계별 구현 계획(3단계). +> 확정 사실: 원인은 저장 `header.xml`(DocInfo). 누락 태그 — `` 22→21, +> `` 2→1, `//` 56→55. + +## 대상 코드 (조사 완료) +| 방출 | 위치 | +|------|------| +| winBrush (borderFill/fillBrush) | `src/serializer/hwpx/header.rs` (~1489–1517) | +| tabItem (TabDef) | `src/serializer/hwpx/header.rs:708` `write_tab_pr` / `:723` | +| switch/case/default (호환 변형 블록) | `src/serializer/hwpx/header.rs` | +| DocInfo 등록 컨텍스트 | `src/serializer/hwpx/context.rs:108` (CharShape/ParaShape/BorderFill/TabDef/…) | + +대표 케이스: `36382669`(PASS, 8→1), `36384160`(d10, 29→1). 산출물 `output/poc/fidelity2/rt/`. + +--- + +## Stage 1 — root-cause element 확정 (조사, 코드 수정 없음) +**목표**: header.xml 의 세 누락 후보 중 **붕괴 유발 element** 를 단정. + +- header.xml 내부 bisection(격리 재압축 + 한글 PageCount): + - rt header 에 원본의 (a)winBrush 블록 / (b)tabItem(TabDef) / (c)누락 switch 블록 을 + 하나씩 복원 → 8쪽 복원되는 항목이 원인. +- 다중 케이스 교차 확인(36384160 등 다른 붕괴 파일에서 동일 element 누락·동일 복원인지). +- 원인 element 를 `header.rs` 방출 코드 경로에 매핑 + 누락 메커니즘(off-by-one/조건 누락/ + 등록 누락) 1차 규명. +- **산출**: `task_m100_1557_stage1.md`(원인 element + 코드 지점 + 메커니즘). 커밋(보고서만). + +## Stage 2 — serializer 수정 +**목표**: 누락 element 방출 정합 → 저장 header.xml 이 원본과 동형(해당 항목 보존). + +- `header.rs` 의 해당 방출(또는 `context.rs` 등록) 결함 수정. 최소 변경 — 누락 1건 복원에 한정. +- `cargo build --release` + `cargo test`(기존) 회귀 없음. +- **검증(필수)**: 대표 케이스 한글 PageCount 복원 — `rhwp hwpx-roundtrip` 재생성 후 + pyhwpx 로 36382669 **8→8**, 36384160 **29→29** 확인. +- IR diff 불변(수정이 IR 의미 변경 아님): 해당 파일 `hwpx-roundtrip` diff 증가 없음. +- **산출**: `task_m100_1557_stage2.md` + 소스 커밋. + +## Stage 3 — 회귀 가드 + 광역 검증 + 최종 보고 +**목표**: 재발 봉인 + 효과 정량화. + +- 코드 레벨 회귀 가드: 누락되던 element 가 방출되는지 단위 테스트(header.rs 직렬화 단언). +- `cargo test --test hwpx_roundtrip_baseline` 회귀 없음(samples/hwpx 전건 PASS 유지). +- 광역 효과 측정: `hwpx-roundtrip --batch hwpdocs/samples` 재실행 후 T3 페이지 붕괴 + 표본(40건) 재측정 — 붕괴율 감소 정량화(목표 0). +- **산출**: `task_m100_1557_stage3.md` + `mydocs/report/task_m100_1557_report.md` + 커밋. + +--- + +## 공통 주의 +- HWPX serializer 범위 내 최소 수정. HWP3/HWP5 전용 분기 추가 금지(CLAUDE.md). +- 한글 의존 검증의 한계를 코드 레벨 가드로 보완(한글 미접근 환경에서도 회귀 감지). +- pic 드롭(V2-B)·F3(#1556)은 본 타스크에서 건드리지 않음(혼합 금지). +- 단계마다 완료보고서 + 소스 동반 커밋, 승인 후 다음 단계(자동승인 시 연속 진행). diff --git a/mydocs/report/task_m100_1557_report.md b/mydocs/report/task_m100_1557_report.md new file mode 100644 index 000000000..2fa1cdefe --- /dev/null +++ b/mydocs/report/task_m100_1557_report.md @@ -0,0 +1,46 @@ +# Task #1557 최종 결과보고서 — HWPX 저장본 한글 페이지 붕괴 해소 (secCnt) + +- 이슈: #1557 (M100) +- 브랜치: `local/task1557` (from devel) +- 일자: 2026-06-26 + +## 1. 문제 +`serialize_hwpx` 저장본을 한글 2024 가 열면 다중 페이지 문서가 **1쪽으로 붕괴**. +rhwp 자신은 원본·저장본을 동일 페이지수로 보고(자기 일관), **IR diff 게이트도 +rhwp 페이지수도 검출 불가** — 한글 오라클 전용. PASS(IR diff=0) 문서도 붕괴. +fidelity v2(실문서 1135건) 표본 40건 중 **4건(10%)**. + +## 2. 근본원인 (Stage 1) +`src/serializer/hwpx/header.rs:37` 가 `secCnt` 를 stale 가능한 +`doc.doc_properties.section_count`(=1)에서 가져옴. 섹션 **파일**은 `doc.sections` +(=3) 기준으로 방출되어 **`secCnt`(1) < 실제 섹션 수(3)** 불일치 → 한글이 구역 1·2 를 +로드하지 않고 1쪽으로 붕괴. + +격리 실험으로 확정: 저장본의 `secCnt="1"`→`"3"` 치환만으로 36382669 가 한글 8→8 완전 복원. + +## 3. 수정 (Stage 2) +```rust +- let sec_cnt = doc.doc_properties.section_count.max(1).to_string(); ++ let sec_cnt = doc.sections.len().max(1).to_string(); +``` +`secCnt` 를 실제 직렬화 섹션 수와 항상 일치. IR 의미 변경 없음(메타데이터 교정). + +## 4. 검증 (Stage 2·3) +- 36382669: 한글 **8→8 완전 복원**(IR diff=0 불변), 36388145 **5→5 복원**, 36384160 1→3 개선. +- 회귀 가드 단위 테스트(`write_header_seccnt_matches_section_count`) 통과. +- `hwpx_roundtrip_baseline` 4 passed(samples/hwpx 전건 회귀 없음). +- 광역: 표본 붕괴율 **10%(4/40) → 5%(2/40)** — 다중구역 붕괴 전부 복구. + +## 5. 잔여 / 후속 +- 잔여 붕괴(secCnt 무관): 36388284·36388429 **2→1**(단일구역 2쪽 → 1쪽 손실). 별도 원인 → 후속 이슈 후보. +- 표 셀 pic 드롭(V2-B, 545건), char_shape 8유닛 시프트(F3 #1556), header.xml 기타 차이 + (imgBrush/strikeout/shadow/tabDef switch)는 본 타스크 범위 외. + +## 6. 변경 파일 +- `src/serializer/hwpx/header.rs` (secCnt 산출 1행 + 회귀 가드 테스트) +- 계획/보고: `mydocs/plans/task_m100_1557{,_impl}.md`, `mydocs/working/task_m100_1557_stage{1..3}.md` + +## 7. 한계 +- 한글 의존 검증(자동화 한계)은 코드 레벨 가드로 보완. +- 게이트(`hwpx-roundtrip`)는 여전히 페이지 붕괴를 직접 검출 못 함(IR 비교라) — secCnt + 불일치는 코드 가드로 봉인했으나, 일반 한글 페이지 오라클 연동은 후속 개선 후보. diff --git a/mydocs/working/task_m100_1557_stage1.md b/mydocs/working/task_m100_1557_stage1.md new file mode 100644 index 000000000..9b778e8b7 --- /dev/null +++ b/mydocs/working/task_m100_1557_stage1.md @@ -0,0 +1,45 @@ +# Task #1557 Stage 1 완료보고서 — root-cause element 확정 + +## 목표 +header.xml 내부 bisection 으로 한글 페이지 붕괴 유발 element 확정 + serializer 코드 매핑. + +## 결과 — 원인은 `` 불일치 + +### 격리 (36382669 PASS, 8→1) +| 실험 | 한글 페이지 | +|------|-----------| +| 원본 | 8 | +| 저장본(rt) | 1 | +| rt + 원본 section0.xml | 1 (section0 원인 아님) | +| rt + 원본 header.xml | 8 (header 가 원인) | +| **rt + header 의 `secCnt="1"`→`"3"` 만 치환** | **8 (완전 복원)** ✅ | + +다중 케이스 교차: +| 파일 | rt section*.xml 수 | 원본 | rt(secCnt=1) | rt(secCnt=3) | +|------|----:|----:|----:|----:| +| 36382669 | 3 | 8 | 1 | **8 (완전 복원)** | +| 36384160 | 3 | 29 | 1 | **3** (1→3 개선, 잔여 별도 요인) | + +### 진단 +- 저장본은 `Contents/section0..2.xml` **3개**를 쓰면서 `header.xml` 에는 **`secCnt="1"`** 기록. +- 한글은 `secCnt` 만큼만 구역을 로드 → 구역 1·2 무시 → 페이지 붕괴. +- 36384160 은 secCnt 교정으로 1→3 회복(완전 29 아님 — 잔여는 본문 내 다른 요인, 본 타스크 범위 외 가능). + +### 코드 지점 +`src/serializer/hwpx/header.rs:37` +```rust +let sec_cnt = doc.doc_properties.section_count.max(1).to_string(); +``` +- 섹션 **파일**은 `doc.sections`(=3) 기준으로 방출되나, `secCnt` 는 `doc.doc_properties.section_count`(=1, 파서가 갱신 안 한 stale 값)에서 가져와 **불일치**. +- `doc_properties.section_count` 가 실제 섹션 수와 어긋나는 것이 근인. + +## 수정 방향 (Stage 2) +`secCnt` 를 실제 직렬화 대상인 **`doc.sections.len()`** 으로 산출(섹션 파일 수와 항상 일치). + +## 부수 관찰 (별개·범위 외) +header.xml 의 기타 차이(페이지 붕괴와 무관): `imgBrush` mode TOTAL→FIT, `gradation` colorNum 누락, +`strikeout` 3D→NONE, `shadow` DROP→CONTINUOUS, TabDef switch(HwpUnitChar) 래퍼 미방출. +→ 시각 충실도 후속 이슈 후보(본 타스크는 secCnt 만 다룸). + +## 다음 +Stage 2 — `header.rs:37` 수정 + 대표 케이스 한글 페이지 복원 검증. diff --git a/mydocs/working/task_m100_1557_stage2.md b/mydocs/working/task_m100_1557_stage2.md new file mode 100644 index 000000000..0738f2c73 --- /dev/null +++ b/mydocs/working/task_m100_1557_stage2.md @@ -0,0 +1,25 @@ +# Task #1557 Stage 2 완료보고서 — serializer 수정 (secCnt) + +## 변경 +`src/serializer/hwpx/header.rs:37` +```rust +- let sec_cnt = doc.doc_properties.section_count.max(1).to_string(); ++ let sec_cnt = doc.sections.len().max(1).to_string(); +``` +`secCnt` 를 stale 가능한 `doc_properties.section_count` 대신 실제 직렬화 섹션 수 +(`doc.sections.len()`, 섹션 파일 수와 항상 일치)로 산출. 주석으로 사유 명시. + +## 검증 (fixed 바이너리) +| 파일 | header secCnt | 게이트 | 한글 원본→fixed_rt | +|------|----:|--------|----| +| 36382669 | 1 → **3** | PASS diff=0 (불변) | 8 → **8 완전 복원** ✅ | +| 36384160 | 1 → **3** | IR_DIFF d10 (불변) | 1 → **3** (3구역 로드, 잔여는 본문 내 별도 요인) | + +- IR diff **불변**(36382669 PASS 유지) — 수정이 IR 의미를 바꾸지 않음(직렬화 메타만 교정). +- 순수 secCnt 붕괴(36382669) **완전 해소**. 36384160 은 secCnt 외 추가 요인(표 내 pic 드롭 등, V2-B 계열)으로 29 미달 — 본 타스크 범위 외. + +## 빌드 +`cargo build --release` 성공. + +## 다음 +Stage 3 — 코드 레벨 회귀 가드(secCnt == 섹션 수 단언) + baseline 회귀 없음 + hwpdocs 광역 재측정(붕괴율 감소) + 최종 보고. diff --git a/mydocs/working/task_m100_1557_stage3.md b/mydocs/working/task_m100_1557_stage3.md new file mode 100644 index 000000000..73f5abe07 --- /dev/null +++ b/mydocs/working/task_m100_1557_stage3.md @@ -0,0 +1,30 @@ +# Task #1557 Stage 3 완료보고서 — 회귀 가드 + 광역 검증 + +## 변경 +- `src/serializer/hwpx/header.rs` 테스트 추가: `write_header_seccnt_matches_section_count` + — 섹션 3개·`doc_properties.section_count=1`(stale 모사)에서 `secCnt="3"` 방출 단언. + 한글 미접근 환경에서도 회귀 감지. + +## 검증 +- `cargo test --lib write_header_seccnt`: **1 passed** (가드). +- `cargo test --test hwpx_roundtrip_baseline`: **4 passed** (samples/hwpx 전건 — 회귀 없음). + +## 광역 재측정 (hwpdocs 실문서, fixed 바이너리) +동일 T3 표본 40건 한글 페이지 붕괴율: + +| | 붕괴 | +|---|---| +| 수정 전 | **4/40 (10%)** | +| 수정 후 | **2/40 (5%)** | + +- **secCnt 수정으로 복구**(다중구역): 36382669 **8→8**, 36388145 **5→5**. 완전 복원. +- 36384160(29쪽) 1→3 개선(3구역 로드). +- **잔여 붕괴**(secCnt 무관, 별도 원인): 36388284 **2→1**, 36388429 **2→1** + — 단일구역 2쪽 문서의 1쪽 손실. 본 타스크 범위 외 → 후속 이슈 후보. + +> IR_DIFF 건수(892/1398)는 secCnt 수정과 무관(XML 메타라 IR 비교 대상 아님) — 변동 없음. +> pic 드롭(V2-B)·잔여 2→1 붕괴는 별도. + +## 결론 +HWPX 저장 시 **다중 구역 문서의 한글 페이지 붕괴(secCnt 불일치)를 해소**. 회귀를 코드 +레벨(단위 테스트)로 봉인. 표본 붕괴율 10%→5%(다중구역 케이스 전부 복구). diff --git a/src/serializer/hwpx/header.rs b/src/serializer/hwpx/header.rs index 18783f793..270279639 100644 --- a/src/serializer/hwpx/header.rs +++ b/src/serializer/hwpx/header.rs @@ -34,7 +34,10 @@ pub fn write_header(doc: &Document, ctx: &SerializeContext) -> Result, S write_xml_decl(&mut w)?; // 루트 + 전체 네임스페이스 (parser가 기대하는 접두어 모두 선언) - let sec_cnt = doc.doc_properties.section_count.max(1).to_string(); + // secCnt 는 실제 직렬화하는 섹션 파일 수(`doc.sections`)와 일치해야 한다 (#1557). + // doc_properties.section_count 는 파서가 갱신하지 않아 stale(1)일 수 있어, 그대로 + // 쓰면 secCnt < 실제 섹션 수가 되어 한글이 뒤 구역을 로드하지 않고 페이지가 붕괴한다. + let sec_cnt = doc.sections.len().max(1).to_string(); // HWPML 스키마 버전: 원본 보존값(문서별 상이, 1.2~1.5). 없으면 "1.2" 폴백. let hwpml_version = doc.doc_info.hwpml_version.as_deref().unwrap_or("1.2"); start_tag_attrs( @@ -1253,6 +1256,23 @@ mod tests { assert!(xml.contains(r#"version="1.2""#), "기본 버전 폴백은 1.2"); } + #[test] + fn write_header_seccnt_matches_section_count() { + // #1557: secCnt 는 실제 직렬화 섹션 수(doc.sections)와 일치해야 한다. + // doc_properties.section_count 가 stale(1) 이어도 섹션 수가 우선 — 불일치 시 + // 한글이 뒤 구역을 로드하지 않아 다중 페이지 문서가 1쪽으로 붕괴한다. + use crate::model::document::Section; + let mut doc = Document::default(); + doc.sections = vec![Section::default(), Section::default(), Section::default()]; + doc.doc_properties.section_count = 1; // stale 모사 + let ctx = SerializeContext::collect_from_document(&doc); + let xml = String::from_utf8(write_header(&doc, &ctx).expect("write_header")).unwrap(); + assert!( + xml.contains(r#"secCnt="3""#), + "secCnt 가 섹션 수(3)와 일치해야 함(붕괴 회귀 가드)" + ); + } + #[test] fn write_header_emits_preserved_hwpml_version() { // [Finding 17] 원본 HWPML 버전(문서별 상이, 예: 1.5)을 하드코딩 1.2 로