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__":