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
79 changes: 79 additions & 0 deletions mydocs/plans/task_m100_1556.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 수행계획서 — Task #1556

## 제목
[HWPX/직렬화] 컨트롤 슬롯 8유닛 시프트 — 실문서 char_offset 변위 (hwpx-roundtrip IR_DIFF)

## 1. 배경 및 이슈 요약
Task #1552 무손실 검증(F3)에서, HWPX serializer 가 일부 실문서의 슬롯을 직렬화할 때
char_offset/char_shape 위치를 **8유닛(=1글자 슬롯)** 시프트시키는 잔여 결함이 보고됐다.
`samples/hwpx/` 큐레이션 샘플엔 없고 서울 열린데이터 실문서에서 9/25 노출.

## 2. 근본 원인 (조사 완료 — 코드 무수정)

### 2.1 재현
- 코퍼스: `/mnt/samba/rnd/Products/Ci-Search/GOVOpenDocs/Seoul/2023-01-05/` (서울 열린데이터)
- 2023-01-05 폴더 60건 중 24건이 `hwpx-roundtrip` IR_DIFF(diff=1) 재현.
- 대표: `고시원 안전시설 등 설치신고서 접수에 따른 건축법령 확인요청 dt2854.hwpx`

```
rhwp ir-diff <원본> <rt>
--- 문단 0.16 --- "붙임 1. 영업장 평면도 등(PDF) 1부. 끝."
[차이] cc: A=38 vs B=30 (8 감소)
[차이] cs[1].pos: A=37 vs B=29 (8 감소)
```

### 2.2 원인
- 문단 0.16 의 원본 XML 말미에 **고아 `<hp:fieldEnd>`** 가 존재한다:
```xml
<hp:t>붙임 1. 영업장 평면도 등(PDF) 1부. 끝.</hp:t>
<hp:ctrl><hp:fieldEnd beginIDRef="1878228493" fieldid="627272811"/></hp:ctrl>
```
- 짝이 되는 `<hp:fieldBegin id="1878228493" type="CLICK_HERE" name="본문">`(누름틀)은
**약 18개 문단 앞**(표 뒤)에 있다 → **문단 경계를 넘는 다단락 필드**.
- 파서(`src/parser/hwpx/section.rs`)의 `field_stack` 은 **문단 단위(per-paragraph)** 로
리셋되므로, begin 과 end 가 다른 문단이면 짝지을 수 없다:
- **begin 문단**: `Control::Field` + `\u{0003}`(8유닛) 보존 → fieldBegin 정상 round-trip.
- **end 문단(0.16)**: 고아 fieldEnd → `\u{0004}`(8유닛)만 차지, `field_stack` 이 비어
`FieldRange` 미생성·`Control` 미추가 → **IR 에 이 8유닛 슬롯을 표현할 아무 산출물이 없다.**
- 직렬화기(`src/serializer/hwpx/section.rs::render_runs`)는 `controls` + `field_ranges`
로부터 슬롯을 복원하는데, 둘 다 비어 있어 fieldEnd 를 방출하지 못함 → 8유닛 소실.

### 2.3 검증된 사실
- `rhwp dump -s 0 -p 16` → `controls=0`, `cc=38, text_len=29` (29 + 8 + 1 = 38). 컨트롤이 아닌
**고아 fieldEnd 의 8유닛 갭**임을 확정.
- 표본 dt2906/dt2952/dt3004 등도 동일 패턴(문서 전체에서 begin/end id 균형, 문단만 분리).
- **ir-diff 가 `controls` 차이를 보고하지 않는 점**과 정합: 컨트롤은 보존되고 char 폭만 시프트.

## 3. 해결 방향 (구현계획서에서 상세화)
1. **IR**: `Paragraph` 에 고아(다단락) fieldEnd 를 기록할 경량 필드 추가
(위치=visible char idx, `beginIDRef`, `fieldid`).
2. **파서**: `field_stack` 이 빈 상태의 fieldEnd 를 폐기하지 않고 위 필드에 기록
(현재 `skip_element` 로 버리는 `beginIDRef`/`fieldid` 속성 포착).
3. **직렬화기**: `render_runs` 에서 기록된 고아 fieldEnd 를 해당 위치에 `<hp:fieldEnd>` 로
방출하고 8유닛 슬롯을 소비(기존 `field_ranges` fieldEnd 경로와 동형).
- `write_field_end` 가 `beginIDRef` 만 받으므로 `fieldid` 동시 방출 필요 시 확장.

