From 8d152dbb7e12bff50fb953d14325442c894e0a02 Mon Sep 17 00:00:00 2001 From: Jaeook Ryu Date: Fri, 26 Jun 2026 15:42:04 +0900 Subject: [PATCH] =?UTF-8?q?Task=20#1556:=20=EB=8B=A4=EB=8B=A8=EB=9D=BD=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=ED=8B=80=20=ED=95=84=EB=93=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=95=84=20fieldEnd=208=EC=9C=A0=EB=8B=9B=20=EC=8B=9C=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95=20(HWPX=20serializer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다단락(문단 경계를 넘는) CLICK_HERE(누름틀) 필드에서, 종료 마커가 있는 문단의 고아 `` 가 8유닛 슬롯을 IR 로 표현하지 못해 직렬화 시 소실되어 char_offset/char_count 가 8 시프트되는 결함을 수정. - 원인: 파서의 field_stack 이 문단 단위라 begin·end 가 다른 문단인 필드는 연결되지 않음. end 문단은 Control·FieldRange 둘 다 없어 8유닛 슬롯 산출물 부재. - 해결: Paragraph.orphan_field_ends 로 고아 fieldEnd(위치+beginIDRef+fieldid) 기록, 직렬화기가 같은 위치에 fieldEnd 복원. inferred_control_slot_count 에서 orphan_field_ends.len() 차감으로 슬롯 추정 정합. 검증: 대표 실문서 diff=1→0, 2023-01-05 코퍼스 IR_DIFF 24→1(잔여 1건은 별개 그림 BinData 결함), 단위 테스트 5건, hwpx_roundtrip_baseline 무회귀, 전체 cargo test 무회귀. 회귀 가드: samples/hwpx/field-multipara-clickhere.hwpx + 합성 roundtrip 테스트. --- mydocs/plans/task_m100_1556.md | 79 +++++++++ mydocs/plans/task_m100_1556_impl.md | 125 ++++++++++++++ mydocs/report/task_m100_1556_report.md | 66 ++++++++ mydocs/working/task_m100_1556_stage1.md | 36 ++++ mydocs/working/task_m100_1556_stage2.md | 42 +++++ mydocs/working/task_m100_1556_stage3.md | 34 ++++ samples/hwpx/field-multipara-clickhere.hwpx | Bin 0 -> 39711 bytes src/model/paragraph.rs | 18 ++ src/parser/hwpx/section.rs | 113 +++++++++++- src/serializer/hwpx/field.rs | 19 +++ src/serializer/hwpx/section.rs | 179 +++++++++++++++++++- 11 files changed, 704 insertions(+), 7 deletions(-) create mode 100644 mydocs/plans/task_m100_1556.md create mode 100644 mydocs/plans/task_m100_1556_impl.md create mode 100644 mydocs/report/task_m100_1556_report.md create mode 100644 mydocs/working/task_m100_1556_stage1.md create mode 100644 mydocs/working/task_m100_1556_stage2.md create mode 100644 mydocs/working/task_m100_1556_stage3.md create mode 100644 samples/hwpx/field-multipara-clickhere.hwpx 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 0000000000000000000000000000000000000000..1228651451712e51a15cd3c131f015578bd452b6 GIT binary patch literal 39711 zcmY(p1F$H;4lTNE^K9ET&bDpawr$(CZQHhO+r~fl-dFYho0?1|JxOLwx~n^#YB@<@ z5EOv_T?7CDcl@Hr|AhY##6Pk&w>EZiw=>qav$HZc)ORwswV^X}wWIPdx0CyS3@h%h zY*_#S0RBVJ{|sG>9UT8D)4Ew(aq<3p;QyZnZbAlHdP00-8$(+oa~o4`LS-cp8g@c_ zM<;z7BYi7d8)I%lcVkCFd|nh}ONajev$mpRq-S8D`#)bJ#COs+HFo0vzv#IMRb>@~

BDg%uTL6$tUI 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πSfA*ee9KXD*2yp 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

RZzVIE#A1%&b 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 MgL-p}l&4Ss19Ccmy#N3J literal 0 HcmV?d00001 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 562403b6c..484fe8786 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의 갭으로 표현되므로 원본 순서를 유지해 계산한다. @@ -3849,6 +3874,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 { @@ -3905,6 +3931,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()); @@ -3986,6 +4014,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" => {} @@ -4014,6 +4044,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() { @@ -6625,6 +6669,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(), "고아 기록 없음"); + } + #[test] fn test_collect_hwpx_section_master_page_refs() { let xml = r#" 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};