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
55 changes: 55 additions & 0 deletions mydocs/plans/task_m100_1557.md
Original file line number Diff line number Diff line change
@@ -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개):
`<hc:winBrush>` 22→21, `<hh:tabItem>` 2→1, `<hp:switch>/<hp:case>/<hp:default>` 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 회귀
작성 → 재승인 후 단계별 진행.
55 changes: 55 additions & 0 deletions mydocs/plans/task_m100_1557_impl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Task #1557: HWPX 저장본 한글 페이지 붕괴 해소 — 구현 계획서

> 수행계획서 `task_m100_1557.md`(승인 완료). 본 문서는 단계별 구현 계획(3단계).
> 확정 사실: 원인은 저장 `header.xml`(DocInfo). 누락 태그 — `<hc:winBrush>` 22→21,
> `<hh:tabItem>` 2→1, `<hp:switch>/<hp:case>/<hp:default>` 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)은 본 타스크에서 건드리지 않음(혼합 금지).
- 단계마다 완료보고서 + 소스 동반 커밋, 승인 후 다음 단계(자동승인 시 연속 진행).
46 changes: 46 additions & 0 deletions mydocs/report/task_m100_1557_report.md
Original file line number Diff line number Diff line change
@@ -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
불일치는 코드 가드로 봉인했으나, 일반 한글 페이지 오라클 연동은 후속 개선 후보.
45 changes: 45 additions & 0 deletions mydocs/working/task_m100_1557_stage1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Task #1557 Stage 1 완료보고서 — root-cause element 확정

## 목표
header.xml 내부 bisection 으로 한글 페이지 붕괴 유발 element 확정 + serializer 코드 매핑.

## 결과 — 원인은 `<hh:head secCnt>` 불일치

### 격리 (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` 수정 + 대표 케이스 한글 페이지 복원 검증.
25 changes: 25 additions & 0 deletions mydocs/working/task_m100_1557_stage2.md
Original file line number Diff line number Diff line change
@@ -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 광역 재측정(붕괴율 감소) + 최종 보고.
30 changes: 30 additions & 0 deletions mydocs/working/task_m100_1557_stage3.md
Original file line number Diff line number Diff line change
@@ -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%(다중구역 케이스 전부 복구).
22 changes: 21 additions & 1 deletion src/serializer/hwpx/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ pub fn write_header(doc: &Document, ctx: &SerializeContext) -> Result<Vec<u8>, S
write_xml_decl(&mut w)?;

// <hh:head> 루트 + 전체 네임스페이스 (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(
Expand Down Expand Up @@ -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 로
Expand Down
Loading