> 대안(문서 단위 field_stack)은 `FieldRange` 가 단일 문단 내 start/end 를 가정하므로
> 다단락을 표현할 수 없어 채택하지 않는다. 고아 fieldEnd 기록이 최소 변경.

## 4. 영향 범위
- `src/model/paragraph.rs` (IR 필드 1개 추가)
- `src/parser/hwpx/section.rs` (고아 fieldEnd 기록)
- `src/serializer/hwpx/section.rs` (고아 fieldEnd 방출)
- `src/serializer/hwpx/field.rs` (필요 시 fieldEnd 속성 확장)
- HWP3/HWP5 경로 무관 (HWPX 전용). HWP5 측 #1554/#1555 와 분리.

## 5. 수용 기준 (이슈 정의)
1. 영향 실문서들에서 `hwpx-roundtrip` IR diff=0.
2. 회귀 가드: 대표 실문서를 `samples/hwpx/` 추가 또는
`tests/hwpx_roundtrip_baseline.rs` 게이트로 봉인.
3. `samples/hwpx/` 전수 회귀(`cargo test --test hwpx_roundtrip_baseline`) 무회귀.
4. 전체 `cargo test` 무회귀.

## 6. 미결정 사항 (승인 요청 시 확인)
- 회귀 가드 방식: (a) 대표 실문서를 `samples/hwpx/` 에 추가 vs (b) 합성 최소 HWPX 단위
테스트. 실문서는 서울 열린데이터(공개)이나 파일 추가 정책 확인 필요.

