diff --git a/mydocs/plans/task_m100_1556.md b/mydocs/plans/task_m100_1556.md new file mode 100644 index 000000000..e3ea969c8 --- /dev/null +++ b/mydocs/plans/task_m100_1556.md @@ -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 <원본> +--- 문단 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 말미에 **고아 ``** 가 존재한다: + ```xml + 붙임 1. 영업장 평면도 등(PDF) 1부. 끝. + + ``` +- 짝이 되는 ``(누름틀)은 + **약 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 를 해당 위치에 `` 로 + 방출하고 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단계, 승인 요청) → 단계별 구현·보고 → 최종 보고서. diff --git a/mydocs/plans/task_m100_1556_impl.md b/mydocs/plans/task_m100_1556_impl.md new file mode 100644 index 000000000..07e1ecee4 --- /dev/null +++ b/mydocs/plans/task_m100_1556_impl.md @@ -0,0 +1,125 @@ +# 구현계획서 — Task #1556 + +다단락 누름틀 필드의 고아 `` 8유닛 슬롯 소실 수정 (HWPX serializer). + +근거: 수행계획서 `task_m100_1556.md` §2 (근본 원인 확정). + +## 설계 요지 +- begin/end 가 다른 문단인 다단락 필드의 **end 문단**에서, 고아 fieldEnd 가 + `\u{0004}`(8유닛)만 차지하고 IR 산출물(Control·FieldRange)이 없어 직렬화기가 소실. +- **해결**: `Paragraph` IR 에 고아 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` 추가 + (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)` 또는 인자 확장). + 원본 `` 속성 정합. + +### 2.3 단위 테스트 (직렬화기) +- `orphan_field_ends` 가진 `Paragraph` → 직렬화 → `` + 존재·위치 검증, 재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 = 본 결함 단일). diff --git a/mydocs/report/task_m100_1556_report.md b/mydocs/report/task_m100_1556_report.md new file mode 100644 index 000000000..68902a32d --- /dev/null +++ b/mydocs/report/task_m100_1556_report.md @@ -0,0 +1,66 @@ +# 최종 결과보고서 — Task #1556 + +## 제목 +[HWPX/직렬화] 컨트롤 슬롯 8유닛 시프트 — 실문서 char_offset 변위 (hwpx-roundtrip IR_DIFF) + +## 1. 결론 +HWPX serializer 의 "8유닛 시프트" 잔여 결함의 **근본 원인은 다단락(문단 경계를 넘는) +필드의 고아 ``** 였다. 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 공통 모듈 무수정. 기능 변경과 포맷 변경 분리. diff --git a/mydocs/working/task_m100_1556_stage1.md b/mydocs/working/task_m100_1556_stage1.md new file mode 100644 index 000000000..d7f9d00f1 --- /dev/null +++ b/mydocs/working/task_m100_1556_stage1.md @@ -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` 필드 추가 (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` 차감 정합. diff --git a/mydocs/working/task_m100_1556_stage2.md b/mydocs/working/task_m100_1556_stage2.md new file mode 100644 index 000000000..c2009d7cb --- /dev/null +++ b/mydocs/working/task_m100_1556_stage2.md @@ -0,0 +1,42 @@ +# 단계 2 완료보고서 — Task #1556 + +## 목표 +직렬화기가 `orphan_field_ends` 를 `` 로 복원(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)` 헬퍼 추가 (``). +- **`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`: 텍스트 뒤 `` 방출·순서 검증. + - `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/` 추가. diff --git a/mydocs/working/task_m100_1556_stage3.md b/mydocs/working/task_m100_1556_stage3.md new file mode 100644 index 000000000..b3af5ffcb --- /dev/null +++ b/mydocs/working/task_m100_1556_stage3.md @@ -0,0 +1,34 @@ +# 단계 3 완료보고서 — Task #1556 + +## 목표 +합성 parse→serialize→parse roundtrip 테스트 + 대표 실문서 회귀 샘플 추가 (둘 다). + +## 변경 사항 + +### 합성 roundtrip 테스트 (`src/serializer/hwpx/section.rs`) +- `task1556_multipara_field_parse_serialize_parse_roundtrip`: + 다단락 필드(begin=문단0, end=문단1) 합성 section → `parse_hwpx_section` → + `write_section` → 재`parse_hwpx_section`. end 문단의 + `text`/`char_count`/`char_offsets`/`char_shapes`/`orphan_field_ends` 전부 보존 검증 + (= IR diff=0). 8유닛 소실 없음. + +### 실문서 회귀 샘플 (`samples/hwpx/`) +- `field-multipara-clickhere.hwpx` 추가 (서울 열린데이터 공개 행정문서, ≈39KB). + - 원본: `…고시원 안전시설…건축법령 확인요청 dt2854.hwpx`. + - 다단락 CLICK_HERE(누름틀 "본문") 필드 — fieldBegin(표 뒤) ~ fieldEnd(말미 "…끝.") + 가 ~18문단 가로지름. 수정 전 `hwpx-roundtrip` diff=1(문단 0.16 cc 38→30). + - 파일 권한 644 정규화. +- `tests/hwpx_roundtrip_baseline.rs` 의 `collect_samples` 가 신규 샘플 자동 포함 + → `baseline_all_samples_roundtrip` 게이트에 봉인. (XFAIL/EXCLUDED 미등록 = 통과 필수.) + +## 검증 +- `cargo test --lib task1556`: **5건 통과** (파서2 + 직렬화3, 합성 roundtrip 포함). +- `rhwp hwpx-roundtrip samples/hwpx/field-multipara-clickhere.hwpx` → **diff=0 PASS**. +- `cargo test --test hwpx_roundtrip_baseline` (신규 샘플 자동 포함) **무회귀 통과**. + +## 회귀 가드 성격 +수정을 되돌리면 `field-multipara-clickhere.hwpx` 의 roundtrip 이 diff=1 로 회귀하여 +`baseline_all_samples_roundtrip` 가 실패 → 결함 재유입을 차단. + +## 다음 단계 +단계 4 — 코퍼스 다건 전수 검증 + 전체 `cargo test` + fmt/clippy + 최종 보고서. diff --git a/samples/hwpx/field-multipara-clickhere.hwpx b/samples/hwpx/field-multipara-clickhere.hwpx new file mode 100644 index 000000000..122865145 Binary files /dev/null and b/samples/hwpx/field-multipara-clickhere.hwpx differ diff --git a/src/model/paragraph.rs b/src/model/paragraph.rs index c427a94e1..cb4f59ab8 100644 --- a/src/model/paragraph.rs +++ b/src/model/paragraph.rs @@ -30,6 +30,8 @@ pub struct Paragraph { pub range_tags: Vec, /// 필드 텍스트 범위 (0x03~0x04 사이 텍스트 인덱스 + 컨트롤 인덱스) pub field_ranges: Vec, + /// 고아 FIELD_END (다단락 필드의 종료 마커 — begin 이 다른 문단). HWPX 전용 (Task #1556). + pub orphan_field_ends: Vec, /// 컨트롤 목록 (표, 그림, 각주 등) pub controls: Vec, /// 각 컨트롤에 대응하는 CTRL_DATA 레코드 (라운드트립 보존용) @@ -253,6 +255,21 @@ pub struct FieldRange { pub control_idx: usize, } +/// 고아 FIELD_END (0x04) — 짝이 되는 FIELD_BEGIN 이 다른 문단에 있는 +/// 다단락 필드의 종료 마커. begin 문단에서 `Control::Field` 로 보존되는 것과 달리, +/// end 문단에는 컨트롤·FieldRange 가 없어 8유닛 슬롯을 표현할 산출물이 없다. +/// 이를 기록해 직렬화기가 `` 를 같은 위치에 복원한다 (Task #1556). +#[derive(Debug, Clone, Default)] +pub struct OrphanFieldEnd { + /// text 문자열 내 위치 (이 인덱스 직전에 8유닛 fieldEnd 슬롯이 놓인다). + /// 텍스트 끝이면 `text.chars().count()`. + pub char_idx: usize, + /// `` — 짝 fieldBegin 의 id 참조. + pub begin_id_ref: u32, + /// `` — 필드 인스턴스 id. + pub field_id: u32, +} + impl Paragraph { pub(crate) fn is_split_movable_control(ctrl: &Control) -> bool { matches!( @@ -830,6 +847,7 @@ impl Paragraph { line_segs: new_line_segs, range_tags: new_range_tags, field_ranges: Vec::new(), // controls가 이동하지 않으므로 새 문단에는 필드 없음 + orphan_field_ends: Vec::new(), char_count: new_char_count, para_shape_id: self.para_shape_id, style_id: self.style_id, diff --git a/src/parser/hwpx/section.rs b/src/parser/hwpx/section.rs index 047dde064..103216403 100644 --- a/src/parser/hwpx/section.rs +++ b/src/parser/hwpx/section.rs @@ -21,7 +21,7 @@ use crate::model::page::{ BindingMethod, ColumnDef, ColumnDirection, ColumnType, PageBorderBasis, PageBorderFill, PageBorderUiBasis, PageDef, }; -use crate::model::paragraph::{CharShapeRef, FieldRange, LineSeg, Paragraph}; +use crate::model::paragraph::{CharShapeRef, FieldRange, LineSeg, OrphanFieldEnd, Paragraph}; use crate::model::shape::{ ArcShape, CommonObjAttr, CurveShape, DrawingObjAttr, EllipseShape, GroupShape, HorzAlign, HorzRelTo, LineShape, PolygonShape, RectangleShape, ShapeComponentAttr, ShapeObject, @@ -396,6 +396,9 @@ fn parse_paragraph( let mut text_parts: Vec = Vec::new(); let mut current_char_shape_id: u32 = 0; let mut char_shape_changes: Vec<(u32, u32)> = Vec::new(); // (utf16_pos, char_shape_id) + // [Task #1556] fieldEnd 의 (beginIDRef, fieldid) 를 출현 순서대로 보관 — text_parts 의 + // `\u{0004}` 와 1:1 대응. 고아 fieldEnd 복원에 사용. + let mut field_end_attrs: Vec<(u32, u32)> = Vec::new(); loop { match reader.read_event_into(&mut buf) { @@ -495,7 +498,13 @@ fn parse_paragraph( para.controls.push(group); } b"ctrl" => { - parse_ctrl(ce, reader, &mut para.controls, &mut text_parts)?; + parse_ctrl( + ce, + reader, + &mut para.controls, + &mut text_parts, + &mut field_end_attrs, + )?; } b"compose" => { // 글자겹침 (CharOverlap) @@ -593,9 +602,11 @@ fn parse_paragraph( // HWPX 파싱 결과를 HWP로 다시 저장할 때 FIELD_END를 복원하려면, visible text // 범위와 해당 Field 컨트롤 index를 field_ranges에 남겨야 한다. let mut field_ranges: Vec = Vec::new(); + let mut orphan_field_ends: Vec = Vec::new(); let mut field_stack: Vec<(usize, usize)> = Vec::new(); let mut control_idx: usize = 0; let mut visible_char_idx: usize = 0; + let mut field_end_idx: usize = 0; for part in &text_parts { match part.as_str() { @@ -606,12 +617,25 @@ fn parse_paragraph( control_idx += 1; } "\u{0004}" => { + let (begin_id_ref, field_id) = field_end_attrs + .get(field_end_idx) + .copied() + .unwrap_or((0, 0)); + field_end_idx += 1; if let Some((start_char_idx, control_idx)) = field_stack.pop() { field_ranges.push(FieldRange { start_char_idx, end_char_idx: visible_char_idx, control_idx, }); + } else { + // [Task #1556] 짝 fieldBegin 이 다른 문단에 있는 다단락 필드의 종료 마커. + // 현 문단에 컨트롤·FieldRange 가 없으므로 위치+attrs 를 기록해 직렬화기가 복원. + orphan_field_ends.push(OrphanFieldEnd { + char_idx: visible_char_idx, + begin_id_ref, + field_id, + }); } } "\u{0002}" => { @@ -627,6 +651,7 @@ fn parse_paragraph( } } para.field_ranges = field_ranges; + para.orphan_field_ends = orphan_field_ends; // 텍스트 조립: 제어 문자(\u{0002}, \u{0003}, \u{0004})는 HWP와 동일하게 텍스트에서 제외 // HWP에서 컨트롤 위치는 char_offsets의 갭으로 표현되므로 원본 순서를 유지해 계산한다. @@ -3851,6 +3876,7 @@ fn parse_ctrl( reader: &mut Reader<&[u8]>, controls: &mut Vec, text_parts: &mut Vec, + field_end_attrs: &mut Vec<(u32, u32)>, ) -> Result<(), HwpxError> { let mut buf = Vec::new(); loop { @@ -3907,6 +3933,8 @@ fn parse_ctrl( text_parts.push("\u{0003}".to_string()); } b"fieldEnd" => { + // [Task #1556] beginIDRef/fieldid 포착 (고아 fieldEnd 복원용). + field_end_attrs.push(parse_field_end_attrs(ce)); skip_element(reader, b"fieldEnd")?; // FIELD_END 제어 문자 추가 (Task #11) text_parts.push("\u{0004}".to_string()); @@ -3988,6 +4016,8 @@ fn parse_ctrl( text_parts.push("\u{0003}".to_string()); } b"fieldEnd" => { + // [Task #1556] 자기닫힘 fieldEnd — beginIDRef/fieldid 포착. + field_end_attrs.push(parse_field_end_attrs(ce)); text_parts.push("\u{0004}".to_string()); } b"hiddenComment" => {} @@ -4016,6 +4046,20 @@ fn parse_bool_attr(attr: &quick_xml::events::attributes::Attribute) -> bool { s == "1" || s == "true" } +/// `` 속성 → (begin_id_ref, field_id) (Task #1556). +fn parse_field_end_attrs(e: &quick_xml::events::BytesStart) -> (u32, u32) { + let mut begin_id_ref = 0u32; + let mut field_id = 0u32; + for attr in e.attributes().flatten() { + match attr.key.as_ref() { + b"beginIDRef" => begin_id_ref = parse_u32(&attr), + b"fieldid" => field_id = parse_u32(&attr), + _ => {} + } + } + (begin_id_ref, field_id) +} + fn parse_page_hiding_attrs(e: &quick_xml::events::BytesStart) -> PageHide { let mut ph = PageHide::default(); for attr in e.attributes().flatten() { @@ -6628,6 +6672,71 @@ mod tests { } } + // ---------- #1556: 다단락 필드의 고아 fieldEnd ---------- + + #[test] + fn task1556_orphan_field_end_recorded_in_end_paragraph() { + // fieldBegin 은 문단 0, fieldEnd 는 문단 1 (다단락 누름틀 필드). + // 문단 1 은 컨트롤·field_range 없이 8유닛 슬롯만 갖는다 → orphan_field_ends 로 기록. + let xml = r#" + + + 본문시작 + + + 끝. + + +"#; + let section = parse_hwpx_section(xml).unwrap(); + // 문단 0: fieldBegin 보존 (Control::Field), 고아 없음. + let p0 = §ion.paragraphs[0]; + assert!( + matches!(p0.controls.first(), Some(Control::Field(_))), + "문단 0 은 fieldBegin 컨트롤 보존" + ); + assert!(p0.orphan_field_ends.is_empty(), "문단 0 고아 없음"); + + // 문단 1: 텍스트 "끝." (2자) + 고아 fieldEnd 8유닛. + let p1 = §ion.paragraphs[1]; + assert_eq!(p1.text, "끝."); + assert_eq!(p1.orphan_field_ends.len(), 1, "고아 fieldEnd 1개 기록"); + let ofe = &p1.orphan_field_ends[0]; + assert_eq!(ofe.char_idx, 2, "텍스트 끝(인덱스 2) 위치"); + assert_eq!(ofe.begin_id_ref, 1_878_228_493); + assert_eq!(ofe.field_id, 627_272_811); + // char_count = 텍스트 2 + fieldEnd 8 + 끝마커 1 = 11. + assert_eq!( + p1.char_count, 11, + "고아 fieldEnd 8유닛이 char_count 에 반영" + ); + // 두 번째 char_shape(run charPrIDRef=30)는 offsets 축 10 (텍스트 2 + 8). + assert_eq!( + p1.char_shapes + .iter() + .map(|c| (c.start_pos, c.char_shape_id)) + .collect::>(), + vec![(0, 3), (10, 30)], + ); + } + + #[test] + fn task1556_same_paragraph_field_uses_range_not_orphan() { + // 동일 문단 내 begin+end 는 종전대로 field_ranges 로만 처리 (고아 0) — 회귀 가드. + let xml = r#" + + + 링크 + +"#; + let section = parse_hwpx_section(xml).unwrap(); + let p = §ion.paragraphs[0]; + assert_eq!(p.field_ranges.len(), 1, "동일 문단 필드는 field_range"); + assert!(p.orphan_field_ends.is_empty(), "고아 기록 없음"); + } + /// #1512: 비-Memo 필드도 고유 OWPML `id` 를 field_id 로 써야 한다. 같은 종류 필드가 /// 공유하는 `fieldid` 를 우선하면 모든 필드가 동일 ID 로 반환된다(누름틀 구분 불가). #[test] diff --git a/src/serializer/hwpx/field.rs b/src/serializer/hwpx/field.rs index 16548f6a1..35c2fff5c 100644 --- a/src/serializer/hwpx/field.rs +++ b/src/serializer/hwpx/field.rs @@ -78,6 +78,25 @@ pub fn write_field_end(w: &mut Writer, field_id: u32) -> Result<(), empty_tag(w, "hp:fieldEnd", &[("beginIDRef", &id_str)]) } +/// `` — beginIDRef 와 fieldid 동시 방출. +/// 다단락 필드의 고아 fieldEnd 복원용 (Task #1556). `field_id == 0` 이면 `fieldid` 생략. +pub fn write_field_end_full( + w: &mut Writer, + begin_id_ref: u32, + field_id: u32, +) -> Result<(), SerializeError> { + let begin_str = begin_id_ref.to_string(); + if field_id == 0 { + return empty_tag(w, "hp:fieldEnd", &[("beginIDRef", &begin_str)]); + } + let field_str = field_id.to_string(); + empty_tag( + w, + "hp:fieldEnd", + &[("beginIDRef", &begin_str), ("fieldid", &field_str)], + ) +} + // ===================================================================== // 하이퍼링크 (필드의 특수형) — 변형 // ===================================================================== diff --git a/src/serializer/hwpx/section.rs b/src/serializer/hwpx/section.rs index 5dc5c4c20..871f9889b 100644 --- a/src/serializer/hwpx/section.rs +++ b/src/serializer/hwpx/section.rs @@ -29,13 +29,13 @@ use crate::model::document::{Document, Section}; use crate::model::footnote::{Endnote, Footnote}; use crate::model::header_footer::{Footer, Header, HeaderFooterApply}; use crate::model::page::{ColumnDef, ColumnDirection, ColumnType}; -use crate::model::paragraph::{ColumnBreakType, LineSeg, Paragraph}; +use crate::model::paragraph::{ColumnBreakType, LineSeg, OrphanFieldEnd, Paragraph}; use crate::model::shape::{ CommonObjAttr, HorzAlign, HorzRelTo, ShapeObject, TextWrap, VertAlign, VertRelTo, }; use super::context::SerializeContext; -use super::field::{write_bookmark, write_field_begin, write_field_end}; +use super::field::{write_bookmark, write_field_begin, write_field_end, write_field_end_full}; use super::utils::xml_escape; use super::SerializeError; use super::{picture, table}; @@ -358,6 +358,15 @@ fn emit_field_end(out: &mut String, para: &Paragraph, control_idx: usize) { } } +/// 고아(다단락) fieldEnd 를 `` 로 방출 (Task #1556). +fn emit_orphan_field_end(out: &mut String, ofe: &OrphanFieldEnd) { + if let Ok(xml) = writer_to_string(|w| write_field_end_full(w, ofe.begin_id_ref, ofe.field_id)) { + out.push_str(""); + out.push_str(&xml); + out.push_str(""); + } +} + /// 문단 텍스트 전체를 char_shapes 경계로 분할하며 `splitter` 에 누적한다. /// /// `char_offsets` 로 문자 idx → UTF-16 위치를 매핑하므로 IR 내 컨트롤(8 유닛 갭)이 @@ -430,8 +439,12 @@ fn render_runs(para: &Paragraph, ctx: &mut SerializeContext) -> String { let mut tab_idx = 0usize; - // fast path: 슬롯·필드·경계 없음 — 텍스트 전체를 단일 run 으로 - if slots.is_empty() && para.field_ranges.is_empty() && splitter.single_run() { + // fast path: 슬롯·필드·고아 fieldEnd·경계 없음 — 텍스트 전체를 단일 run 으로 + if slots.is_empty() + && para.field_ranges.is_empty() + && para.orphan_field_ends.is_empty() + && splitter.single_run() + { let t = render_hp_t_content(¶.text, ¶.tab_extended, &mut tab_idx); splitter.content.push_str(&t); return splitter.finish(); @@ -443,6 +456,11 @@ fn render_runs(para: &Paragraph, ctx: &mut SerializeContext) -> String { for slot in &slots { render_control_slot(&mut splitter.content, slot, ctx); } + // [Task #1556] 위치 추정 불가 경로에서도 고아 fieldEnd 의 8유닛 슬롯은 복원한다 + // (정확한 위치 대신 말미 일괄 — 최소한 char_count 보존). + for ofe in ¶.orphan_field_ends { + emit_orphan_field_end(&mut splitter.content, ofe); + } return splitter.finish(); } @@ -451,6 +469,9 @@ fn render_runs(para: &Paragraph, ctx: &mut SerializeContext) -> String { let mut slot_idx = 0usize; let mut expected_utf16_pos = 0u32; let mut field_end_emitted = vec![false; para.field_ranges.len()]; + // [Task #1556] 고아 fieldEnd 방출 추적. + let mut orphan_emitted = vec![false; para.orphan_field_ends.len()]; + let text_char_count = para.text.chars().count(); // 빈 문단(text == "")의 0-length 필드: 메인 루프가 실행되지 않아 // pre-char 검사를 통과하지 못하므로 루프 전에 slots → fieldEnd 순으로 방출한다. @@ -469,6 +490,15 @@ fn render_runs(para: &Paragraph, ctx: &mut SerializeContext) -> String { field_end_emitted[i] = true; } } + // [Task #1556] 빈 문단의 고아 fieldEnd (char_idx == 0). + for (i, ofe) in para.orphan_field_ends.iter().enumerate() { + if ofe.char_idx == 0 && !orphan_emitted[i] { + splitter.cut_before(expected_utf16_pos); + emit_orphan_field_end(&mut splitter.content, ofe); + expected_utf16_pos = expected_utf16_pos.saturating_add(8); + orphan_emitted[i] = true; + } + } } for (idx, c) in para.text.chars().enumerate() { @@ -494,6 +524,22 @@ fn render_runs(para: &Paragraph, ctx: &mut SerializeContext) -> String { expected_utf16_pos = expected_utf16_pos.saturating_add(8); } + // [Task #1556] 고아 fieldEnd (char_idx == idx): 문자 push 전에 8유닛 슬롯 방출. + for (i, ofe) in para.orphan_field_ends.iter().enumerate() { + if ofe.char_idx == idx && !orphan_emitted[i] { + flush_text_fragment( + &mut splitter.content, + &mut text_buf, + ¶.tab_extended, + &mut tab_idx, + ); + splitter.cut_before(expected_utf16_pos); + emit_orphan_field_end(&mut splitter.content, ofe); + expected_utf16_pos = expected_utf16_pos.saturating_add(8); + orphan_emitted[i] = true; + } + } + // 0-length 필드(start == end == idx): fieldBegin 방출 직후, 문자 push 전에 fieldEnd 방출. // post-char 검사(next_idx 기준)는 end-1 번째 문자 처리 후 방출하므로 0-length 필드에서 // fieldEnd가 fieldBegin 앞에 나오거나 텍스트 뒤로 밀리는 문제가 생긴다. @@ -606,6 +652,20 @@ fn render_runs(para: &Paragraph, ctx: &mut SerializeContext) -> String { } } + // [Task #1556] 텍스트 끝(char_idx == text_char_count) 의 고아 fieldEnd — para 0.16 케이스. + for (i, ofe) in para.orphan_field_ends.iter().enumerate() { + if !orphan_emitted[i] { + debug_assert!( + ofe.char_idx >= text_char_count, + "미방출 고아 fieldEnd 는 텍스트 끝이어야 함" + ); + splitter.cut_before(expected_utf16_pos); + emit_orphan_field_end(&mut splitter.content, ofe); + expected_utf16_pos = expected_utf16_pos.saturating_add(8); + orphan_emitted[i] = true; + } + } + while slot_idx < slots.len() { splitter.cut_before(expected_utf16_pos); render_control_slot(&mut splitter.content, slots[slot_idx], ctx); @@ -649,9 +709,11 @@ fn inferred_control_slot_count(para: &Paragraph) -> usize { // fieldEnd는 8 code unit 슬롯이지만 para.controls[]에 대응 컨트롤이 없다. // field_ranges.len()이 fieldEnd 수와 정확히 일치하므로 빼서 보정한다. + // [Task #1556] 고아(다단락) fieldEnd 도 컨트롤 없는 8유닛 슬롯이므로 동일하게 차감. from_char_count .max(from_offsets) - .saturating_sub(para.field_ranges.len() as u32) as usize + .saturating_sub(para.field_ranges.len() as u32) + .saturating_sub(para.orphan_field_ends.len() as u32) as usize } pub(crate) fn is_hwpx_inline_slot(control: &Control) -> bool { @@ -2065,6 +2127,113 @@ mod tests { ); } + // ---------- #1556: 고아(다단락) fieldEnd 방출 ---------- + + #[test] + fn task1556_orphan_field_end_emitted_at_text_end() { + // 다단락 필드의 end 문단(para 0.16 동형): 텍스트 "끝." 뒤에 고아 fieldEnd 8유닛. + use crate::model::paragraph::{CharShapeRef, OrphanFieldEnd}; + let mut para = Paragraph::default(); + para.text = "끝.".to_string(); + para.char_offsets = vec![0, 1]; + para.char_count = 11; // 텍스트 2 + fieldEnd 8 + 끝마커 1 + para.char_shapes = vec![ + CharShapeRef { + start_pos: 0, + char_shape_id: 3, + }, + CharShapeRef { + start_pos: 10, + char_shape_id: 30, + }, + ]; + para.orphan_field_ends = vec![OrphanFieldEnd { + char_idx: 2, + begin_id_ref: 1_878_228_493, + field_id: 627_272_811, + }]; + let (doc, section) = make_doc_with_paragraph(para); + let mut ctx = SerializeContext::collect_from_document(&doc); + let xml = String::from_utf8(write_section(§ion, &doc, 0, &mut ctx).unwrap()).unwrap(); + assert!( + xml.contains(r#""#), + "고아 fieldEnd 가 attrs 와 함께 방출되어야 함: {xml}" + ); + // 텍스트가 fieldEnd 보다 앞에 나온다 (run 말미 fieldEnd 패턴). + let t_pos = xml.find("끝.").expect("텍스트"); + let fe_pos = xml.find(" + + + 본문 + + + 끝. + + +"#; + let sec1 = parse_hwpx_section(xml).unwrap(); + let mut doc = Document::default(); + doc.sections.push(sec1.clone()); + let mut ctx = SerializeContext::collect_from_document(&doc); + let bytes = write_section(&sec1, &doc, 0, &mut ctx).unwrap(); + let xml2 = String::from_utf8(bytes).unwrap(); + let sec2 = parse_hwpx_section(&xml2).unwrap(); + + // 두 번째 문단(고아 fieldEnd 보유) IR 보존. + let a = &sec1.paragraphs[1]; + let b = &sec2.paragraphs[1]; + assert_eq!(b.text, a.text, "text 보존"); + assert_eq!( + b.char_count, a.char_count, + "char_count 보존 (8유닛 소실 없음)" + ); + assert_eq!(b.char_offsets, a.char_offsets, "char_offsets 보존"); + assert_eq!( + b.char_shapes + .iter() + .map(|c| (c.start_pos, c.char_shape_id)) + .collect::>(), + a.char_shapes + .iter() + .map(|c| (c.start_pos, c.char_shape_id)) + .collect::>(), + "char_shape 경계 보존" + ); + assert_eq!(b.orphan_field_ends.len(), 1, "고아 fieldEnd 재파싱 보존"); + assert_eq!(b.orphan_field_ends[0].begin_id_ref, 1_878_228_493); + } + + #[test] + fn task1556_orphan_field_end_zero_fieldid_omits_attr() { + use crate::model::paragraph::OrphanFieldEnd; + let mut para = Paragraph::default(); + para.text = "a".to_string(); + para.char_offsets = vec![0]; + para.char_count = 10; // 1 + 8 + 1 + para.orphan_field_ends = vec![OrphanFieldEnd { + char_idx: 1, + begin_id_ref: 42, + field_id: 0, + }]; + let (doc, section) = make_doc_with_paragraph(para); + let mut ctx = SerializeContext::collect_from_document(&doc); + let xml = String::from_utf8(write_section(§ion, &doc, 0, &mut ctx).unwrap()).unwrap(); + assert!( + xml.contains(r#""#), + "field_id 0 이면 fieldid 속성 생략: {xml}" + ); + } + // ---------- #1289: Bookmark / Field dispatcher 연결 ---------- use crate::model::control::{Bookmark, Control, Field, FieldType};