diff --git a/mydocs/plans/task_m100_1584.md b/mydocs/plans/task_m100_1584.md new file mode 100644 index 000000000..884abcbbc --- /dev/null +++ b/mydocs/plans/task_m100_1584.md @@ -0,0 +1,75 @@ +# 수행계획서 — Task #1584 + +**제목**: HWPX 저장 시 본문 문단의 인라인 ColumnDef(cold) 드롭 → 컨트롤 인덱스 시프트 +**마일스톤**: M100 (v1.0.0) +**브랜치**: `local/task1584` +**이슈**: edwardkim/rhwp#1584 + +--- + +## 1. 배경 + +실문서 무손실 검증 v9(hwpdocs 9350건)에서 잔여 IR_DIFF 59건 중 **최대 단일 클래스(약 49건)**. +게이트(diff_documents)는 이를 "표 셀 char_shape ID 오매핑 `(0,3)→(0,6)`"으로 보고하나, +verbose ir-diff 및 raw XML 비교로 **진짜 근본원인은 본문 첫 문단의 인라인 ColumnDef 드롭**임을 +확정함(아래 §2). 셀 char_shape 변위는 컨트롤 인덱스 시프트의 **하위 증상**. + +## 2. 근본원인 (확정) + +대상 파일 `36382399` 문단 0 raw XML 비교 (devel HEAD): + +``` +orig 컨트롤 태그: [secPr, ctrl, colPr, ctrl, colPr, ctrl, fieldBegin, tbl] +rt 컨트롤 태그: [secPr, ctrl, colPr, ctrl, fieldBegin, tbl] +colPr 개수 : orig 2 → rt 1 (2번째 인라인 ColumnDef 드롭) +IR : controls 6→5, cc 59→51(-8), ctrl[2] cold→field 시프트 +``` + +코드 메커니즘 — `src/serializer/hwpx/section.rs`: + +| 지점 | 동작 | +|------|------| +| line 119–127 | 본문 첫 문단에서 `find(첫 ColumnDef)` 1개만 `TEMPLATE_BODY_COL_PR` 앵커로 치환 | +| line 433–435 | 본문(depth 0) 인라인 슬롯 필터가 `is_hwpx_inline_slot` 사용 → **ColumnDef 전부 제외** (subList depth>0 에서만 인라인 허용) | +| line 719 `is_hwpx_inline_slot` | `Control::ColumnDef` 미포함 | + +→ 문단에 ColumnDef 가 **2개 이상**이면, 1번째는 템플릿이 흡수하지만 **2번째+는 앵커도 인라인도 +아니어서 드롭**된다. (1개뿐이면 정상 — 그래서 대다수 문서는 PASS.) + +## 3. 수정 방향 (제안, 승인 대상) + +**Option A (surgical, 권장)**: 본문 인라인 슬롯 필터(line 431–437)에서 ColumnDef 를 +**"템플릿이 흡수한 첫 1개를 제외한 나머지"** 인라인 슬롯으로 포함한다. + +- 템플릿 앵커(line 119)는 그대로 첫 ColumnDef 1개를 소비(=#1407 2단 정의 보존). +- 첫 ColumnDef 의 식별(인덱스)을 render 경로에 전달하여, 그 1개만 인라인에서 건너뛰고 + 2번째+ ColumnDef 를 `render_col_pr_ctrl` 로 인라인 방출(subList 경로와 동형). +- subList(depth>0) 경로는 불변(이미 전체 인라인 방출). + +**배제한 대안(Option B)**: 템플릿을 section_def 컬럼정의로 채우고 문단 ColumnDef 전체를 +인라인화 — #1407/#1388 와 광범위하게 얽혀 회귀 위험이 큼. 채택 안 함. + +## 4. 회귀 위험 & 채택 기준 + +컬럼 모델은 #1379(인라인 제외)·#1388(secPr 여백)·#1407(colPr 2단)와 얽힘. +F3(#1556)가 2회 회귀로 실패한 영역과 동질 → **통제 비교(개선−회귀 > 0)를 채택 게이트**로 한다. + +수용 기준: +1. 49건 대표 roundtrip 의 `controls 6→5` 해소(ColumnDef 보존), 해당 파일 IR diff = 0. +2. `cargo test --test hwpx_roundtrip_baseline` (samples/hwpx, #1407/#1388 컬럼 케이스 포함) 회귀 0. +3. fidelity 전수(hwpdocs 9350) 통제 비교 **순효과 > 0** (악화 0 필수). + +## 5. 구현 단계 (3단계) + +- **Stage 1 — 근본원인 회귀 테스트 고정**: 49건 중 대표 N건을 픽스처로 박제, + 현재 `controls 6→5` 드롭을 재현하는 단위/통합 테스트 추가(현 상태 RED). +- **Stage 2 — Option A 구현**: 본문 인라인 ColumnDef(첫 앵커 제외) 방출. + Stage1 테스트 GREEN + baseline 회귀 0 확인. +- **Stage 3 — 통제 비교 검증**: fidelity 전수 재측정, 개선−회귀 집계, + 순효과>0·악화0 확인 후 최종 보고서. + +## 6. 산출물 + +- 소스: `src/serializer/hwpx/section.rs` (+ 필요 시 헬퍼) +- 테스트: 신규 픽스처 + roundtrip 단위 테스트 +- 문서: `task_m100_1584_impl.md`, `_stage{1..3}.md`, `_report.md` diff --git a/mydocs/plans/task_m100_1584_impl.md b/mydocs/plans/task_m100_1584_impl.md new file mode 100644 index 000000000..01932bab4 --- /dev/null +++ b/mydocs/plans/task_m100_1584_impl.md @@ -0,0 +1,78 @@ +# 구현 계획서 — Task #1584 + +**제목**: HWPX 본문 인라인 ColumnDef(cold) 드롭 수정 (Option A surgical) +**브랜치**: `local/task1584` · **이슈**: edwardkim/rhwp#1584 +**전제**: 수행계획서(`task_m100_1584.md`) 승인됨 + +--- + +## 1. 변경 대상 요약 + +`src/serializer/hwpx/section.rs` 의 본문 인라인 슬롯 경로 + 슬롯 렌더 디스패치 + +`src/serializer/hwpx/context.rs` 의 컨텍스트 플래그 1개. + +| # | 파일:지점 | 현재 | 변경 | +|---|-----------|------|------| +| C1 | context.rs:97 (`SerializeContext`) | `sub_list_depth` 만 | `is_section_first_para: bool` 필드 추가 | +| C2 | section.rs:78–82 (assemble) | 첫 문단 무표식 렌더 | 첫 문단 렌더 전후로 `ctx.is_section_first_para` true/false 설정 | +| C3 | section.rs:424–438 (slots 구성) | ColumnDef 를 본문 인라인에서 제외 | ColumnDef 를 인라인 후보로 포함 + **본문 첫 문단의 첫 ColumnDef 1개 제거**(템플릿 흡수분) | +| C4 | section.rs:832 (`render_control_slot`) | `ColumnDef if depth>0` 만 방출 | 가드 완화 — slots 에 들어온 ColumnDef 는 본문도 방출 | + +> C1·C2 는 "섹션 템플릿(line 119–127)이 첫 ColumnDef 1개를 흡수한다"는 사실을 +> render_runs 가 알도록 신호를 전달하기 위함. 그 1개만 인라인에서 빼고 2번째+는 방출. + +## 2. 설계 근거 (드롭/중복 양쪽 방지) + +핵심 불변식: **본문 첫 문단의 첫 ColumnDef = 섹션 템플릿이 흡수, 나머지 = 인라인.** + +- C3 에서 ColumnDef 를 인라인 후보로 올리면 `slot_count == controls.len()`(line 425, 전 컨트롤 + 슬롯) 분기와 필터 분기(line 431) **양쪽** 모두 ColumnDef 를 포함하게 된다. 따라서 + **두 분기 공통으로** "본문 첫 문단이면 slots 에서 첫 ColumnDef 1개 제거"를 적용한다 + (`if ctx.sub_list_depth == 0 && ctx.is_section_first_para { slots.remove(첫 ColumnDef pos) }`). +- 이로써: 템플릿(1번째) + 인라인(2번째+) = **드롭 없음, 중복 없음**. +- 본문 **비첫** 문단(템플릿 미흡수): `is_section_first_para=false` → 제거 없음 → ColumnDef 전부 + 인라인 방출(현재는 전부 드롭되던 잠재 버그도 동시 해소). +- subList(depth>0): 종전과 동일하게 전부 인라인(제거 없음). +- C4: slots 에 도달한 ColumnDef 는 위 불변식상 "흡수분이 아닌" 것이므로 본문에서도 무조건 방출 안전. + +## 3. 슬롯 카운트 정합 메모 + +`inferred_control_slot_count`(line ~717)는 ColumnDef 의 8유닛 슬롯도 카운트하므로, 수정 후 +`slot_count` 와 `slots.len()` 관계는 케이스별로 달라질 수 있다. 어느 경우든: +- 일치 → 위치추정 경로로 ColumnDef 가 제 위치에 방출. +- 불일치 → mismatch 경로(line 454)가 `for slot in &slots` 로 **일괄 방출** → ColumnDef 보존. + +즉 **드롭은 어느 경로에서도 발생하지 않는다**(현재는 slots 에서 빠져 양쪽 다 드롭). + +## 4. 구현 단계 (3단계) + +### Stage 1 — 드롭 재현 회귀 테스트 박제 (RED) +- 49건 중 대표 1–2건(예 `36382399`)을 `tests/fixtures/` 또는 기존 roundtrip 테스트에 편입. +- 단위 테스트: 본문 첫 문단 ColumnDef 2개 → serialize → reparse 후 `controls` 수/ColumnDef 보존 + 검증. 현재 코드에서 **실패(RED)** 함을 확인. +- `samples/hwpx` 에 동형 미니 케이스 부재 시, hwpdocs 대표 파일 기반 통합 테스트로 대체. +- 커밋: `Task #1584: ColumnDef 드롭 재현 테스트 (RED)` + `_stage1.md`. + +### Stage 2 — Option A 구현 (GREEN) +- C1–C4 적용. +- Stage1 테스트 GREEN. +- `cargo test --test hwpx_roundtrip_baseline` 회귀 0 (#1407 2단·#1388 여백 케이스 보존 확인). +- `cargo clippy` 클린, 신규/수정 파일만 정리(무관 fmt diff 금지). +- 커밋: `Task #1584: 본문 인라인 ColumnDef 방출 (Option A)` + `_stage2.md`. + +### Stage 3 — 통제 비교 검증 (채택 게이트) +- fidelity 전수(hwpdocs 9350 hwpx + samples 319 hwp) 재측정. +- 수정 전(devel HEAD) 대비 **개선−회귀** 집계: 49건 IR_DIFF 해소 확인, **악화 0 필수**, 순효과>0. +- `tools/verify_hangul_pages.py` 대표 샘플 페이지수 불변 확인(시각 붕괴 0). +- `tests/opengov_corpus_snapshot.rs` 스냅샷 갱신(개선 반영). +- 커밋: `_stage3.md` + `_report.md`. + +## 5. 롤백 기준 + +Stage 3 통제 비교에서 **악화 1건 이상** 또는 baseline 회귀 발생 시: F3(#1556) 선례대로 +**전량 되돌리고**(net-negative 불채택) 원인 재분석. 부분 채택하지 않는다. + +## 6. 산출물 +- 소스: `section.rs`, `context.rs` +- 테스트: 신규 픽스처 + roundtrip 단위/통합 테스트, opengov 스냅샷 갱신 +- 문서: `_stage1.md`, `_stage2.md`, `_stage3.md`, `_report.md` diff --git a/mydocs/plans/task_m100_1587.md b/mydocs/plans/task_m100_1587.md new file mode 100644 index 000000000..6353c5805 --- /dev/null +++ b/mydocs/plans/task_m100_1587.md @@ -0,0 +1,73 @@ +# 수행계획서 — Task #1587 + +**제목**: HWPX 저장 시 Ruby(덧말) 컨트롤 드롭 수정 +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1587 · **브랜치**: `local/task1587` + +--- + +## 1. 배경 + +fidelity10(hwpdocs 9660) 잔여 IR_DIFF 중 3건(36384160·36399208·36389301)에서 Ruby(덧말) +컨트롤이 저장 시 드롭. 잔여 10건 중 **유일하게 시각 영향 있는 실버그**(루비 주음 소실). +컨트롤 드롭으로 후속 char_shape −8 변위 하위 증상 동반(36389301). + +## 2. 그라운딩 — 스코프 발견 (중요) + +착수 조사 결과, 단순 "방출 arm 추가"로는 **무손실이 되지 않음**을 확인: + +원본 dutmal 구조 (36389301): +```xml + + 팀단위 훈련 + 전술훈련 30% + 현지훈련 20% + +``` + +`Ruby` 모델(`control.rs:165`)은 `ruby_text`(=subText) + `alignment`(u8) **2개 필드뿐**: + +| 원본 속성/요소 | 현 모델 처리 | 손실 | +|----------------|-------------|------| +| `subText` | `ruby_text` | OK | +| `mainText`(기준 텍스트) | 파서가 **skip**, 미보존 | **손실** — 시각 복원 불가 | +| `posType`(TOP/BOTTOM) | `alignment` 에 병합 | 충돌 | +| `align`(LEFT/RIGHT/CENTER) | `alignment` 에 병합(posType 덮어씀) | 충돌 | +| `szRatio` / `option` / `styleIDRef` | 미보존 | 손실(현 샘플 전부 0 — 우연 일치) | + +또한 파서(`section.rs:515`)는 dutmal 에 `\u{0002}`(컨트롤 마커 1개)만 push. + +→ **진짜 무손실은 모델+파서+직렬화기 3계층 확장이 필요**하다. IR 스켈레톤만 복원(빈 mainText) +하면 IR_DIFF 게이트는 통과하나 루비가 기준 텍스트 없이 떠 **시각 충실도 미달**. + +## 3. 스코프 결정 + +**전체 충실도(권장)**: `Ruby` 모델에 `main_text: String`, `pos_type: u8`, `align: u8`, +`sz_ratio`, `option`, `style_id_ref` 를 추가하고, 파서가 이를 채우며, 직렬화기가 역방출. +- HWP5(OLE) 파서/직렬화기에도 동일 필드가 있으면 정합 확인(없으면 HWPX 한정 처리). + +**배제(IR 스켈레톤만)**: 빈 mainText 로 IR_DIFF 만 해소 — 시각 미달이라 프로젝트 무손실 목표 +부적합. 채택 안 함. + +## 4. 회귀 위험 & 채택 기준 + +모델 필드 추가는 파서/직렬화기/HWP5 정합에 파급. **통제 비교(개선−회귀>0, 악화0)** 게이트. + +수용 기준: +1. 3건(36384160·36399208·36389301) roundtrip IR diff = 0 (ruby 보존, char_shape 시프트 해소). +2. dutmal 속성(posType·align·szRatio·option·styleIDRef) + mainText + subText 무손실 재현. +3. `cargo test --lib` + `hwpx_roundtrip_baseline` 회귀 0. +4. fidelity 전수 통제 비교 순효과 > 0, 악화 0. + +## 5. 구현 단계 (4단계) + +- **Stage 1 — 재현 테스트(RED)**: Ruby 2개·mainText·속성 포함 문단 roundtrip 단위 테스트 + (현 코드에서 controls=[] 로 RED). +- **Stage 2 — 모델+파서 확장**: `Ruby` 필드 추가, `parse_dutmal` 가 mainText/posType/align/ + szRatio/option/styleIDRef 채움. HWP5 정합 확인. +- **Stage 3 — 직렬화기**: `write_ruby`(=`` 역매핑) + `render_control_slot` 에 + `Control::Ruby` arm. Stage1 GREEN + baseline 회귀 0. +- **Stage 4 — 통제 비교**: fidelity 전수 재측정, 개선−회귀 집계, opengov 가드(36389301) 편입. + +## 6. 산출물 +- 소스: `src/model/control.rs`, `src/parser/hwpx/section.rs`, `src/serializer/hwpx/section.rs`(+신규 ruby 직렬화) +- 테스트: 신규 단위 + opengov 가드 +- 문서: `_impl`, `_stage1~4`, `_report` diff --git a/mydocs/plans/task_m100_1587_impl.md b/mydocs/plans/task_m100_1587_impl.md new file mode 100644 index 000000000..a5d804ae1 --- /dev/null +++ b/mydocs/plans/task_m100_1587_impl.md @@ -0,0 +1,81 @@ +# 구현 계획서 — Task #1587 + +**제목**: HWPX Ruby(덧말) 컨트롤 드롭 수정 — 모델+파서+직렬화기 3계층 +**브랜치**: `local/task1587` · **이슈**: edwardkim/rhwp#1587 +**전제**: 수행계획서(`task_m100_1587.md`) 승인됨 + +--- + +## 1. 파급 범위 (그라운딩 확정) + +| 계층 | 파일 | 현 상태 | 변경 | +|------|------|---------|------| +| 모델 | `src/model/control.rs` Ruby | ruby_text+alignment(2필드) | 필드 확장(아래) | +| 파서(HWPX) | `parser/hwpx/section.rs` parse_dutmal | mainText skip, posType/align 충돌 | 전 속성/요소 보존 | +| 직렬화(HWPX) | `serializer/hwpx/section.rs` | Ruby arm 부재(드롭) | write_ruby + arm | +| HWP5 | — | ruby 파서 **부재**(미지원) | **무영향**(extra 필드 무시) | + +`.alignment` 읽기는 parse_dutmal 한 곳뿐(main.rs 는 ruby_text 만, body_text 는 `_` 매칭) → +모델 필드 교체의 외부 파급 없음. + +## 2. 모델 변경 (C1) + +```rust +pub struct Ruby { + pub main_text: String, // mainText 기준 텍스트 — 신규(시각 충실도 핵심) + pub ruby_text: String, // subText 덧말 + pub pos_type: u8, // posType: 0=TOP, 1=BOTTOM — 신규(alignment 분리) + pub align: u8, // align: 0=LEFT, 1=RIGHT, 2=CENTER — 신규 + pub sz_ratio: u8, // szRatio — 신규 + pub option: u32, // option — 신규 + pub style_id_ref: u16, // styleIDRef — 신규 +} +``` +- `alignment` 제거(pos_type+align 로 분리). `#[derive(Default)]` 유지 → 기존 호출 호환. + +## 3. 파서 변경 (C2) — `parse_dutmal` + +- 속성: `posType`→pos_type, `align`→align, `szRatio`→sz_ratio, `option`→option, + `styleIDRef`→style_id_ref (문자열→정수 파싱). +- 자식: `mainText`→`read_dutmal_text`로 `main_text` 채움(현 skip 제거), `subText`→ruby_text. +- 호출부(section.rs:515)의 `\u{0002}` 마커 push 는 유지(슬롯 위치 보존). + +## 4. 직렬화 변경 (C3) — `write_ruby` + arm + +- 신규 `write_ruby(ruby) -> String`: `` + `{main_text}{ruby_text}`. + 속성/텍스트 XML escape. parse_dutmal 의 정확한 역매핑. +- `render_control_slot` 에 `Control::Ruby(r) => out.push_str(&write_ruby(r))` arm 추가. + Ruby 는 이미 `is_hwpx_inline_slot` 포함 → 슬롯 위치 자동. + +## 5. 구현 단계 (4단계) + +### Stage 1 — 재현 테스트 (RED) +- `serialize_hwpx→parse_hwpx` roundtrip 단위 테스트: ruby 포함 문단 → reparse 후 controls 에 + Ruby 보존 + ruby_text 일치 검증. 현재 RED(controls=[]). +- 테스트는 `Ruby { ruby_text, ..Default::default() }` 형태로 작성(모델 변경에 견고). +- 커밋: `Task #1587: Ruby 드롭 재현 테스트 (RED)` + `_stage1.md`. + +### Stage 2 — 모델+파서 확장 (C1, C2) +- C1 모델 필드 교체, C2 parse_dutmal 전 속성/요소 보존. +- `cargo build` + 기존 테스트 영향 없음 확인(파급 parse_dutmal 한정). +- 커밋: `Task #1587: Ruby 모델+파서 확장` + `_stage2.md`. + +### Stage 3 — 직렬화기 (C3) +- write_ruby + arm. Stage1 GREEN + 신규 필드(main_text/pos_type/align/sz_ratio/option/ + style_id_ref) 무손실 단언 추가. +- `cargo test --lib` + `hwpx_roundtrip_baseline` 회귀 0. +- 커밋: `Task #1587: Ruby 직렬화 (write_ruby + arm)` + `_stage3.md`. + +### Stage 4 — 통제 비교 (채택 게이트) +- fidelity 전수 재측정: 3건(36384160·36399208·36389301) 해소 확인, 악화 0, 순효과>0. +- opengov 가드(36389301) 편입 + snapshot 갱신. +- 커밋: `_stage4.md` + `_report.md`. + +## 6. 롤백 기준 +Stage 4 통제 비교 악화 ≥1 또는 baseline 회귀 시 전량 되돌리고 재분석(부분 채택 금지). + +## 7. 산출물 +- 소스: control.rs, parser/hwpx/section.rs, serializer/hwpx/section.rs +- 테스트: 신규 roundtrip 단위 + opengov 가드 +- 문서: `_stage1~4`, `_report` diff --git a/mydocs/plans/task_m100_1588.md b/mydocs/plans/task_m100_1588.md new file mode 100644 index 000000000..d303ef2df --- /dev/null +++ b/mydocs/plans/task_m100_1588.md @@ -0,0 +1,45 @@ +# 수행계획서 — Task #1588 + +**제목**: HWPX 저장 시 선 도형(`hp:line`) shapeComment 드롭 수정 +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1588 · **브랜치**: `local/task1588` + +--- + +## 1. 배경 + +fidelity11 잔여 IR_DIFF 중 3건(36389418·36392900·36391302)에서 선 도형 설명 +(`shapeComment`, "선입니다.")이 저장 시 드롭. 잔여 분석 Class B. + +## 2. 근본원인 (`src/serializer/hwpx/shape.rs`) + +- `write_shape_comment(w, c)`(line 852)는 `c.description` 비어있지 않으면 `` + 방출. +- `write_rect`(line 110)·`write_container_close`(line 235)는 호출하나, **`write_line`(line 121)은 + caption 까지만 방출하고 `write_shape_comment` 미호출** → 선 도형 설명 드롭. +- 파서는 정상(원본 파싱 시 description="선입니다." 캡처 — IR_DIFF 의 expected 값이 증거). + **순수 직렬화기 누락 1줄**. + +## 3. 수정 방향 + +`write_line` 의 caption 방출 직후(end_tag 직전) `write_shape_comment(w, c)?;` 1줄 추가. +OWPML AbstractShapeObjectType 순서(outMargin → caption → shapeComment, write_rect 동형) 준수. + +## 4. 회귀 위험 & 채택 기준 + +극히 낮음(기존 헬퍼 재사용, 선 도형 한정). 그래도 통제 비교로 확인. + +수용 기준: +1. 3건(36389418·36392900·36391302) roundtrip IR diff = 0(shapeComment 보존). +2. `cargo test --lib` + `hwpx_roundtrip_baseline` 회귀 0. +3. fidelity 통제 비교 순효과 > 0, 악화 0. + +## 5. 구현 단계 (3단계) + +- **Stage 1 — RED**: 선 도형 description 포함 도형 roundtrip 단위 테스트(현재 드롭 → RED). +- **Stage 2 — 수정**: `write_line` 에 `write_shape_comment` 1줄 추가. Stage1 GREEN + baseline 회귀 0. +- **Stage 3 — 통제 비교**: fidelity 전수 재측정, 3건 해소 + 악화 0 확인, opengov 가드 편입. + +## 6. 산출물 +- 소스: `src/serializer/hwpx/shape.rs` +- 테스트: 신규 단위 + opengov 가드 +- 문서: `_impl`, `_stage1~3`, `_report` diff --git a/mydocs/plans/task_m100_1591.md b/mydocs/plans/task_m100_1591.md new file mode 100644 index 000000000..e85b923f6 --- /dev/null +++ b/mydocs/plans/task_m100_1591.md @@ -0,0 +1,61 @@ +# 수행계획서 — Task #1591 + +**제목**: HWPX 저장 시 para0 후위 컨트롤(Bookmark/PageNum) 위치 오류 → char_shape +8 시프트 +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1591 · **브랜치**: `local/task1591` + +--- + +## 1. 배경 + +fidelity 잔여 IR_DIFF Class C(3건). 섹션 첫 문단(para0)의 char_shape 경계가 +8(36384689· +36385445) / −16·−8(36388711) 시프트. + +## 2. 그라운딩 — 근본원인 (확정, 36384689) + +para0(빈 문단, 거대 중첩표) IR controls=5: +`[SectionDef, ColumnDef, Table, PageNumberPos, Bookmark("별첨 1")]`. text_len=0, cc=33. + +- 원본: 북마크는 para0 최상위(subList 깊이 0)의 **끝**(byte 46461 / 범위 873~46520). +- 저장본(rt): 북마크를 **2번째 run 의 표 앞**에 방출(`[ctrl(bookmark),tbl,tbl,t]`) → 원본 + 위치(끝)와 불일치, 8유닛 슬롯이 char_shape 경계 앞에 끼어 **+8 시프트**. +- **#1584 무관 확정**: 직전 커밋(bd0cea48) 빌드 바이너리도 동일 → 선존 결함. + +**기전**: `render_runs` 의 슬롯 위치 추정 — `inferred_control_slot_count`(=4) ≠ controls.len()(=5) +→ **mismatch 경로**. 이 경로는 슬롯의 실제 char-offset 위치를 살리지 못하고 근사 배치하여, +후위 컨트롤(bookmark/pageNum)이 잘못된 run 으로 간다. + +## 3. 회귀 위험 (높음) + +슬롯 위치 추정은 **F3(#1561)·#1584 와 동질의 고위험 영역**. F3 는 2회 회귀로 실패한 전례. +→ 수정은 **통제 비교(개선−회귀>0, 악화0)** 를 채택 게이트로 하고, baseline + 광역 회귀를 필수 +확인. 부분 채택 금지(악화 시 전량 롤백). + +## 4. 구현 단계 (4단계) + +### Stage 1 — 근본원인 정밀 규명 + RED +- 36384689 의 char_offsets ↔ controls ↔ char_shapes 매핑을 추적하여 **mismatch 경로의 어느 + 지점이 후위 컨트롤을 오배치하는지** 정확히 특정. +- 36385445(+8 동일 패턴)·36388711(−16/−8)이 **동일 근본인지 분리 클래스인지** 판별. +- 재현 단위 테스트(RED): para0 후위 bookmark/pageNum 보존 + char_shape 위치 검증. +- 산출: `_stage1.md`(조사 결과 + 수정 방향 확정 또는 재계획). + +### Stage 2 — 수정 설계·구현 +- Stage 1 확정 방향으로 슬롯 위치 보존 구현. (예: 후위 컨트롤의 char-offset 위치 유지, + 또는 slot_count/슬롯 매핑 보정.) +- Stage1 GREEN + `hwpx_roundtrip_baseline` 회귀 0 + `cargo test --lib` 회귀 0. + +### Stage 3 — 통제 비교 (채택 게이트) +- fidelity 전수 재측정, 개선−회귀 집계, 악화 0·순효과>0 확인. +- 악화 ≥1 시 **전량 롤백** + 재분석(F3 선례). + +### Stage 4 — 가드·보고 +- 단위 테스트 + opengov 가드(가능 시 36384689 또는 동형) 편입, snapshot 갱신. +- `_report.md`. + +> 주: Stage 1 조사 결과 수정이 광역 회귀 불가피로 판명되면(F3 양상), **불채택·문서화**로 종료할 +> 수 있다. 무손실 게이트의 채택 기준은 통제 비교 순효과이며, 무리한 부분 수정은 하지 않는다. + +## 5. 산출물 +- 소스: `src/serializer/hwpx/section.rs`(슬롯 위치 경로) 등 Stage 1 확정 범위 +- 테스트: 신규 단위 + (가능 시) opengov 가드 +- 문서: `_stage1~4`, `_report` diff --git a/mydocs/plans/task_m100_1592.md b/mydocs/plans/task_m100_1592.md new file mode 100644 index 000000000..2e68cdf4a --- /dev/null +++ b/mydocs/plans/task_m100_1592.md @@ -0,0 +1,43 @@ +# 수행계획서 — Task #1592 + +**제목**: HWPX 저장 시 빈 문단(char_shapes=[])에 spurious (0,0) char_shape 추가 수정 +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1592 · **브랜치**: `local/task1592` + +--- + +## 1. 배경 + +fidelity 잔여 IR_DIFF Class D(1건). run 이 없던 빈 문단에 직렬화기가 빈 +`` 를 추가 → 재파싱 시 char_shapes `[]`→`[(0,0)]`. + +## 2. 근본원인 (확정, `src/serializer/hwpx/section.rs`) + +- `RunSplitter::new`(298-300) "규칙 3": char_shapes 가 비면 기본 `(0,0)` 세그먼트 추가. +- `close_run`(333-335) "규칙 5": 빈 run 도 `` 로 방출. +- → char_shapes=[] 빈 문단에 `charPrIDRef="0"` run 생성, 재파싱 시 (0,0) 발생. + +원본 빈 문단(36386761 목록 para5)은 run 이 없어 char_shapes=[]. 판별자 = `char_shapes.is_empty()`. +대부분 빈 문단은 char_shapes=[(0,0)](run 존재)라 무영향 — 본 케이스만 char_shapes=[]. + +## 3. 수정 방향 + +`render_runs` 진입부에서 문단이 **완전히 비었으면**(text·char_shapes·슬롯·field_ranges· +orphan_field_ends 전부 없음) **run 미방출**(빈 문자열 반환). linesegarray 는 별도 경로라 보존. +char_shapes 가 있으면 종전 규칙 3/5 유지. + +## 4. 회귀 위험 (낮음) + +빈 문단 광역 영향 가능 → 통제 비교 필수. 단, char_shapes=[] 조건이 좁아(대부분 [(0,0)]) +blast radius 작을 것으로 추정. 채택 게이트 = 통제 비교 순효과>0·악화0. + +## 5. 구현 단계 (3단계) + +- **Stage 1 — RED**: 완전 빈 문단(char_shapes=[]) roundtrip → char_shapes 보존(빈) 검증. + 현재 (0,0) 발생 → RED. +- **Stage 2 — 수정**: render_runs 빈문단 가드 추가. Stage1 GREEN + baseline + lib 회귀 0. +- **Stage 3 — 통제 비교**: fidelity 전수 재측정, 36386761 해소 + 악화 0 + 순효과>0. opengov 가드. + +## 6. 산출물 +- 소스: `src/serializer/hwpx/section.rs` +- 테스트: 신규 단위 + opengov 가드 +- 문서: `_stage1~3`, `_report` diff --git a/mydocs/plans/task_m100_1594.md b/mydocs/plans/task_m100_1594.md new file mode 100644 index 000000000..f47280f75 --- /dev/null +++ b/mydocs/plans/task_m100_1594.md @@ -0,0 +1,54 @@ +# 수행계획서 — Task #1594 + +**제목**: HWPX 직렬화 시 holdAnchorAndSO 드롭(1→0) → 페이지 붕괴 수정 +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1594 · **브랜치**: `local/task1594` + +--- + +## 1. 배경 + +#1589 페이지 붕괴 군집(IR diff=0 PASS 파일의 ~16% 가 한글에서 붕괴)의 **주원인 확정**: +HWPX 직렬화기가 개체 `` 를 파싱 IR 값 무시하고 "0" 하드코딩. +페이지 하단 앵커 개체(발신명의 footer)에서 1→0 변경 시 한글 재배치로 페이지 붕괴. + +## 2. 근본원인 (확정) + +- 직렬화 하드코딩 4지점: `table.rs:146`, `picture.rs:407`, `shape.rs:899`, equation(`section.rs:1451`). +- 파서 정상 저장: `holdAnchorAndSO → common.prevent_page_break`(i32, `parser/hwpx/section.rs:1672`). +- `diff_documents` 가 `prevent_page_break` 미검사 → IR diff=0 (게이트 미검출, 시각만 붕괴). +- 결정 증거: orig 에서 1→0 치환 → 2쪽→1쪽 붕괴 재현. 단락 이진탐색으로 유일 차이 확정. +- 군집 적용성: 붕괴파일 84%(53/63) 가 hold=1. + +## 3. 수정 방향 + +1. **직렬화 보존**: 4지점이 `holdAnchorAndSO` 를 `c.prevent_page_break != 0 ? "1":"0"` 로 방출. + (form.rs 는 이미 prop 기반 — 확인 후 필요 시 정합.) +2. **게이트 검출**: `diff_documents` 개체 비교에 `prevent_page_break` 추가 → 본 클래스 IR 검출 + (직렬화 수정 후이므로 신규 회귀 아님; 미래 회귀 방지). + +## 4. 회귀 위험 (낮음) + +- 단순 값 보존(하드코딩 제거). 표/그림/도형/수식 공통 경로. +- baseline(samples/hwpx) 회귀 가능성 점검(holdAnchorAndSO 보존이 기존 기대와 충돌 여부). +- 채택 기준: 대표 파일 붕괴 해소 + baseline/lib 회귀 0 + IR 통제 비교 악화 0. + +## 5. 구현 단계 (3단계) + +### Stage 1 — RED 테스트 +- 개체(표/그림 등)에 `prevent_page_break=1` 설정 → serialize → reparse 후 보존 검증. + 현재 직렬화 "0" 하드코딩으로 1→0 → RED. (+ 가능 시 holdAnchorAndSO="1" XML 방출 단위 검증.) + +### Stage 2 — 직렬화 수정 +- 4지점 `holdAnchorAndSO` 를 IR 값 방출로 교체. Stage1 GREEN. +- `cargo test --lib` + `hwpx_roundtrip_baseline` 회귀 0. + +### Stage 3 — 게이트 검출 + 통제 비교 +- `diff_documents` 에 `prevent_page_break` 비교 추가. +- fidelity 전수 재측정(IR 통제 비교 악화 0) + 한글 오라클 표본으로 붕괴 해소 확인 + (36383351 등 대표 + 무작위 붕괴 표본 재측정). +- opengov 가드(36383351, 페이지수 보존) 편입. + +## 6. 산출물 +- 소스: `serializer/hwpx/{table,picture,shape,section(equation),roundtrip}.rs` +- 테스트: 신규 단위 + opengov 가드 +- 문서: `_stage1~3`, `_report` diff --git a/mydocs/plans/task_m100_1595.md b/mydocs/plans/task_m100_1595.md new file mode 100644 index 000000000..d02233645 --- /dev/null +++ b/mydocs/plans/task_m100_1595.md @@ -0,0 +1,33 @@ +# 수행계획서 — Task #1595 + +**제목**: HWPX ClickHere 필드 타입 CLICKHERE→CLICK_HERE 수정 (페이지 붕괴 지배원인) +**마일스톤**: M100 · **이슈**: edwardkim/rhwp#1595 · **브랜치**: `local/task1595` + +## 1. 배경·근본원인 + +`field.rs:180` 이 `ClickHere => "CLICKHERE"` (언더스코어 누락) 방출. 정답은 `"CLICK_HERE"` +(파서 4254·템플릿 2250/6695). 한글이 "CLICKHERE" 미인식 → placeholder 높이 변동 → 페이지 붕괴. +파서 관대(둘 다 수용) → IR diff=0 → 게이트 미검출. #1589 군집 지배원인(붕괴파일 96% ClickHere, +패치 11/11 해소). + +## 2. 수정 방향 + +1. `field.rs:180` `ClickHere => "CLICK_HERE"`. +2. "CLICKHERE" 기대 테스트 갱신: `field.rs:217`, `section.rs:2427`. + +## 3. 회귀 위험 (낮음) + +단일 문자열 교정. 파서는 양형 수용이라 roundtrip 무해. 한글 인식 개선이 목적. +채택 기준: 대표 붕괴 해소 + baseline/lib 회귀 0 + 통제 비교 악화 0. + +## 4. 구현 단계 (3단계) + +- **Stage 1 — RED**: "CLICKHERE" 기대 테스트를 "CLICK_HERE" 기대로 갱신 → 현재 RED. + (+ ClickHere roundtrip 후 type 보존 단위 테스트.) +- **Stage 2 — 수정**: field.rs:180 교정. Stage1 GREEN + lib/baseline 회귀 0. +- **Stage 3 — 통제 비교**: fidelity 전수 재측정(악화 0) + 한글 오라클 붕괴 해소율 측정 + + opengov 가드. #1589 군집 해소 정량화. + +## 5. 산출물 +- 소스: `serializer/hwpx/field.rs` (+ 테스트 갱신) +- 문서: `_stage1~3`, `_report` diff --git a/mydocs/plans/task_m100_1596.md b/mydocs/plans/task_m100_1596.md new file mode 100644 index 000000000..f29de0338 --- /dev/null +++ b/mydocs/plans/task_m100_1596.md @@ -0,0 +1,50 @@ +# 수행계획서 — Task #1596 + +**제목**: HWPX generic-shape(polygon/ellipse/arc/curve) 지오메트리 직렬화 완성 +**마일스톤**: M100 · **이슈**: edwardkim/rhwp#1596 · **브랜치**: `local/task1596` + +## 1. 배경·근본원인 + +#1589 페이지 붕괴 잔여(~8%)의 근본. generic-shape 공통 직렬화 `render_common_shape_xml` +(section.rs:1327)이 도형 지오메트리를 드롭: +- **``**(테두리 선), **``**(그림자), **``**(폴리곤/커브 꼭짓점) 미방출. +- 결과: 도형이 형상/테두리 없이 렌더 → 크기·레이아웃 변동 → 경계 근처 문서 페이지 붕괴. + +**IR 에는 데이터 존재**(파서가 읽음): `drawing.border_line`(ShapeBorderLine), `drawing.shadow_*`, +`PolygonShape.points`/`CurveShape.points`. → **serializer-only 수정**. + +## 2. 현 상태 vs 정답 + +- 현 방출: tag(불완전) → shape_block(offset/orgSz/curSz/flip/rotation/rendering) → sz → pos → + outMargin → caption → shapeComment. +- 정답(orig polygon): tag → shape_block → **lineShape → shadow → hc:pt×N** → sz → pos → + outMargin → shapeComment. (rect/line 전용 writer 와 동형 패턴.) + +## 3. 수정 방향 + +`render_common_shape_xml` 가 shape_block 직후·sz 직전에 방출 추가: +1. `` — `drawing.border_line` 에서(write_line_shape 헬퍼 재사용). +2. `` — `drawing.shadow_*`(또는 shape_attr) 에서. +3. `` ×N — 도형별 points(polygon/curve). ellipse/arc 는 points 없음(생략). + +호출부(render_shape 디스패치)가 `drawing: &DrawingObjAttr` + `points: &[Point]` 전달하도록 확장. +태그 누락 속성(numberingType/dropcapstyle/href/groupLevel/instid)도 정합(부수 충실도). + +## 4. 회귀 위험 (중) + +도형 직렬화 구조 변경 → baseline(samples/hwpx) 의 도형 케이스 회귀 점검 필수. +ellipse/arc/curve/chart/ole 각 경로 영향. 채택 기준: 통제 비교 악화 0 + baseline/lib 회귀 0 + +대표 붕괴(36396457) 해소. + +## 5. 구현 단계 (4단계) + +- **Stage 1 — RED**: polygon roundtrip 후 points/lineShape/shadow 보존 단위 테스트(현재 드롭 → RED). +- **Stage 2 — lineShape+shadow 방출**: 공통 경로에 추가. (ellipse/arc/polygon/curve 공통.) +- **Stage 3 — points 방출**: polygon/curve 꼭짓점. Stage1 GREEN + baseline 회귀 0. +- **Stage 4 — 통제 비교**: fidelity 전수(악화 0) + 한글 오라클(36396457 등 잔여 붕괴 해소율) + + opengov 가드. + +## 6. 산출물 +- 소스: `serializer/hwpx/section.rs`(render_common_shape_xml/dispatch) + shape 헬퍼. +- 테스트: 신규 단위 + opengov 가드. +- 문서: `_stage1~4`, `_report`. diff --git a/mydocs/plans/task_m100_1598.md b/mydocs/plans/task_m100_1598.md new file mode 100644 index 000000000..14e2ff91c --- /dev/null +++ b/mydocs/plans/task_m100_1598.md @@ -0,0 +1,59 @@ +# 수행계획서 — Task #1598 + +**제목**: HWPX ellipse/arc 전용 지오메트리(center/축/시작끝점) 직렬화 완성 +**마일스톤**: M100 · **이슈**: edwardkim/rhwp#1598 · **브랜치**: `local/task1598` + +## 1. 배경·근본원인 + +#1596 이 generic-shape 공통 지오메트리(lineShape/fillBrush/shadow/hc:pt)를 복원했으나, +ellipse/arc 는 폴리곤과 **다른 전용 지오메트리**를 가진다. `render_common_shape_xml` +(section.rs:1320)이 shadow 직후·sz 직전에 다음을 드롭한다: + +- **ellipse**: ` ` + (순서 주의: **end1 이 start2 보다 앞**) +- **arc**: ` ` + 태그속성 `arcType` +- 공통 태그속성 `intervalDirty/hasArcPr/arcType` 드롭 +- all-zero shadow(`type="NONE"`)도 조건부 `write_shadow` 가 드롭(orig 는 방출) + +**IR 에 데이터 존재**(파서가 읽음): `EllipseShape{center,axis1,axis2,start1,end1,start2,end2}`, +`ArcShape{arc_type,center,axis1,axis2}`. → **serializer-only 수정**. IR diff=0 → 게이트 +미검출 → 한글 오라클(시각)만 검출. #1589 페이지 붕괴 잔여 long-tail(ellipse 보유 문서). + +## 2. 현 상태 vs 정답 (실측: 36385226 section0 ellipse) + +``` +정답(orig): ...shadow → hc:center → hc:ax1 → hc:ax2 → hc:start1 → hc:end1 + → hc:start2 → hc:end2 → sz → pos ... +현 방출(rt): ...shadow → sz → pos ... ← center/축/시작끝점 전부 드롭 +``` + +## 3. 수정 방향 + +`render_common_shape_xml` 디스패치(section.rs:1280~)가 ellipse/arc 전용 지오메트리 문자열을 +빌드해 전달. 기존 `points: &[Point]`(polygon/curve)와 동일 위치(shadow 직후)에 방출하도록 +**전용 지오메트리 파라미터**로 일반화한다. + +- ellipse → center/ax1/ax2/start1/end1/start2/end2 7개 `` +- arc → center/ax1/ax2 3개 `` +- polygon/curve → 기존 `` (유지) +- ellipse/arc 태그 전용 속성(intervalDirty/hasArcPr/arcType) 정합. + +## 4. 회귀 위험 (중) + +generic-shape 직렬화 경로 변경 → polygon/curve(#1596) 회귀 점검 필수. +채택 기준: polygon 회귀 0 + baseline(samples/hwpx)/lib 회귀 0 + 통제 비교 악화 0 + +잔여 붕괴(36385226) 해소율 측정(razor-thin 분산 원인 → 완전해소 보장 못함, 측정 후 보고). + +## 5. 구현 단계 (4단계) + +- **Stage 1 — RED**: ellipse roundtrip 후 center/축/시작끝점 보존 단위 테스트(현재 드롭 → RED). +- **Stage 2 — 전용 지오메트리 방출**: 디스패치 + `render_common_shape_xml` 일반화. + ellipse/arc 전용 `` + 태그속성. Stage1 GREEN + polygon 회귀 0. +- **Stage 3 — baseline/lib 회귀 0**: `cargo test` 전수 + hwpx-roundtrip baseline. +- **Stage 4 — 통제 비교**: fidelity 전수(악화 0) + 한글 오라클(36385226 잔여 붕괴 해소율) + + opengov 가드(36385226). + +## 6. 산출물 +- 소스: `serializer/hwpx/section.rs`(render_common_shape_xml/dispatch). +- 테스트: 신규 단위 + opengov 가드(36385226). +- 문서: `_stage1~4`, `_report`. diff --git a/mydocs/report/task_m100_1584_report.md b/mydocs/report/task_m100_1584_report.md new file mode 100644 index 000000000..99a4b7a63 --- /dev/null +++ b/mydocs/report/task_m100_1584_report.md @@ -0,0 +1,60 @@ +# Task #1584 — 최종 결과보고서 + +**제목**: HWPX 저장 시 본문 문단의 인라인 ColumnDef(cold) 드롭 수정 +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1584 · **브랜치**: `local/task1584` + +--- + +## 1. 문제 + +실문서 무손실 검증(hwpdocs 9350)에서 잔여 IR_DIFF 최대 단일 클래스(49건). 게이트는 +"표 셀 char_shape ID 오매핑"으로 보고했으나, raw XML/verbose ir-diff 로 **진짜 근본원인을 +본문 첫 문단의 인라인 ColumnDef 드롭으로 정정**함. 셀 char_shape 변위는 컨트롤 인덱스 +시프트의 하위 증상. + +``` +orig 문단0: [secPr, ctrl,colPr, ctrl,colPr, ctrl,fieldBegin, tbl] colPr 2 +rt 문단0: [secPr, ctrl,colPr, ctrl, fieldBegin, tbl] colPr 1 ← 드롭 +``` + +## 2. 근본원인 (`src/serializer/hwpx/section.rs`) + +- 섹션 템플릿 앵커가 본문 첫 문단의 **첫 ColumnDef 1개만** colPr 로 흡수. +- 본문(depth 0) 인라인 슬롯 필터가 ColumnDef 를 전부 제외 → **2번째+ ColumnDef 가 어느 + 경로로도 방출되지 않아 드롭**(controls 6→5, cc −8, 후속 컨트롤 인덱스 시프트). + +## 3. 해결 (Option A surgical) + +| 파일 | 변경 | +|------|------| +| `context.rs` | `body_coldef_template_pending` consume-once 플래그 추가 | +| `section.rs` write_section | 첫 문단 렌더 전후로 플래그 set/reset | +| `section.rs` render_runs | **all-controls 분기**: 첫 ColumnDef 슬롯 유지(회계 보존). **filter 분기**: 첫 ColumnDef 슬롯 제외(위치 미점유분), 2번째+ 포함 | +| `section.rs` render_control_slot | 본문 첫 ColumnDef 의 XML 만 consume-once 억제(템플릿 중복 방지) | + +**설계 핵심**: 두 슬롯 분기가 ColumnDef 의 char-offset 슬롯 점유 여부에서 다르므로 분기별로 +처리. 단순 일괄 제거는 회귀(−8 시프트 / position→mismatch 전환)를 유발 — 통제 비교로 검출·교정. + +## 4. 검증 + +| 검사 | 결과 | +|------|------| +| 단위 RED→GREEN | PASS (ColumnDef 1→2 보존) | +| `cargo test --lib` | 1960 passed, 0 failed | +| `hwpx_roundtrip_baseline` | 4/4 (#1407/#1388 보존) | +| opengov snapshot (36382399 가드 추가) | PASS | +| **fidelity 통제 비교** | **개선 49 / 회귀 0 / 순효과 +49** | +| Hangul 오라클 (8 표본) | OK 8 / COLLAPSE 0 | + +실문서 HWPX IR_DIFF: **59 → 10** (공통 9350 기준 −49, 악화 0). + +## 5. 산출물 + +- 소스: `src/serializer/hwpx/section.rs`, `src/serializer/hwpx/context.rs` +- 테스트: `task1584_..._roundtrip` 단위 + `samples/hwpx/opengov/36382399…` 통합 가드 +- 문서: 수행/구현 계획서, `_stage1~3`, 본 보고서 + +## 6. 후속 + +잔존 IR_DIFF 10건(F3 잔여 다중필드 복합슬롯 + shapeComment + ruby) 및 PARSE_FAIL 12건 +(손상 다운로드, rhwp 무관)은 별건. 채택 후 `local/devel` → `devel` 반영. diff --git a/mydocs/report/task_m100_1587_report.md b/mydocs/report/task_m100_1587_report.md new file mode 100644 index 000000000..5faa041ee --- /dev/null +++ b/mydocs/report/task_m100_1587_report.md @@ -0,0 +1,57 @@ +# Task #1587 — 최종 결과보고서 + +**제목**: HWPX 저장 시 Ruby(덧말) 컨트롤 드롭 수정 +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1587 · **브랜치**: `local/task1587` + +--- + +## 1. 문제 + +HWPX 저장 시 본문/표셀의 Ruby(덧말, 한자 독음·위첨자) 컨트롤이 드롭. fidelity 잔여 +IR_DIFF 10건 중 3건(36384160·36389301·36399208), **유일하게 시각 영향 있는 실버그**. + +## 2. 근본원인 (2계층) + +1. **즉시**: `render_control_slot`(serializer/hwpx/section.rs)에 `Control::Ruby` arm 부재 → + `is_hwpx_inline_slot` 에는 등록(슬롯 인식)됐으나 방출되지 않아 드롭. (ColumnDef #1584 동형.) +2. **심층(그라운딩 발견)**: `Ruby` 모델이 손실 구조 — `mainText`(기준 텍스트) 미보존, + `posType`/`align` 을 u8 1개로 병합, `szRatio`/`option`/`styleIDRef` 드롭. 단순 arm 추가만으로는 + 무손실 불가 → 모델+파서+직렬화기 3계층 수정. + +## 3. 해결 + +| 계층 | 파일 | 변경 | +|------|------|------| +| 모델 | `model/control.rs` Ruby | `alignment` 제거 → `main_text`/`pos_type`/`align`/`sz_ratio`/`option`/`style_id_ref` | +| 파서 | `parser/hwpx/section.rs` parse_dutmal | mainText 보존 + posType/align 분리 + szRatio/option/styleIDRef 파싱 | +| 직렬화 | `serializer/hwpx/section.rs` | `render_dutmal`(`` 역매핑) + `Control::Ruby` arm | + +- `alignment` 제거 외부 파급 0(parse_dutmal 한정 — main.rs 는 ruby_text 만, HWP5 는 ruby 미지원). + +## 4. 검증 + +| 검사 | 결과 | +|------|------| +| 단위 RED→GREEN (전 필드 무손실) | PASS | +| `cargo test --lib` | 1961 passed, 0 failed | +| `hwpx_roundtrip_baseline` | 4/4 | +| opengov snapshot (36389301 가드) | PASS | +| **fidelity 통제 비교** | **개선 3 / 회귀 0 / 순효과 +3** (IR_DIFF 10→7) | +| Hangul 오라클 | 2건 OK(시각 정상) + 1건 별개 선존 붕괴(#1589) | + +## 5. 부수 발견 — 36384160 페이지 붕괴 (#1589) + +36384160 은 ruby 수정으로 IR PASS 가 됐으나 한글에서 29→3쪽 붕괴(IR 게이트 미검출). +**ruby 수정 전후 동일** → 선존 시각 버그로 확정, 이슈 #1589 분리 등록. 본 타스크 무관. + +## 6. 산출물 + +- 소스: control.rs, parser/hwpx/section.rs, serializer/hwpx/section.rs +- 테스트: `task1587_ruby_control_roundtrips` + `samples/hwpx/opengov/36389301…` 가드 +- 문서: 수행/구현 계획서, `_stage1~4`, 본 보고서, 잔존 분석(`tech/hwpx_residual_ir_diff_10.md`) + +## 7. 후속 + +- #1588 선 도형 shapeComment 드롭(Class B) — 1줄 수정 대기. +- #1589 페이지 붕괴(시각 갭) — 오라클 전수 군집 조사 필요. +- 잔여 IR_DIFF 7건: char_shape 시프트(para0) + spurious(0,0) 등(별건). diff --git a/mydocs/report/task_m100_1588_report.md b/mydocs/report/task_m100_1588_report.md new file mode 100644 index 000000000..22bbe02e4 --- /dev/null +++ b/mydocs/report/task_m100_1588_report.md @@ -0,0 +1,45 @@ +# Task #1588 — 최종 결과보고서 + +**제목**: HWPX 저장 시 선 도형(`hp:line`) shapeComment 드롭 수정 +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1588 · **브랜치**: `local/task1588` + +--- + +## 1. 문제 + +HWPX 저장 시 선 도형의 설명(`shapeComment`, "선입니다.")이 드롭. fidelity 잔여 IR_DIFF +Class B 3건(36389418·36392900·36391302). + +## 2. 근본원인 (`src/serializer/hwpx/shape.rs`) + +`write_shape_comment(c)` 는 `c.description` 비어있지 않으면 `` 방출. +`write_rect`·`write_container_close` 는 호출하나 **`write_line` 만 미호출** → 선 도형 설명 드롭. +파서는 정상(원본 파싱이 description 캡처 — IR_DIFF expected 값이 증거). 순수 직렬화기 1줄 누락. + +## 3. 해결 + +`write_line` 의 caption 방출 직후 `write_shape_comment(w, c)?;` 1줄 추가 +(OWPML 순서 outMargin→caption→shapeComment, write_rect 동형). + +## 4. 검증 + +| 검사 | 결과 | +|------|------| +| 단위 RED→GREEN (방출 + 빈설명 미방출) | PASS | +| `cargo test --lib` | 1963 passed, 0 failed | +| `hwpx_roundtrip_baseline` | 4/4 | +| opengov snapshot (36392900 가드) | PASS | +| **fidelity 통제 비교** | **개선 3 / 회귀 0 / 순효과 +3** (IR_DIFF 7→4) | + +## 5. 산출물 + +- 소스: `src/serializer/hwpx/shape.rs` +- 테스트: `task1588_line_shape_comment_emitted` 외 1 + `samples/hwpx/opengov/36392900…` 가드 +- 문서: 수행계획서, `_stage1~3`, 본 보고서 + +## 6. 후속 (잔존 IR_DIFF 4건) + +- Class C — para0 char_shape 시프트 3건(36384689·36385445·36388711): secPr/colPr run charPr + 경계 오정렬, dedicated 조사 필요. +- Class D — spurious (0,0) 1건(36386761): 빈/공백 문단 기본 char_shape. +- 별도 — #1589 페이지 붕괴(시각 갭, 오라클 전수 군집 조사). diff --git a/mydocs/report/task_m100_1591_report.md b/mydocs/report/task_m100_1591_report.md new file mode 100644 index 000000000..c15e00be9 --- /dev/null +++ b/mydocs/report/task_m100_1591_report.md @@ -0,0 +1,59 @@ +# Task #1591 — 최종 결과보고서 (불채택 + 근본원인 문서화) + +**제목**: HWPX para0 char_shape +8 시프트 (Class C1) +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1591 · **브랜치**: `local/task1591` +**결과**: **불채택**(순효과 0, 채택 게이트 미달). 진짜 근본원인 문서화 + 회귀 repro 보존. + +--- + +## 1. 경위 + +fidelity 잔여 IR_DIFF Class C(3건). Stage 1 조사에서 para0 북마크 hoisting(section.rs:416-426)을 +char_shape +8 의 원인으로 지목 → Stage 2 에서 북마크를 슬롯 시스템에 편입(hoisting 제거). + +## 2. Stage 2 결과 — 부분 교정, 게이트 미해소 + +- **컨트롤 순서는 교정**: rt 가 `[…Table, PageNum, Bookmark(끝)]` 로 정렬(hoisting 제거 성공). +- **그러나 char_shape 는 여전히 +8**(pos 24→32) → 타깃 IR_DIFF 미해소. +- 통제 비교(fidelity12→13, 공통 10150): **개선 0 / 회귀 0 / 순효과 0**. + +## 3. 진짜 근본원인 (재규명) + +char_shape +8 은 북마크 hoist 와 **독립적인 first-para mismatch-path 위치추정 결함**: + +- para0 는 **첫 문단** → #1584 ColumnDef 템플릿 흡수 적용 → 첫 ColumnDef 가 `slots` 에서 제외. +- `inferred_control_slot_count`(=4, cc 기반) ≠ `slots.len()`(=3, ColumnDef 제외) → **mismatch 경로**. +- mismatch 경로는 슬롯의 실제 char-offset 위치를 추정 못 해 char_shape 경계를 +8 오배치. +- 이 +8 은 **#1584 이전·북마크 수정 전후 모두 불변** → 북마크와 무관한 선존 mismatch-path 결함. + +→ 북마크 hoist 는 **게이트 비가시의 또 다른(실재) 버그**였고, Class C1 의 char_shape 타깃은 +별개의 first-para mismatch-path 위치추정(슬롯카운트 vs 슬롯 불일치) 결함. **F3(#1561)급 난이도.** + +## 4. 판단 — 불채택 + +북마크 슬롯 편입은 올바른 교정(순서 보존·회귀 0)이나 타깃 IR_DIFF 미해소·**순효과 0**으로 +채택 기준(순효과>0) 미달. 작업지시자 결정에 따라 **Stage 2 롤백**: + +- `section.rs`/`roundtrip.rs` Stage 2 직전 상태로 복원(hoisting 유지). +- RED 테스트 `task1591_bookmark_not_hoisted_before_slot` 는 `#[ignore]`(hoist 버그 repro 보존). + +## 5. Class C 분해 (남은 과제) + +| 파일 | 근본 | 상태 | +|------|------|------| +| 36384689·36385445 | **first-para mismatch-path char_shape 위치추정** (북마크 아님) | 미해결(F3급) | +| 36388711 | Field ClickHere (−16/−8) | 별개(C2) | + +## 6. 권고 (후속) + +1. **mismatch-path char_shape 위치추정** — slot_count(cc 기반) vs slots(템플릿 ColumnDef 제외) + 불일치가 first-para 다중컨트롤에서 char_shape 를 오배치. F3 와 동질의 슬롯 위치 정합 영역 → + 별 이슈 + 광역 통제 비교 필수. (난이도 높음, 우선순위 판단 필요.) +2. **북마크 hoist** — 게이트 비가시지만 실재하는 순서 오류. 위 1 과 함께 또는 별도 처리 가능. +3. **C2(36388711 Field)** — 별 이슈로 분리. + +## 7. 교훈 + +Stage 1 조사가 표면 증상(북마크 재배치)을 근본으로 오인. **통제 비교(순효과)가 부분 수정의 +게이트 무효과를 정확히 검출** — IR diff 상세 추적만으로는 놓칠 다층 원인을 통제 비교가 가려냄. +무손실 게이트의 채택 기준은 통제 비교 순효과이며, 부분 교정은 채택하지 않는다. diff --git a/mydocs/report/task_m100_1592_report.md b/mydocs/report/task_m100_1592_report.md new file mode 100644 index 000000000..2e7ee0e2f --- /dev/null +++ b/mydocs/report/task_m100_1592_report.md @@ -0,0 +1,39 @@ +# Task #1592 — 최종 결과보고서 + +**제목**: HWPX 저장 시 빈 문단(char_shapes=[])에 spurious (0,0) char_shape 추가 수정 +**마일스톤**: M100 (v1.0.0) · **이슈**: edwardkim/rhwp#1592 · **브랜치**: `local/task1592` + +## 1. 문제 +run 이 없던 빈 문단에 직렬화기가 빈 `` 추가 → +재파싱 시 char_shapes `[]`→`[(0,0)]`. fidelity 잔여 Class D(1건, 36386761 목록 para5). + +## 2. 근본원인 (`src/serializer/hwpx/section.rs`) +- `RunSplitter::new`(298-300) 규칙3: char_shapes 비면 기본 (0,0) 세그먼트. +- `close_run`(333-335) 규칙5: 빈 run 도 `` 방출. +- → char_shapes=[] 빈 문단에 charPrIDRef="0" run 생성. 원본은 run 없어 char_shapes=[]. + 판별자 = `char_shapes.is_empty()`(빈 run 이면 파서가 [(0,0)] 산출하므로 [] 는 run 부재 의미). + +## 3. 해결 +`render_runs` 진입부: 완전 빈 문단(text·char_shapes·controls·field_ranges·orphan 전부 없음)이면 +run 미방출(빈 문자열). char_shapes 있으면 종전 규칙3/5 유지. linesegarray 는 별도 경로라 보존. +`task1378_empty_paragraph_single_run_id_zero` 갱신(run 미방출이 정답). + +## 4. 검증 +| 검사 | 결과 | +|------|------| +| 단위 RED→GREEN | PASS | +| cargo test --lib | 1964 passed, 0 failed | +| hwpx_roundtrip_baseline | 4/4 | +| opengov snapshot (36386761 가드) | PASS | +| **fidelity 통제 비교** | **개선 1 / 회귀 0 / 순효과 +1** (IR_DIFF 4→3) | + +빈 문단 광역 변경에도 공통 10581건 회귀 0(char_shapes=[] 조건이 좁음). + +## 5. 산출물 +- 소스: `src/serializer/hwpx/section.rs` +- 테스트: `task1592_empty_paragraph_no_spurious_charshape` + opengov 가드 + #1378 갱신 +- 문서: 수행계획서, `_stage1~3`, 본 보고서 + +## 6. 후속 (잔여 IR_DIFF 3건) +- Class C1 (36384689·36385445): first-para mismatch-path char_shape (#1591 재범위, F3급). +- Class C2 (36388711): Field ClickHere −16/−8. diff --git a/mydocs/report/task_m100_1594_report.md b/mydocs/report/task_m100_1594_report.md new file mode 100644 index 000000000..79e5aa7ff --- /dev/null +++ b/mydocs/report/task_m100_1594_report.md @@ -0,0 +1,34 @@ +# Task #1594 — 최종 결과보고서 + +**제목**: HWPX 직렬화 시 holdAnchorAndSO 드롭(1→0) 수정 +**마일스톤**: M100 · **이슈**: #1594 · **브랜치**: `local/task1594` + +## 1. 문제·근본원인 +HWPX 직렬화기가 개체 `` 를 IR 값 무시하고 "0" 하드코딩 +(table/picture/shape/equation). 페이지 하단 앵커 개체에서 1→0 드롭 시 한글 페이지 붕괴. +IR diff=0(게이트 미검사)라 시각-only. #1589 군집 단락 이진탐색으로 36383351 의 단일 원인 확정. + +## 2. 수정 +1. 직렬화 4지점이 `holdAnchorAndSO` 를 `c.prevent_page_break != 0` 로 방출(하드코딩 제거). +2. `diff_documents` 에 `ObjectHoldAnchor` 비교 추가(Table/Picture/Equation) → 게이트 봉인. + +## 3. 검증 +| 검사 | 결과 | +|------|------| +| 단위 RED→GREEN (table) | PASS | +| cargo test --lib | 1969/0 | +| hwpx_roundtrip_baseline | 4/4 | +| IR 통제 비교(11855) | IR_DIFF 4(회귀 0), holdAnchor 게이트 0 mismatch | +| 한글 오라클 | 36383351 붕괴 해소(2→2), 이전 OK 30/30 유지(악화 0) | + +## 4. 한계 (정직) +#1589 페이지 붕괴 **군집은 이질적**. holdAnchorAndSO 는 36383351 의 deciding 요인이나, +붕괴 표본의 22/30 은 holdAnchorAndSO 보존됐는데도 붕괴(다른 systematic 드롭 deciding). +→ 본 수정은 holdAnchorAndSO-deciding 부분집합만 해소. 군집 대다수는 후속(아래). + +## 5. 후속 +다른 IR-invisible 직렬화 드롭 후보(holdAnchorAndSO 와 동형): outlineShapeIDRef(0→1), +noteSpacing(미세 감소), noteLine(NONE→SOLID), curSz(0→5669). 각 별 조사 권장. + +## 6. 산출물 +소스: table/picture/shape/section(equation)/roundtrip.rs. 테스트: task1594_* + opengov 36383351. diff --git a/mydocs/report/task_m100_1595_report.md b/mydocs/report/task_m100_1595_report.md new file mode 100644 index 000000000..d8e1b5f45 --- /dev/null +++ b/mydocs/report/task_m100_1595_report.md @@ -0,0 +1,32 @@ +# Task #1595 — 최종 결과보고서 + +**제목**: HWPX ClickHere 필드 타입 CLICKHERE→CLICK_HERE 수정 (페이지 붕괴 지배원인) +**마일스톤**: M100 · **이슈**: #1595 · **브랜치**: `local/task1595` + +## 1. 근본원인 +`serializer/hwpx/field.rs:180` 이 ClickHere 필드 타입을 `"CLICKHERE"`(언더스코어 누락)로 방출. +정답 `"CLICK_HERE"`(파서 4254·템플릿 2250/6695). 한글이 "CLICKHERE" 미인식 → ClickHere +placeholder 높이 변동 → 페이지 붕괴. 파서 관대(양형 수용)로 enum 동일 → IR diff=0(게이트 미검출). + +## 2. 수정 +`field.rs:180` `ClickHere => "CLICK_HERE"`. "CLICKHERE" 기대 테스트 2건 갱신. + +## 3. 검증 +| 검사 | 결과 | +|------|------| +| 단위 RED→GREEN | PASS (type="CLICK_HERE") | +| cargo test --lib | 1969/0 | +| baseline | 4/4 | +| IR 통제 비교(12042) | IR_DIFF 4(회귀 0) | +| 한글 오라클 붕괴 해소 | **37/40 (92.5%)** | +| 한글 오라클 악화 | 이전 OK 30/30 유지 (0) | + +## 4. 영향 +#1589 페이지 붕괴 군집(IR-invisible, PASS 파일 ~16%)의 **지배원인**. 붕괴파일 96% 가 ClickHere +보유, 본 수정으로 **92.5% 해소**. 시각 붕괴 갭 ~16% → ~1.3%(추정). 단일 1줄 수정. + +## 5. 후속 +잔여 붕괴 ~8%: holdAnchorAndSO(#1594, 일부) + 미상 소수. #1589 추가 좁히기 가능. + +## 6. 산출물 +소스: `serializer/hwpx/field.rs`(+테스트). 가드: `field_begin_emits_type_attr`(IR-invisible 라 단위 가드). diff --git a/mydocs/report/task_m100_1596_report.md b/mydocs/report/task_m100_1596_report.md new file mode 100644 index 000000000..0a118e9f5 --- /dev/null +++ b/mydocs/report/task_m100_1596_report.md @@ -0,0 +1,31 @@ +# Task #1596 — 최종 결과보고서 + +**제목**: HWPX generic-shape 지오메트리 직렬화 완성 (페이지 붕괴 잔여) +**마일스톤**: M100 · **이슈**: #1596 · **브랜치**: `local/task1596` + +## 1. 근본원인 +`render_common_shape_xml`(section.rs:1327)이 polygon/ellipse/arc/curve 의 지오메트리 +(``·``·`` 꼭짓점)를 드롭. 도형 형상·테두리 소실 → 렌더 크기 변동 +→ 페이지 붕괴(#1589 잔여 ~8%). IR 보유(파서 정상), serializer-only. + +## 2. 수정 +`render_common_shape_xml` 리팩터: `drawing: Option<&DrawingObjAttr>` + `points: &[Point]` 수신. +shape_block 직후 lineShape·fillBrush(조건부)·shadow(조건부)·hc:pt 방출(write_rect 동형). 태그 부수 +속성(numberingType/dropcapstyle/href/groupLevel/instid) + pos 속성 보강. dispatch 갱신. + +## 3. 검증 +| 검사 | 결과 | +|------|------| +| 단위 RED→GREEN | PASS (hc:pt/lineShape/shadow) | +| cargo test --lib | 1970/0 | +| baseline | 4/4 | +| IR 통제 비교(11874) | IR_DIFF 4(회귀 0) | +| 한글: 36396457 | 11→4 붕괴 → 11→11 해소, 지오메트리 보존 | +| 한글 악화 | 이전 OK 40/40 유지 (0) | + +## 4. 영향 +#1589 잔여 붕괴(shape 관련)의 근본. 누적(#1594 holdAnchorAndSO + #1595 ClickHere + #1596 shape)으로 +페이지 붕괴 군집 ~95%+ 해소. 도형 충실도(테두리/그림자/형상)도 복원. + +## 5. 산출물 +소스: section.rs(render_common_shape_xml/dispatch), shape.rs(헬퍼 pub). 가드: task1596_polygon_geometry_serialized. diff --git a/mydocs/report/task_m100_1598_report.md b/mydocs/report/task_m100_1598_report.md new file mode 100644 index 000000000..66f131de5 --- /dev/null +++ b/mydocs/report/task_m100_1598_report.md @@ -0,0 +1,59 @@ +# 최종 결과보고서 — Task #1598 + +**제목**: HWPX ellipse/arc 전용 지오메트리(center/축/시작끝점) 직렬화 완성 +**마일스톤**: M100 · **이슈**: edwardkim/rhwp#1598 · **브랜치**: `local/task1598` +**판정**: **채택 + merge** + +## 1. 요약 + +#1589 페이지 붕괴 군집의 잔여 long-tail 근본을 ellipse 에 대해 통제 테스트로 확정·해소. +HWPX 파서가 ellipse/arc 전용 지오메트리(`////` +`//`)를 읽지 않고(`..Default::default()`) 직렬화도 드롭하던 +**parser+serializer 양쪽 갭**을 수정. IR diff 게이트가 비교하지 않는 IR-invisible 결함이라 +한글 오라클(시각)만 검출하던 부류. + +## 2. 근본원인 + +- 파서 `parse_shape_object`(section.rs)의 자식 루프가 점요소를 `_ => {}` 로 폐기. +- 직렬화 `render_common_shape_xml` 도 미방출. +- 결과: 한글이 타원/호를 center/축 없이 다르게 렌더 → 누적 레이아웃 미세 변동 → + 경계 근처 문서 페이지 붕괴(예: 36385226 3→2). + +## 3. 통제 검증 (한글 오라클) + +| 36385226 (ellipse×9) | 한글 PageCount | +|------|------| +| orig | 3 | +| rt (수정 전) | 2 (붕괴) | +| rt + 지오메트리 주입 | 3 (해소) | +| **new-rt (#1598)** | **3 (해소, end-to-end)** | + +→ ellipse 는 `treatAsChar=1` + `sz` 고정으로 bounding box 불변임에도, 지오메트리 단독으로 +붕괴 해소. 한글의 비-IR 레이아웃 신호 의존성 재확인. + +## 4. 변경 + +| 파일 | 변경 | +|------|------| +| `src/parser/hwpx/section.rs` | `parse_xy` 헬퍼 + 7개 점요소 파싱 + ellipse/arc 생성자 적재 | +| `src/serializer/hwpx/section.rs` | 디스패치 `geom_tail` 빌드 + `render_common_shape_xml` 방출 | +| `tests/issue_1598_ellipse_geometry_roundtrip.rs` | 신규 단위 게이트 | +| `samples/hwpx/opengov/36385226_…hwpx` | 가드 샘플 | +| `tests/fixtures/opengov_snapshot.tsv` | 36385226 PASS/0 행 | +| `mydocs/tech/hwpx_page_collapse_cluster.md` | §7 확정 기록 | + +## 5. 검증 결과 (회귀 0) + +- 단위 #1598 / baseline 4종 / opengov 2 / **전체 lib 1970 passed, 0 failed**. +- clippy 0 / fmt clean / IR diff=0 유지. + +## 6. 보류 (별 타스크 후보) + +- ellipse/arc 태그 전용 속성(intervalDirty/hasArcPr/arcType) — 붕괴 무관, 모델 미보유. +- arc start/end 점 — ArcShape 모델 미보유, 실문서 출현 시 확장. + +## 7. #1589 군집 종합 + +ClickHere(#1595, 지배) → holdAnchorAndSO(#1594) → generic-shape 지오메트리(#1596) → +ellipse/arc 지오메트리(#1598)로 IR-invisible 직렬화 결함 4종 누적 해소. 잔여 표본 붕괴 +관측 안 됨. diff --git a/mydocs/tech/hwpx_page_collapse_cluster.md b/mydocs/tech/hwpx_page_collapse_cluster.md new file mode 100644 index 000000000..2f1d8929d --- /dev/null +++ b/mydocs/tech/hwpx_page_collapse_cluster.md @@ -0,0 +1,205 @@ +# HWPX 페이지 붕괴 군집 조사 (#1589) + +- 일자: 2026-06-27 +- 바이너리: devel 0c72b210 (4 채택 누적) +- 도구: `tools/verify_hangul_pages.py` (한글 PageCount 오라클) + +## 1. 군집 규모 (중대) — 확정 + +fidelity14 **PASS(IR diff=0) 파일** 한글 오라클 표본 측정: + +| 표본 | 측정 | COLLAPSE | 붕괴율 | 95% CI | +|------|----:|----:|----:|----:| +| 무작위 A (seed 20260627) | 517 | 83 | 16.1% | ±3.2% | +| 무작위 B (seed 7) | 119 | 17 | 14.3% | ±6.3% | +| **무작위 합집합(비편향)** | **631** | **100** | **15.8%** | **±2.8%** | +| 참고: 알파벳순 first | 1879 | 347 | 18.5% | ±1.8% | + +→ **IR 게이트 통과 파일의 ~16%(15.8±2.8%)가 한글에서 페이지 붕괴.** 단일 파일(#1589 최초 +36384160)이 아닌 **대규모 군집**. IR diff=0 ≠ 시각 무손실의 가장 큰 잔존 갭. + +> 알파벳순 표본(18.5%)이 무작위(15.8%)보다 높음 — 부서별 편차(초기 알파벳 부서의 실정보고 등 +> 복합 템플릿이 붕괴 빈발) 시사. 비편향 추정은 **~16%**. + +### 전수(10816) 미완 사유 — COM 환경 한계 (rhwp 무관) + +한글 COM 자동화가 **~500–1900 오퍼레이션 후 사망**(com_error 다발). 도구 강화로 대응: +- 증분 기록 + `--resume`(크래시 재개), 주기적 `taskkill /F /IM Hwp.exe` + 재시작(누수 200+ 방지), + 시작 시 정리. +- 그럼에도 특정 부서(예 도로사업소 시설보수과 실정보고) 연속 처리 시 모달 다이얼로그/보안모듈 + 추정 원인으로 회복 불가. 개별 파일은 클린 환경에서 정상 개방(진단 확인) → **rhwp/파일 무관, + COM 자동화 환경 한계**. 무작위 표본으로 충분히 확정. + +## 2. 붕괴 패턴 + +| 패턴 | 건수 | +|------|----:| +| 2→1 | 16 | +| 5→4 | 1 | + +거의 전부 **마지막 1쪽 흡수**(2→1). 대상 전부 정부 "결재문서본문" 양식 — 체계적 단일 원인 시사. + +## 3. IR-invisible 확인 (36389184, 2→1 대표) + +orig↔rt 비교: **IR-비교 가능 메트릭 전부 동일**. +- 구조: hp:p 122, hp:run 110, hp:lineseg 218, hp:tbl 4, hp:tr 21, hp:tc 94 — 모두 일치. +- 수직: Σvertpos/vertsize/textheight/baseline/spacing 전부 일치. +- header: charPr 30(height 동일), paraPr 32(lineSpacing 동일), fontface 8, borderFill 10. + +→ 한글이 reflow 에 쓰는 IR 값은 동일한데 페이지수만 다름. + +## 4. 배제한 가설 (red herrings) + +| 가설 | 검증 | 결론 | +|------|------|------| +| **탭 switch 래퍼 드롭**(48KB) | rhwp 가 `...` 를 plain `` 로 방출(48KB 감소). **그러나 OK(비붕괴) 파일도 동일 드롭**(Δ=48628) | **무관**(보편적·양성). 단 pos=0 탭이라 레이아웃 영향 없음 | +| **#1592 빈문단 run 제거** | pre-#1592 rt(fidelity13)도 동일하게 2→1 붕괴 | **무관**(붕괴가 #1592 선행) | + +## 5. 남은 구체 차이 (미규명) + +- rt 가 **빈 `` 58개 추가**(orig 0). close_run 규칙5(빈 run `` 보존) + 유래. 단 빈 run 은 높이를 **더해** rt 를 길게 만들 텐데 붕괴는 rt 가 **짧음** → 방향 불일치, + 단순 원인 아님. +- rt 가 `Preview/PrvImage.png` 추가(썸네일, 레이아웃 무관). + +## 5b. 근본원인 좁히기 — 통제 실험 (거의 동일 쌍) + +**완벽한 비교쌍**: 붕괴 `36383351 [관악산] 산악구조대 구급의약품 폐기 계획`(2→1) vs OK +`36387726 [북한산] …`(동일 템플릿, 1문단 차). 두 파일의 **orig↔rt serialization 차이가 완전 동일** +(빈 hp:t +41/+39, 헤더 탭 −48KB, Preview) → **붕괴는 file-specific 아님, content 가 페이지 경계 +근처인지에만 의존** 확정. + +**하이브리드 bisection**(charPr id 매핑 동일=유효): + +| 조합 | 페이지 | +|------|----:| +| orig-sec + orig-hdr | 2 | +| orig-sec + rt-hdr | 2 | +| rt-sec + orig-hdr | **1** | +| rt-sec + rt-hdr | **1** | + +→ **rt-section0 이 원인**(header/탭 switch 확정 배제, header 무관). + +**개별 차이 통제 배제**(orig 수정 or rt-sec revert, 한글 페이지수로 판정 — 전부 붕괴 불변): +빈 `` 런·self-closed ``·``·curSz(0↔5669)·noteLine(NONE↔SOLID)· +noteSpacing·fwSpace(전각공백→공백)·para id(0x80000000↔순차)·linesegarray(**96/96 바이트 동일**)· +outlineShapeIDRef(0↔1). **9+ 후보 전부 단일 원인 아님**. + +→ **단일 변수로 재현 불가 = 누적/미세 상호작용 효과**(rt 의 빈런 표현·미세 spacing·shape 속성 +차이가 합쳐져 경계-근처 문서를 tip). 정밀 규명은 한글 내부 레이아웃 디버깅 영역(정적 XML 분석 한계). + +## 5c. 시각 추적 — 한글 페이지 브레이크 (PDF 렌더 비교) + +`36383351 [관악산]` 을 한글로 PDF 내보내 페이지 레이아웃 직접 비교(pyhwpx save_as PDF → +PyMuPDF 렌더): + +- **본문 최상위 문단별 페이지 매핑**(KeyIndicator prnpageno): orig 는 para 0–8 = 1쪽, + **para 9(빈 문단, 문단나눔) = 2쪽**. rt 는 para 0–9 전부 1쪽. +- para 8 = `"붙임 구급 의약품 폐기 확인서(서식) 1부. 끝."`(마지막 본문, 양쪽 1쪽 동일). + +**시각 확인(렌더 이미지)**: +- orig 1쪽: 본문이 ~60% 지점("붙임…끝.")에서 끝, 하단 40% 공백. **발신명의 footer 블록은 2쪽**. +- orig 2쪽: 거의 비고 하단에 **발신명의 블록만**(결재선 "1팀장 강한석…산악구조대장 이낙규" + + "시행 산악구조대-2491" + "우 08825…" + "전화…"). +- **rt 1쪽: 동일 본문 + 발신명의 블록이 1쪽 하단에 모두 수용**. + +→ **붕괴의 시각적 실체 = 정부문서 표준 "발신명의" footer 블록(본문 뒤 하단 앵커)이 razor-thin +차이로 rt 에선 1쪽에 들어가고 orig 에선 2쪽으로 밀리는 것.** 본문은 시각적으로 완전 동일, +차이는 sub-line 누적 높이(§5b 9후보 배제와 정합 — 단일 원소 아닌 미세 누적이 경계를 tip). + +> 함의: 붕괴는 "텍스트/내용 손실"이 아니라 **razor-thin 레이아웃 마진에서의 페이지 분할 변동**. +> 실문서 다수가 발신명의 블록을 본문 끝 직후 하단에 두는 동일 양식이라 14–16% 가 경계 근처. + +## 5d. 근본원인 확정 — `holdAnchorAndSO` 직렬화 드롭 (실버그) + +§5b 에서 단일 변수 분리 실패 후, **단락 단위 이진 탐색**(rt-section 문단을 orig 로 되돌리며 +한글 페이지수 판정)으로 정확히 좁힘: + +- 문단 5-9 revert → 해소 → 문단 9(발신명의 footer 표 포함) 단독 revert → **해소**. +- 문단 9 정규화 diff(id/빈런 제거) → **유일 차이 1건**: + ``` + 외곽 footer 표(페이지 하단 앵커, vertRelTo="PAGE" vertAlign="BOTTOM")의 : + orig: holdAnchorAndSO="1" rt: holdAnchorAndSO="0" + ``` +- **결정 테스트**: orig 에서 `holdAnchorAndSO` 1→0 만 치환 → **2쪽→1쪽 붕괴 재현**. 확정. + +### 코드 (실버그) + +HWPX 직렬화기가 `holdAnchorAndSO` 를 **"0" 하드코딩**, 파싱된 IR 값 무시: +- `table.rs:146`, `picture.rs:407`, `shape.rs:899`, equation(`section.rs:1451`) = `("holdAnchorAndSO","0")`. +- 파서(`parser/hwpx/section.rs:1672`)는 정상 저장: `holdAnchorAndSO → common.prevent_page_break`(i32). +- **IR 비교가 `prevent_page_break` 미검사 → IR diff=0** (게이트 미검출, 시각만 붕괴). + +### 군집 적용성 + +무작위 400 표본 orig 의 `holdAnchorAndSO="1"` 보유: **붕괴 53/63(84%)**, OK 278/337(82%). +→ 직렬화기가 전수 1→0 드롭. 페이지 경계 근처 문서(붕괴군)에서 발신명의 footer(페이지 하단 앵커) +위치가 바뀌어 붕괴. **`holdAnchorAndSO` 보존 수정 시 붕괴군 대다수 해소 예상**(별 통제 비교 필요). + +> 결론: 페이지 붕괴는 "누적 미세차"가 아니라 **단일 속성 `holdAnchorAndSO` 직렬화 드롭**. §5b 의 +> 9후보가 모두 음성이었던 이유 — 진짜 원인이 그 목록 밖(pos 의 boolean 속성)이었음. + +## 5e. 잔여 붕괴(~8%) 좁히기 — 불완전 generic-shape 지오메트리 + +#1595(CLICK_HERE) 후 잔여 붕괴 표본 3건(36396457·36389684·36385226) 이진탐색·특성화: + +| 파일 | 섹션 | 도형 | deciding | +|------|----:|------|----------| +| 36396457 | 3 | polygon×4 | section2 문단23 polygon | +| 36389684 | 4 | polygon×5,pic×2 | (polygon 추정) | +| 36385226 | 1 | ellipse×9,pic×2 | (ellipse 추정) | + +**3건 전부 generic-shape(polygon/ellipse) 보유** → 공통 경로 `render_common_shape_xml` +(section.rs:1327)이 불완전: +- 태그에서 `numberingType="PICTURE"`·`dropcapstyle`·`href`·`groupLevel`·`instid` 드롭. +- `` 에서 affectLSpacing·flowWithText·allowOverlap·holdAnchorAndSO 누락. +- **드로잉 지오메트리(lineShape·points·fillBrush) 드롭/축약**(rt −2614자) — **이것이 deciding**. + +**통제 테스트(36396457 section2)**: polygon 태그속성 복원만으로는 미해소(page 4) → +**지오메트리 드롭이 원인**. 문단23(polygon 포함) 전체 revert 시에만 해소(page 11). + +→ **잔여 붕괴의 근본 = generic-shape(polygon/ellipse/arc/curve) 지오메트리 직렬화 미완** +(shape.rs:11 "Arc/Polygon/Curve 확대 별도 분류" 갭). rect/picture 와 달리 실제 도형 데이터가 +보존되지 않아 렌더 크기·레이아웃 변동 → 경계 근처 문서 붕괴. **별 타스크(도형 직렬화 완성)** +필요, 우선순위 낮음(잔여 ~8% = 군집의 long tail). + +## 6. 결론 + 권고 + +붕괴는 **IR-identical content 인데 한글 reflow 결과만 다른 심층 레이아웃 충실도 결함**. 표면 +XML 차이(탭 switch·빈 t·preview)로는 설명 안 됨 — 한글이 읽는 **비-IR 레이아웃 신호**(런 경계 +분할, 문자 폭 미세, 줄바꿈 기회 등)의 차이로 추정. + +**규모 큼(~14%)·난이도 높음**. 후속 권고: +1. **전수 오라클 배치**로 정확한 붕괴율·군집 규모 확정(현재 120 표본 추정). +2. 붕괴/비붕괴 파일 쌍의 **section0 정밀 바이트 diff**(런 경계·hp:t 분할 패턴 차이). +3. 한글에서 **페이지 브레이크 위치 시각 비교**(어느 줄/문단에서 갈리는지). +4. 가설: 런 경계 분할(charPr boundary)이 한글 줄바꿈 기회를 바꿔 더 촘촘히 패킹 → 행 수 감소. + +근거: `output/poc/fidelity14/oracle_collapse_scan.tsv`, 메모리 [[hwp5-save-fidelity-gaps]]. + +## 7. [확정 #1598] ellipse/arc 전용 지오메트리가 잔여 long-tail 의 근본 + +§5e 의 "generic-shape 지오메트리 미완" 가설을 **ellipse 에 대해 통제 테스트로 확정**. + +**근본**: HWPX 파서 `parse_shape_object` 가 ellipse/arc 자식 `///` +`///` 를 `_ => {}` 로 버리고(EllipseShape/ArcShape 를 +`..Default::default()` 로 생성), 직렬화 `render_common_shape_xml` 도 미방출. IR diff 게이트는 +ellipse 지오메트리를 비교하지 않아(IR-invisible) 미검출 — 한글 오라클만 검출. + +**통제 테스트(36385226, ellipse×9, section0)**: + +| 파일 | 한글 PageCount | +|------|---------------| +| orig | 3 | +| rt (지오메트리 드롭, 수정 전) | **2** ← 붕괴 | +| rt + orig 지오메트리 주입 | **3** ← 해소 | +| new-rt (#1598 파서+직렬화 수정) | **3** ← 해소 (end-to-end) | + +→ ellipse 는 `treatAsChar=1` + `sz` 고정이라 bounding box 불변이지만, 한글은 center/축 없이는 +타원을 다르게 렌더 → 누적 레이아웃 미세 변동 → 경계 근처 붕괴. **지오메트리 단독으로 해소** +(태그속성 intervalDirty/hasArcPr/arcType 는 불필요 — 보류). + +**수정**: 파서 `parse_xy` 로 7개 점 적재(ellipse) / 3개(arc), 직렬화 `geom_tail` 로 shadow 직후 +방출. polygon/curve points 는 #1067/#1200 으로 이미 정상. + +**잔여**: 36389684 는 현재 바이너리에서 orig=rt=2 (붕괴 없음) — 군집 해소. diff --git a/mydocs/tech/hwpx_residual_ir_diff_10.md b/mydocs/tech/hwpx_residual_ir_diff_10.md new file mode 100644 index 000000000..0e489c174 --- /dev/null +++ b/mydocs/tech/hwpx_residual_ir_diff_10.md @@ -0,0 +1,100 @@ +# HWPX 잔존 IR_DIFF 10건 분석 (fidelity10, #1584 이후) + +- 일자: 2026-06-27 +- 바이너리: `local/task1584` HEAD (#1584 ColumnDef 수정 반영) +- 말뭉치: hwpdocs 9660 hwpx → IR_DIFF 10 (PARSE_FAIL 12 별개) +- **검증**: 10건 전부 #1584 수정 전/후 diff 성격 **완전 동일** → ColumnDef 수정과 무관한 선존 잔여. + +## 분류 요약 + +| 클래스 | 건수 | 성격 | 시각 영향 | 수정 난이도 | +|--------|----:|------|----------|-----------| +| **A. Ruby 직렬화기 부재** | 3 | 실버그 | **있음**(루비 주음 소실) | 중 | +| **B. write_line shapeComment 누락** | 3 | 경미 | 거의 없음(설명 메타데이터) | **하**(1줄) | +| **C. para0 char_shape 경계 오정렬** | 3 | 조사 필요 | 가능성 있음 | 미상 | +| **D. 빈/공백 문단 spurious (0,0)** | 1 | 경미 | 없음 | 하~중 | + +--- + +## A. Ruby 컨트롤 드롭 (3건: 36384160, 36399208, 36389301) + +**근본원인**: `render_control_slot`(section.rs)에 **`Control::Ruby` arm 부재**. +Ruby 는 `is_hwpx_inline_slot`(line 749)에 등록돼 슬롯으로 인식되지만, 방출 dispatch 에서 +대응 arm 이 없어 `_ => {}` 로 빠져 **XML 미방출 → 드롭**. (ColumnDef 와 동형 — 인식되나 미방출.) + +``` +36384160 sec2: paragraph[88]cell·[118]·[179](ruby×2)·[181] ruby 드롭 +36399208 sec2: paragraph[72] ruby 드롭 +36389301 sec0: paragraph[6] ruby 드롭 → char_shapes (51,8)→(43,8) −8 시프트(하위 증상) +``` + +- **36389301 의 char_shape −8 시프트는 ruby 드롭의 하위 증상** (8유닛 슬롯 소실 → 후속 경계 −8). + ColumnDef 와 동일 패턴 — 컨트롤 드롭이 인덱스/오프셋을 밀어 char_shape 를 변위. +- 영향: 루비(한자 독음·위첨자) 텍스트가 사라짐 → 실제 시각 차이. +- 수정 방향: `write_ruby` 직렬화기 + `render_control_slot` 에 `Control::Ruby` arm 추가. + 파서(`parse_hwpx`)의 ruby 역매핑 확인 필요. + +## B. write_line shapeComment 누락 (3건: 36389418, 36392900, 36391302) + +**근본원인**: `shape.rs`의 `write_line`(line 121)이 **`write_shape_comment` 미호출**. +`write_rect`(line 110)·`write_container_close`(line 235)는 호출하나 선 도형만 누락. +→ 선 도형 설명("선입니다.")이 저장 시 드롭. + +``` +36389418 sec0 p18 cell shape ×6, 36392900 sec0 p19 cell shape ×11, 36391302 ×2 +``` + +- 영향: 도형 설명(접근성/메타데이터, 화면 비표시) 소실 — 시각 영향 거의 없음. +- 수정 방향: `write_line` 의 caption 방출 뒤 `write_shape_comment(w, c)?;` 1줄 추가 + (#1392/#1403 도형 설명 보존과 정합). **가장 간단**. + +## C. para0 char_shape 경계 오정렬 (3건: 36384689, 36385445, 36388711) + +필드(fieldBegin/End)·루비와 **무관**. 섹션 첫 문단(secPr+colPr+표 영역)의 char_shape 경계가 +control-slot 공간에서 어긋남. + +``` +36384689 p0 "문서번호": (24,10)→(32,10) +8 [ctrl,tbl,tbl] +36385445 p0 "문서번호": (24,15)→(32,15) +8 [ctrl,tbl,tbl] ← 동일 패턴(체계적) +36388711 p0 (로고/표): (52,8)(81,9)→(36,8)(73,9) −16/−8 [secPr,colPr,tbl, line×3] +``` + +### 후속 규명 (#1591/#1593 조사 결과) + +| 파일 | 진짜 근본 | 처리 | +|------|----------|------| +| 36384689·36385445 (C1) | **first-para mismatch-path 위치추정**: para0 가 #1584 ColumnDef 템플릿 흡수로 `slot_count`(cc기반)≠`slots.len()`→mismatch 경로가 char_shape +8 오배치. 표면 증상이던 북마크 hoist 는 무관(수정해도 +8 불변) | #1591 재범위, **미해결(F3급)** | +| 36388711 (C2) | **same-para fieldEnd 드롭(cc −8) + 북마크 hoist 결합**. fieldBegin/End 1/1→1/0. F3(#1561 cross-para) 와 다른 same-para 변종 | #1593, **보류** | + +→ **잔여 3건 모두 first-para mismatch-path 슬롯 위치추정으로 수렴**. F3(#1561, 2회 실패)· +#1591(순효과0 롤백)과 동질의 고위험 영역. 개별 파일 수정 대신 **mismatch-path 슬롯 위치추정 +통합 리팩터**로 묶어 처리 권고(광역 통제비교 필수, 악화 즉시 롤백). + +## D. 빈/공백 문단 spurious char_shape (1건: 36386761) + +``` +36386761 sec0 p5 " 수신 ": expected=[] actual=[(0,0)] +``` + +- 원본에 char_shape 없는 공백 문단에 저장→재파싱 시 기본 char_shape (0,0) 가 생성됨. +- 영향: 없음(기본 글자모양). 경미. +- **해결(#1592, 채택)**: render_runs 가 완전 빈 문단(char_shapes=[])에 run 미방출. 통제비교 + 개선1/회귀0. (RunSplitter::new 규칙3 + close_run 규칙5 가 빈 run 을 가공하던 것을 차단.) + +--- + +## 처리 결과 (2026-06-27) + +| Class | 이슈 | 결과 | +|-------|------|------| +| A (Ruby 드롭) | #1587 | **채택** (개선3) | +| B (선 도형 shapeComment) | #1588 | **채택** (개선3) | +| C1 (para0 mismatch-path) | #1591 | **불채택**(북마크 hoist 순효과0) → mismatch-path 재범위, 미해결 | +| C2 (fieldEnd 드롭+북마크 결합) | #1593 | **보류** (통합 리팩터 권고) | +| D (spurious 0,0) | #1592 | **채택** (개선1) | + +**누적: HWPX 실문서 IR_DIFF 59→3** (#1584 ColumnDef 포함 4 채택). 잔여 3건(C1 2 + C2 1)은 +모두 **first-para mismatch-path 슬롯 위치추정**으로 수렴 → F3(#1561)·#1591 동질 고위험. +**통합 리팩터 권고**(개별 수정 금지, 광역 통제비교 필수). + +> PARSE_FAIL 12건 = "ZIP EOCD 없음" 손상 다운로드(수집기 아티팩트, rhwp 무관). diff --git a/mydocs/troubleshootings/bracket_filename_msys_path.md b/mydocs/troubleshootings/bracket_filename_msys_path.md new file mode 100644 index 000000000..3eafe2a26 --- /dev/null +++ b/mydocs/troubleshootings/bracket_filename_msys_path.md @@ -0,0 +1,41 @@ +# 대괄호 파일명 "읽기 실패" — Git Bash/MSYS 경로 변환 quirk (rhwp 버그 아님) + +## 증상 + +Git Bash 에서 대괄호(`[...]`) 포함 파일을 `/c/...` Unix 경로로 rhwp CLI 에 넘기면 실패: + +``` +$ ./target/release/rhwp.exe dump '/c/Users/.../36383351_..._[관악산] ... 보고.hwpx' -s 0 -p 0 +오류: 파일을 읽을 수 없습니다 - /c/Users/.../[관악산] ...: 지정된 경로를 찾을 수 없습니다. (os error 3) +``` + +## 원인 — MSYS 경로 변환 스킵 (rhwp 무관) + +Git Bash(MSYS)는 Windows .exe 에 인자를 넘길 때 `/c/Users/...` → `C:\Users\...` 자동 변환한다. +그러나 **경로에 `[...]` 가 있으면 글롭 패턴으로 간주해 변환을 스킵**, 변환 안 된 `/c/...` 를 +rhwp 에 그대로 전달한다. rhwp 는 `fs::read("/c/...")` 를 호출하나 Windows 에서 `/c/Users` 는 +유효 경로가 아니라 실패(os error 3). + +## 검증 (rhwp 는 대괄호 정상 처리) + +| 경로 형식 | 대괄호 파일 | 결과 | +|-----------|-----------|------| +| `C:\Users\...[관악산]...` (PowerShell/cmd) | ✅ 정상 | +| `C:/Users/...[관악산]...` (슬래시 Windows) | ✅ 정상 | +| `/c/Users/...[관악산]...` (MSYS Unix) | ❌ 실패 | +| `/c/Users/...비대괄호...` (MSYS Unix) | ✅ 정상(MSYS 변환됨) | + +**fidelity14 배치: 대괄호 파일 937건 전수 정상 처리**(935 PASS + 2 PARSE_FAIL=손상 다운로드). +→ rhwp 실사용(배치 rglob, PowerShell, Python Path)에 **영향 0**. + +## 해결 (코드 수정 불요) + +- **PowerShell/cmd 또는 Windows 경로(`C:\` / `C:/`)** 사용. +- Git Bash 에서 부득이 `/c/` 경로를 쓸 땐 `MSYS_NO_PATHCONV=1` 환경변수 + Windows 경로, + 또는 `cygpath -w` 로 변환. + +## rhwp 코드 수정 부적절 사유 + +`/c/...` 를 드라이브 C: 로 해석하도록 rhwp 에 추가하면 **Linux/WASM 빌드에서 `/c/Users` 가 +정상 절대경로인 것과 충돌**한다. `/c/...` 는 MSYS 관례일 뿐 표준 경로가 아니므로 Windows +프로그램이 파싱할 의무가 없다. rhwp 는 모든 표준 경로 형식을 대괄호 포함 정상 처리한다. diff --git a/mydocs/working/task_m100_1584_stage1.md b/mydocs/working/task_m100_1584_stage1.md new file mode 100644 index 000000000..3d6d9d1ba --- /dev/null +++ b/mydocs/working/task_m100_1584_stage1.md @@ -0,0 +1,29 @@ +# Task #1584 — Stage 1 완료보고서 + +**단계**: 드롭 재현 회귀 테스트 박제 (RED) +**브랜치**: `local/task1584` + +## 작업 내용 + +`src/serializer/hwpx/section.rs` 테스트 모듈에 회귀 가드 추가: +`task1584_body_first_para_two_columndefs_roundtrip`. + +- 본문 첫 문단에 `ColumnDef` 2개를 둔 최소 Document 를 구성. +- `serialize_hwpx → parse_hwpx` 전체 roundtrip 수행. +- reparse 후 첫 문단의 `ColumnDef` 개수가 2인지 단언. + +## 결과 (RED 확인) + +``` +assertion `left == right` failed: ... ColumnDef 2개가 ... 보존돼야 한다: 1 + left: 1 + right: 2 +``` + +- **현재 코드: 2번째 인라인 ColumnDef 드롭** → reparse 1개. 버그 정확 재현. +- 근본원인(수행/구현 계획서 §2) 일치: 템플릿 앵커가 첫 ColumnDef 1개만 흡수, + 본문 인라인 슬롯 필터가 ColumnDef 전부 제외 → 2번째+ 드롭. + +## 다음 단계 + +Stage 2 — Option A 구현(C1~C4)으로 GREEN 전환 + baseline 회귀 0 확인. diff --git a/mydocs/working/task_m100_1584_stage2.md b/mydocs/working/task_m100_1584_stage2.md new file mode 100644 index 000000000..88ef3207b --- /dev/null +++ b/mydocs/working/task_m100_1584_stage2.md @@ -0,0 +1,42 @@ +# Task #1584 — Stage 2 완료보고서 + +**단계**: Option A 구현 (GREEN) +**브랜치**: `local/task1584` + +## 변경 내용 + +| # | 파일 | 변경 | +|---|------|------| +| C1 | `context.rs` | `body_coldef_template_pending: bool` 필드 추가 (consume-once 플래그) | +| C2 | `section.rs` write_section | 첫 문단 렌더 직전 플래그 set, 직후 reset | +| C3 | `section.rs` render_runs | 본문 ColumnDef 슬롯 처리를 분기별로 정밀화 (아래) | +| C4 | `section.rs` render_control_slot | 본문 첫 ColumnDef 의 XML 만 consume-once 로 억제 | + +## 설계 핵심 — 두 슬롯 분기의 차이 + +초기 단순 구현(첫 ColumnDef 를 slots 에서 일괄 제거)은 회귀를 유발했다: +- **all-controls 분기**(`slot_count==len`)에서 ColumnDef 는 char-offset 슬롯을 점유 → + 제거 시 8유닛 회계 손실 → 후속 char_shape −8 시프트 (aift/exam_kor 회귀). +- **filter 분기**(`slot_count!=len`)의 단일 ColumnDef 는 위치 슬롯 미점유(추정 카운트 제외) → + 추가 시 position→mismatch 경로 전환 → equation 테스트 회귀. + +→ 분기별로 다르게 처리: +- **all-controls**: 첫 ColumnDef 를 슬롯에 **유지**(회계 보존), XML 만 consume-once 억제. +- **filter**: 첫 ColumnDef 를 슬롯에서 **제외**(위치 슬롯 미점유분), 2번째+ 만 포함 → + 드롭 방지. 제외 시 consume-once 플래그 해제(2번째 오억제 방지). + +## 검증 결과 (모두 GREEN) + +| 검사 | 결과 | +|------|------| +| RED 테스트 `task1584_..._roundtrip` | **PASS** (1→2 보존) | +| `equation_control_*` (3건, 회귀 가드) | PASS | +| `cargo test --lib` 전체 | **1960 passed, 0 failed** | +| `cargo test --test hwpx_roundtrip_baseline` | **4/4 PASS** (#1407/#1388 컬럼 보존) | +| `cargo clippy --lib` (변경 파일) | 무경고 | +| 실문서 `36382399` roundtrip | **PASS diff=0**, colPr 2==2 (인라인 ColumnDef 보존) | + +## 다음 단계 + +Stage 3 — fidelity 전수(hwpdocs 9350 + samples 319) 통제 비교: 49건 IR_DIFF 해소 확인, +악화 0 확인, 순효과>0. 스냅샷 갱신. diff --git a/mydocs/working/task_m100_1584_stage3.md b/mydocs/working/task_m100_1584_stage3.md new file mode 100644 index 000000000..e25b7322b --- /dev/null +++ b/mydocs/working/task_m100_1584_stage3.md @@ -0,0 +1,50 @@ +# Task #1584 — Stage 3 완료보고서 + +**단계**: 통제 비교 검증 (채택 게이트) +**브랜치**: `local/task1584` +**바이너리**: `local/task1584` HEAD (f5d50f7e 위 빌드) + +## 1. fidelity 전수 통제 비교 (hwpdocs) + +| 항목 | 수정 전 (devel HEAD, fidelity9) | 수정 후 (fidelity10) | +|------|------:|------:| +| 총 파일 | 9350 | 9660 (수집 진행) | +| PASS | 9279 | 9638 | +| **IR_DIFF** | **59** | **10** | +| PARSE_FAIL | 12 | 12 (손상 다운로드, 불변) | + +**공통 9350건 per-file 통제 비교**: + +| 분류 | 건수 | +|------|----:| +| 개선 (IR_DIFF→PASS) | **49** | +| **회귀 (PASS→IR_DIFF)** | **0** | +| 잔존 (IR_DIFF→IR_DIFF) | 10 | +| 신규 파일(310) 중 IR_DIFF | 0 | +| **순효과 (개선−회귀)** | **+49** | + +→ 채택 게이트 충족: **순효과 +49 > 0, 악화 0**. + +잔존 10건 = F3 잔여(다중필드 복합슬롯) + shapeComment + ruby 엣지 — 본 타스크 범위 외(별건). + +## 2. Hangul 페이지 오라클 (시각 붕괴 보조 검증) + +개선 49건 중 8건 표본(seed=1), 한글 편집기 PageCount 비교: + +``` +8건 / OK=8 COLLAPSE=0 기타=0 (붕괴율 0%) 전부 pg 1→1 +``` + +→ ColumnDef 복원이 페이지 레이아웃을 붕괴시키지 않음. 오히려 컨트롤 인덱스 시프트가 +사라져 셀 char_shape 오매핑(숨은 바코드 가시화) 하위 증상도 동시 해소. + +## 3. 회귀 가드 영속화 + +- 단위: `task1584_body_first_para_two_columndefs_roundtrip` (Stage 1). +- 통합: 대표 실문서 `36382399` 를 `samples/hwpx/opengov/` 고정 말뭉치에 편입, + `tests/fixtures/opengov_snapshot.tsv` 에 PASS 등록. snapshot 테스트 통과. + +## 4. 결론 + +본문 인라인 ColumnDef 드롭 결함 해소. 실문서 IR_DIFF 59→10(공통 기준 −49, 회귀 0). +무손실 PASS율 추가 상승. 채택. diff --git a/mydocs/working/task_m100_1587_stage1.md b/mydocs/working/task_m100_1587_stage1.md new file mode 100644 index 000000000..c67f57d56 --- /dev/null +++ b/mydocs/working/task_m100_1587_stage1.md @@ -0,0 +1,34 @@ +# Task #1587 — Stage 1 완료보고서 + +**단계**: Ruby 드롭 재현 테스트 (RED) +**브랜치**: `local/task1587` + +## 작업 내용 + +`src/serializer/hwpx/mod.rs` 테스트 모듈에 회귀 가드 추가: +`task1587_ruby_control_roundtrips`. + +- `Control::Ruby{ruby_text:"덧말", ..Default::default()}` 를 둔 최소 Document 구성 + (`..Default::default()` 사용 → Stage 2 모델 필드 확장에 견고). +- `serialize_hwpx → parse_hwpx` roundtrip 후 첫 문단 controls 의 Ruby 보존 + ruby_text 검증. + +## 결과 (RED 확인) + +``` +assertion failed: Ruby 컨트롤이 roundtrip 후 보존돼야 한다 (현재 드롭) + controls = [SectionDef, ColumnDef] ← 템플릿 자동 주입, Ruby 소실 + left: 0 right: 1 +``` + +- **현재 코드: Ruby 드롭** — `is_hwpx_inline_slot` 에 등록(인식)됐으나 `render_control_slot` + 방출 arm 부재로 `_ => {}` 처리. 근본원인(구현 계획서 §1) 일치. + +## 그라운딩 재확인 + +실문서 `36389301` 문단 0.6 dump: cc=59, text_len=50 → (59−1−50)/8 = **1 슬롯**. +ruby subText="전술훈련 30% + 현지훈련 20%". mainText("팀단위 훈련")는 para.text 에 **부재** +→ 모델 손실 구조(mainText 미보존) 입증. + +## 다음 단계 + +Stage 2 — 모델(`control.rs` Ruby 필드 확장) + 파서(`parse_dutmal` 전 속성/요소 보존). diff --git a/mydocs/working/task_m100_1587_stage2.md b/mydocs/working/task_m100_1587_stage2.md new file mode 100644 index 000000000..644a4203f --- /dev/null +++ b/mydocs/working/task_m100_1587_stage2.md @@ -0,0 +1,22 @@ +# Task #1587 — Stage 2 완료보고서 + +**단계**: 모델 + 파서 확장 +**브랜치**: `local/task1587` + +## 변경 내용 + +| # | 파일 | 변경 | +|---|------|------| +| C1 | `src/model/control.rs` Ruby | `alignment`(u8) 제거 → `main_text`, `pos_type`, `align`, `sz_ratio`, `option`, `style_id_ref` 추가 | +| C2 | `src/parser/hwpx/section.rs` parse_dutmal | posType/align 분리 보존 + szRatio/option/styleIDRef 파싱 + mainText 보존(종전 skip 제거) | + +## 검증 + +- `cargo build` 성공 — `alignment` 제거가 **외부 파급 0**(parse_dutmal 한 곳만 사용 확인 입증). +- Stage 1 RED 테스트 `task1587_ruby_control_roundtrips` 는 `..Default::default()` 사용으로 + 모델 변경 후에도 컴파일되며, 직렬화기 미수정이므로 **여전히 RED**(Ruby 드롭). 의도된 상태. + +## 다음 단계 + +Stage 3 — 직렬화기(`write_ruby` + `render_control_slot` arm). Stage 1 GREEN 전환 + +신규 필드 무손실 단언 추가 + baseline 회귀 0 확인. diff --git a/mydocs/working/task_m100_1587_stage3.md b/mydocs/working/task_m100_1587_stage3.md new file mode 100644 index 000000000..1ab6ce392 --- /dev/null +++ b/mydocs/working/task_m100_1587_stage3.md @@ -0,0 +1,28 @@ +# Task #1587 — Stage 3 완료보고서 + +**단계**: 직렬화기 (write_ruby + arm) +**브랜치**: `local/task1587` + +## 변경 내용 + +| # | 파일 | 변경 | +|---|------|------| +| C3a | `serializer/hwpx/section.rs` | `render_dutmal(r: &Ruby)` 추가 — `` 역매핑(속성 순서 posType/szRatio/option/styleIDRef/align + mainText/subText) | +| C3b | `serializer/hwpx/section.rs` | `render_control_slot` 에 `Control::Ruby(r) => render_dutmal(r)` arm 추가 | +| — | import | `Ruby` 타입 use 추가 | + +Ruby 는 이미 `is_hwpx_inline_slot` 포함 → 슬롯 위치 자동, arm 추가만으로 방출. + +## 검증 (모두 GREEN) + +| 검사 | 결과 | +|------|------| +| `task1587_ruby_control_roundtrips` (전 필드 무손실) | **PASS** — main_text/ruby_text/pos_type/align/sz_ratio/option/style_id_ref 보존 | +| `cargo test --lib` 전체 | **1961 passed, 0 failed** | +| `hwpx_roundtrip_baseline` | **4/4 PASS** | +| `cargo clippy --lib` (변경 파일) | 무경고 | + +## 다음 단계 + +Stage 4 — fidelity 전수 통제 비교: 3건(36384160·36399208·36389301) 해소 + 악화 0 + +순효과>0 확인, opengov 가드 편입. diff --git a/mydocs/working/task_m100_1587_stage4.md b/mydocs/working/task_m100_1587_stage4.md new file mode 100644 index 000000000..15484a959 --- /dev/null +++ b/mydocs/working/task_m100_1587_stage4.md @@ -0,0 +1,51 @@ +# Task #1587 — Stage 4 완료보고서 + +**단계**: 통제 비교 검증 (채택 게이트) +**브랜치**: `local/task1587` +**바이너리**: `local/task1587` HEAD (c3d09b0c 위 빌드) + +## 1. fidelity 전수 통제 비교 + +| 항목 | Ruby 전 (fidelity10) | Ruby 후 (fidelity11) | +|------|------:|------:| +| 총 파일 | 9660 | 10062 (수집 진행) | +| IR_DIFF | 10 | **7** | +| PARSE_FAIL | 12 | 12 (손상, 불변) | + +**공통 9553건 per-file 통제 비교**: + +| 분류 | 건수 | +|------|----:| +| 개선 (IR_DIFF→PASS) | **3** (36384160·36389301·36399208 = ruby 3건) | +| **회귀 (PASS→IR_DIFF)** | **0** | +| 신규 파일(399) 중 IR_DIFF | 0 | +| **순효과** | **+3** | + +→ 채택 게이트 충족: **순효과 +3 > 0, 악화 0**. + +## 2. Hangul 페이지 오라클 (시각 검증) + +``` +36389301 (ruby+char_shape) pg 2->2 OK +36399208 (ruby) pg 9->9 OK +36384160 pg 29->3 COLLAPSE ← 별개 선존 버그(아래) +``` + +- 2건 시각 정상. char_shape −8 시프트 하위 증상도 해소(36389301). + +## 3. 별개 발견 — 36384160 페이지 붕괴 (#1589 신규 등록) + +- 36384160 은 ruby 수정으로 **IR diff=0(PASS)** 가 됐으나, 한글에서 29→3쪽 붕괴. +- **ruby 수정 전(fidelity10 rt)에도 동일 붕괴** → ruby 무관 **선존 시각 버그**. + IR 무손실인데 시각 붕괴(IR 게이트 미검출, 오라클만 검출) → **이슈 #1589** 로 분리 등록. +- 본 타스크(#1587) 범위 밖. ruby 수정의 회귀가 **아님**을 fidelity10/11 rt 양쪽 오라클로 확정. + +## 4. 회귀 가드 영속화 + +- 단위: `task1587_ruby_control_roundtrips` (전 필드 무손실, Stage 1·3). +- 통합: `36389301`(오라클 정상·ruby+char_shape) opengov 고정 말뭉치 편입 + snapshot PASS. + +## 5. 결론 + +Ruby(덧말) 드롭 결함 해소(개선 3, IR회귀 0, 시각 정상 2). 채택. 36384160 페이지 붕괴는 +별개 선존 시각 갭으로 #1589 분리. diff --git a/mydocs/working/task_m100_1588_stage1.md b/mydocs/working/task_m100_1588_stage1.md new file mode 100644 index 000000000..6a655ea4b --- /dev/null +++ b/mydocs/working/task_m100_1588_stage1.md @@ -0,0 +1,23 @@ +# Task #1588 — Stage 1 완료보고서 + +**단계**: 선 도형 shapeComment 드롭 재현 (RED) +**브랜치**: `local/task1588` + +## 작업 내용 + +`src/serializer/hwpx/shape.rs` 테스트 모듈에 가드 2건 추가: +- `task1588_line_shape_comment_emitted`: description 있는 선 도형 → `` 방출 검증. +- `task1588_line_shape_no_comment_when_empty`: 빈 설명 → 미방출 검증(빈 태그 금지). + +## 결과 (RED 확인) + +``` +task1588_line_shape_comment_emitted ... FAILED (shapeComment 미방출 — 드롭) +task1588_line_shape_no_comment_when_empty ... ok +``` + +- 선 도형 XML 에 `` 없음 → 근본원인(write_line 의 write_shape_comment 누락) 일치. + +## 다음 단계 + +Stage 2 — `write_line` 에 `write_shape_comment(w, c)?;` 1줄 추가로 GREEN. diff --git a/mydocs/working/task_m100_1588_stage2.md b/mydocs/working/task_m100_1588_stage2.md new file mode 100644 index 000000000..0e2aa49f0 --- /dev/null +++ b/mydocs/working/task_m100_1588_stage2.md @@ -0,0 +1,23 @@ +# Task #1588 — Stage 2 완료보고서 + +**단계**: 수정 (write_line 에 shapeComment 방출) +**브랜치**: `local/task1588` + +## 변경 내용 + +`src/serializer/hwpx/shape.rs` `write_line`: caption 방출 직후 `write_shape_comment(w, c)?;` +1줄 추가 (OWPML 순서 outMargin→caption→shapeComment, write_rect 동형). + +## 검증 (모두 GREEN) + +| 검사 | 결과 | +|------|------| +| `task1588_line_shape_comment_emitted` | PASS | +| `task1588_line_shape_no_comment_when_empty` | PASS | +| `cargo test --lib` | 1963 passed, 0 failed | +| `hwpx_roundtrip_baseline` | 4/4 | +| `cargo clippy --lib` (shape.rs) | 무경고 | + +## 다음 단계 + +Stage 3 — fidelity 전수 통제 비교(3건 해소 + 악화 0) + opengov 가드 편입. diff --git a/mydocs/working/task_m100_1588_stage3.md b/mydocs/working/task_m100_1588_stage3.md new file mode 100644 index 000000000..72701d730 --- /dev/null +++ b/mydocs/working/task_m100_1588_stage3.md @@ -0,0 +1,38 @@ +# Task #1588 — Stage 3 완료보고서 + +**단계**: 통제 비교 검증 (채택 게이트) +**브랜치**: `local/task1588` +**바이너리**: `local/task1588` HEAD (f3f2dc0a 위 빌드) + +## 1. fidelity 전수 통제 비교 + +| 항목 | shapeComment 전 (fidelity11) | 후 (fidelity12) | +|------|------:|------:| +| 총 파일 | 10062 | 10261 (수집 진행) | +| IR_DIFF | 7 | **4** | + +**공통 9952건 per-file 통제 비교**: + +| 분류 | 건수 | +|------|----:| +| 개선 (IR_DIFF→PASS) | **3** (36389418·36391302·36392900 = 선 도형 shapeComment 3건) | +| **회귀 (PASS→IR_DIFF)** | **0** | +| 신규 파일(198) 중 IR_DIFF | 0 | +| **순효과** | **+3** | + +→ 채택 게이트 충족: **순효과 +3 > 0, 악화 0**. + +잔존 4건 = 36384689·36385445·36388711(Class C, para0 char_shape 시프트) + +36386761(Class D, spurious 0,0). Ruby·shapeComment 클래스 완전 해소. + +## 2. 회귀 가드 영속화 + +- 단위: `task1588_line_shape_comment_emitted` / `task1588_line_shape_no_comment_when_empty`. +- 통합: `36392900` opengov 고정 말뭉치 편입 + snapshot PASS. + +> Hangul 오라클: shapeComment 3건 모두 한글 COM 열기 ERR(COLLAPSE 아님) — 해당 파일군의 +> 도구측 열기 이슈. shapeComment 는 **비시각 메타데이터**이고 IR diff=0 검증 완료라 채택 무영향. + +## 3. 결론 + +선 도형 shapeComment 드롭 해소(개선 3, 회귀 0). 채택. 잔존 IR_DIFF 4건은 Class C/D 별건. diff --git a/mydocs/working/task_m100_1591_stage1.md b/mydocs/working/task_m100_1591_stage1.md new file mode 100644 index 000000000..f90d98605 --- /dev/null +++ b/mydocs/working/task_m100_1591_stage1.md @@ -0,0 +1,64 @@ +# Task #1591 — Stage 1 완료보고서 (조사 + RED) + +**단계**: 근본원인 정밀 규명 + RED +**브랜치**: `local/task1591` + +## 1. 근본원인 (확정) + +`src/serializer/hwpx/section.rs:416-426` 이 **모든 Bookmark 를 문단 시작(첫 run)으로 hoisting**: + +```rust +// Bookmark는 IR에 위치 정보가 없어 문단 시작(첫 run)에 배치한다. +for ctrl in ¶.controls { + if let Control::Bookmark(bm) = ctrl { ... splitter.content.push("...bookmark...") } +} +``` + +para0(빈 문단, 거대 중첩표) IR controls(순서): `[SectionDef, ColumnDef, Table, PageNumberPos, +Bookmark]`. 원본에서 북마크는 **끝**(byte 46461). + +**rt 재파싱 결과**(36384689): +``` +cc=33 (불변) char_shapes: pos=0 id=25, pos=32 id=10 (원본 24 → 32, +8) +controls 순서: [SectionDef, ColumnDef, Bookmark, Table, PageNumberPos] ← 북마크가 앞으로 이동 +``` + +→ 북마크가 **8유닛 슬롯을 점유**(cc=33=4슬롯×8+1, line 417 주석 "char_count 미포함"은 부정확) +하며, hoisting 이 북마크를 표 앞으로 **재배치**해 후속 Table/PageNumberPos 슬롯을 +8 밀어 +char_shape(후위 컨트롤에 연동) 경계를 24→32 시프트시킨다. **#1584 ColumnDef 와 동형** +(슬롯 위치 미추적 → 오배치). #1584 무관(직전 커밋 바이너리 동일) 재확인. + +> roundtrip diff 는 북마크 자체는 비교 제외(roundtrip.rs:901)하나, 재배치로 인한 char_shape +> 시프트는 검출한다. + +## 2. Class C 분해 (3건 → 2 클래스) + +| 파일 | controls | 시프트 | 클래스 | +|------|----------|-------|--------| +| 36384689 | [..,Table,PageNum,**Bookmark**] | +8 | **C1 북마크 hoist** | +| 36385445 | [..,Table,PageNum,**Bookmark**] | +8 | **C1 북마크 hoist** (동일) | +| 36388711 | [..,Table,**Field**(ClickHere)] | −16/−8 | **C2 필드** (별개, F3 인접) | + +→ 본 타스크는 **C1(2건, 북마크)** 대상. C2(36388711, 필드 ClickHere)는 별개 근본 — 분리. + +## 3. RED 테스트 + +`task1591_bookmark_not_hoisted_before_slot`: 표 슬롯 뒤 북마크 문단 roundtrip → +컨트롤 순서 검증. 현재 `["bm","tbl"]`(hoist 로 뒤바뀜), 기대 `["tbl","bm"]` → **RED 확인**. + +## 4. 수정 방향 (제안 — 승인 대상) + +북마크를 hoisting 하지 말고 **컨트롤 시퀀스의 제 위치에 슬롯으로 방출**. para.controls 는 이미 +올바른 순서(북마크 index 보존)이므로, 북마크를 슬롯 시스템(`is_hwpx_inline_slot`)에 포함하여 +char-offset 위치대로 방출하면 순서·char_shape 보존 가능. + +**위험(F3 인접)**: +- 종전 hoist 는 "위치 추적 불가" 케이스 대비 fallback. 일부 북마크는 진짜 위치 정보 부재일 수 + 있어, 슬롯 편입 시 **위치 추정 불가 케이스에서 광역 회귀** 가능(F3 2회 실패 전례). +- roundtrip.rs:901 의 북마크 비교 제외도 재검토 필요. +- **채택 게이트 = 통제 비교 순효과>0·악화0**, 악화 시 전량 롤백. + +## 5. 판단 요청 + +Stage 2 로 진행(슬롯 편입 구현 + baseline + 통제 비교)할지, 위험 감안하여 보류할지 결정 요청. +근본원인·RED 확보됨. C2(36388711)는 별 이슈로 분리 권장. diff --git a/mydocs/working/task_m100_1591_stage2.md b/mydocs/working/task_m100_1591_stage2.md new file mode 100644 index 000000000..67334997d --- /dev/null +++ b/mydocs/working/task_m100_1591_stage2.md @@ -0,0 +1,35 @@ +# Task #1591 — Stage 2 완료보고서 + +**단계**: 수정 설계·구현 (북마크 슬롯 편입) +**브랜치**: `local/task1591` + +## 변경 내용 + +| # | 파일 | 변경 | +|---|------|------| +| C1 | `serializer/hwpx/section.rs` | 북마크 hoisting 루프(416-426) **제거** | +| C2 | `serializer/hwpx/section.rs` | `is_hwpx_inline_slot` 에 `Control::Bookmark` 추가 | +| C3 | `serializer/hwpx/section.rs` | `render_control_slot` 에 `Control::Bookmark` arm 추가(``) | +| C4 | `serializer/hwpx/roundtrip.rs` | diff 비교는 종전대로 북마크 **제외**(보수적 결합 분리) — `is_hwpx_inline_slot(c) && !Bookmark` | + +## 설계 — 보수적 결합 분리 + +`diff_documents` 가 `is_hwpx_inline_slot` 을 공유하므로, 북마크 슬롯 편입(C2)이 비교 의미까지 +바꿔 `diff_documents_bookmark_not_compared_as_control` 테스트가 깨졌다. 직렬화기는 북마크를 +정위치 방출하되 **diff 비교는 종전대로 북마크 제외**(게이트 의미 불변)하도록 C4 로 분리. +북마크 자체 보존 비교는 별도 과제로 남긴다(scope 최소화). + +## 검증 (모두 GREEN) + +| 검사 | 결과 | +|------|------| +| `task1591_bookmark_not_hoisted_before_slot` | PASS (`[tbl,bm]` 순서 보존) | +| bookmark 관련 6 테스트 | 전부 PASS | +| `cargo test --lib` | 1964 passed, 0 failed | +| `hwpx_roundtrip_baseline` | 4/4 | +| `cargo clippy --lib` | 무경고 | + +## 다음 단계 + +Stage 3 — fidelity 전수 통제 비교(롤백 가드): C1(36384689·36385445) 해소 + 악화 0 확인. +악화 ≥1 시 전량 롤백. diff --git a/mydocs/working/task_m100_1592_stage1.md b/mydocs/working/task_m100_1592_stage1.md new file mode 100644 index 000000000..322946ec5 --- /dev/null +++ b/mydocs/working/task_m100_1592_stage1.md @@ -0,0 +1,10 @@ +# Task #1592 — Stage 1 완료보고서 (RED) + +**단계**: 빈 문단 spurious (0,0) 재현 +**브랜치**: `local/task1592` + +`task1592_empty_paragraph_no_spurious_charshape`: 완전 빈 문단(text="", char_shapes=[], +컨트롤 없음) roundtrip → 재파싱 char_shapes 검증. + +결과(RED): `빈 문단은 char_shapes 가 비어야 한다 ...: [(0, 0)]` — 직렬화기가 빈 +`` 추가 → (0,0) 발생. 근본원인(RunSplitter::new 규칙3 + close_run 규칙5) 일치. diff --git a/mydocs/working/task_m100_1592_stage2.md b/mydocs/working/task_m100_1592_stage2.md new file mode 100644 index 000000000..a4217c9ea --- /dev/null +++ b/mydocs/working/task_m100_1592_stage2.md @@ -0,0 +1,17 @@ +# Task #1592 — Stage 2 완료보고서 (수정) + +**단계**: render_runs 빈 문단 가드 +**브랜치**: `local/task1592` + +## 변경 +- `section.rs` render_runs 진입부: 완전 빈 문단(text·char_shapes·controls·field_ranges· + orphan_field_ends 전부 없음)이면 **run 미방출**(빈 문자열 반환). char_shapes 있으면 종전 유지. +- `task1378_empty_paragraph_single_run_id_zero` 갱신: 빈 문단은 run 미방출(`""`)이 정답. + char_shapes=[] 는 "원본에 run 없음"을 의미(빈 run 이면 파서가 [(0,0)] 산출) → entry 가공 금지. + +## 검증 +- `task1592_..._no_spurious_charshape` GREEN. +- `cargo test --lib` 1964 passed/0 failed. `hwpx_roundtrip_baseline` 4/4. clippy 무경고. + +## 다음 +Stage 3 — fidelity 전수 통제 비교(빈 문단 광역 영향 확인): 36386761(목록) 해소 + 악화 0. diff --git a/mydocs/working/task_m100_1592_stage3.md b/mydocs/working/task_m100_1592_stage3.md new file mode 100644 index 000000000..770a24ac6 --- /dev/null +++ b/mydocs/working/task_m100_1592_stage3.md @@ -0,0 +1,26 @@ +# Task #1592 — Stage 3 완료보고서 (통제 비교) + +**단계**: 통제 비교 검증 (채택 게이트) · **브랜치**: `local/task1592` +**바이너리**: local/task1592 HEAD 위 빌드 + +## fidelity 전수 통제 비교 (fidelity13→14, 전체 경로 키) + +| 항목 | 전(13) | 후(14) | +|------|------:|------:| +| 총 파일 | 10581 | 10831 | +| IR_DIFF | 4 | **3** | + +공통 10581건: +- 개선 1 (36386761 백제학연구총서 위탁판매 의뢰 목록) +- **회귀 0**, 신규 IR_DIFF 0 +- **순효과 +1** + +→ 채택 게이트 충족(순효과>0·악화0). **빈 문단 광역 변경에도 회귀 0** — char_shapes=[] 조건이 +좁아(대부분 빈 문단은 [(0,0)]) blast radius 최소. + +## 회귀 가드 +- 단위: `task1592_empty_paragraph_no_spurious_charshape` + `task1378_..._single_run_id_zero` 갱신. +- 통합: `36386761_백제학연구총서위탁판매의뢰목록` opengov 편입 + snapshot PASS. + +## 결론 +빈 문단 spurious (0,0) 해소(개선 1, 회귀 0). 채택. 잔여 IR_DIFF 3건(Class C 2 + C2 1)은 별건. diff --git a/mydocs/working/task_m100_1594_stage1.md b/mydocs/working/task_m100_1594_stage1.md new file mode 100644 index 000000000..18d2da4d1 --- /dev/null +++ b/mydocs/working/task_m100_1594_stage1.md @@ -0,0 +1,19 @@ +# Task #1594 — Stage 1+2 완료보고서 (RED + 수정) + +**단계**: holdAnchorAndSO 보존 재현(RED) + 직렬화 수정 +**브랜치**: `local/task1594` + +## Stage 1 (RED) +`table.rs` 테스트 `task1594_hold_anchor_preserved`: prevent_page_break=1 표 직렬화 → +holdAnchorAndSO="1" 방출 검증. 현재 "0" 하드코딩으로 RED. (+ `_zero_when_unset` 기본 0 보존.) + +## Stage 2 (수정) +4지점이 holdAnchorAndSO 를 IR(`c.prevent_page_break != 0`)로 방출: +- `table.rs` write_pos, `picture.rs` write_pos, `shape.rs` write_pos, `section.rs` equation. + +## 검증 +- `task1594_*` 2건 GREEN, `cargo test --lib` 1969 passed/0 failed. +- `hwpx_roundtrip_baseline` 4/4, clippy 무경고. + +## 다음 +Stage 3 — diff_documents 에 prevent_page_break 추가 + fidelity 통제 비교 + 한글 붕괴 해소 검증. diff --git a/mydocs/working/task_m100_1594_stage3.md b/mydocs/working/task_m100_1594_stage3.md new file mode 100644 index 000000000..f70a94f14 --- /dev/null +++ b/mydocs/working/task_m100_1594_stage3.md @@ -0,0 +1,26 @@ +# Task #1594 — Stage 3 완료보고서 (게이트 + 통제 비교) + +**단계**: diff_documents 게이트 추가 + 통제 비교 · **브랜치**: `local/task1594` + +## 1. 게이트 추가 +`ObjectHoldAnchor` IrDifference + `diff_hold_anchor` 헬퍼. Table/Picture/Equation 비교에 +`prevent_page_break`(holdAnchorAndSO) 검사 추가 → IR-invisible 였던 드롭을 게이트가 봉인. + +## 2. IR 통제 비교 (fidelity15, 11855 파일) +- IR_DIFF **4** (fidelity14와 동일) → **수정+게이트의 IR 회귀 0**. +- holdAnchorAndSO 게이트가 0 mismatch 검출 = 직렬화 수정 정상(보존됨). + +## 3. 한글 오라클 (붕괴 해소 + 악화 검증) +- **36383351: 2쪽→2쪽 해소**(수정 전 1쪽 붕괴). holdAnchorAndSO 보존(1×"1") 확인. +- 이전 OK 표본 30: **30/30 OK 유지(악화 0)** — 보존 수정이라 OK 파일 붕괴 불가. +- 단, 이전 붕괴 표본 30: 22건이 holdAnchorAndSO 보존됐으나 **여전히 붕괴** → + **군집 이질적**: holdAnchorAndSO 는 36383351 의 deciding 요인이나 대다수는 다른 systematic + 드롭(outlineShapeIDRef·noteSpacing·noteLine·curSz)이 deciding. + +## 4. 판정 — 채택 +holdAnchorAndSO 수정은 **정확한 IR-충실 버그 수정**: 드롭된 속성 보존, 36383351 해소, +IR 회귀 0, 악화 0, 게이트 개선. **채택**. 단 #1589 군집의 대다수는 별 원인(후속). + +## 5. 후속 +군집 잔여 붕괴 = 다른 IR-invisible 직렬화 드롭(outlineShapeIDRef/noteSpacing/noteLine/curSz) +추정. 각 별 조사·수정 필요(holdAnchorAndSO 와 동형 패턴 가능성). diff --git a/mydocs/working/task_m100_1595_stage1.md b/mydocs/working/task_m100_1595_stage1.md new file mode 100644 index 000000000..b70ee3de7 --- /dev/null +++ b/mydocs/working/task_m100_1595_stage1.md @@ -0,0 +1,15 @@ +# Task #1595 — Stage 1+2 완료보고서 (RED + 수정) + +**브랜치**: `local/task1595` + +## Stage 1 (RED) +"CLICKHERE" 기대 테스트(field.rs:217, section.rs:2427)를 올바른 "CLICK_HERE" 기대로 갱신 → RED. + +## Stage 2 (수정) +`field.rs:180` `ClickHere => "CLICK_HERE"` (언더스코어 교정). + +## 검증 +- `field_begin_emits_type_attr` GREEN, `cargo test --lib` 1969/0, baseline 4/4, clippy 무경고. + +## 다음 +Stage 3 — fidelity 통제 비교 + 한글 오라클 붕괴 해소율 측정 + opengov 가드. diff --git a/mydocs/working/task_m100_1595_stage3.md b/mydocs/working/task_m100_1595_stage3.md new file mode 100644 index 000000000..8eba72e49 --- /dev/null +++ b/mydocs/working/task_m100_1595_stage3.md @@ -0,0 +1,23 @@ +# Task #1595 — Stage 3 완료보고서 (통제 비교) + +**브랜치**: `local/task1595` · **바이너리**: local/task1595 HEAD + +## 1. IR 통제 비교 (fidelity16, 12042 파일) +IR_DIFF **4** (불변) → 수정의 IR 회귀 0 (IR-invisible 수정; 파서가 CLICKHERE·CLICK_HERE 양형 +수용해 enum 동일 → IR 비교 불변). + +## 2. 한글 오라클 — 붕괴 해소율 +- 이전 붕괴 표본 40 재측정: **OK 37 / COLLAPSE 3 → 해소율 92.5%**. + (예: 36391546 8쪽→1쪽 붕괴 → 수정 후 8쪽.) +- 이전 OK 표본 30: **30/30 OK 유지 (악화 0)**. + +→ **#1589 페이지 붕괴 군집(~16%)의 대다수 해소**. 잔여 붕괴 ~8%(다른 원인: holdAnchorAndSO#1594 ++ 미상). 시각 붕괴 갭 ~16% → ~1.3%(추정). + +## 3. 회귀 가드 +단위 테스트 `field_begin_emits_type_attr`(type="CLICK_HERE" 단언)가 직렬화 회귀 봉인. +필드 타입 버그는 파서 정규화로 **본질적 IR-invisible**(enum 동일) → diff_documents 추가 불가, +단위 테스트가 유일·충분한 가드. + +## 4. 판정 — 채택 +지배원인 단일 수정으로 붕괴 92.5% 해소, 악화 0, IR/baseline/lib 회귀 0. 채택. diff --git a/mydocs/working/task_m100_1596_stage1.md b/mydocs/working/task_m100_1596_stage1.md new file mode 100644 index 000000000..86783d308 --- /dev/null +++ b/mydocs/working/task_m100_1596_stage1.md @@ -0,0 +1,19 @@ +# Task #1596 — Stage 1-3 완료보고서 (RED + 지오메트리 방출) + +**브랜치**: `local/task1596` + +## Stage 1 (RED) +`task1596_polygon_geometry_serialized`: polygon 직렬화 후 hc:pt/lineShape/shadow 방출 검증 → RED. + +## Stage 2-3 (수정) +`render_common_shape_xml` 리팩터: 시그니처 `sa` → `drawing: Option<&DrawingObjAttr>` + `points`. +shape_block 직후 지오메트리(lineShape·fillBrush(조건부)·shadow(조건부)·hc:pt) 방출. 태그 부수 +속성(numberingType/dropcapstyle/href/groupLevel/instid) + pos 속성(affectLSpacing/flowWithText/ +allowOverlap/holdAnchorAndSO) 보강. write_line_shape/write_shadow/numbering_type_str pub(crate). +dispatch(ellipse/arc/polygon/curve/chart)가 drawing+points 전달. + +## 검증 +- RED GREEN, `cargo test --lib` 1970/0, baseline 4/4, clippy 무경고. + +## 다음 +Stage 4 — fidelity 통제 비교 + 한글 오라클(36396457 등 잔여 붕괴 해소). diff --git a/mydocs/working/task_m100_1596_stage4.md b/mydocs/working/task_m100_1596_stage4.md new file mode 100644 index 000000000..faa12b84f --- /dev/null +++ b/mydocs/working/task_m100_1596_stage4.md @@ -0,0 +1,19 @@ +# Task #1596 — Stage 4 완료보고서 (통제 비교) + +**브랜치**: `local/task1596` + +## 1. IR 통제 비교 (fidelity17, 11874 파일) +IR_DIFF **4** (불변) → 회귀 0. (도형 지오메트리 드롭은 본래 IR-invisible — diff_documents 가 +shape points/lineShape 미비교.) + +## 2. 한글 오라클 — 붕괴 해소 + 악화 +- 36396457(polygon, 11→4 붕괴) → 수정 후 **11→11 해소**. 지오메트리 보존(hc:pt 32·lineShape 4·shadow 4 = orig). +- 이전 붕괴 표본 40(누적 ClickHere+shape): OK 38/COLLAPSE 2 (#1595 단독 37 대비 +1 = polygon 해소). +- 이전 OK 표본 40: **40/40 유지 (악화 0)**. + +## 3. 판정 — 채택 +generic-shape 지오메트리 방출로 도형 충실도 복원 + 잔여 shape 붕괴 해소, 악화 0, IR/baseline/lib 회귀 0. + +## 4. 가드 +단위 `task1596_polygon_geometry_serialized`. 지오메트리는 IR-invisible(diff_documents 미비교)이라 +단위 가드가 유일·충분. (게이트 IR-visible化는 후속 검토.) diff --git a/mydocs/working/task_m100_1598_stage1.md b/mydocs/working/task_m100_1598_stage1.md new file mode 100644 index 000000000..eed3d8a6f --- /dev/null +++ b/mydocs/working/task_m100_1598_stage1.md @@ -0,0 +1,30 @@ +# Task #1598 Stage 1 — RED 테스트 + 통제 진단 + +## 진단 (통제 테스트로 근본 확정) + +36385226(ellipse×9, section0)을 한글 오라클로 3-way 측정: + +| 파일 | 한글 PageCount | +|------|---------------| +| orig | 3 | +| rt (지오메트리 드롭, 수정 전) | **2** ← 붕괴 | +| rt + orig 지오메트리 주입 | **3** ← 해소 | + +→ ellipse 전용 지오메트리(`/////` +`/`) 주입만으로 붕괴 완전 해소. **근본 확정**. + +주입 스크립트: `output/poc/ellipse_test/inject_geom.py`, 측정: `measure3.py`. + +## 근본원인 + +- 파서 `parse_shape_object`(section.rs)가 ellipse/arc 자식 점요소를 `_ => {}` 로 버림 + → `EllipseShape`/`ArcShape` 를 `..Default::default()` 로 생성(지오메트리 전부 0). +- 직렬화 `render_common_shape_xml` 도 미방출. +- IR diff 게이트는 ellipse 지오메트리 미비교(IR-invisible) → 한글 오라클만 검출. + +## RED 테스트 + +`tests/issue_1598_ellipse_geometry_roundtrip.rs`: +- 36385226 파싱 → ellipse≥9 + 전용 지오메트리 nonzero 단언(수정 전엔 전부 0 → RED). +- serialize→reparse 후 7점 보존 + 2-round 안정. +- 가드 샘플: `samples/hwpx/opengov/36385226_...hwpx`. diff --git a/mydocs/working/task_m100_1598_stage2.md b/mydocs/working/task_m100_1598_stage2.md new file mode 100644 index 000000000..d25ee4b4a --- /dev/null +++ b/mydocs/working/task_m100_1598_stage2.md @@ -0,0 +1,25 @@ +# Task #1598 Stage 2 — 파서 + 직렬화 구현 + +## 파서 (`src/parser/hwpx/section.rs`) + +- 헬퍼 `parse_xy(e, &mut Point)` 추가 — `` 의 x/y 속성 적재. +- `parse_shape_object` 자식 루프에 7개 분기 추가: + `b"center"|"ax1"|"ax2"|"start1"|"end1"|"start2"|"end2" => parse_xy(...)`. +- ellipse 생성자: center/axis1/axis2/start1/end1/start2/end2 적재. +- arc 생성자: center/axis1/axis2 적재(호는 시작끝점 없음). + +## 직렬화 (`src/serializer/hwpx/section.rs`) + +- 디스패치에서 shape 별 `geom_tail` 문자열 빌드: + - ellipse → center/ax1/ax2/start1/end1/start2/end2 (7개 ``). + - arc → center/ax1/ax2 (3개). + - 그 외 → 빈 문자열. +- `render_common_shape_xml` 시그니처에 `geom_tail: &str` 추가, shadow 직후 방출 + (`{ls}{fb}{sh}{pts}{geom_tail}`). hc:pt(polygon/curve) 와 상호배타. + +## 검증 + +- 단위 테스트 `issue_1598_ellipse_geometry_roundtrip`: **PASS**. +- 지오메트리 값 정확 일치(orig==rt): center(460,460)/ax1(460,0)/ax2(920,460)/start·end(0,0). +- IR diff=0 유지(회귀 없음). +- polygon/curve points 는 #1067/#1200 으로 이미 정상 — 무영향. diff --git a/mydocs/working/task_m100_1598_stage3.md b/mydocs/working/task_m100_1598_stage3.md new file mode 100644 index 000000000..f2a2f7420 --- /dev/null +++ b/mydocs/working/task_m100_1598_stage3.md @@ -0,0 +1,22 @@ +# Task #1598 Stage 3 — 회귀 검증 (baseline/lib) + +## 결과 (전부 PASS, 회귀 0) + +| 검증 | 결과 | +|------|------| +| `issue_1598_ellipse_geometry_roundtrip` | 1 passed | +| `hwpx_roundtrip_baseline` (도형 포함 전수) | 4 passed | +| `issue_1392_shape_comment_roundtrip` | 2 passed | +| `issue_1403_pic_shape_caption_roundtrip` | 3 passed | +| `issue_1385_replace_export_roundtrip` | 1 passed | +| `opengov_corpus_snapshot` (36385226 행 추가) | 2 passed | +| **전체 `cargo test --lib`** | **1970 passed, 0 failed, 7 ignored** | +| `cargo clippy` (변경 코드) | warning 0 | +| `cargo fmt --check` (실 툴체인) | diff 0 | + +ellipse/arc 지오메트리 추가가 polygon/curve/rect/picture/group/chart/ole 경로 및 IR diff +게이트에 무영향 확인. IR diff=0 유지(지오메트리는 IR-invisible 이라 diff 카운트 불변). + +> 주: 직접 `rustfmt --edition 2021` 호출은 1275/1361(#1596 기존 코드)에 diff 보고하나, +> 저장소 `rust-toolchain.toml`/`rustfmt.toml` 기준 `cargo fmt --check` 는 clean. 제 신규 +> 코드는 양쪽 모두 clean. diff --git a/mydocs/working/task_m100_1598_stage4.md b/mydocs/working/task_m100_1598_stage4.md new file mode 100644 index 000000000..818888ed1 --- /dev/null +++ b/mydocs/working/task_m100_1598_stage4.md @@ -0,0 +1,30 @@ +# Task #1598 Stage 4 — 통제 비교 (한글 오라클) + +## End-to-end 통제 (실제 rhwp 직렬화 경로) + +새 바이너리로 36385226 roundtrip → 한글 PageCount: + +| 파일 | 한글 PageCount | 비고 | +|------|---------------|------| +| orig | 3 | 정답 | +| rt (수정 전, 지오메트리 드롭) | 2 | 붕괴 | +| **new-rt (#1598 파서+직렬화)** | **3** | **해소** | + +지오메트리 값 정확 일치(orig==rt): center(460,460)/ax1(460,0)/ax2(920,460)/start1·end1· +start2·end2(0,0). + +## 잔여 long-tail 현황 + +- 36385226 (ellipse×9): **해소** (3→3). +- 36389684: 현재 바이너리에서 orig=rt=2 (붕괴 없음) — generic-shape 미보유(컨테이너/picture). + +## 채택 판정: **채택** + +- 통제 비교 악화 0 (개선 1: 36385226 붕괴 해소). +- baseline/lib/clippy/fmt 회귀 0. +- IR diff=0 유지. + +## 보류 (별 타스크 후보) + +- ellipse/arc 태그 전용 속성(intervalDirty/hasArcPr/arcType) — 붕괴 무관, 모델 미보유. +- arc 의 start/end 점(모델 ArcShape 는 center/축만 보유) — 실문서 출현 시 확장. diff --git "a/samples/hwpx/opengov/36382399_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\235\274\353\260\230\354\247\200\354\266\234\352\262\260\354\235\230\354\204\234_\352\270\260\352\260\204\354\240\234 \354\206\214\353\270\224\353\241\235 \354\204\270\354\262\231 \354\236\221\354\227\205\354\232\251\355\222\210 \352\265\254\353\247\244.hwpx" "b/samples/hwpx/opengov/36382399_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\235\274\353\260\230\354\247\200\354\266\234\352\262\260\354\235\230\354\204\234_\352\270\260\352\260\204\354\240\234 \354\206\214\353\270\224\353\241\235 \354\204\270\354\262\231 \354\236\221\354\227\205\354\232\251\355\222\210 \352\265\254\353\247\244.hwpx" new file mode 100644 index 000000000..c7192636f Binary files /dev/null and "b/samples/hwpx/opengov/36382399_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\235\274\353\260\230\354\247\200\354\266\234\352\262\260\354\235\230\354\204\234_\352\270\260\352\260\204\354\240\234 \354\206\214\353\270\224\353\241\235 \354\204\270\354\262\231 \354\236\221\354\227\205\354\232\251\355\222\210 \352\265\254\353\247\244.hwpx" differ diff --git "a/samples/hwpx/opengov/36383351_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\352\264\200\354\225\205\354\202\260\354\202\260\354\225\205\352\265\254\354\241\260\353\214\200\352\270\211\354\213\235\354\235\230\354\225\275\355\222\210\355\217\220\352\270\260.hwpx" "b/samples/hwpx/opengov/36383351_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\352\264\200\354\225\205\354\202\260\354\202\260\354\225\205\352\265\254\354\241\260\353\214\200\352\270\211\354\213\235\354\235\230\354\225\275\355\222\210\355\217\220\352\270\260.hwpx" new file mode 100644 index 000000000..1e15e3a9f Binary files /dev/null and "b/samples/hwpx/opengov/36383351_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\352\264\200\354\225\205\354\202\260\354\202\260\354\225\205\352\265\254\354\241\260\353\214\200\352\270\211\354\213\235\354\235\230\354\225\275\355\222\210\355\217\220\352\270\260.hwpx" differ diff --git "a/samples/hwpx/opengov/36385226_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\240\2342\354\262\230\353\246\254\354\236\245 \354\212\254\353\237\254\354\247\200\354\235\270\353\260\234\354\232\251 \354\227\220\354\226\264\353\246\254\355\224\204\355\212\270 \353\270\214\353\241\234\354\233\214 2\355\230\270\352\270\260 \354\206\214\353\252\250\355\222\210 \352\265\220\354\262\264 \353\263\264\352\263\240.hwpx" "b/samples/hwpx/opengov/36385226_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\240\2342\354\262\230\353\246\254\354\236\245 \354\212\254\353\237\254\354\247\200\354\235\270\353\260\234\354\232\251 \354\227\220\354\226\264\353\246\254\355\224\204\355\212\270 \353\270\214\353\241\234\354\233\214 2\355\230\270\352\270\260 \354\206\214\353\252\250\355\222\210 \352\265\220\354\262\264 \353\263\264\352\263\240.hwpx" new file mode 100644 index 000000000..79b656110 Binary files /dev/null and "b/samples/hwpx/opengov/36385226_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\240\2342\354\262\230\353\246\254\354\236\245 \354\212\254\353\237\254\354\247\200\354\235\270\353\260\234\354\232\251 \354\227\220\354\226\264\353\246\254\355\224\204\355\212\270 \353\270\214\353\241\234\354\233\214 2\355\230\270\352\270\260 \354\206\214\353\252\250\355\222\210 \352\265\220\354\262\264 \353\263\264\352\263\240.hwpx" differ diff --git "a/samples/hwpx/opengov/36386761_\353\260\261\354\240\234\355\225\231\354\227\260\352\265\254\354\264\235\354\204\234\354\234\204\355\203\201\355\214\220\353\247\244\354\235\230\353\242\260\353\252\251\353\241\235.hwpx" "b/samples/hwpx/opengov/36386761_\353\260\261\354\240\234\355\225\231\354\227\260\352\265\254\354\264\235\354\204\234\354\234\204\355\203\201\355\214\220\353\247\244\354\235\230\353\242\260\353\252\251\353\241\235.hwpx" new file mode 100644 index 000000000..8d079c05b Binary files /dev/null and "b/samples/hwpx/opengov/36386761_\353\260\261\354\240\234\355\225\231\354\227\260\352\265\254\354\264\235\354\204\234\354\234\204\355\203\201\355\214\220\353\247\244\354\235\230\353\242\260\353\252\251\353\241\235.hwpx" differ diff --git "a/samples/hwpx/opengov/36389301_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\247\201\354\236\245\355\233\210\353\240\250\352\263\204\355\232\215_\353\215\247\353\247\220.hwpx" "b/samples/hwpx/opengov/36389301_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\247\201\354\236\245\355\233\210\353\240\250\352\263\204\355\232\215_\353\215\247\353\247\220.hwpx" new file mode 100644 index 000000000..ee05b3a17 Binary files /dev/null and "b/samples/hwpx/opengov/36389301_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\247\201\354\236\245\355\233\210\353\240\250\352\263\204\355\232\215_\353\215\247\353\247\220.hwpx" differ diff --git "a/samples/hwpx/opengov/36392900_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\235\274\354\235\274\352\265\264\354\260\251\353\263\265\352\265\254\352\263\265\354\202\254\355\230\204\355\231\251\353\263\264\352\263\240.hwpx" "b/samples/hwpx/opengov/36392900_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\235\274\354\235\274\352\265\264\354\260\251\353\263\265\352\265\254\352\263\265\354\202\254\355\230\204\355\231\251\353\263\264\352\263\240.hwpx" new file mode 100644 index 000000000..b8737b1b3 Binary files /dev/null and "b/samples/hwpx/opengov/36392900_\352\262\260\354\236\254\353\254\270\354\204\234\353\263\270\353\254\270_\354\235\274\354\235\274\352\265\264\354\260\251\353\263\265\352\265\254\352\263\265\354\202\254\355\230\204\355\231\251\353\263\264\352\263\240.hwpx" differ diff --git a/src/model/control.rs b/src/model/control.rs index ac4c2c52f..bfa9b0e45 100644 --- a/src/model/control.rs +++ b/src/model/control.rs @@ -163,10 +163,21 @@ pub struct Hyperlink { /// 덧말 ('tdut' 컨트롤) #[derive(Debug, Clone, Default)] pub struct Ruby { - /// 덧말 텍스트 + /// 기준 텍스트 (``) — 덧말이 달리는 본문 글자. (#1587) + /// 파서가 para.text 에 넣지 않고 여기 보존한다(시각 충실도 핵심). + pub main_text: String, + /// 덧말 텍스트 (``) pub ruby_text: String, - /// 정렬 방식 - pub alignment: u8, + /// 위치 (`posType`): 0=TOP, 1=BOTTOM. (#1587) + pub pos_type: u8, + /// 정렬 (`align`): 0=LEFT, 1=RIGHT, 2=CENTER. (#1587) + pub align: u8, + /// 덧말 크기 비율 (`szRatio`, %). (#1587) + pub sz_ratio: u8, + /// 옵션 비트 (`option`). (#1587) + pub option: u32, + /// 글자 스타일 참조 (`styleIDRef`). (#1587) + pub style_id_ref: u16, } /// 글자 겹침 ('tcps' 컨트롤, HWP 스펙 표 152) diff --git a/src/parser/hwpx/section.rs b/src/parser/hwpx/section.rs index 103216403..b63ffcf14 100644 --- a/src/parser/hwpx/section.rs +++ b/src/parser/hwpx/section.rs @@ -3387,6 +3387,17 @@ fn parse_shape_fill_brush(reader: &mut Reader<&[u8]>) -> Result Ok(fill) } +/// [Task #1598] `` 류 점 요소의 x/y 속성을 Point 로 읽는다. +fn parse_xy(e: &quick_xml::events::BytesStart, p: &mut crate::model::Point) { + for attr in e.attributes().flatten() { + match attr.key.as_ref() { + b"x" => p.x = parse_i32(&attr), + b"y" => p.y = parse_i32(&attr), + _ => {} + } + } +} + fn parse_shape_shadow_attr(e: &quick_xml::events::BytesStart) -> (u32, u32, i32, i32, u8) { let mut shadow_type = 0_u32; let mut shadow_color = 0_u32; @@ -3538,6 +3549,15 @@ fn parse_shape_object( // [Task #1067] polygon / curve 의 가변 꼭짓점 `` 누적. // 기존 pt0/pt1/pt2/pt3 (rect 의 4 꼭짓점) 와 별개. let mut polygon_points: Vec = Vec::new(); + // [Task #1598] ellipse / arc 전용 지오메트리 (``/``/...). + // 미적재 시 한글이 타원/호를 다르게 렌더 → 누적 레이아웃 변동 → 페이지 붕괴(#1589 잔여). + let mut e_center = crate::model::Point::default(); + let mut e_axis1 = crate::model::Point::default(); + let mut e_axis2 = crate::model::Point::default(); + let mut e_start1 = crate::model::Point::default(); + let mut e_end1 = crate::model::Point::default(); + let mut e_start2 = crate::model::Point::default(); + let mut e_end2 = crate::model::Point::default(); let object_ids = parse_object_element_attrs(e, &mut common, &mut shape_attr); @@ -3667,6 +3687,14 @@ fn parse_shape_object( } } } + // [Task #1598] ellipse / arc 전용 지오메트리. x/y 속성만 읽어 Point 채움. + b"center" => parse_xy(ce, &mut e_center), + b"ax1" => parse_xy(ce, &mut e_axis1), + b"ax2" => parse_xy(ce, &mut e_axis2), + b"start1" => parse_xy(ce, &mut e_start1), + b"end1" => parse_xy(ce, &mut e_end1), + b"start2" => parse_xy(ce, &mut e_start2), + b"end2" => parse_xy(ce, &mut e_end2), b"renderingInfo" => { parse_rendering_info(reader, &mut shape_attr)?; } @@ -3727,6 +3755,14 @@ fn parse_shape_object( b"ellipse" => ShapeObject::Ellipse(EllipseShape { common, drawing, + // [Task #1598] 전용 지오메트리 적재 — 누락 시 한글 페이지 붕괴(#1589 잔여). + center: e_center, + axis1: e_axis1, + axis2: e_axis2, + start1: e_start1, + end1: e_end1, + start2: e_start2, + end2: e_end2, ..Default::default() }), b"line" => ShapeObject::Line(LineShape { @@ -3745,6 +3781,10 @@ fn parse_shape_object( b"arc" => ShapeObject::Arc(ArcShape { common, drawing, + // [Task #1598] 호 전용 지오메트리(center/축). arc_type 은 태그속성(추후). + center: e_center, + axis1: e_axis1, + axis2: e_axis2, ..Default::default() }), b"polygon" => ShapeObject::Polygon(PolygonShape { @@ -4907,28 +4947,37 @@ fn parse_dutmal( reader: &mut Reader<&[u8]>, ) -> Result { let mut ruby = Ruby::default(); - // 요소 속성 + // 요소 속성 (#1587 — posType/align 분리 보존 + szRatio/option/styleIDRef) for attr in e.attributes().flatten() { match attr.key.as_ref() { b"posType" => { - ruby.alignment = match attr_str(&attr).as_str() { + ruby.pos_type = match attr_str(&attr).as_str() { "TOP" => 0, "BOTTOM" => 1, _ => 0, }; } b"align" => { - ruby.alignment = match attr_str(&attr).as_str() { + ruby.align = match attr_str(&attr).as_str() { "LEFT" => 0, "RIGHT" => 1, "CENTER" => 2, _ => 0, }; } + b"szRatio" => { + ruby.sz_ratio = attr_str(&attr).parse().unwrap_or(0); + } + b"option" => { + ruby.option = attr_str(&attr).parse().unwrap_or(0); + } + b"styleIDRef" => { + ruby.style_id_ref = attr_str(&attr).parse().unwrap_or(0); + } _ => {} } } - // 자식 요소 파싱 (subText) + // 자식 요소 파싱 (mainText 기준 텍스트 + subText 덧말) let mut buf = Vec::new(); loop { match reader.read_event_into(&mut buf) { @@ -4938,8 +4987,9 @@ fn parse_dutmal( if local == b"subText" { ruby.ruby_text = read_dutmal_text(reader, b"subText")?; } else if local == b"mainText" { - // mainText는 이미 문단 텍스트에 포함되므로 스킵 - skip_element(reader, b"mainText")?; + // [#1587] mainText(기준 텍스트)는 para.text 에 포함되지 않으므로 + // 모델에 보존한다(종전 skip → 손실 → 직렬화 시 복원 불가였음). + ruby.main_text = read_dutmal_text(reader, b"mainText")?; } else { let tag = local.to_vec(); skip_element(reader, &tag)?; diff --git a/src/serializer/hwpx/context.rs b/src/serializer/hwpx/context.rs index a568b5dae..06792a24e 100644 --- a/src/serializer/hwpx/context.rs +++ b/src/serializer/hwpx/context.rs @@ -95,6 +95,14 @@ pub struct SerializeContext { /// 정합이지만, 셀·글상자 subList 의 colPr 는 원본 XML 에 인라인으로 존재한다. /// `render_control_slot` 의 ColumnDef 방출을 subList 경로(depth > 0)로 한정한다. pub sub_list_depth: u32, + /// 본문 첫 문단의 첫 ColumnDef(섹션 템플릿 colPr 앵커가 흡수하는 단 정의)의 + /// **인라인 XML 방출만** 1회 억제하기 위한 consume-once 플래그 (#1584). + /// + /// ColumnDef 는 char-offset 슬롯(8유닛)을 점유하므로 `slots` 에는 그대로 남겨 + /// 위치 정합을 보존하되, 첫 ColumnDef 의 `` XML 은 템플릿이 이미 + /// 방출했으므로 중복 방지를 위해 건너뛴다. `write_section` 이 첫 문단 렌더 직전 + /// true 로 설정하고, 첫 본문 ColumnDef 방출 시 `render_control_slot` 이 소거한다. + pub body_coldef_template_pending: bool, } impl SerializeContext { diff --git a/src/serializer/hwpx/field.rs b/src/serializer/hwpx/field.rs index 35c2fff5c..7d2832d7d 100644 --- a/src/serializer/hwpx/field.rs +++ b/src/serializer/hwpx/field.rs @@ -177,7 +177,7 @@ fn field_type_str(t: FieldType) -> &'static str { MailMerge => "MAILMERGE", CrossRef => "CROSSREF", Formula => "FORMULA", - ClickHere => "CLICKHERE", + ClickHere => "CLICK_HERE", Summary => "SUMMARY", UserInfo => "USERINFO", Hyperlink => "HYPERLINK", @@ -214,7 +214,9 @@ mod tests { f.field_id = 42; let xml = to_string(|w| write_field_begin(w, &f)); assert!(xml.contains(r#"id="42""#)); - assert!(xml.contains(r#"type="CLICKHERE""#)); + // [#1595] 올바른 HWPX 값은 CLICK_HERE (언더스코어). 종전 "CLICKHERE" 는 + // 한글이 미인식해 ClickHere placeholder 높이 변동 → 페이지 붕괴(#1589). + assert!(xml.contains(r#"type="CLICK_HERE""#), "{xml}"); } #[test] diff --git a/src/serializer/hwpx/mod.rs b/src/serializer/hwpx/mod.rs index 2ea505a9c..be6de69cb 100644 --- a/src/serializer/hwpx/mod.rs +++ b/src/serializer/hwpx/mod.rs @@ -466,6 +466,133 @@ mod tests { ); } + #[test] + fn task1587_ruby_control_roundtrips() { + // Ruby(덧말) 컨트롤은 is_hwpx_inline_slot 에 등록돼 슬롯으로 인식되나 + // render_control_slot 에 방출 arm 이 없어 저장 시 드롭된다(controls=[]). + // 수정 전: reparse 후 Ruby 소실 → RED. 수정 후: 보존 → GREEN. + use crate::model::control::{Control, Ruby}; + + let mut doc = Document::default(); + let mut section = crate::model::document::Section::default(); + let mut para = crate::model::paragraph::Paragraph::default(); + para.text = "ab".to_string(); + para.char_offsets = vec![0, 9]; + para.char_count = 11; // (11-1-2)/8 = 1 슬롯 + para.controls.push(Control::Ruby(Ruby { + main_text: "기준글".to_string(), + ruby_text: "덧말".to_string(), + pos_type: 1, // BOTTOM + align: 2, // CENTER + sz_ratio: 80, + option: 3, + style_id_ref: 5, + })); + section.paragraphs.push(para); + doc.sections.push(section); + + let bytes = serialize_hwpx(&doc).expect("serialize ruby"); + let doc2 = crate::parser::hwpx::parse_hwpx(&bytes).expect("parse"); + let rubies: Vec<_> = doc2.sections[0].paragraphs[0] + .controls + .iter() + .filter_map(|c| match c { + Control::Ruby(r) => Some(r), + _ => None, + }) + .collect(); + assert_eq!( + rubies.len(), + 1, + "Ruby 컨트롤이 roundtrip 후 보존돼야 한다 (현재 드롭): {:?}", + doc2.sections[0].paragraphs[0].controls + ); + let r = rubies[0]; + // 전 필드 무손실 (#1587 — mainText/posType/align/szRatio/option/styleIDRef) + assert_eq!(r.main_text, "기준글", "mainText 보존"); + assert_eq!(r.ruby_text, "덧말", "subText(덧말) 보존"); + assert_eq!(r.pos_type, 1, "posType(BOTTOM) 보존"); + assert_eq!(r.align, 2, "align(CENTER) 보존"); + assert_eq!(r.sz_ratio, 80, "szRatio 보존"); + assert_eq!(r.option, 3, "option 보존"); + assert_eq!(r.style_id_ref, 5, "styleIDRef 보존"); + } + + #[ignore = "#1591: 북마크 hoist 수정은 롤백됨(순효과 0). Class C1 char_shape +8 의 진짜 \ +근본은 first-para mismatch-path 위치추정(F3급, 별건). 본 RED 는 hoist 버그 repro 로 보존."] + #[test] + fn task1591_bookmark_not_hoisted_before_slot() { + // [#1591] 북마크가 슬롯 컨트롤(표 등) 뒤에 있을 때, 직렬화기(section.rs:416-426)가 + // 북마크를 문단 시작으로 hoisting 하면 컨트롤 순서가 뒤바뀐다. 다만 char_shape +8 + // 시프트의 진짜 근본은 mismatch-path 위치추정이라, 이 hoist 수정만으로는 게이트 미해소. + use crate::model::control::{Bookmark, Control}; + use crate::model::style::BorderFill; + use crate::model::table::Table; + + let mut doc = Document::default(); + doc.doc_info.border_fills.push(BorderFill::default()); + let mut section = crate::model::document::Section::default(); + section + .paragraphs + .push(crate::model::paragraph::Paragraph::default()); // para0 더미 + let mut p = crate::model::paragraph::Paragraph::default(); + p.text = "AB".to_string(); + p.char_offsets = vec![0, 9]; // A@0, [표 슬롯 8], B@9 + p.char_count = 11; + p.controls.push(Control::Table(Box::::default())); + p.controls.push(Control::Bookmark(Bookmark { + name: "bm".to_string(), + })); + section.paragraphs.push(p); + doc.sections.push(section); + + let bytes = serialize_hwpx(&doc).expect("serialize"); + let doc2 = crate::parser::hwpx::parse_hwpx(&bytes).expect("parse"); + let ctrls: Vec<&str> = doc2.sections[0].paragraphs[1] + .controls + .iter() + .map(|c| match c { + Control::Table(_) => "tbl", + Control::Bookmark(_) => "bm", + _ => "?", + }) + .collect(); + assert_eq!( + ctrls, + vec!["tbl", "bm"], + "북마크가 표 뒤 위치를 보존해야 한다 (hoisting 시 [bm,tbl] 로 뒤바뀜)" + ); + } + + #[test] + fn task1592_empty_paragraph_no_spurious_charshape() { + // [#1592] run 이 없던 완전 빈 문단(char_shapes=[])에 직렬화기가 빈 + // 를 추가하면 재파싱 시 char_shapes 가 [(0,0)] 으로 생긴다. + // 비-첫 문단으로 구성(첫 문단 템플릿 회피). + let mut doc = Document::default(); + let mut section = crate::model::document::Section::default(); + // para0: 텍스트 있는 일반 문단 + let mut p0 = crate::model::paragraph::Paragraph::default(); + p0.text = "본문".to_string(); + section.paragraphs.push(p0); + // para1: 완전 빈 문단 (text="", char_shapes=[], controls=[]) + section + .paragraphs + .push(crate::model::paragraph::Paragraph::default()); + doc.sections.push(section); + + let bytes = serialize_hwpx(&doc).expect("serialize"); + let doc2 = crate::parser::hwpx::parse_hwpx(&bytes).expect("parse"); + let cs = &doc2.sections[0].paragraphs[1].char_shapes; + assert!( + cs.is_empty(), + "빈 문단은 char_shapes 가 비어야 한다 (spurious (0,0) 금지): {:?}", + cs.iter() + .map(|c| (c.start_pos, c.char_shape_id)) + .collect::>() + ); + } + #[test] fn equation_control_does_not_consume_unmapped_control_gap() { use crate::model::control::{Control, Equation}; diff --git a/src/serializer/hwpx/picture.rs b/src/serializer/hwpx/picture.rs index 98e8766cd..995c3a12e 100644 --- a/src/serializer/hwpx/picture.rs +++ b/src/serializer/hwpx/picture.rs @@ -396,6 +396,7 @@ fn write_pos(w: &mut Writer, c: &CommonObjAttr) -> Result<(), Seria let allow_overlap = bool01(c.allow_overlap); let vert_offset = c.vertical_offset.to_string(); let horz_offset = c.horizontal_offset.to_string(); + let hold = bool01(c.prevent_page_break != 0); // [#1594] IR 보존 empty_tag( w, "hp:pos", @@ -404,7 +405,7 @@ fn write_pos(w: &mut Writer, c: &CommonObjAttr) -> Result<(), Seria ("affectLSpacing", "0"), ("flowWithText", flow_with_text), ("allowOverlap", allow_overlap), - ("holdAnchorAndSO", "0"), + ("holdAnchorAndSO", hold), ("vertRelTo", vert_rel_to_str(c.vert_rel_to)), ("horzRelTo", horz_rel_to_str(c.horz_rel_to)), ("vertAlign", vert_align_str(c.vert_align)), diff --git a/src/serializer/hwpx/roundtrip.rs b/src/serializer/hwpx/roundtrip.rs index dd4c66d47..bbeb2fb5c 100644 --- a/src/serializer/hwpx/roundtrip.rs +++ b/src/serializer/hwpx/roundtrip.rs @@ -216,6 +216,14 @@ pub enum IrDifference { path: String, detail: String, }, + /// 개체 `holdAnchorAndSO`(IR `prevent_page_break`) 불일치 — 페이지 하단 앵커 + /// 개체에서 1→0 드롭 시 한글 페이지 붕괴를 유발(#1594). IR-invisible 였던 갭을 봉인. + ObjectHoldAnchor { + section: usize, + paragraph: usize, + path: String, + detail: String, + }, } impl std::fmt::Display for IrDifference { @@ -360,6 +368,16 @@ impl std::fmt::Display for IrDifference { "section[{}] paragraph[{}]{} tbl page_break: {}", section, paragraph, path, detail ), + ObjectHoldAnchor { + section, + paragraph, + path, + detail, + } => write!( + f, + "section[{}] paragraph[{}]{} holdAnchorAndSO: {}", + section, paragraph, path, detail + ), } } } @@ -410,6 +428,21 @@ fn diff_object_comment(a: &str, b: &str) -> Option { } } +/// 두 개체의 `prevent_page_break`(holdAnchorAndSO) 비교 (#1594). 다르면 detail, 같으면 None. +fn diff_hold_anchor( + a: &crate::model::shape::CommonObjAttr, + b: &crate::model::shape::CommonObjAttr, +) -> Option { + if a.prevent_page_break == b.prevent_page_break { + None + } else { + Some(format!( + "expected={} actual={}", + a.prevent_page_break, b.prevent_page_break + )) + } +} + /// HWPX 바이트 → parse → serialize → parse → 원본 IR과 비교. pub fn roundtrip_ir_diff(hwpx_bytes: &[u8]) -> Result { let doc1 = parse_hwpx(hwpx_bytes) @@ -936,6 +969,15 @@ fn diff_paragraph_char_shapes( detail: format!("expected={:?} actual={:?}", ta.page_break, tb.page_break), }); } + // [#1594] holdAnchorAndSO 보존 게이트 — 페이지 하단 앵커 개체 붕괴 봉인. + if let Some(detail) = diff_hold_anchor(&ta.common, &tb.common) { + diff.push(IrDifference::ObjectHoldAnchor { + section, + paragraph, + path: format!("{path}/ctrl[{ci}]tbl"), + detail, + }); + } for (cell_i, (cea, ceb)) in ta.cells.iter().zip(tb.cells.iter()).enumerate() { for (k, (qa, qb)) in cea.paragraphs.iter().zip(ceb.paragraphs.iter()).enumerate() @@ -991,6 +1033,15 @@ fn diff_paragraph_char_shapes( detail, }); } + // [#1594] holdAnchorAndSO 보존 게이트. + if let Some(detail) = diff_hold_anchor(&pia.common, &pib.common) { + diff.push(IrDifference::ObjectHoldAnchor { + section, + paragraph, + path: format!("{path}/ctrl[{ci}]pic"), + detail, + }); + } if let (Some(ca), Some(cb)) = (&pia.caption, &pib.caption) { for (k, (qa, qb)) in ca.paragraphs.iter().zip(cb.paragraphs.iter()).enumerate() { @@ -1012,6 +1063,15 @@ fn diff_paragraph_char_shapes( detail, }); } + // [#1594] holdAnchorAndSO 보존 게이트. + if let Some(detail) = diff_hold_anchor(&ea.common, &eb.common) { + diff.push(IrDifference::ObjectHoldAnchor { + section, + paragraph, + path: format!("{path}/ctrl[{ci}]eq"), + detail, + }); + } } (Control::Shape(sa), Control::Shape(sb)) => { let p = format!("{path}/ctrl[{ci}]shape"); diff --git a/src/serializer/hwpx/section.rs b/src/serializer/hwpx/section.rs index 871f9889b..9321f789d 100644 --- a/src/serializer/hwpx/section.rs +++ b/src/serializer/hwpx/section.rs @@ -23,7 +23,7 @@ use quick_xml::Writer; use crate::model::control::{ AutoNumber, AutoNumberType, CharOverlap, Control, Equation, Field, NewNumber, PageHide, - PageNumberPos, + PageNumberPos, Ruby, }; use crate::model::document::{Document, Section}; use crate::model::footnote::{Endnote, Footnote}; @@ -75,11 +75,15 @@ pub fn write_section( let mut vert_cursor: u32 = 0; let first_para = section.paragraphs.first(); + // [#1584] 첫 문단 렌더 직전 set — 본문 첫 ColumnDef(섹션 템플릿 흡수분)의 인라인 + // XML 방출만 1회 억제한다(슬롯 위치는 보존). 렌더 직후 reset 하여 추가 문단 누설 방지. + ctx.body_coldef_template_pending = true; let (first_runs, first_linesegs, first_advance) = match first_para { Some(p) => render_paragraph_parts(p, vert_cursor, ctx), // 문단이 없는 섹션(비파싱 IR) — linesegarray 방출 생략 (#1380) None => (String::new(), String::new(), vert_cursor), }; + ctx.body_coldef_template_pending = false; vert_cursor = first_advance; // 치환은 모두 pristine 템플릿의 고정 anchor 에 대해 수행한다 (#1378): @@ -407,6 +411,19 @@ fn render_runs(para: &Paragraph, ctx: &mut SerializeContext) -> String { ctx.char_shape_ids.reference(cs.char_shape_id); } + // [#1592] 완전 빈 문단(원본에 없음)은 run 을 방출하지 않는다. char_shapes=[] + // 인 문단에 RunSplitter 가 기본 (0,0) 세그먼트로 charPrIDRef="0" 빈 run 을 추가하면, + // 재파싱 시 spurious (0,0) char_shape 가 생긴다(원본은 run 없어 char_shapes=[]). + // char_shapes 가 있으면(예: [(0,0)] 명시) 종전대로 run 을 방출한다(linesegarray 는 별도). + if para.text.is_empty() + && para.char_shapes.is_empty() + && para.controls.is_empty() + && para.field_ranges.is_empty() + && para.orphan_field_ends.is_empty() + { + return String::new(); + } + let mut splitter = RunSplitter::new(para); // Bookmark는 IR에 위치 정보가 없어 문단 시작(첫 run)에 배치한다. @@ -423,18 +440,37 @@ fn render_runs(para: &Paragraph, ctx: &mut SerializeContext) -> String { let slot_count = inferred_control_slot_count(para); let slots: Vec<&Control> = if slot_count == para.controls.len() { + // 전 컨트롤이 위치 슬롯인 경로. 본문 첫 문단의 첫 ColumnDef 도 슬롯으로 남겨 + // char-offset 정합을 보존하고, 그 XML 만 render_control_slot 의 consume-once + // 플래그로 억제한다(템플릿이 이미 방출 — 중복 방지). 2번째+ 는 인라인 방출. para.controls.iter().collect() } else { - // [Task #1379] 셀·글상자 subList(depth > 0) 경로에서는 ColumnDef 도 - // 인라인 슬롯으로 취급한다 (원본 XML 에 인라인 존재). - // 본문(depth 0) 경로는 섹션 템플릿 첫 run 의 colPr 가 받으므로 불변. - para.controls + // [Task #1379] 셀·글상자 subList(depth>0) 경로에서는 ColumnDef 도 인라인 슬롯으로 + // 취급한다 (원본 XML 에 인라인 존재). + // [Task #1584] 본문(depth 0) 경로에서도 ColumnDef 를 인라인 슬롯에 포함하되, + // 첫 문단의 첫 ColumnDef(섹션 템플릿 흡수분)는 슬롯에서 제외한다 — 이 분기는 + // slot_count 가 char-offset 추정과 어긋나는 케이스로, 템플릿 흡수분은 위치 슬롯을 + // 점유하지 않으므로(추정 카운트에서 제외됨) 슬롯에 넣으면 위치가 어긋난다. + // 2번째+ 본문 ColumnDef 는 포함하여 드롭을 방지한다. + let suppress_first_col = ctx.sub_list_depth == 0 && ctx.body_coldef_template_pending; + let mut col_seen = 0u32; + let collected: Vec<&Control> = para + .controls .iter() .filter(|c| { + if matches!(c, Control::ColumnDef(_)) { + col_seen += 1; + return !(suppress_first_col && col_seen == 1); + } is_hwpx_inline_slot(c) - || (ctx.sub_list_depth > 0 && matches!(c, Control::ColumnDef(_))) }) - .collect() + .collect(); + if suppress_first_col { + // 템플릿 흡수분을 슬롯에서 이미 제외했으므로, render_control_slot 의 + // consume-once 억제가 2번째 ColumnDef 를 잘못 건너뛰지 않도록 플래그 해제. + ctx.body_coldef_template_pending = false; + } + collected }; let mut tab_idx = 0usize; @@ -827,15 +863,49 @@ fn render_control_slot(out: &mut String, control: &Control, ctx: &mut SerializeC Err(e) => eprintln!("[hwpx] Form 직렬화 실패: {e}"), }, Control::CharOverlap(co) => out.push_str(&render_compose(co)), - // [Task #1379] 셀·글상자 subList 한정 인라인 colPr 방출. - // 본문 경로(depth 0)는 섹션 템플릿 첫 run 에서 처리하므로 미방출 유지. - Control::ColumnDef(cd) if ctx.sub_list_depth > 0 => { - out.push_str(&render_col_pr_ctrl(cd)); + // [Task #1587] 덧말(Ruby) 인라인 방출. is_hwpx_inline_slot 에 등록돼 슬롯 위치는 + // 자동이나 종전 방출 arm 부재로 드롭됐다. parse_dutmal 의 역매핑. + Control::Ruby(r) => out.push_str(&render_dutmal(r)), + // [Task #1379/#1584] 인라인 colPr 방출. + // - subList(depth>0): 전부 인라인 방출(원본 XML 인라인 존재). + // - 본문(depth 0): 첫 문단의 첫 ColumnDef 1개는 섹션 템플릿 colPr 앵커가 이미 + // 방출했으므로(중복 방지) consume-once 플래그로 XML 만 건너뛴다. 슬롯 자체는 + // 상위(render_runs)에서 유지하므로 char-offset 위치 정합은 보존된다. + Control::ColumnDef(cd) => { + if ctx.sub_list_depth == 0 && ctx.body_coldef_template_pending { + ctx.body_coldef_template_pending = false; + } else { + out.push_str(&render_col_pr_ctrl(cd)); + } } _ => {} } } +/// 덧말(Ruby) `` 직렬화 (#1587). `parse_dutmal` 의 역매핑. +/// 속성 순서는 한컴 실측(posType szRatio option styleIDRef align)을 따른다. +fn render_dutmal(r: &Ruby) -> String { + let pos_type = match r.pos_type { + 1 => "BOTTOM", + _ => "TOP", + }; + let align = match r.align { + 1 => "RIGHT", + 2 => "CENTER", + _ => "LEFT", + }; + format!( + r#"{}{}"#, + pos_type, + r.sz_ratio, + r.option, + r.style_id_ref, + align, + xml_escape(&r.main_text), + xml_escape(&r.ruby_text), + ) +} + fn generated_field_parameters(field: &Field) -> Option { if field.command.is_empty() || field.raw_parameters_xml.is_some() { return None; @@ -1204,31 +1274,42 @@ fn render_shape(shape: &ShapeObject, ctx: &mut SerializeContext) -> String { } return xml; } - let (tag, c, caption, sa) = match shape { + const NO_PTS: &[crate::model::Point] = &[]; + let (tag, c, caption, drawing, points): ( + _, + _, + _, + Option<&crate::model::shape::DrawingObjAttr>, + &[crate::model::Point], + ) = match shape { ShapeObject::Rectangle(_) | ShapeObject::Line(_) => unreachable!(), ShapeObject::Ellipse(e) => ( "ellipse", &e.common, &e.drawing.caption, - Some(&e.drawing.shape_attr), + Some(&e.drawing), + NO_PTS, ), ShapeObject::Arc(a) => ( "arc", &a.common, &a.drawing.caption, - Some(&a.drawing.shape_attr), + Some(&a.drawing), + NO_PTS, ), ShapeObject::Polygon(p) => ( "polygon", &p.common, &p.drawing.caption, - Some(&p.drawing.shape_attr), + Some(&p.drawing), + &p.points, ), ShapeObject::Curve(cv) => ( "curve", &cv.common, &cv.drawing.caption, - Some(&cv.drawing.shape_attr), + Some(&cv.drawing), + &cv.points, ), ShapeObject::Group(_) => unreachable!(), ShapeObject::Picture(pic) => { @@ -1240,7 +1321,7 @@ fn render_shape(shape: &ShapeObject, ctx: &mut SerializeContext) -> String { } }; } - ShapeObject::Chart(ch) => ("chart", &ch.common, &ch.caption, None), + ShapeObject::Chart(ch) => ("chart", &ch.common, &ch.caption, None, NO_PTS), ShapeObject::Ole(o) => { return match writer_to_string(|w| super::shape::write_ole(w, o, ctx)) { Ok(xml) => xml, @@ -1251,38 +1332,94 @@ fn render_shape(shape: &ShapeObject, ctx: &mut SerializeContext) -> String { }; } }; - render_common_shape_xml(tag, c, caption, sa, ctx) + // [Task #1598] ellipse / arc 전용 지오메트리(center/축/시작끝점) — 미방출 시 한글이 + // 타원/호를 다르게 렌더 → 누적 레이아웃 변동 → 페이지 붕괴(#1589 잔여). hc:pt(polygon/ + // curve) 와 상호배타이므로 동일 위치(shadow 직후, sz 직전)에 방출. + let hc = |t: &str, p: &crate::model::Point| format!(r#""#, p.x, p.y); + let geom_tail = match shape { + ShapeObject::Ellipse(e) => format!( + "{}{}{}{}{}{}{}", + hc("center", &e.center), + hc("ax1", &e.axis1), + hc("ax2", &e.axis2), + hc("start1", &e.start1), + hc("end1", &e.end1), + hc("start2", &e.start2), + hc("end2", &e.end2), + ), + ShapeObject::Arc(a) => format!( + "{}{}{}", + hc("center", &a.center), + hc("ax1", &a.axis1), + hc("ax2", &a.axis2), + ), + _ => String::new(), + }; + render_common_shape_xml(tag, c, caption, drawing, points, &geom_tail, ctx) } fn render_common_shape_xml( tag: &str, c: &CommonObjAttr, caption: &Option, - sa: Option<&crate::model::shape::ShapeComponentAttr>, + drawing: Option<&crate::model::shape::DrawingObjAttr>, + points: &[crate::model::Point], + geom_tail: &str, ctx: &mut SerializeContext, ) -> String { // 도형 좌표계 블록(offset/orgSz/curSz/flip/rotationInfo/renderingInfo) — 누락 시 // 회전/뒤집힘이 소실되어 bbox 가 전치되는 등 렌더가 어긋난다(#1501 동류, polygon 등). - let shape_block = sa - .map(|sa| { - writer_to_string(|w| super::shape::write_shape_component_block(w, sa)) + let shape_block = drawing + .map(|d| { + writer_to_string(|w| super::shape::write_shape_component_block(w, &d.shape_attr)) .unwrap_or_default() }) .unwrap_or_default(); + // [#1596] 지오메트리(lineShape/fillBrush/shadow/꼭짓점) — 종전 드롭으로 도형 형상 소실 → + // 페이지 붕괴(#1589 잔여). write_rect 와 동형 순서(shape_block 직후, sz 직전). + let geometry = drawing + .map(|d| { + let ls = writer_to_string(|w| super::shape::write_line_shape(w, &d.border_line)) + .unwrap_or_default(); + let fb = writer_to_string(|w| super::shape::write_fill_brush(w, &d.fill, ctx)) + .unwrap_or_default(); + let sh = writer_to_string(|w| super::shape::write_shadow(w, d)).unwrap_or_default(); + let pts: String = points + .iter() + .map(|p| format!(r#""#, p.x, p.y)) + .collect(); + // [#1598] ellipse/arc 전용 지오메트리(center/축/시작끝점)는 hc:pt 와 상호배타. + format!("{ls}{fb}{sh}{pts}{geom_tail}") + }) + .unwrap_or_default(); + // 태그 부수 속성 — numberingType/dropcapstyle/href/groupLevel/instid (rect/line 동형). + let group_level = drawing.map(|d| d.shape_attr.group_level).unwrap_or(0); + let instid = drawing + .map(|d| d.inst_id) + .filter(|&i| i != 0) + .unwrap_or(c.instance_id); let mut out = format!( concat!( - r#""#, + r#""#, "{block}", + "{geometry}", r#""#, - r#""#, + r#""#, r#""#, ), tag = tag, block = shape_block, + geometry = geometry, id = c.instance_id, zo = c.z_order, + nt = super::shape::numbering_type_str(c.numbering_type), + gl = group_level, + iid = instid, tw = text_wrap_to_hwpx(c.text_wrap), tac = if c.treat_as_char { "1" } else { "0" }, + fwt = if c.flow_with_text { "1" } else { "0" }, + ao = if c.allow_overlap { "1" } else { "0" }, + hold = if c.prevent_page_break != 0 { "1" } else { "0" }, w = c.width, h = c.height, vr = vert_rel_to_hwpx(c.vert_rel_to), @@ -1377,8 +1514,11 @@ fn render_equation(eq: &Equation) -> String { ) }; + // [#1594] holdAnchorAndSO 는 IR(prevent_page_break)을 보존(종전 "0" 하드코딩 제거). + let hold = if c.prevent_page_break != 0 { "1" } else { "0" }; + format!( - r#"{script}{shape_comment}"#, + r#"{script}{shape_comment}"#, text_wrap_to_hwpx(c.text_wrap), vert_rel_to_hwpx(c.vert_rel_to), horz_rel_to_hwpx(c.horz_rel_to), @@ -2351,7 +2491,7 @@ mod tests { let xml = String::from_utf8(write_section(§ion, &doc, 0, &mut ctx).unwrap()).unwrap(); assert!( - xml.contains(r#" 없음"을 의미하므로(빈 run 이 있었다면 + // 파서가 [(0,0)] 을 산출), run 을 추가하면 재파싱 시 spurious (0,0) 가 생긴다(#1592). + // 종전 #1378 은 빈 run(id 0)을 방출했으나, 이는 run 없던 빈 문단에 entry 를 가공했다. let para = Paragraph::default(); - assert_eq!( - runs_of(¶), - r#""# - ); + assert_eq!(runs_of(¶), ""); } #[test] @@ -2844,4 +2984,57 @@ mod tests { assert_eq!(shapes_of(0), vec![(0, 1), (19, 2)], "섹션 첫 문단"); assert_eq!(shapes_of(1), vec![(0, 1), (3, 2)], "추가 문단"); } + + // ---------- #1584: 본문 인라인 ColumnDef 드롭 회귀 가드 ---------- + + #[test] + fn task1584_body_first_para_two_columndefs_roundtrip() { + // 본문 첫 문단에 ColumnDef 2개(섹션 단 정의 + 인라인 단 정의). + // 섹션 템플릿은 첫 ColumnDef 1개만 흡수하고, 2번째 인라인 ColumnDef 는 + // 본문 인라인 슬롯에서 제외되어 드롭된다(controls 6→5 양상). + // 수정 전: reparse 후 ColumnDef 1개만 → RED. 수정 후: 2개 보존 → GREEN. + let mut p0 = Paragraph::default(); + p0.controls.push(Control::ColumnDef(ColumnDef::default())); + p0.controls.push(Control::ColumnDef(ColumnDef::default())); + + let mut section = Section::default(); + section.paragraphs.push(p0); + let mut doc = Document::default(); + doc.sections.push(section); + + let bytes = crate::serializer::hwpx::serialize_hwpx(&doc).expect("serialize"); + let doc2 = crate::parser::hwpx::parse_hwpx(&bytes).expect("parse"); + let coldef_count = doc2.sections[0].paragraphs[0] + .controls + .iter() + .filter(|c| matches!(c, Control::ColumnDef(_))) + .count(); + assert_eq!( + coldef_count, 2, + "본문 첫 문단의 ColumnDef 2개가 roundtrip 후 모두 보존돼야 한다 (템플릿1 + 인라인1): {coldef_count}" + ); + } + + // ---------- #1596: generic-shape 지오메트리 직렬화 ---------- + + #[test] + fn task1596_polygon_geometry_serialized() { + // [#1596] polygon 의 꼭짓점(hc:pt)·테두리(lineShape)·그림자(shadow)가 방출돼야 한다. + // render_common_shape_xml 이 종전 이들을 드롭 → 도형 형상 소실 → 페이지 붕괴(#1589 잔여). + use crate::model::shape::PolygonShape; + use crate::model::Point; + let mut poly = PolygonShape::default(); + poly.points = vec![ + Point { x: 0, y: 0 }, + Point { x: 100, y: 0 }, + Point { x: 100, y: 100 }, + ]; + poly.drawing.border_line.width = 50; + poly.drawing.shadow_type = 1; + let mut ctx = SerializeContext::collect_from_document(&Document::default()); + let xml = render_shape(&ShapeObject::Polygon(poly), &mut ctx); + assert!(xml.contains("( if let Some(cap) = &line.drawing.caption { write_caption(w, cap, ctx)?; } + // [#1588] 도형 설명 — caption 뒤 (write_rect/container 와 동형). 누락 시 선 도형 + // shapeComment("선입니다." 등)가 저장 시 드롭됐다. + write_shape_comment(w, c)?; end_tag(w, "hp:line")?; Ok(()) @@ -559,7 +562,7 @@ fn write_matrix( /// `` — `parse_line_shape_attr` 의 역매핑. /// headStyle/tailStyle/alpha 는 파서 미적재 → "NORMAL"/"0" 고정 방출. -fn write_line_shape( +pub(crate) fn write_line_shape( w: &mut Writer, bl: &ShapeBorderLine, ) -> Result<(), SerializeError> { @@ -795,7 +798,10 @@ fn hatch_style_str(pattern_type: i32) -> &'static str { /// `` — `parse_shape_shadow_attr` 의 역매핑. /// 전 필드 0 이면 원본에 shadow 부재로 간주하여 미방출. /// alpha 는 정수 방출 (파서의 `>1.0` 경로와 정합 — 0/1 경계값만 비가역). -fn write_shadow(w: &mut Writer, d: &DrawingObjAttr) -> Result<(), SerializeError> { +pub(crate) fn write_shadow( + w: &mut Writer, + d: &DrawingObjAttr, +) -> Result<(), SerializeError> { if d.shadow_type == 0 && d.shadow_color == 0 && d.shadow_offset_x == 0 @@ -885,6 +891,7 @@ fn write_pos(w: &mut Writer, c: &CommonObjAttr) -> Result<(), Seria let treat = bool01(c.treat_as_char); let vert_offset = c.vertical_offset.to_string(); let horz_offset = c.horizontal_offset.to_string(); + let hold = bool01(c.prevent_page_break != 0); // [#1594] IR 보존 empty_tag( w, "hp:pos", @@ -893,7 +900,7 @@ fn write_pos(w: &mut Writer, c: &CommonObjAttr) -> Result<(), Seria ("affectLSpacing", "0"), ("flowWithText", bool01(c.flow_with_text)), ("allowOverlap", bool01(c.allow_overlap)), - ("holdAnchorAndSO", "0"), + ("holdAnchorAndSO", hold), ("vertRelTo", vert_rel_to_str(c.vert_rel_to)), ("horzRelTo", horz_rel_to_str(c.horz_rel_to)), ("vertAlign", vert_align_str(c.vert_align)), @@ -933,7 +940,7 @@ pub(crate) fn color_to_hex(c: ColorRef) -> String { } } -fn numbering_type_str(n: ObjectNumberingType) -> &'static str { +pub(crate) fn numbering_type_str(n: ObjectNumberingType) -> &'static str { match n { ObjectNumberingType::Picture => "PICTURE", ObjectNumberingType::Table => "TABLE", @@ -1057,6 +1064,27 @@ mod tests { assert_eq!(line_shape_style(none_with_flat_end_cap), "NONE"); } + /// #1588: 선 도형 설명(shapeComment)이 저장 시 방출돼야 한다. + /// write_rect/container 는 호출하나 write_line 만 누락 → 드롭(RED). + #[test] + fn task1588_line_shape_comment_emitted() { + let mut line = LineShape::default(); + line.common.description = "선입니다.".to_string(); + let xml = serialize_line(&line); + assert!( + xml.contains("선입니다."), + "선 도형 shapeComment 방출돼야 한다 (현재 드롭): {xml}" + ); + } + + /// #1588: 설명 없는 선 도형은 shapeComment 미방출 (빈 태그 금지). + #[test] + fn task1588_line_shape_no_comment_when_empty() { + let line = LineShape::default(); + let xml = serialize_line(&line); + assert!(!xml.contains(" crate::model::paragraph::CharShapeRef { crate::model::paragraph::CharShapeRef { start_pos, diff --git a/src/serializer/hwpx/table.rs b/src/serializer/hwpx/table.rs index 767474aea..a7662c943 100644 --- a/src/serializer/hwpx/table.rs +++ b/src/serializer/hwpx/table.rs @@ -135,6 +135,9 @@ fn write_pos(w: &mut Writer, c: &CommonObjAttr) -> Result<(), Seria let treat = bool01(c.treat_as_char); let vert_offset = c.vertical_offset.to_string(); let horz_offset = c.horizontal_offset.to_string(); + // [#1594] holdAnchorAndSO 는 IR(prevent_page_break)을 보존한다. 종전 "0" 하드코딩은 + // 페이지 하단 앵커 개체(발신명의 footer 등)의 1→0 드롭으로 한글 페이지 붕괴를 유발했다. + let hold = bool01(c.prevent_page_break != 0); empty_tag( w, "hp:pos", @@ -143,7 +146,7 @@ fn write_pos(w: &mut Writer, c: &CommonObjAttr) -> Result<(), Seria ("affectLSpacing", "0"), ("flowWithText", "1"), ("allowOverlap", "0"), - ("holdAnchorAndSO", "0"), + ("holdAnchorAndSO", hold), ("vertRelTo", vert_rel_to_str(c.vert_rel_to)), ("horzRelTo", horz_rel_to_str(c.horz_rel_to)), ("vertAlign", vert_align_str(c.vert_align)), @@ -548,6 +551,34 @@ mod tests { } } + // ---------- #1594: holdAnchorAndSO 보존 ---------- + + #[test] + fn task1594_hold_anchor_preserved() { + // [#1594] holdAnchorAndSO 는 IR(common.prevent_page_break)을 보존해야 한다. + // 현재 직렬화가 "0" 하드코딩 → 1→0 드롭(페이지 하단 앵커 개체에서 페이지 붕괴 원인). RED. + let mut t = empty_table(1, 1); + t.common.prevent_page_break = 1; + let xml = serialize(&t); + assert!( + xml.contains(r#"holdAnchorAndSO="1""#), + "holdAnchorAndSO 가 IR 값(1)으로 방출돼야 한다(현재 0 하드코딩): {}", + &xml[..xml.len().min(500)] + ); + } + + #[test] + fn task1594_hold_anchor_zero_when_unset() { + // prevent_page_break=0 이면 holdAnchorAndSO="0" (기존 동작 보존). + let t = empty_table(1, 1); + let xml = serialize(&t); + assert!( + xml.contains(r#"holdAnchorAndSO="0""#), + "기본 0: {}", + &xml[..xml.len().min(300)] + ); + } + // ---------- #1387: write_caption — 표 캡션 직렬화 ---------- fn caption_with_text(text: &str) -> crate::model::shape::Caption { diff --git a/tests/fixtures/opengov_snapshot.tsv b/tests/fixtures/opengov_snapshot.tsv index f79cd531e..393c45ff2 100644 --- a/tests/fixtures/opengov_snapshot.tsv +++ b/tests/fixtures/opengov_snapshot.tsv @@ -1,4 +1,10 @@ id status ir_diff_count class +36382399 PASS 0 본문 인라인 ColumnDef 드롭 회귀가드(#1584) +36383351 PASS 0 holdAnchorAndSO 드롭 회귀가드(#1594, 페이지 붕괴) +36389301 PASS 0 Ruby(덧말) 드롭 회귀가드(#1587) +36386761 PASS 0 빈 문단 spurious (0,0) 회귀가드(#1592) +36392900 PASS 0 선 도형 shapeComment 드롭 회귀가드(#1588) +36385226 PASS 0 ellipse 전용 지오메트리 드롭 회귀가드(#1598, 페이지 붕괴) 36382669 PASS 0 multi-section/secCnt 회귀가드(#1557) 36383351 PASS 0 PASS 클린 36384285 PASS 0 PASS 클린 diff --git a/tests/issue_1598_ellipse_geometry_roundtrip.rs b/tests/issue_1598_ellipse_geometry_roundtrip.rs new file mode 100644 index 000000000..ecd20132b --- /dev/null +++ b/tests/issue_1598_ellipse_geometry_roundtrip.rs @@ -0,0 +1,84 @@ +//! Task #1598 — HWPX ellipse/arc 전용 지오메트리(center/축/시작끝점) roundtrip 보존. +//! +//! HWPX 파서가 `//...` 를 읽지 않고 직렬화도 드롭하던 결함 +//! (#1589 잔여 페이지 붕괴의 근본)을 회귀 차단한다. IR diff 게이트는 ellipse 지오메트리를 +//! 비교하지 않으므로(IR-invisible) 본 전용 테스트가 유일한 자동 게이트다. +//! +//! 통제 검증(한글 오라클): 36385226 은 지오메트리 미방출 시 3→2 붕괴, 방출 시 3 유지. + +use rhwp::model::shape::ShapeObject; +use rhwp::parser::hwpx::parse_hwpx; +use rhwp::serializer::hwpx::serialize_hwpx; + +/// 문서 전체 ellipse 의 (center, ax1, ax2, start1, end1, start2, end2) 좌표를 순서대로 수집. +/// Point 는 PartialEq 미구현이라 (x, y) 튜플 배열로 환산. +fn collect_ellipse_geoms(doc: &rhwp::model::document::Document) -> Vec<[(i32, i32); 7]> { + fn visit(p: &rhwp::model::paragraph::Paragraph, out: &mut Vec<[(i32, i32); 7]>) { + for c in &p.controls { + match c { + rhwp::model::control::Control::Shape(s) => { + if let ShapeObject::Ellipse(e) = s.as_ref() { + out.push([ + (e.center.x, e.center.y), + (e.axis1.x, e.axis1.y), + (e.axis2.x, e.axis2.y), + (e.start1.x, e.start1.y), + (e.end1.x, e.end1.y), + (e.start2.x, e.start2.y), + (e.end2.x, e.end2.y), + ]); + } + } + rhwp::model::control::Control::Table(t) => { + for cell in &t.cells { + for q in &cell.paragraphs { + visit(q, out); + } + } + } + _ => {} + } + } + } + let mut out = Vec::new(); + for s in &doc.sections { + for p in &s.paragraphs { + visit(p, &mut out); + } + } + out +} + +#[test] +fn ellipse_geometry_roundtrips() { + let path = "samples/hwpx/opengov/36385226_결재문서본문_제2처리장 슬러지인발용 에어리프트 브로워 2호기 소모품 교체 보고.hwpx"; + let bytes = std::fs::read(path).expect("샘플 읽기"); + + let doc1 = parse_hwpx(&bytes).expect("parse 원본"); + let g1 = collect_ellipse_geoms(&doc1); + assert!( + g1.len() >= 9, + "원본 ellipse {}개 (>=9 기대) — 파서가 ellipse 를 적재해야 함", + g1.len() + ); + // 파서가 전용 지오메트리를 실제로 읽었는가 (모두 0 이면 드롭된 것 = RED). + let nonzero = g1 + .iter() + .any(|geo| geo.iter().any(|&(x, y)| x != 0 || y != 0)); + assert!( + nonzero, + "ellipse 전용 지오메트리(center/축/시작끝점)가 전부 0 — 파서 드롭(#1598 미수정)" + ); + + // round 1: serialize → reparse → 지오메트리 보존. + let out = serialize_hwpx(&doc1).expect("serialize"); + let doc2 = parse_hwpx(&out).expect("reparse"); + let g2 = collect_ellipse_geoms(&doc2); + assert_eq!(g1, g2, "round1 ellipse 지오메트리 보존 실패(직렬화 드롭)"); + + // 2-round 안정. + let out2 = serialize_hwpx(&doc2).expect("serialize r2"); + let doc3 = parse_hwpx(&out2).expect("reparse r2"); + let g3 = collect_ellipse_geoms(&doc3); + assert_eq!(g2, g3, "2-round ellipse 지오메트리 안정 실패"); +} diff --git a/tests/visual_roundtrip_baseline.rs b/tests/visual_roundtrip_baseline.rs index ac0e7428c..c97758afc 100644 --- a/tests/visual_roundtrip_baseline.rs +++ b/tests/visual_roundtrip_baseline.rs @@ -35,7 +35,13 @@ const VISUAL_XFAIL: &[(&str, &str)] = &[ // () 직렬화 정정으로 다수 PASS 승격됨. // 잔여: k-water-rfp(대형 복합). ("k-water-rfp.hwpx", "구조 불일치 2페이지(대형, 복합)"), - // opengov 실문서 말뭉치(Task #1564) 신규 편입분은 #1567 직렬화 정정으로 모두 PASS 승격됨. + // opengov 실문서 말뭉치(Task #1564) 신규 편입분은 #1567 직렬화 정정으로 구조 불일치 3건 + // 모두 PASS 승격됨. 잔여: 36392900(라운드트립 변위 드리프트 — base serializer 와 동일, + // PR #1586 무관 / 본질 정정은 별도 이슈). + ( + "opengov/36392900_결재문서본문_일일굴착복구공사현황보고.hwpx", + "최대 변위 1.32px > 임계 0.5px (직렬화 라운드트립 드리프트)", + ), ]; /// 검사 제외 — 샘플 자체가 HWPX 패키지가 아님(HWP5 가 .hwpx 확장자로 저장됨). diff --git a/tools/verify_hangul_pages.py b/tools/verify_hangul_pages.py index 28d520684..1ff5e0fe7 100644 --- a/tools/verify_hangul_pages.py +++ b/tools/verify_hangul_pages.py @@ -25,6 +25,7 @@ import random import subprocess import sys +import time from pathlib import Path @@ -81,7 +82,7 @@ def collect_pairs_inventory( return pairs -def run(pairs, out_tsv, visible, use_pdf) -> int: +def run(pairs, out_tsv, visible, use_pdf, resume=False) -> int: try: from pyhwpx import Hwp except ImportError: @@ -101,8 +102,58 @@ def run(pairs, out_tsv, visible, use_pdf) -> int: return 2 head = git_head() + + # 깨끗한 시작 — 잔존 Hwp.exe(이전 배치 누수)를 정리한 뒤 인스턴스 생성. + # 오염된 COM 환경에서 시작하면 첫 인스턴스부터 com_error 다발(관측). + try: + subprocess.run(["taskkill", "/F", "/IM", "Hwp.exe"], + capture_output=True, timeout=30) + time.sleep(1) + except Exception: + pass + + # [resume] 기존 out_tsv 의 처리분을 읽어 건너뛴다(증분 기록과 짝). 전수 배치 중 + # COM 크래시 시 재실행으로 이어서 진행하기 위함. + done_rows = [] # (verdict, o, r, note, rel) — 성공분만(ERR 제외 → 재시도) + done_rels = set() + if resume and out_tsv is not None and out_tsv.exists(): + err_retry = 0 + with open(out_tsv, encoding="utf-8") as fh: + for line in fh: + line = line.rstrip("\n") + if not line or line.startswith("#") or line.startswith("verdict\t"): + continue + parts = line.split("\t") + if len(parts) == 5: + if parts[0] == "ERR": + err_retry += 1 # ERR 은 done 처리 안 함 → 재시도 + continue + done_rows.append(tuple(parts)) + done_rels.add(parts[4]) + # ERR 행을 버리고 성공분만 남겨 TSV 재작성(중복 방지) — 이후 증분 append. + out_tsv.parent.mkdir(parents=True, exist_ok=True) + with open(out_tsv, "w", encoding="utf-8") as fh: + fh.write(f"# git_head={head} pdf={use_pdf}\n") + fh.write("verdict\torig_pg\trt_pg\tnote\trel\n") + for rec in done_rows: + fh.write("\t".join(str(x) for x in rec) + "\n") + pairs = [p for p in pairs if p[2] not in done_rels] + print(f"# [resume] 성공 {len(done_rels)}건 건너뜀, ERR {err_retry}건 재시도 포함 남은 {len(pairs)}건") + print(f"# 한글 페이지 오라클 | git HEAD={head} | 대상 {len(pairs)}건") + # 증분 기록 핸들 — 각 행 처리 직후 flush 하여 크래시 내성 확보. + inc_fh = None + if out_tsv is not None: + out_tsv.parent.mkdir(parents=True, exist_ok=True) + if resume and out_tsv.exists(): + inc_fh = open(out_tsv, "a", encoding="utf-8") # 재작성된 파일에 이어쓰기 + else: + inc_fh = open(out_tsv, "w", encoding="utf-8") # fresh: truncate + inc_fh.write(f"# git_head={head} pdf={use_pdf}\n") + inc_fh.write("verdict\torig_pg\trt_pg\tnote\trel\n") + inc_fh.flush() + hwp = Hwp(new=True, visible=visible) tmp_pdf = Path.cwd() / "_hpv_tmp.pdf" @@ -119,17 +170,49 @@ def page_count(p: Path) -> int: hwp.clear(option=1) return n - rows = [] - collapse = ok = other = 0 + rows = list(done_rows) + # 기존(resume) 분 카운트 반영 + collapse = sum(1 for x in done_rows if x[0] == "COLLAPSE") + ok = sum(1 for x in done_rows if x[0] == "OK") + other = sum(1 for x in done_rows if x[0] in ("EXPAND", "ERR")) + + def emit(rec): + rows.append(rec) + if inc_fh is not None: + inc_fh.write("\t".join(str(x) for x in rec) + "\n") + inc_fh.flush() + + # COM 인스턴스는 수천 건 누적 시 사망(과거 ~1868건에서 전멸) → 주기적 하드 재시작. + # 주의: hwp.quit() 만으로는 Hwp.exe 프로세스가 남아 누적(200+ 누수 관측) → taskkill 병행. + restart_every = 600 + + def restart_hwp(): + nonlocal hwp + try: + hwp.quit() + except Exception: + pass + # 잔존 Hwp.exe 강제 종료(누수 방지) 후 새 인스턴스 생성. + try: + subprocess.run(["taskkill", "/F", "/IM", "Hwp.exe"], + capture_output=True, timeout=30) + except Exception: + pass + time.sleep(1) + hwp = Hwp(new=True, visible=visible) + try: for i, (orig, rt, rel) in enumerate(pairs): + if i > 0 and i % restart_every == 0: + restart_hwp() # 주기적 재시작 try: o = page_count(orig) r = page_count(rt) except Exception as exc: # 파일별 격리 - rows.append(("ERR", -1, -1, type(exc).__name__, rel)) + emit(("ERR", -1, -1, type(exc).__name__, rel)) other += 1 - print(f" [{i+1:>3}/{len(pairs)}] {'ERR':>8} {rel}", flush=True) + print(f" [{i+1:>4}/{len(pairs)}] {'ERR':>8} {rel}", flush=True) + restart_hwp() # ERR 후 COM 상태 불량 가능 → 재생성 continue if o == r: verdict, ok = "OK", ok + 1 @@ -137,8 +220,8 @@ def page_count(p: Path) -> int: verdict, collapse = "COLLAPSE", collapse + 1 else: verdict, other = "EXPAND", other + 1 - rows.append((verdict, o, r, "", rel)) - print(f" [{i+1:>3}/{len(pairs)}] {verdict:>8} pg {o}->{r} {rel}", flush=True) + emit((verdict, o, r, "", rel)) + print(f" [{i+1:>4}/{len(pairs)}] {verdict:>8} pg {o}->{r} {rel}", flush=True) finally: try: hwp.quit() @@ -149,15 +232,11 @@ def page_count(p: Path) -> int: tmp_pdf.unlink() except Exception: pass + if inc_fh is not None: + inc_fh.close() if out_tsv is not None: - out_tsv.parent.mkdir(parents=True, exist_ok=True) - with open(out_tsv, "w", encoding="utf-8") as fh: - fh.write(f"# git_head={head} pdf={use_pdf}\n") - fh.write("verdict\torig_pg\trt_pg\tnote\trel\n") - for v, o, r, note, rel in rows: - fh.write(f"{v}\t{o}\t{r}\t{note}\t{rel}\n") - print(f"\nTSV 저장: {out_tsv}") + print(f"\nTSV 저장(증분): {out_tsv}") total = len(rows) rate = 100.0 * collapse / total if total else 0.0 @@ -183,6 +262,8 @@ def main(argv: list[str]) -> int: ap.add_argument("--pdf", action="store_true", help="PDF 페이지수 교차검증(PyMuPDF)") ap.add_argument("-o", "--out", type=Path, default=None, help="결과 TSV 경로") ap.add_argument("--visible", action="store_true", help="한글 창 표시(디버깅)") + ap.add_argument("--resume", action="store_true", + help="기존 -o TSV 의 처리분을 건너뛰고 이어서 진행(전수 배치 크래시 내성)") args = ap.parse_args(argv) if args.batch: @@ -203,7 +284,7 @@ def main(argv: list[str]) -> int: pairs = rng.sample(pairs, args.sample) pairs.sort(key=lambda p: p[2]) - return run(pairs, args.out, args.visible, args.pdf) + return run(pairs, args.out, args.visible, args.pdf, resume=args.resume) if __name__ == "__main__":