## 7. 절차
이슈(#1556) → 브랜치(`local/task1556`, 완료) → 본 수행계획서(승인 요청) →
구현계획서(3~6단계, 승인 요청) → 단계별 구현·보고 → 최종 보고서.
125 changes: 125 additions & 0 deletions mydocs/plans/task_m100_1556_impl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 구현계획서 — Task #1556

다단락 누름틀 필드의 고아 `<hp:fieldEnd>` 8유닛 슬롯 소실 수정 (HWPX serializer).

근거: 수행계획서 `task_m100_1556.md` §2 (근본 원인 확정).

## 설계 요지
- begin/end 가 다른 문단인 다단락 필드의 **end 문단**에서, 고아 fieldEnd 가
`\u{0004}`(8유닛)만 차지하고 IR 산출물(Control·FieldRange)이 없어 직렬화기가 소실.
- **해결**: `Paragraph` IR 에 고아 fieldEnd 를 기록 → 파서가 폐기 대신 기록 →
직렬화기가 해당 위치에 `<hp:fieldEnd>` 방출(8유닛 소비).

## 핵심 정합 포인트 (직렬화기 슬롯 카운팅)
`render_runs` 의 `inferred_control_slot_count(para)` 는 char_offset 갭에서 추정한
슬롯 수에서 `field_ranges.len()`(컨트롤 없는 fieldEnd 슬롯)을 차감한다. 고아 fieldEnd
도 **컨트롤 없는 8유닛 슬롯**이므로 동일하게 `orphan_field_ends.len()` 을 차감해야
`slot_count == slots.len()` 메인 경로로 진입한다. (미차감 시 mismatch 경로로 빠져
현 버그가 유지됨 — para 0.16 은 현재 from_offsets=1, slots=0 → mismatch.)

---

## 단계 1 — IR 모델 + 파서 기록

### 1.1 `src/model/paragraph.rs`
- 신규 구조체:
```rust
#[derive(Debug, Default, Clone)]
pub struct OrphanFieldEnd {
/// text 문자열 내 위치 (이 인덱스 직전에 8유닛 fieldEnd 슬롯이 놓임)
pub char_idx: usize,
pub begin_id_ref: u32,
pub field_id: u32,
}
```
- `Paragraph` 에 `pub orphan_field_ends: Vec<OrphanFieldEnd>` 추가
(Default 파생으로 빈 벡터 기본값 — 기존 `..Default::default()` 생성부 무영향).

### 1.2 `src/parser/hwpx/section.rs`
- `parse_ctrl` 의 `b"fieldEnd"` 분기: 현재 `skip_element` 으로 버리는
`beginIDRef`/`fieldid` 속성을 포착해 발생 순서대로 `field_end_attrs: Vec<(u32,u32)>`
에 push (parse_ctrl 시그니처에 파라미터 1개 추가, 호출부 1곳 갱신).
- `visible_char_idx` 루프(현 600~628행)에서 `"\u{0004}"` 처리 시:
- fieldEnd 카운터로 `field_end_attrs` 인덱싱.
- `field_stack.pop()` 이 `Some` 이면 기존대로 `FieldRange` 생성(동일 문단 필드).
- `None`(고아)이면 `OrphanFieldEnd { char_idx: visible_char_idx, begin_id_ref, field_id }`
를 `para.orphan_field_ends` 에 push.
- `\u{0004}` 의 8유닛 가산(visual_text/char_offsets 조립 루프, 현 639행)은 불변.

### 1.3 단위 테스트 (파서)
- begin 문단 + end 문단 분리된 합성 section XML → end 문단의
`orphan_field_ends` 위치·attrs·`char_count`(텍스트+8) 검증.
- 동일 문단 begin+end 는 종전대로 `field_ranges` 로만 처리(고아 0) 회귀 가드.

**커밋**: `Task #1556 Stage1: 고아 fieldEnd IR 기록 (파서)` + `_stage1.md`

---

## 단계 2 — 직렬화기 방출

### 2.1 `src/serializer/hwpx/section.rs`
- `inferred_control_slot_count`: 반환식에서 `field_ranges.len()` 과 함께
`orphan_field_ends.len()` 도 `saturating_sub`.
- `render_runs`:
- fast-path 조건에 `&& para.orphan_field_ends.is_empty()` 추가.
- 메인 루프에서 기존 fieldEnd(field_ranges) 방출과 동형으로, 각 문자 idx 에서
`orphan_field_ends` 중 `char_idx == idx`(pre-char) 항목을 8유닛 슬롯으로 방출.
- text 끝(루프 후) 위치의 고아(`char_idx == text.chars().count()`)는 post-loop
블록(현 601~607행 인근)에서 방출. ← para 0.16 케이스.
- 방출 순서: 동일 위치에서 슬롯/fieldEnd 와의 순서는 원본 XML(run 말미 fieldEnd)
재현 기준으로 텍스트 직후·다음 run 경계 앞.

### 2.2 `src/serializer/hwpx/field.rs`
- `write_field_end` 가 `beginIDRef` 만 방출 → `fieldid` 동반 방출 변형 추가
(`write_field_end_full(w, begin_id_ref, field_id)` 또는 인자 확장).
원본 `<hp:fieldEnd beginIDRef=".." fieldid="..">` 속성 정합.

### 2.3 단위 테스트 (직렬화기)
- `orphan_field_ends` 가진 `Paragraph` → 직렬화 → `<hp:fieldEnd beginIDRef=..>`
존재·위치 검증, 재parse char_count 정합(IR diff=0).

**커밋**: `Task #1556 Stage2: 고아 fieldEnd 직렬화 방출` + `_stage2.md`

---

## 단계 3 — 합성 roundtrip + 실문서 회귀 가드

### 3.1 합성 회귀 (둘 다 — 합성)
- 단계 1·2 단위 테스트로 parser/serializer 양측 합성 커버.
- 추가: 다단락 fieldEnd 최소 HWPX 합성 → parse→serialize→parse IR diff=0 테스트
(`src/serializer/hwpx/roundtrip.rs` 또는 section 단위 테스트).

### 3.2 실문서 샘플 추가 (둘 다 — 실문서)
- 대표 실문서 `dt2854`(≈39KB, diff=1 단일 결함)를 `samples/hwpx/` 에 추가.
파일명: 영문 식별자 부여(예: `field-multipara-clickhere.hwpx`) — 한글 장문 회피,
의미 명시. (서울 열린데이터 = 공개 행정문서.)
- `cargo test --test hwpx_roundtrip_baseline` 자동 포함 → 수정 후 diff=0 통과 확인.

**커밋**: `Task #1556 Stage3: 합성 roundtrip + 실문서 회귀 샘플` + `_stage3.md`

---

## 단계 4 — 전수 검증 + 최종 보고

### 4.1 검증
- `rhwp ir-diff` 로 코퍼스 reproducer 다건(dt2854/dt2906/dt2952/dt3004/…) diff=0 확인.
- 2023-01-05 폴더 재스캔: IR_DIFF 24건 → 0건(또는 잔여 패턴 분리 보고).
- `cargo test --test hwpx_roundtrip_baseline` (전수, 신규 샘플 포함) 무회귀.
- 전체 `cargo test` 무회귀 (레이아웃/미주 회귀 가드 — `feedback_full_cargo_test_before_pr`).
- 수정 파일 한정 `cargo fmt`·`cargo clippy` 점검.

### 4.2 보고
- `task_m100_1556_report.md` 작성 (결과·검증 수치·잔여사항).

**커밋**: `Task #1556 Stage4: 전수 검증 + 최종 보고서` + `_report.md`

---

## 리스크 / 주의
- **slot 카운팅 정합**: §핵심 정합 포인트의 `orphan_field_ends.len()` 차감 누락 시
mismatch 경로 유지로 무효과 — 단계 2 필수 검증 항목.
- **begin 문단 무영향**: begin 문단은 `Control::Field` 보존 경로 불변(고아 기록은 end 문단
한정). 스퓨리어스 fieldEnd 방출 없음 — ir-diff 로 확인.
- **HWP5/HWP3 무관**: HWPX 전용. `OrphanFieldEnd` 는 HWPX 파서만 채움(다른 파서는 빈 채로).
- 신규 샘플이 이 결함 외 다른 roundtrip 결함을 보유하지 않음을 단계 3 에서 확인
(dt2854 는 현재 diff=1 = 본 결함 단일).
66 changes: 66 additions & 0 deletions mydocs/report/task_m100_1556_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 최종 결과보고서 — Task #1556

## 제목
[HWPX/직렬화] 컨트롤 슬롯 8유닛 시프트 — 실문서 char_offset 변위 (hwpx-roundtrip IR_DIFF)

## 1. 결론
HWPX serializer 의 "8유닛 시프트" 잔여 결함의 **근본 원인은 다단락(문단 경계를 넘는)
필드의 고아 `<hp:fieldEnd>`** 였다. begin/end 가 다른 문단인 누름틀(CLICK_HERE) 필드에서,
종료 마커 문단이 8유닛 슬롯을 IR 로 표현하지 못해 직렬화 시 소실됐다. 고아 fieldEnd 를
IR(`Paragraph.orphan_field_ends`)에 기록·복원하여 해소했다.

## 2. 근본 원인
- 파서(`src/parser/hwpx/section.rs`)의 `field_stack` 은 **문단 단위**로 동작한다.
begin·end 가 같은 문단이면 `FieldRange` 로 연결되지만, 다단락 필드는 연결되지 않는다.
- **end 문단**: 고아 fieldEnd → `\u{0004}`(8유닛)만 차지하고 `Control`·`FieldRange`
둘 다 미생성 → IR 에 8유닛 슬롯을 표현할 산출물이 전무.
- 직렬화기는 `controls` + `field_ranges` 로부터 슬롯을 복원하므로 fieldEnd 를 방출하지
못함 → char_count/char_offset 이 8 감소.
- 대표 증거(`…dt2854`): 문단 0.16 `cc 38→30`, `cs[1].pos 37→29` (정확히 −8),
`controls=0` (= 컨트롤 아닌 fieldEnd 슬롯), `ir-diff` 가 controls 차이 미보고와 정합.

## 3. 해결
1. **IR** (`src/model/paragraph.rs`): `OrphanFieldEnd { char_idx, begin_id_ref, field_id }`
+ `Paragraph.orphan_field_ends`.
2. **파서** (`src/parser/hwpx/section.rs`): fieldEnd 의 `beginIDRef`/`fieldid` 포착,
`field_stack` 이 빈 fieldEnd 를 고아로 기록(`parse_field_end_attrs` 헬퍼,
`parse_ctrl` 시그니처 확장).
3. **직렬화기** (`src/serializer/hwpx/section.rs`, `field.rs`):
`write_field_end_full`(beginIDRef+fieldid) / `emit_orphan_field_end` 추가,
메인 루프·post-loop·빈 문단·mismatch 경로에서 고아 fieldEnd 방출.
**핵심**: `inferred_control_slot_count` 에서 `orphan_field_ends.len()` 도 차감해
슬롯 추정을 정합시켜 메인 경로로 진입(누락 시 무효과).

## 4. 검증 (수용 기준 대비)

| 수용 기준 | 결과 |
|----------|------|
| 영향 실문서 `hwpx-roundtrip` IR diff=0 | ✅ 대표 4건(dt2854/2906/2952/3004) diff=1→0 |
| 회귀 가드(샘플/게이트) | ✅ 합성 roundtrip 테스트 + 실문서 `samples/hwpx/field-multipara-clickhere.hwpx` |
| `samples/hwpx/` 전수 회귀 | ✅ `hwpx_roundtrip_baseline` 무회귀 |
| 전체 `cargo test` | ✅ 무회귀 (exit 0) |

- **코퍼스 전수**: 2023-01-05 폴더 60건 `IR_DIFF 24 → 1`. 다른 날짜 폴더 표본에서도
잔여 IR_DIFF 는 전부 동일 패턴.
- **잔여 IR_DIFF (범위 밖)**: `Picture 직렬화 실패: BinDataContent 누락` (그림 BinData
드롭). `ir-diff` 텍스트/오프셋 비교는 0건. 이는 **#1552 F1(HWP5/그림 BinData) 계열의
별개 결함**으로, 본 이슈(HWPX 컨트롤 슬롯 8유닛 시프트)와 무관 → 후속 분리.
- 단위 테스트 5건(`cargo test --lib task1556`): 파서 2 + 직렬화 3 (합성 roundtrip 포함).
- fmt(변경 파일 한정) clean, clippy(lib) 무경고.

## 5. 변경 파일
- `src/model/paragraph.rs` — `OrphanFieldEnd` + 필드.
- `src/parser/hwpx/section.rs` — 고아 fieldEnd 기록.
- `src/serializer/hwpx/section.rs` — 고아 fieldEnd 방출 + 슬롯 카운팅 정합.
- `src/serializer/hwpx/field.rs` — `write_field_end_full`.
- `samples/hwpx/field-multipara-clickhere.hwpx` — 회귀 샘플 (신규).

## 6. 분리/후속
- HWP5 F1(#1554)·F2'(#1555) 와 무관(본 건 HWPX serializer 전용).
- 코퍼스 잔여 그림 BinData 드롭(`Picture 직렬화 실패`)은 별도 이슈 권고.
- (편집 경로) 문단 분할 시 `orphan_field_ends` 는 `field_ranges` 와 동일하게 비움 —
roundtrip 보존 범위 밖, 필요 시 후속.

## 7. 범위 준수
하이퍼-워터폴 절차(이슈→브랜치→수행계획서→구현계획서→단계별 구현·보고→최종 보고서)
준수. HWP3/HWP5 공통 모듈 무수정. 기능 변경과 포맷 변경 분리.
36 changes: 36 additions & 0 deletions mydocs/working/task_m100_1556_stage1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 단계 1 완료보고서 — Task #1556

## 목표
고아(다단락) fieldEnd 를 IR 에 기록하도록 모델·파서 확장.

## 변경 사항

### `src/model/paragraph.rs`
- `OrphanFieldEnd { char_idx, begin_id_ref, field_id }` 구조체 추가.
- `Paragraph.orphan_field_ends: Vec<OrphanFieldEnd>` 필드 추가 (Default 빈 벡터).
- 문단 분할(`split_off` 류) 생성부 literal 에 `orphan_field_ends: Vec::new()` 보강.

### `src/parser/hwpx/section.rs`
- `parse_field_end_attrs(ce) -> (u32, u32)` 헬퍼 추가 — `beginIDRef`/`fieldid` 포착.
- `parse_ctrl` 시그니처에 `field_end_attrs: &mut Vec<(u32,u32)>` 추가.
- Start/Empty 두 fieldEnd 분기 모두 attrs 를 출현 순서대로 push
(종전 `skip_element` 으로 폐기하던 속성 보존).
- `parse_paragraph`: `field_end_attrs` 선언 + 호출부 전달.
- `visible_char_idx` 루프: `\u{0004}` 처리 시 `field_stack.pop()` 이
- `Some` → 종전대로 `FieldRange` (동일 문단 필드),
- `None` → `OrphanFieldEnd { char_idx: visible_char_idx, begin_id_ref, field_id }` 기록.
- `para.orphan_field_ends` 대입.

## 검증
신규 단위 테스트 2건 (`cargo test --lib task1556`) — 통과:
- `task1556_orphan_field_end_recorded_in_end_paragraph`:
다단락 필드 → 문단0 fieldBegin 보존·고아 0, 문단1 고아 1건
(`char_idx=2, begin_id_ref=1878228493, field_id=627272811`),
`char_count=11`(텍스트2+8+끝1), 두번째 char_shape 경계 offsets 축 10.
- `task1556_same_paragraph_field_uses_range_not_orphan`:
동일 문단 begin+end → `field_ranges` 1, 고아 0 (회귀 가드).

`cargo build` 클린. 직렬화기 미수정 단계이므로 roundtrip 효과는 단계 2 이후.

## 다음 단계
단계 2 — 직렬화기에서 `orphan_field_ends` 방출 + `inferred_control_slot_count` 차감 정합.
42 changes: 42 additions & 0 deletions mydocs/working/task_m100_1556_stage2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 단계 2 완료보고서 — Task #1556

## 목표
직렬화기가 `orphan_field_ends` 를 `<hp:fieldEnd>` 로 복원(8유닛 슬롯 소비)하도록 확장.

## 변경 사항

### `src/serializer/hwpx/field.rs`
- `write_field_end_full(w, begin_id_ref, field_id)` 추가 — `beginIDRef`+`fieldid` 동시 방출
(`field_id == 0` 이면 `fieldid` 생략).

### `src/serializer/hwpx/section.rs`
- `emit_orphan_field_end(out, ofe)` 헬퍼 추가 (`<hp:ctrl><hp:fieldEnd .../></hp:ctrl>`).
- **`inferred_control_slot_count`**: 반환식에 `orphan_field_ends.len()` `saturating_sub`
추가 → 고아 fieldEnd(컨트롤 없는 8유닛 슬롯)를 슬롯 추정에서 제외, 메인 경로 진입.
(핵심 정합 포인트 — 누락 시 mismatch 경로로 빠져 무효과.)
- **fast-path** 조건에 `&& para.orphan_field_ends.is_empty()` 추가.
- **메인 루프**:
- pre-char: `char_idx == idx` 고아를 문자 push 전 방출(+8).
- post-loop: `char_idx == text_char_count`(텍스트 끝) 고아 방출 — para 0.16 케이스.
- 빈 문단(text=="") 의 `char_idx == 0` 고아도 별도 처리.
- **mismatch 경로**: 위치 추정 불가 시에도 말미 일괄 방출로 char_count 보존(안전망).

## 검증

### 단위 테스트 (`cargo test --lib task1556`, 4건 통과)
- (Stage1) 파서 2건 + (Stage2) 직렬화 2건:
- `task1556_orphan_field_end_emitted_at_text_end`: 텍스트 뒤 `<hp:fieldEnd
beginIDRef="1878228493" fieldid="627272811"/>` 방출·순서 검증.
- `task1556_orphan_field_end_zero_fieldid_omits_attr`: `field_id=0` → `fieldid` 생략.

### 실문서 roundtrip (`rhwp hwpx-roundtrip`)
- 대표 4건(dt2854/dt2906/dt2952/dt3004) **diff=0 PASS** (수정 전 diff=1).
- 2023-01-05 폴더 60건 재스캔: **IR_DIFF 24 → 1**.
- 잔여 1건(dt3001)은 `ir-diff` 0건 / roundtrip diff=1 = **그림 BinData 누락**
(`Picture 직렬화 실패: BinDataContent 누락`) — #1552 F1 계열 별개 결함, #1556 범위 밖.

### 회귀 가드
- `cargo test --test hwpx_roundtrip_baseline` (큐레이션 전수) **무회귀 통과**.

## 다음 단계
단계 3 — 합성 parse→serialize→parse roundtrip 테스트 + 실문서 `samples/hwpx/` 추가.
Loading
Loading