diff --git a/mydocs/orders/20260512.md b/mydocs/orders/20260512.md index e0e3690d5..5469c4a33 100644 --- a/mydocs/orders/20260512.md +++ b/mydocs/orders/20260512.md @@ -7,6 +7,12 @@ | **PR #818 (closes #790)** | perf — release 빌드 LTO + codegen-units=1 + strip 활성화 | **완료 (옵션 A — 2 commits cherry-pick + Cargo.toml 충돌 수동 해결 + 정량 측정 + no-ff merge `f5abcf8d`, 시각 판정 면제 + sweep byte-identical 입증)** | 컨트리뷰터 @oksure (Hyunwoo Park) — **20+ 사이클** 핵심 컨트리뷰터 (5/11 사이클 15번째 시도, PR #815/#817 close 후 다른 본질, 5/12 사이클 본질적으로 첫 머지). 본질: Issue #790 (외부 제안, ripgrep profile 패턴 정합) 영역 Cargo.toml [profile.release] 영역 LTO + codegen-units=1 + strip 활성화 — 바이너리 크기 축소 + 런타임 성능 개선. 정정 (`Cargo.toml` +5/-0, 1 file): `[profile.release]` 영역 영역 `lto = true` (Fat LTO 크로스 크레이트 인라이닝) + `codegen-units = 1` (전역 최적화 극대화) + `strip = "debuginfo"` (디버그 정보 제거, panic backtrace symbol table 보존). **리뷰 반영 commit** (`9ccb0c38`): 초기 `strip = true` → `strip = "debuginfo"` 정정 영역 panic backtrace 보존 정합. **본 환경 충돌 수동 해결** (Cargo.toml): devel 측 PR #599 example pr599_png_gateway (native-skia required) + PR 측 [profile.release] 양측 모두 보존 정합. **본 환경 정량 측정 ★ 핵심 결과**: (1) rhwp CLI 크기 **14 MB → 10 MB (-4 MB, -28%)** (2) WASM 크기 **4.6 MB → 4.3 MB (-0.3 MB, -6.5%)** (3) cargo build --release (clean) ~58s → 2m 53s (+1m 55s, ~3배) (4) WASM 빌드 (Docker) ~1m 30s → 2m 23s (+53s, +59%) (5) cargo test --release ALL GREEN + cargo clippy --release --lib -D warnings 통과 (6) **광범위 sweep 7 fixture / 170 페이지 / 170 same / 0 diff (byte-identical)** ✅ — SVG 출력 무영향 입증. 본 환경 cherry-pick 2 commits 영역 영역 1 충돌 수동 해결. **효과 분석**: 이득 — rhwp CLI -28% + WASM -6.5% 크기 감소 (사용자 다운로드 시간 + 메모리 사용량 개선) / 비용 — release 빌드 시간 ~3배 증가 (개발 빌드 영향 부재) / 회귀 부재 — sweep byte-identical + cargo test/clippy 통과. CI 결과 부재 (DIRTY 영역, 본 환경 자기 검증 + 정량 측정 + sweep 영역 보완). 검토 보고서: `mydocs/pr/archives/pr_818_review.md`. 처리 보고서: `mydocs/pr/archives/pr_818_report.md`. **`feedback_contributor_cycle_check` 정합** — @oksure 20+ 사이클 (5/11 15번째 시도, 5/12 본질적으로 첫 머지). **`feedback_image_renderer_paths_separate` 정합** — Cargo.toml 빌드 설정 영역 영역 렌더링 경로 무관. **`feedback_process_must_follow` 권위 사례 강화** — 본 환경 영역 영역 WASM 빌드 측정 필수 — 컨트리뷰터 PR 영역 영역 native 측정만, 본 환경 영역 영역 WASM 4.3 MB 추가 측정 (4.6 → 4.3, -6.5%) + sweep 결정적 검증 표준 영역 영역 회귀 부재 입증. **`feedback_small_batch_release_strategy` 권위 사례 강화** — 작은 변경 (+5/-0) + opt-in (release 한정, 개발 빌드 영향 부재) + 명확 효과 (-28%/-6.5%) — PATCH cycle 머지 정합. **`feedback_hancom_compat_specific_over_general` 정합** — strip = "debuginfo" (panic backtrace 보존) 영역 회귀 부재 가드 + sweep byte-identical. **`feedback_visual_judgment_authority` 정합** — 빌드 설정 영역 영역 시각 판정 면제, sweep 결정적 검증 통과. **`feedback_pr_supersede_chain` 정합** — Issue #790 (외부 제안, OPEN) → **PR #818** (LTO + CU1 + strip 적용) 본질 정합. | | **PR #817 (close, Refs #726)** | fix — 1×1 래퍼 표 shortcut 다수 중첩 표 누락 수정 | **close 완료 (옵션 C — Task #688 이미 해결 + byte-identical SVG + 컨트리뷰터 분리 PR 가이드)** | 컨트리뷰터 @oksure (Hyunwoo Park) — **20+ 사이클** 핵심 컨트리뷰터 (5/11 사이클 14번째 시도, PR #815 close 후 다른 본질). PR 본문 본질: table-vpos-01.hwpx 5쪽 nested 11×3 그리드 SVG 완전 누락 정정 — `find_map` 영역 영역 첫 nested table 만 반환 결함 영역 영역 `nested_table_count == 1` 가드 영역 영역 다수 table 시 일반 경로 (`src/renderer/layout/table_layout.rs` +6/-1). **⚠️ devel HEAD 영역 영역 이미 해결됨 (Task #688, PR #694, commit `40ecbe26`)**: `cell.paragraphs.len() == 1` 가드 — paragraphs 수 가드 + 외곽 박스 border 렌더링 추가 정합 (exam_social.hwp pi=15 자료 박스 등 padding + border_fill 영역 외곽 4 라인 추가). table-vpos-01.hwpx p.5 영역 영역 셀[0] paras=2 → Task #688 가드 false → shortcut 우회 → 일반 경로 영역 모든 nested table 렌더링. **PR base 분석**: PR #817 base = `30351cdf` (5/9, Task #688 머지 전) — 본 PR 작성 후 Task #688 영역 영역 같은 본질 영역 다른 방식 영역 먼저 머지 → 본 PR 영역 중복 정정 + 외곽 박스 border 회귀 위험. **작업지시자 시각 비교 요청** — 본 환경 cherry-pick + 5쪽 SVG 내보내기 (output/svg/pr817/before vs after): text=343/polygon=0/image=0/path=2/lines=474 양측 동일 + **diff exit code 0 byte-identical** 입증 → PR 영역 영역 본 환경 효과 부재. **Issue #726 영역 영역 진짜 본질**: 4대 그룹 사이 구분 도형 (화살표) 2개 SVG 미출력 — 본 PR 본문 (nested 11×3 그리드 누락) 영역 영역 다른 본질. devel HEAD = PR 적용 후 동일 — `` 0개 + `` 0개 + IR 영역 영역 1개 다각형 (셀[18] tac=true wrap=TopAndBottom) 존재 → 셀 안 다각형/도형 SVG 렌더링 경로 누락 영역 영역 본 PR 무관. 두 결함 후보 (Issue #726 본문): (a) SVG renderer 다각형 미출력 (`src/renderer/` 도형 분기 svg.rs/web_canvas.rs/paint/json.rs 4 backend) / (b) HWPX 파서 다각형 1개 누락 (`src/parser/hwpx/` table cell GenShape 파싱, 셀[6]/셀[13] ctrls=0). **본 환경 reset**: cherry-pick + 시각 비교 → byte-identical 확인 → `git reset --hard origin/devel` 영역 영역 devel 무영향. **컨트리뷰터 안내 [#817#issuecomment-4425741327](https://github.com/edwardkim/rhwp/pull/817#issuecomment-4425741327)** (정중 톤): byte-identical SVG 결과 명시 + Task #688 이미 해결됨 + 외곽 박스 border 정교한 정합 안내 + Issue #726 진짜 본질 (화살표 도형) 분리 PR 가이드 + 두 결함 후보 (a/b) 진단 권장. Issue #726 OPEN 유지. 검토 보고서: `mydocs/pr/archives/pr_817_review.md`. 처리 보고서: `mydocs/pr/archives/pr_817_report.md`. **`feedback_contributor_cycle_check` 정합** — @oksure 20+ 사이클. **`feedback_image_renderer_paths_separate` 권위 사례 강화 후보** — Issue #726 진짜 본질 영역 영역 셀 안 다각형 SVG/Canvas/paint json 4 backend 동기 정정 후속. **`feedback_process_must_follow` 정합** — PR base 5/9 영역 작성 → Task #688 먼저 머지 → 중복 정정 영역 영역 base 갱신 점검 필요 사례. **`feedback_hancom_compat_specific_over_general` 정합** — Task #688 영역 영역 외곽 박스 border 영역 영역 추가 정합 (exam_social.hwp pi=15 정정 포함) — 본 PR 영역 영역 단순 가드만, 정교한 정합 부재. **`feedback_diagnosis_layer_attribution` 권위 사례 강화** — 본 PR 본질 (nested 11×3 그리드 누락, Task #688 이미 해결) vs Issue #726 진짜 본질 (셀 안 화살표 도형 미출력) 두 본질 분리 진단 + PR 영역 영역 잘못 연결 (`closes #726`) 명확화. **`feedback_visual_judgment_authority` 권위 사례 강화** — 작업지시자 영역 영역 5쪽 SVG 시각 비교 요청 → byte-identical 결과 입증 — 결정적 검증 영역 영역 PR 효과 없음 명확화 패턴. **`feedback_pr_supersede_chain` 정합** — PR #694 (Task #688) → Issue #726 (잔존 본질) → **PR #817** (close, 중복 정정) → 분리 PR (Issue #726 진짜 본질) (a) 패턴. | +## M100 — 내부 타스크 (5/12 사이클) + +| Issue | 타스크 | 상태 | 비고 | +|------|--------|------|------| +| **#857 (closes #857)** | fix — table-vpos-01.hwp p.5 중첩 11×3 표 c=2 column 셀 클릭 misroute (외곽 placeholder TextRun first-match 선점) | **완료 (3 stage GREEN + WASM 재빌드 + 사용자 E2E 확인 + cargo test 전체 PASS + SVG byte-identical)** | 작업지시자 직접 보고 (table-vpos-01.hwp p.5 셀 클릭 안됨). 본 환경 Phase 1 진단 — pi=30/pi=32 header/title 셀은 정상, **pi=34 외곽 1×1 안 inner 11×3 c=2 column 본문 셀만 misroute** 확인. Root cause: `cursor_rect.rs:648-666` 의 1차 bbox 매칭이 cell-context TextRun first-match (`if hit_cell.is_none()`) — 외곽 셀 빈 placeholder TextRun (bbox 712.5 px paragraph 1 전체) 이 depth-first 순회 순서상 inner 11×3 실제 텍스트 TextRun (cellPath 길이 2, 작은 bbox) 보다 먼저 매칭. **Pre-Task #717 검증** (commit `1c783a89`): c=2 column 4개 케이스 동일 FAIL → v0.5.0 부터 잠재 (Task #717 무관). **Issue #850 과 별개** — #850 은 Task #717 직접 회귀 (L391-403, inner cell_context layout 결함, API 에러 `"컨트롤 인덱스 0 범위 초과"`) / 본 #857 은 v0.5.0 부터 L648-666 first-match (silent misroute, 콘솔 에러 없음). Fix (`src/document_core/queries/cursor_rect.rs` L648-666, +6/-1): cell-context TextRun selection `first-match` → `min area best-match` 변경 — Task #717 의 cell_bboxes selection (L671-675) 과 동일 best-match 패턴. **코드 일관성** — cursor_rect.rs 내 L587/L671/L680 모두 best-match, L648-666 만 유일했던 first-match 정책 차이 해소. **검증 결과**: (1) 본 RED 회귀 테스트 13 cases (`tests/issue_table_vpos_01_page5_cell_hit_test.rs`) Stage 1 5 FAIL / 8 PASS → Stage 2 **13 PASS** (2) 전체 `cargo test` **1232 unit + 35 suites 0 fail** (3) Task #717 회귀 3 PASS / `issue_630` PASS / `issue_nested_table_border` PASS (4) clippy 새 warning 없음 (5) **SVG byte-identical** (`diff /tmp/tvp01-p5/ /tmp/svg-after/` exit 0) — 렌더링 단 무영향 (6) WASM 재빌드 (Docker, 2m 27s, `pkg/rhwp_bg.wasm` 4.35 MB) + 작업지시자 직접 시연 확인. 진단 노트: `mydocs/troubleshootings/table_vpos_01_page5_cell_hit_test.md`. 수행 계획서: `mydocs/plans/task_m100_857.md`. 구현 계획서: `mydocs/plans/task_m100_857_impl.md`. Stage 보고서: `mydocs/working/task_m100_857_stage{1,2,3}.md`. 최종 보고서: `mydocs/report/task_m100_857_report.md`. **`feedback_process_must_follow` 정합** — 수행/구현 계획서 → Stage 1 RED → Stage 2 GREEN → Stage 3 회귀 sweep 단계별 승인. **`feedback_visual_judgment_authority` 정합** — SVG diff byte-identical + 작업지시자 직접 시연 확인. **`feedback_diagnosis_layer_attribution` 권위 사례 강화** — #850 (Task #717 회귀, L391-403, layout 단계, API 에러) vs #857 (v0.5.0 잠재, L648-666, hit-test 정책, silent misroute) 두 본질 분리 진단 + git blame + pre-#717 commit 직접 실행으로 객관 입증. **`feedback_hancom_compat_specific_over_general` 정합** — Task #717 의 cell_bboxes best-match 패턴 미러 영역 영역 정교한 정합. | + ## 작업 메모 ### 5/11 → 5/12 사이클 전환 diff --git a/mydocs/plans/task_m100_857.md b/mydocs/plans/task_m100_857.md new file mode 100644 index 000000000..56d64e7bf --- /dev/null +++ b/mydocs/plans/task_m100_857.md @@ -0,0 +1,100 @@ +# Task m100 #857 수행 계획서 + +> Issue: [#857 — table-vpos-01.hwp p.5 중첩 11×3 표 c=2 column 셀 클릭 misroute](https://github.com/edwardkim/rhwp/issues/857) +> 브랜치: `local/task857` +> 작성일: 2026-05-12 + +## 1. Context + +`samples/table-vpos-01.hwp` 의 5쪽에 있는 중첩 11×3 표의 **c=2 column 본문 셀들**(예: "포용과 균형의 기본사회 구현", ④⑤⑥⑦⑧⑨ 본문 항목) 클릭 시 커서가 해당 셀에 진입하지 못함. 글자 입력 시 외곽 1×1 wrapper 의 paragraph 1 텍스트 영역(시각적으로 "4 공공 AX" 행 부근)에 silent 삽입됨. 사용자에게는 "셀이 안 눌린다" 로 인식. + +같은 inner 11×3 의 c=0 column 라벨 셀("1 참여소통", "2 기본사회" 등) 은 정상 동작 — c=2 column 본문 셀만 영향. 콘솔 에러 없음. + +## 2. Phase 1 진단 결과 (완료) + +### 2.1 재현 확인 +- `tests/issue_table_vpos_01_page5_cell_hit_test.rs` (13 케이스, 5 FAIL / 8 PASS) — RED 테스트로 capture +- FAIL: c=2 column row 0/1/3/6 (4 hit-test) + c=2 row 0 insert_text (1) +- PASS: 단순 표 cells, inner 1×1 title, **c=0 column 라벨 셀 4개 모두** + +### 2.2 Root cause 확정 (디버그 출력으로 검증) +[src/document_core/queries/cursor_rect.rs:648-666](../../src/document_core/queries/cursor_rect.rs#L648-L666) 의 1차 bbox 매칭이 **첫 매칭 cell-TextRun** 을 골라 early-return. + +디버그 캡처: +``` +all_cell_hits=[13, 17] hit_cell=Some((13, 0)) + cell_hit[13] tid=Some(32) si=0 pi=1 cs=0 cc=0 bbox=(396.9, 298.0, 625.2×712.5) ctx=[(0, 0, 1)] ← 외곽 placeholder + cell_hit[17] tid=Some(51) si=0 pi=0 cs=0 cc=19 bbox=(184.4, 310.5, 330.0×20.0) ctx=[(0, 0, 1), (0, 2, 0)] ← inner 11×3 r=0,c=2 +``` + +depth-first 트리 순회 순서상 외곽 placeholder TextRun (char_count=0, bbox 712.5 px 영역) 이 inner 실제 TextRun 보다 먼저 매칭 → 선점. inner run 의 cell_context 가 정상(길이 2) 임에도 무시됨. + +**c=0 column 이 정상인 이유**: click x 좌표(≈128) 가 외곽 placeholder x_range `[396.9, 1022.1]` **밖** 이라 매칭 자체 안 됨. + +### 2.3 Task #717 무관 — 장기간 잠재 결함 +- L648-666 first-match 로직은 v0.5.0 초기 커밋 (`f0f7f1a4`) 이후 **변경 없음** (`git log -L` 으로 확인) +- pre-Task #717 commit `1c783a89` 에서 동일 RED 테스트 실행 → c=2 column 4개 케이스 **동일하게 FAIL** +- 즉 **v0.5.0 부터 잠재** 했으며 Task #717 (commit ef67efa1) 도입 무관 + +### 2.4 Issue #850 과의 관계 — 별개 +- #850: Task #717 직접 회귀 (L391-403 변경). 메커니즘: inner cell_context layout 결함. 증상: 클릭 됨 + 입력 시 API 에러 +- 본 이슈: L648-666 first-match (v0.5.0 이후 불변). 메커니즘: hit-test 매칭 정책. 증상: silent misroute +- 동시 발견됐을 뿐 별개 fix 필요 + +## 3. 수정 방향 + +[cursor_rect.rs:648-666](../../src/document_core/queries/cursor_rect.rs#L648-L666) 의 첫 매칭 선택을 **path depth 최대 + tie-break 작은 bbox** 로 변경: + +```rust +let hit_cell = runs.iter().enumerate() + .filter(|(_, r)| /* bbox contains click + cell_context.is_some */) + .max_by_key(|(_, r)| { + let depth = r.cell_context.as_ref().unwrap().path.len(); + let neg_area = -((r.bbox_w * r.bbox_h * 1000.0) as i64); + (depth, neg_area) + }); +``` + +본 사례에서 runs[17] (depth 2, bbox 6600) 가 runs[13] (depth 1, bbox 445400) 을 이김 → 정상 inner cell 매칭. + +`hit_body` 분기는 변경 안 함 (셀 vs 본문 우선순위 유지). + +## 4. 회귀 위험 점검 대상 + +본문/셀 우선순위 변경이 아니므로 회귀 위험 제한적이지만 다음 회귀 테스트 PASS 필수: +- `tests/issue_717_table_cell_hit_test.rs` (Task #717 회귀 — nested cellPath length 2 정상 유지) +- `tests/issue_595_*` (수식 hit-test 회귀) +- `tests/issue_628.rs`, `tests/issue_630.rs`, `tests/issue_nested_table_border.rs` +- `cargo test` 전체 + +본 RED 테스트: +- 5 FAIL → 모두 PASS +- 기존 8 PASS → 유지 + +## 5. 구현 단계 분할 (예정 — 구현 계획서에서 확정) + +- Stage 1 (RED): 본 RED 테스트가 이미 작성됨. 그대로 사용 +- Stage 2 (GREEN): cursor_rect.rs:648-666 first-match → max_by_key 변경 +- Stage 3 (회귀 sweep): 전체 cargo test + 시각 회귀 (export-svg 비교) + rhwp-studio E2E 수동 + +상세는 구현 계획서 `mydocs/plans/task_m100_857_impl.md` 에서 확정. + +## 6. 산출물 인계 + +본 진단 결과는 이미 다음 파일에 남아 있음: +- 진단 노트: `mydocs/troubleshootings/table_vpos_01_page5_cell_hit_test.md` +- RED 테스트: `tests/issue_table_vpos_01_page5_cell_hit_test.rs` +- Plan mode 초안: `/Users/junpyooh/.claude/plans/samples-table-vpos-01-hwp-5-sorted-comet.md` (참고용) + +브랜치 `local/task857` 에서 작업 시작. + +## 7. 진행 순서 + +1. **본 수행 계획서 승인 요청** ← 현재 단계 +2. 구현 계획서 (`task_m100_857_impl.md`) 작성 → 승인 요청 +3. Stage 1 (RED 유지 검증) → `_stage1.md` 보고서 +4. Stage 2 (GREEN 수정) → `_stage2.md` 보고서 +5. Stage 3 (회귀 sweep) → `_stage3.md` 보고서 +6. 최종 보고서 (`mydocs/report/task_m100_857_report.md`) → 승인 +7. `local/task857` → `local/devel` merge +8. `mydocs/orders/20260512.md` 에 task #857 완료 표시 diff --git a/mydocs/plans/task_m100_857_impl.md b/mydocs/plans/task_m100_857_impl.md new file mode 100644 index 000000000..f9b06f04d --- /dev/null +++ b/mydocs/plans/task_m100_857_impl.md @@ -0,0 +1,184 @@ +# Task m100 #857 구현 계획서 + +> Issue: [#857 — table-vpos-01.hwp p.5 중첩 11×3 표 c=2 column 셀 클릭 misroute](https://github.com/edwardkim/rhwp/issues/857) +> 수행 계획서: [task_m100_857.md](task_m100_857.md) +> 브랜치: `local/task857` +> 작성일: 2026-05-12 + +## 1. 수정 범위 + +**파일**: [src/document_core/queries/cursor_rect.rs](../../src/document_core/queries/cursor_rect.rs) +**라인**: L643-666 (1차 bbox 매칭 분기) +**다른 파일은 건드리지 않음** — 본 버그는 hit-test 선택 정책 단일 결함. + +## 2. 핵심 변경 내용 + +### 2.1 현재 코드 (L643-666) + +```rust +// 1. 정확한 bbox 히트 검사 +let mut hit_body: Option<(usize, usize)> = None; +let mut hit_cell: Option<(usize, usize)> = None; +for (i, run) in runs.iter().enumerate() { + if x >= run.bbox_x && x <= run.bbox_x + run.bbox_w + && y >= run.bbox_y && y <= run.bbox_y + run.bbox_h + { + let local_x = x - run.bbox_x; + let char_offset = find_char_at_x(&run.char_positions, local_x); + if run.cell_context.is_some() { + if hit_cell.is_none() { // ← 첫 매칭 wins + hit_cell = Some((i, run.char_start + char_offset)); + } + } else if hit_body.is_none() { + hit_body = Some((i, run.char_start + char_offset)); + } + } +} +// 셀/글상자 히트가 있으면 우선 +if let Some((idx, offset)) = hit_cell.or(hit_body) { + return Ok(format_hit(&runs[idx], offset, page_num)); +} +``` + +### 2.2 변경 후 코드 + +```rust +// 1. 정확한 bbox 히트 검사 +// 셀/글상자 TextRun을 본문 TextRun보다 우선한다. +// 셀 후보가 여럿이면 bbox 면적이 가장 작은 것 = 가장 specific 한 셀 선택 +// (Task #717 의 cell_bboxes selection L671-675 와 동일 best-match 패턴 — closes #857). +// 중첩 표에서 외곽 셀의 빈 placeholder TextRun (bbox 가 paragraph 영역 전체) 이 +// inner cell 의 실제 TextRun (작은 bbox) 보다 트리 순서상 먼저 매칭되어 +// 외곽이 선점되던 결함 정정. +let mut hit_body: Option<(usize, usize)> = None; +let mut hit_cell: Option<(usize, usize)> = None; +let mut hit_cell_area: Option = None; +for (i, run) in runs.iter().enumerate() { + if x >= run.bbox_x && x <= run.bbox_x + run.bbox_w + && y >= run.bbox_y && y <= run.bbox_y + run.bbox_h + { + let local_x = x - run.bbox_x; + let char_offset = find_char_at_x(&run.char_positions, local_x); + if run.cell_context.is_some() { + let area = (run.bbox_w.max(0.0) * run.bbox_h.max(0.0) * 1000.0) as i64; + if hit_cell_area.map_or(true, |best_area| area < best_area) { + hit_cell = Some((i, run.char_start + char_offset)); + hit_cell_area = Some(area); + } + } else if hit_body.is_none() { + hit_body = Some((i, run.char_start + char_offset)); + } + } +} +// 셀/글상자 히트가 있으면 우선 +if let Some((idx, offset)) = hit_cell.or(hit_body) { + return Ok(format_hit(&runs[idx], offset, page_num)); +} +``` + +### 2.3 변경 영향 + +- **셀 vs 본문 우선순위**: 변경 없음 (셀 우선 유지) +- **셀 후보 단일**: 동작 동일 (`hit_cell_area.map_or(true, ...)` 가 첫 후보 무조건 채택) +- **셀 후보 복수 (중첩 표)**: bbox 면적 최소 선택 → 본 버그 해결 +- **본문 TextRun**: 변경 없음 (first-match 유지) +- **코드 일관성**: 같은 함수 L671-675 (Task #717) / L587-588 (안내문) / L680 (cell 안 run 거리) 모두 `min_by_key` best-match → 본 분기만 유일했던 first-match 제거로 정책 통일 + +## 3. Stage 분할 + +### Stage 1 — 진단 산출물 commit (RED 캡처) + +**목표**: 현재 RED 상태(5 FAIL / 8 PASS)를 git history 에 남기고 진단 노트 보존. + +**작업**: +1. 다음 untracked 파일을 add: + - `tests/issue_table_vpos_01_page5_cell_hit_test.rs` + - `mydocs/troubleshootings/table_vpos_01_page5_cell_hit_test.md` + - `mydocs/plans/task_m100_857.md` + - `mydocs/plans/task_m100_857_impl.md` (본 파일) +2. 커밋: `Task #857 Stage 1 (RED): 진단 노트 + 회귀 테스트 + 계획서 추가` +3. **이 단계에서 소스 코드 수정 없음**. + +**완료 조건**: +- `cargo test --test issue_table_vpos_01_page5_cell_hit_test` → 5 FAIL / 8 PASS (현 상태 유지) +- git status clean + +**보고서**: `mydocs/working/task_m100_857_stage1.md` + +### Stage 2 — GREEN: cursor_rect.rs 수정 + +**목표**: cell-hit 선택 로직을 first-match → depth+area priority 로 변경하여 RED 테스트 PASS. + +**작업**: +1. [src/document_core/queries/cursor_rect.rs:643-666](../../src/document_core/queries/cursor_rect.rs#L643-L666) 를 §2.2 코드로 교체 +2. 커밋: `Task #857 Stage 2 (GREEN): cell-hit 선택 priority (depth, neg_area) 로 변경 — closes #857` + +**완료 조건**: +- `cargo test --test issue_table_vpos_01_page5_cell_hit_test` → **13 PASS** (5 FAIL 모두 해결) +- 변경 코드 빌드 PASS (clippy warning 없음) +- 사람이 읽는 주석에 closes #857 명시 + +**보고서**: `mydocs/working/task_m100_857_stage2.md` + +### Stage 3 — 회귀 sweep + 시각 회귀 + 최종 보고서 + +**목표**: 기존 회귀 테스트 모두 PASS 유지 확인 + 시각 회귀 없음 + 최종 보고서. + +**작업**: +1. **단위·통합 테스트**: + ``` + cargo test --release 2>&1 | tee /tmp/task857_full_test.log + ``` + - 핵심 관련 테스트가 PASS 인지 개별 확인: + - `tests/issue_717_table_cell_hit_test.rs` (3 cases) + - `tests/issue_595_*` 또는 관련 + - `tests/issue_628.rs`, `tests/issue_630.rs` + - `tests/issue_nested_table_border.rs` + - `tests/issue_table_vpos_01_page5_cell_hit_test.rs` (13 cases, 모두 PASS) +2. **clippy**: `cargo clippy --tests --release -- -D warnings` PASS +3. **시각 회귀 (SVG diff)**: + ``` + cargo run -- export-svg samples/table-vpos-01.hwp -p 4 --debug-overlay -o /tmp/svg-after/ + diff /tmp/tvp01-p5/ /tmp/svg-after/ || true + ``` + - 렌더링 자체에는 변경이 없어야 하므로 SVG 산출물 동일 예상. +4. **수동 E2E (rhwp-studio)**: + - WASM 빌드: `docker compose --env-file .env.docker run --rm wasm` + - studio 실행 후 `samples/table-vpos-01.hwp` 5쪽으로 이동 + - c=2 column 본문 셀("포용과 균형의 기본사회 구현", ④⑤⑥⑦⑧⑨ 등) 클릭 → 셀 안 커서 진입 확인 + - 글자 입력 → 해당 inner 셀에 텍스트 정상 추가 확인 + - c=0 column 라벨 셀("1 참여소통" 등) 도 회귀 없음 재확인 + - 다른 페이지(page 4 의 pi=28/29 inline 표 등) 회귀 없음 확인 +5. **최종 보고서** 작성: `mydocs/report/task_m100_857_report.md` + - 진단 요약, fix 내용, 회귀 검증 결과, 잔존 위험 등 기록 +6. **`mydocs/orders/20260512.md`** 에 task #857 완료 표시 갱신 +7. 커밋: `Task #857 Stage 3 (회귀 sweep + 최종 보고서)` + +**완료 조건**: +- 전체 cargo test PASS +- clippy clean +- 시각 회귀 없음 +- 수동 E2E 정상 +- 보고서 + orders 갱신 + +**보고서**: `mydocs/working/task_m100_857_stage3.md` + 최종 `mydocs/report/task_m100_857_report.md` + +## 4. 종료 후 처리 + +1. 작업지시자 승인 후 `local/task857` → `local/devel` merge (`--no-ff`) +2. `local/devel` → `devel` merge + push (origin 원격 갱신) +3. Issue #857 close (커밋 메시지의 `closes #857` 으로 자동 close 예상) + +## 5. 잔존 위험·미해결 사항 + +- **HWPX 변환**: 본 fix 는 HWP5 hit-test 만 다룸. 동일 문서 `samples/table-vpos-01.hwpx` 동작 미확인. HWPX 도 영향 가능성 있으나 본 Task 범위 외 (필요 시 별도 task). +- **#850**: 별개 이슈이므로 본 fix 가 #850 에 영향 줄지는 미지수. Stage 3 회귀 sweep 에서 #850 재현 case 도 함께 돌려보면 좋겠지만 우선순위 낮음. +- **다른 first-match 분기**: 같은 함수의 다른 분기 (L592-641 인라인 Shape, L671-762 셀 bbox 매칭) 의 selection 정책은 본 fix 대상 아님. 별도 회귀가 있다면 별 issue. + +## 6. 단계별 진행 승인 프로토콜 + +- 각 Stage 완료 시 보고서 작성 → 작업지시자 승인 요청 +- 승인 없이 다음 Stage 진행 금지 +- 회귀 발견 시 즉시 보고 + 재계획 + +본 구현 계획서 승인 후 Stage 1 진행 가능. diff --git a/mydocs/report/task_m100_857_report.md b/mydocs/report/task_m100_857_report.md new file mode 100644 index 000000000..44797b954 --- /dev/null +++ b/mydocs/report/task_m100_857_report.md @@ -0,0 +1,124 @@ +# Task m100 #857 최종 결과 보고서 + +> Issue: [#857 — table-vpos-01.hwp p.5 중첩 11×3 표 c=2 column 셀 클릭 misroute](https://github.com/edwardkim/rhwp/issues/857) +> 브랜치: `local/task857` +> 완료일: 2026-05-12 + +## 1. 본질 + +`samples/table-vpos-01.hwp` 5쪽의 중첩 11×3 표 안 **c=2 column 본문 셀들** 클릭 시 커서가 inner 셀로 진입하지 않고 외곽 1×1 wrapper 셀의 paragraph 1 영역으로 misroute 되던 결함. 글자 입력 시 inner 셀이 아닌 외곽 paragraph 의 text 영역(시각적으로 "4 공공 AX" 행 부근) 에 silent 삽입. + +같은 inner 11×3 의 c=0 라벨 셀("1 참여소통" 등) 은 정상 동작 — c=2 column 만 영향. + +## 2. Root cause + +[src/document_core/queries/cursor_rect.rs:648-666](../../src/document_core/queries/cursor_rect.rs#L648-L666) 의 1차 bbox 매칭이 cell-context TextRun 후보 중 **첫 매칭** 만 선택 (`if hit_cell.is_none()`). + +`collect_runs` 의 depth-first 트리 순회 순서상 **외곽 셀의 빈 placeholder TextRun** (char_count=0, bbox 712.5 px 의 outer paragraph 1 영역 전체) 이 inner 11×3 의 실제 텍스트 TextRun (cellPath 길이 2, 작은 bbox) 보다 **먼저 매칭** → 외곽 placeholder 선점, inner run 무시. + +c=0 column row 0/3/6 라벨 셀이 정상이었던 이유: click x 좌표(≈128) 가 외곽 placeholder x_range `[396.9, 1022.1]` **밖** 이라 placeholder 매칭 자체가 안 됨. c=2 column 의 click x(≈442.5) 만 placeholder 안에 들어가서 본 버그 발현. + +## 3. Pre-Task #717 검증 — v0.5.0 부터 잠재 + +`git log -L 648,666:src/document_core/queries/cursor_rect.rs` 결과 본 first-match 로직은 **v0.5.0 초기 커밋 `f0f7f1a4` 이후 변경 없음**. commit `1c783a89` (Task #717 parent) 에서 동일 RED 테스트 실행 → c=2 column 4개 케이스 모두 동일 FAIL → **본 버그는 v0.5.0 이후 잠재**. Task #717 의 부수 효과가 아닌 독립 결함. + +부가 발견: pre-#717 에서는 c=0 column row 0/3/6 셀들도 잘못된 pi=30 으로 misroute 됐는데, Task #717 의 cell_bboxes 보완 패스 변경으로 fix 됨. c=2 column 의 first-match 결함은 #717 도 손대지 못함. + +## 4. Issue #850 과의 관계 — 별개 + +[Issue #850](https://github.com/edwardkim/rhwp/issues/850) 도 cellPath 길이 1 증상을 공유하지만 본 이슈와 **메커니즘·발생 시점이 다른 별개 이슈**: + +| | #850 | 본 이슈 #857 | +|---|---|---| +| 발생 시기 | v0.7.11 회귀 (Task #717 직접 원인) | v0.5.0 부터 잠재 | +| 회귀 라인 | L391-403 (Task #717 변경) | L648-666 (v0.5.0 이후 불변) | +| 메커니즘 | inner cell_context layout 단계 결함 | hit-test first-match 정책 | +| 증상 | 클릭 됨 + 입력 시 API 에러 `"컨트롤 인덱스 0 범위 초과"` | 클릭이 외곽 셀로 misroute (silent, 콘솔 에러 없음) | + +본 fix 가 #850 도 함께 해결하는지는 미지수 — 별도 진단·fix 필요. + +## 5. Fix 내용 + +**위치**: [src/document_core/queries/cursor_rect.rs:648-666](../../src/document_core/queries/cursor_rect.rs#L648-L666) + +**변경**: cell-context TextRun 매칭 시 `first-match` → `min area best-match` 로 selection 정책 변경. Task #717 의 cell_bboxes selection (L671-675) 과 동일 패턴. + +```rust +// Before +if run.cell_context.is_some() { + if hit_cell.is_none() { + hit_cell = Some((i, run.char_start + char_offset)); + } +} + +// After +if run.cell_context.is_some() { + let area = (run.bbox_w.max(0.0) * run.bbox_h.max(0.0) * 1000.0) as i64; + if hit_cell_area.map_or(true, |best_area| area < best_area) { + hit_cell = Some((i, run.char_start + char_offset)); + hit_cell_area = Some(area); + } +} +``` + +### 코드 일관성 + +cursor_rect.rs 내 selection 패턴 통일: +- L587-588 (안내문 → 가장 가까운 본문): `min_by_key` best-match +- L671-675 (cell_bboxes 셀 선택, Task #717): `min_by_key(area)` best-match +- L680 (cell 안 거리): `min_by_key` best-match +- **L648-666 (본 fix 전): `is_none()` first-match → 유일한 first-match 였음** +- L648-666 (본 fix 후): **min area best-match — 정책 통일** + +## 6. 검증 결과 + +### 6.1 자동 회귀 (cargo test debug) +- **전체 1232 unit tests + 35 integration test suites 모두 PASS** +- 핵심 개별 확인: Task #717 (3 PASS), `issue_630` (1), `issue_nested_table_border` (1) +- 본 RED 테스트 [tests/issue_table_vpos_01_page5_cell_hit_test.rs](../../tests/issue_table_vpos_01_page5_cell_hit_test.rs): Stage 1 5 FAIL / 8 PASS → Stage 2 후 **13 PASS** + +### 6.2 clippy +- 본 변경 관련 새 warning 없음 + +### 6.3 시각 회귀 +- page 5 SVG `diff` byte-identical — 렌더링 영향 없음 입증 + +### 6.4 수동 E2E (rhwp-studio) +- WASM 재빌드 후 작업지시자 직접 시연 확인 → c=2 본문 셀 클릭 정상 진입, 글자 입력 정상 + +## 7. 산출물 + +| 파일 | 목적 | +|---|---| +| [src/document_core/queries/cursor_rect.rs](../../src/document_core/queries/cursor_rect.rs) (L643-666 수정) | Fix | +| [tests/issue_table_vpos_01_page5_cell_hit_test.rs](../../tests/issue_table_vpos_01_page5_cell_hit_test.rs) | RED → GREEN 회귀 테스트 (13 cases) | +| [mydocs/troubleshootings/table_vpos_01_page5_cell_hit_test.md](../troubleshootings/table_vpos_01_page5_cell_hit_test.md) | 진단 노트 | +| [mydocs/plans/task_m100_857.md](../plans/task_m100_857.md) | 수행 계획서 | +| [mydocs/plans/task_m100_857_impl.md](../plans/task_m100_857_impl.md) | 구현 계획서 | +| [mydocs/working/task_m100_857_stage1.md](../working/task_m100_857_stage1.md) | Stage 1 보고서 (RED) | +| [mydocs/working/task_m100_857_stage2.md](../working/task_m100_857_stage2.md) | Stage 2 보고서 (GREEN) | +| [mydocs/working/task_m100_857_stage3.md](../working/task_m100_857_stage3.md) | Stage 3 보고서 (회귀 sweep) | + +## 8. 커밋 (local/task857) + +``` +b10a83f0 Task #857 Stage 2 보고서 +1135c028 Task #857 Stage 2 (GREEN): cell-hit selection first-match → min area best-match (closes #857) +37e7b7b0 Task #857 Stage 1 보고서 +07168934 Task #857 Stage 1 (RED): 진단 노트 + 회귀 테스트 + 계획서 추가 +``` + +Stage 3 보고서·최종 보고서·orders 갱신 commit 추가 예정. + +## 9. 잔존 위험·미해결 사항 + +- **HWPX 변환** (`samples/table-vpos-01.hwpx`): 미확인. 같은 fix 가 hit-test 단을 변경하므로 HWPX 도 같이 해결될 가능성 높으나 미검증 — 필요 시 별도 task. +- **#850**: 본 fix 와 별개. 별도 진단·fix 필요. +- 같은 셀 안 여러 줄 TextRun 이 동시 매칭되는 case 에서 짧은 줄 wins (기존: 첫 줄). click 좌표가 두 line bbox 모두에 들어가는 케이스 자체가 드물고 전체 cargo test PASS 로 회귀 미발견. + +## 10. 종료 후 처리 (작업지시자 승인 후) + +1. **본 보고서 + Stage 3 보고서 + orders 갱신 commit** +2. `local/task857` → `local/devel` merge (`--no-ff`) +3. `local/devel` → `devel` merge + push (`origin/devel`) +4. Issue #857 자동 close (Stage 2 commit 의 `closes #857`) diff --git a/mydocs/troubleshootings/table_vpos_01_page5_cell_hit_test.md b/mydocs/troubleshootings/table_vpos_01_page5_cell_hit_test.md new file mode 100644 index 000000000..46ae0a964 --- /dev/null +++ b/mydocs/troubleshootings/table_vpos_01_page5_cell_hit_test.md @@ -0,0 +1,366 @@ +# table-vpos-01.hwp 5쪽 인라인 표 셀 클릭 진입 불가 — 진단 노트 + +> 본 노트는 `mydocs/plans/...` 수행 계획서 승인 후 Phase 2 진단 결과를 기록한 임시 메모. +> 정식 구현 계획서/스테이지 보고서는 별도. 이슈 채번 전이라 파일명에 이슈 번호 없음. +> 작성일: 2026-05-12 +> +> **Issue #850 과의 관계 — 별개 이슈** (2026-05-12 pre-#717 commit 1c783a89 직접 검증 결과): +> - [Issue #850](https://github.com/edwardkim/rhwp/issues/850): v0.7.11 회귀 (Task #717 commit ef67efa1 직접 원인). 회귀 라인 [cursor_rect.rs:391-403](../../src/document_core/queries/cursor_rect.rs#L391-L403) (`_ => current_table_meta` → `_ => None`). +> - 본 사례: pre-#717 (commit 1c783a89) 에서도 c=2 column 4개 케이스 동일 FAIL. **장기간 잠재 결함**. 회귀 라인 [cursor_rect.rs:648-666](../../src/document_core/queries/cursor_rect.rs#L648-L666) (v0.5.0 부터 불변). +> - 두 이슈는 cellPath 길이 1 증상은 공유하지만 **메커니즘과 발생 시점이 다름**. 별개 fix 필요. +> - 부가 발견: Task #717 이 c=0 column 라벨 셀의 별개 회귀(잘못된 pi=30 으로 misroute) 는 fix 함. c=2 column 의 first-match 버그는 손대지 못함. + +## 1. 재현 환경 + +- 파일: [samples/table-vpos-01.hwp](../../samples/table-vpos-01.hwp) +- 페이지: 5쪽 (global_idx=4, section=0) +- 증상 (사용자 확인): 5쪽 3개 표 전부 셀 안에 텍스트 커서가 안 들어감 +- 한컴 정품: 정상 동작 +- 본 조사: HWP5 경로(.hwp) 전용. HWPX 무관. + +## 2. dump-pages / dump 결과 (확정) + +페이지 5는 다음 6개 PageItem으로 구성됨: + +``` +=== 페이지 5 (global_idx=4, section=0, page_num=5) === + body_area: x=75.6 y=94.5 w=642.5 h=933.5 + 단 0 (items=6, used=920.4px) + Table pi=30 ci=1 1x2 638.8x37.8px wrap=TopAndBottom tac=true vpos=0 + FullParagraph pi=31 h=1.3 vpos=3284 + Table pi=32 ci=0 1x1 638.8x53.9px wrap=TopAndBottom tac=true vpos=3444 + FullParagraph pi=33 h=30.8 vpos=7932 + Shape pi=33 ci=0 wrap=TopAndBottom tac=true vpos=7932 + Table pi=34 ci=0 1x1 638.8x778.8px wrap=TopAndBottom tac=true vpos=10405 +``` + +**핵심**: pi=30 host 문단의 `PageItem::FullParagraph` 가 **없음**. pi=32, pi=34도 동일(표 PageItem만 발행). pi=31, pi=33은 표 사이를 잇는 빈/Shape host 문단. + +pi=30 조판부호: +``` +--- 문단 0.30 --- cc=17, text_len=0, controls=2 [쪽나누기] + ls[0]: ts=0, vpos=0, lh=2832, tag=0x00060000 + [0] 감추기: header=true, footer=false, ... + [1] 표: 1행×2열, 셀=2, 쪽나눔=RowBreak (attr=0x00000006), ... + treat_as_char=true, wrap=위아래, vert=문단(0=0.0mm), horz=문단(0=0.0mm) +``` + +- 페이지나눔은 **문단 레벨 플래그** (`[쪽나누기]`)로 표시. 별도 control 아님. +- ci=0 = 감추기 (머리말 숨김), ci=1 = 1x2 표. + +## 3. 페이지 4 (정상 동작) 비교 + +``` +=== 페이지 4 === + FullParagraph pi=24 ... + FullParagraph pi=25 ... + FullParagraph pi=26 ... + FullParagraph pi=27 ... + Table pi=28 ci=0 4x6 ... tac=true vpos=38580 + Table pi=29 ci=0 1x3 ... tac=true vpos=44406 +``` + +pi=28/29 조판부호: +- `cc=9, text_len=0, controls=1` (표 단독) +- tac=true, wrap=위아래 +- pi=28: 4x6, pi=29: 1x3 (그림 포함) + +**관찰**: pi=28/29와 pi=32/34는 **문단 구조가 동일**. 둘 다 host paragraph가 `FullParagraph` PageItem으로 발행되지 않음. 그런데 pi=28/29는 정상 동작하고 pi=32/34는 실패한다고 사용자가 주장 → **단순히 "host PageItem 없음"만으로는 원인 설명 불가**. + +페이지 4와 페이지 5의 결정적 차이: +- 페이지 5는 pi=30의 **`[쪽나누기]` 문단 플래그**로 시작 (강제 페이지 진입). +- 페이지 5의 첫 항목이 **vpos=0** (body 최상단)에서 표. +- 페이지 5는 같은 페이지의 **모든** 표 PageItem이 host FullParagraph 없이 등장 (pi=30/32/34 모두). 페이지 4는 pi=24~27 본문 텍스트가 PageItem으로 등장한 뒤 pi=28/29 표 PageItem만 등장 — 같은 페이지 안에 본문 TextRun 다수 존재. + +## 4. SVG 디버그 오버레이 — 셀 bbox 좌표 (RED 테스트 입력 후보) + +`cargo run -- export-svg samples/table-vpos-01.hwp -p 4 --debug-overlay` 산출: + +| 라벨 | cell-clip id | x | y | w | h | 중심 (x, y) | +|---|---|---|---|---|---|---| +| pi=30 cell[0] "참고" | 5 | 75.6 | 94.5 | 76.2 | 37.8 | 113.7, 113.4 | +| pi=30 cell[1] "정부혁신…" | 9 | 151.7 | 94.5 | 562.6 | 37.8 | 433.0, 113.4 | +| pi=32 cell[0] "국민이…" | 20 | 77.4 | 137.0 | 638.8 | 53.9 | 396.8, 164.0 | +| pi=34 outer cell[0] (빈 여백 상단) | 33 | 77.4 | 229.9 | 638.8 | 782.6 | 396.8, 250.0 | +| pi=34 내부 11x3 r=0 c=2 "국민 주도…" | 61 | 177.6 | 298.0 | 529.9 | 45.1 | 442.5, 320.5 | + +## 5. 코드 분석 — Table RenderNode 메타 전파 경로 + +### TableNode 메타 세팅 ([src/renderer/layout/table_layout.rs:354-360](../../src/renderer/layout/table_layout.rs#L354-L360)) + +```rust +RenderNodeType::Table(TableNode { + ... + section_index: Some(section_index), // 항상 Some (인자) + para_index: table_meta.map(|(pi, _)| pi), // table_meta 가 Some 일 때만 Some + control_index: table_meta.map(|(_, ci)| ci), // table_meta 가 Some 일 때만 Some + ... +}) +``` + +→ `table_meta: Option<(usize, usize)>` 가 **Some**으로 전달되어야 meta가 채워진다. + +### 호출 측 ([src/renderer/layout.rs:2453-2473](../../src/renderer/layout.rs#L2453-L2473)) + +`layout_table_item` 의 `renders_outside_body` 분기/일반 분기 모두 `Some((para_index, control_index))` 로 호출: + +```rust +let _table_y_end = self.layout_table( + tree, &mut tmp_node, t, + page_content.section_index, ... + Some((para_index, control_index)), + ... +); +``` + +→ pi=30/32/34 의 Table RenderNode 메타는 **정상 채워질 것으로 예상** (실측 필요). + +### hit_test_native 의 메타 활용 ([src/document_core/queries/cursor_rect.rs:396-426](../../src/document_core/queries/cursor_rect.rs#L396-L426)) + +```rust +let table_meta = if let RenderNodeType::Table(ref tn) = node.node_type { + match (tn.section_index, tn.para_index, tn.control_index) { + (Some(si), Some(pi), Some(ci)) => Some((si, pi, ci)), + _ => None, + } +} else { current_table_meta }; + +if let RenderNodeType::TableCell(ref tc) = node.node_type { + let (si, ppi, ci, has_meta) = table_meta + .map(|(si, ppi, ci)| (si, ppi, ci, true)) + .unwrap_or((0, 0, 0, false)); + cell_bboxes.push(CellBboxInfo { ... has_meta, ... }); +} +``` + +→ section/para/control 모두 Some이면 `has_meta=true`. L673의 셀 필터 통과. + +### TAC inline 표 set_inline_shape_position 누락 ([src/renderer/layout.rs:2393-2421](../../src/renderer/layout.rs#L2393-L2421)) + +`layout_table_item` 의 TAC 분기에서 inline_shape_positions 미세팅 상태(host FullParagraph 없음)인 경우 x 좌표를 수동으로 계산하지만 **`set_inline_shape_position` 호출은 없음**: + +```rust +} else if is_tac { + // TAC 문단에 PageItem::FullParagraph 가 발행되지 않아 + // paragraph_layout 가 호출되지 않는 케이스(선행 공백만 있는 TAC 표 등): + // composed.lines[0] 의 runs 에서 TAC 이전 텍스트 폭을 직접 + // 합산해 표 x 좌표에 반영한다. inline_shape_position 미세팅 상태에서 + // 기본값 col_area.x(body_left) 으로 붕괴되는 현상 방지. + let leading = composed.get(para_index) + .map(|c| compute_tac_leading_width(c, control_index, styles)) + .unwrap_or(0.0); + let base_x = col_area.x + effective_margin + leading; + ... + Some(aligned_x) +} +``` + +코드 작성자가 이미 "FullParagraph 없는 TAC 표" 케이스를 명시적으로 인지하고 있고, x 좌표는 보강했지만 inline_shape_positions 등록은 보강하지 않음. + +## 6. 현 시점 가설 재정렬 + +### 가설 B (Table RenderNode 메타 결손) — **유력성 하향** +- 코드 분석상 `layout_table_item` 두 분기 모두 `table_meta=Some(...)` 전달. 결손 가능성 낮음. +- 단, 1x1 wrapper unwrap 분기 ([table_layout.rs:151-207](../../src/renderer/layout/table_layout.rs#L151-L207))는 본 사례에 적용 안 됨 (pi=30 1x2, pi=32 text 있음, pi=34 paragraphs=2). + +### 가설 A (inline_shape_positions 미등록) — **이론상 사실이나 hit-test 셀 경로(L671)가 대체로 매칭하면 무관** +- L2421 코드는 set 하지 않는다. 이 자체는 hit-test 인라인-Shape 경로(L592-641) 매칭 실패를 야기. +- 하지만 셀 bbox 매칭 경로(L671-762)가 `has_meta=true` 인 셀 후보를 가지면 셀로 진입 가능 → 인라인-Shape 경로 실패만으로는 셀 진입 차단 안 됨. +- 단, **bbox 후보가 클릭 위치를 포함하지 않으면** 셀 경로도 실패. + +### 가설 G (신규) — **셀 bbox 좌표 좌표 자체가 클릭 위치를 포함하지 않을 가능성** +- TAC inline 표의 inline x 좌표 (L2393-2421 의 `aligned_x`) 와 실제 Table RenderNode bbox 좌표가 서로 다른 좌표계로 등록되어 hit-test 가 클릭 좌표를 표 bbox 안으로 인식하지 못할 수 있음. +- SVG 셀 bbox 좌표는 정상이지만 (cell-clip 정확), 그것이 **TableCell 노드 bbox**이고 hit-test 가 사용하는 좌표인지는 별도 검증 필요. + +### 가설 H (신규) — **pi=30 의 `[쪽나누기]` 문단 플래그가 표 RenderNode 부모 노드 분기를 다르게 만들 가능성** +- 페이지 첫 paragraph 가 페이지나눔 플래그 + 감추기 control + TAC 표 ci=1 인 매우 특수 케이스. +- pagination 단계 또는 build_page_tree 단계에서 본 paragraph 의 표 control 이 별도 처리 경로를 타고 Table RenderNode 가 별도 부모(예: 페이지나눔 핸들러)에 부착되어 통상 page-tree-walk 에서 누락될 가능성. +- pi=32/34 는 동일 페이지에 있어 페이지 build 자체의 어떤 상태가 망가지면 같이 영향 받을 수 있음. + +### 결론 (현 시점) +**가설 G/H 가 가장 유력**. 결정적 진단은 `hit_test_native` 의 실측 출력으로만 가능. + +## 7. RED 회귀 테스트 실행 결과 (2026-05-12) + +`tests/issue_table_vpos_01_page5_cell_hit_test.rs` 작성·실행: + +``` +running 5 tests +test page5_header_cell0_center_enters_cell ... ok +test page5_header_cell1_center_enters_cell ... ok +test page5_title_cell_center_enters_cell ... ok +test page5_big_inner_title_cell_returns_outer_meta ... ok +test page5_big_inner_11x3_cell_returns_nested_path ... FAILED + +failures: +---- page5_big_inner_11x3_cell_returns_nested_path ---- +deeply nested click must include full path, got [(0, 0, 1)], +hit={"cellIndex":0,"cellParaIndex":1, + "cellPath":[{"cellIndex":0,"cellParaIndex":1,"controlIndex":0}], + "charOffset":0,"controlIndex":0, + "cursorRect":{"height":712.5,"pageIndex":4,"x":396.9,"y":298.0}, + "paragraphIndex":1,"parentParaIndex":34,"sectionIndex":0} +``` + +### 7.1 의미 +- **4/5 PASS**: pi=30 header 두 셀, pi=32 title 셀, pi=34 외곽 1x1 안의 inner 1x1 title 셀 — 모두 hit-test 반환값이 정상 (cellPath 충분). +- **1/5 FAIL**: pi=34 외곽 1x1 안의 **inner 11x3 r=0,c=2 셀** 텍스트 영역 클릭 시 hit-test 가 **외곽 1x1 셀**(cellIndex=0, cellParaIndex=1)에서 멈춤. cellPath 길이가 1뿐이며 inner 11x3 entry 가 누락. + +### 7.2 사용자 인식과의 차이 +- 사용자는 "5쪽 3개 표 전부 클릭 안됨" 주장. 실측 결과 hit-test 단에서는 pi=30/32/34 외곽까지 정상 반환. pi=34 inner 11x3 만 path 누락. +- 사용자가 본 증상은 pi=34 inner 11x3 가 페이지 5의 시각적 대부분을 차지하므로 (cell-clip 영역 y=298~1011, 약 713px) "큰 표" 안 클릭은 모두 inner 11x3 영역. 결과적으로 외곽 셀에 커서가 들어가 inner 11x3 의 어느 셀도 편집 불가. +- pi=30/32 가 클릭 안된다는 인식은 시각적 추정일 가능성. **추가 사용자 확인 필요**. + +### 7.3 ROOT CAUSE 확정 (디버그 출력 직접 캡처, 2026-05-12) + +**임시 디버그 로그**(cursor_rect.rs 에 일시적으로 `eprintln!` 추가 후 revert) 결과: + +``` +[hit_test_native page=4 (442.5, 320.5)] all_cell_hits=[13, 17] hit_cell=Some((13, 0)) hit_body=None + cell_hit[13] tid=Some(32) si=0 pi=1 cs=0 cc=0 bbox=(396.9, 298.0, 625.2 × 712.5) ctx=[(0, 0, 1)] + cell_hit[17] tid=Some(51) si=0 pi=0 cs=0 cc=19 bbox=(184.4, 310.5, 330.0 × 20.0) ctx=[(0, 0, 1), (0, 2, 0)] +``` + +**해석**: +- runs[13]: 외곽 pi=34 셀 paragraph 1의 **빈 placeholder TextRun**. + - char_count=0 (no text), bbox 높이 712.5 px = 외곽 셀 paragraph 1 영역 전체 (= 내부 11x3 표 host 영역). + - cell_context path 길이 1 (외곽 entry 만). + - table_id = 32 (외곽 표 RenderNode id). +- runs[17]: 내부 11x3 표 r=0,c=2 셀의 **실제 텍스트 TextRun** ("국민 주도 참여‧소통 거버넌스 구현"). + - bbox 정확히 텍스트 폭 (330×20). + - cell_context path 길이 2 (외곽 + 내부 entry). + - table_id = 51 (내부 11x3 RenderNode id, 외곽과 다름 — 정상). + +**버그**: [cursor_rect.rs:648-666](../../src/document_core/queries/cursor_rect.rs#L648-L666) 의 "1. 정확한 bbox 히트 검사" 분기가 **첫 번째 매칭되는 cell-context TextRun**(runs[13]) 을 골라 early-return. 더 깊은(path 깊이 2, 작은 bbox) 내부 TextRun(runs[17]) 은 무시. + +```rust +// 현 코드 — first-match 선택 +for (i, run) in runs.iter().enumerate() { + if x >= run.bbox_x && x <= run.bbox_x + run.bbox_w + && y >= run.bbox_y && y <= run.bbox_y + run.bbox_h + { + ... + if run.cell_context.is_some() { + if hit_cell.is_none() { // ← 첫 매칭만 선택 + hit_cell = Some((i, run.char_start + char_offset)); + } + } + ... + } +} +``` + +트리 순회는 depth-first 이므로 외곽 셀의 placeholder TextRun이 내부 셀의 텍스트 TextRun보다 먼저 매칭 → 외곽이 선택됨. + +### 7.3.1 왜 다른 셀(pi=30/32, pi=34 외곽, inner 1x1 title, inner 11x3 r=0,c=0) 은 정상? + +| 케이스 | click x | 외곽 placeholder x_range | placeholder 매칭? | 결과 | +|---|---|---|---|---| +| pi=30 header c0 (113.7, 113.4) | 113.7 | (pi=30 placeholder 별도 영역) | × | PASS | +| pi=30 header c1 (433.0, 113.4) | 433.0 | (pi=30 placeholder 별도 영역) | × | PASS | +| pi=32 title (396.8, 164.0) | 396.8 | (pi=32 placeholder 별도 영역) | × | PASS | +| pi=34 inner 1x1 title (396.8, 260.6) | 396.8 | (외곽 cell p[0] placeholder x_range 와 y 불일치) | × | PASS | +| pi=34 inner 11x3 r=0,c=0 (128, 380) | 128 | [396.9, 1022.1] (외곽 placeholder x_min=396.9) | × (x 밖) | PASS | +| **pi=34 inner 11x3 r=0,c=2 (442.5, 320.5)** | **442.5** | **[396.9, 1022.1]** | ○ | **FAIL** | +| **pi=34 inner 11x3 r=1,c=2 (442.5, 403)** | **442.5** | **[396.9, 1022.1]** | ○ | **FAIL** | + +즉 click x 좌표가 외곽 placeholder의 x_range 안에 들어가는 inner cell click 만 본 버그가 발현. column c=2 (가장 오른쪽 큰 열) 의 모든 행이 영향. column c=0 (좌측 라벨 열) 은 placeholder x_range 밖이라 정상. + +### 7.4 Task #717 와의 관계 +- Task #717 (commit ef67efa1, 2026-05-09) 은 `cell_bboxes` 의 has_meta 보완 패스([cursor_rect.rs:520-546](../../src/document_core/queries/cursor_rect.rs#L520-L546)) 수정. 본 버그는 그 보완 패스에 도달하기 전 단계 (L643-666 의 first-match) 에서 발생 → Task #717 의 fix 가 본 케이스를 커버하지 못함. + +### 7.4 Task #717 와의 관계 +- Task #717 (commit ef67efa1, 2026-05-09) 의 회귀 테스트 [tests/issue_717_table_cell_hit_test.rs](../../tests/issue_717_table_cell_hit_test.rs) 는 `samples/exam_social.hwp` 의 1x1 중첩 셀 케이스 (cellParaIndex=0). cellPath 길이 2 정상 반환 확인. +- 본 사례는 1x1 (outer) → 11x3 (inner) 중첩이며 inner table 이 외곽 cell 의 **paragraphIndex=1** (두 번째 문단) 에 위치. 차이점: + - cellParaIndex=0 vs cellParaIndex=1 + - 외곽 cell.paragraphs.len()=2 (Task #688 의 1x1 unwrap 가드 발동 안 함) +- 즉 Task #717 의 fix 가 cellParaIndex>0 또는 paragraphs.len()>1 인 케이스를 커버하지 못한 잔여 회귀 가능성. + +### 7.5 수정 방향 (구현 계획서에서 확정) + +핵심 수정 위치: [cursor_rect.rs:648-666](../../src/document_core/queries/cursor_rect.rs#L648-L666). 옵션: + +**옵션 1 — `cell_context.path.len()` 우선 (deepest cell wins)** +```rust +// 모든 매칭 후 path 깊이가 가장 깊은 것 선택 +let cell_hits: Vec<(usize, usize, usize)> = runs.iter().enumerate() + .filter(|(_, r)| /* bbox contains click */) + .filter(|(_, r)| r.cell_context.is_some()) + .map(|(i, r)| { + let depth = r.cell_context.as_ref().unwrap().path.len(); + (i, char_offset_of(r), depth) + }) + .collect(); +let hit_cell = cell_hits.into_iter().max_by_key(|(_, _, depth)| *depth); +``` +- 장점: 명확한 의미 ("가장 깊이 중첩된 셀이 클릭 주체") +- 단점: 동일 깊이에서 first-match 유지 → tie-break 룰 필요 + +**옵션 2 — bbox 면적 최소 우선 (smallest cell wins)** +```rust +let hit_cell = runs.iter().enumerate() + .filter(|(_, r)| /* bbox contains click + cell_context */) + .min_by_key(|(_, r)| (r.bbox_w * r.bbox_h * 1000.0) as i64); +``` +- 장점: 셀 bbox 매칭 경로(L671-675)와 일관된 selection 룰 +- 단점: 면적 비교가 본 의도(중첩 깊이)에 항상 맞지는 않음 (큰 inner cell vs 작은 outer 빈 paragraph 면적이 역전될 수 있음) + +**옵션 3 — char_count=0 placeholder 제외 + first-match 유지** +```rust +if run.cell_context.is_some() && run.char_count > 0 { + if hit_cell.is_none() { + hit_cell = Some((i, run.char_start + char_offset)); + } +} +``` +- 장점: 최소 변경, 본문 placeholder만 무시 +- 단점: 합법적인 빈 셀(char_count=0)에 대한 hit도 함께 무시 → 빈 셀 진입 회귀 위험. Task #717 RED 케이스(셀 빈 영역 클릭) 회귀 가능. + +**옵션 4 (추천) — path depth 우선, tie-break 로 작은 bbox** +```rust +let hit_cell = runs.iter().enumerate() + .filter(|(_, r)| /* bbox contains click + cell_context */) + .max_by_key(|(_, r)| { + let depth = r.cell_context.as_ref().unwrap().path.len(); + let neg_area = -((r.bbox_w * r.bbox_h * 1000.0) as i64); // 작을수록 우선 + (depth, neg_area) + }); +``` +- 가장 안정적. 본 사례 inner runs[17] (depth 2, bbox 6600) 가 outer runs[13] (depth 1, bbox 445400) 을 이긴다. + +### 7.6 검증 (구현 계획서 단계) +1. 본 노트에 첨부된 RED 테스트 [tests/issue_table_vpos_01_page5_cell_hit_test.rs](../../tests/issue_table_vpos_01_page5_cell_hit_test.rs) 의 `page5_big_inner_11x3_cell_returns_nested_path` 가 PASS. +2. 기존 회귀 [tests/issue_717_table_cell_hit_test.rs](../../tests/issue_717_table_cell_hit_test.rs), `tests/issue_630.rs`, `tests/issue_nested_table_border.rs` 등 PASS 유지. +3. `cargo test` 전체 PASS. +4. rhwp-studio E2E: 페이지 5 inner 11x3 의 각 셀 클릭 시 텍스트 커서가 해당 inner 셀 안에 진입. + +### 7.7 다음 액션 (사용자 승인 필요) + +### 옵션 1 — RED 회귀 테스트 작성 (수행 계획서 Step 4) ← **2026-05-12 완료** +- 신규 파일: `tests/issue_table_vpos_01_page5_cell_hit_test.rs` +- 패턴: [tests/issue_717_table_cell_hit_test.rs](../../tests/issue_717_table_cell_hit_test.rs) 복제 (`load_*`, `hit_json`, `assert_table_hit`, `path_tuples`) +- 케이스 (4개) — 4절 표에서 도출한 좌표 사용: + - `page5_header_cell0_center`: hitTest(4, 113.7, 113.4) → `parentParaIndex=Some(30)`, `controlIndex=Some(1)` + - `page5_header_cell1_center`: hitTest(4, 433.0, 113.4) → `parentParaIndex=Some(30)`, `controlIndex=Some(1)`, `cellIndex=1` + - `page5_title_cell_center`: hitTest(4, 396.8, 164.0) → `parentParaIndex=Some(32)`, `controlIndex=Some(0)` + - `page5_big_outer_top_blank`: hitTest(4, 396.8, 250.0) → `parentParaIndex=Some(34)`, `controlIndex=Some(0)` + - (선택) `page5_big_nested_center`: hitTest(4, 442.5, 320.5) → `cellPath.len()==2` +- 본 테스트가 **현 시점 FAIL** 해야 함. PASS 면 좌표/케이스 재조정. + +### 옵션 2 — 디버그 출력 1회성 추가 후 cargo run 으로 dump +- `hit_test_native` 진입부에 `eprintln!("…")` 임시 삽입 → 트리 구조 / cell_bboxes 내용 출력 +- 결과 확인 후 디버그 코드 제거 (커밋 안 함) + +### 옵션 3 — Phase 2 종료, 가설별 추가 진단 정지하고 가설 G/H 어느 쪽을 우선 검증할지 결정 후 구현 계획서로 진행 + +## 8. 참조 좌표 + +- [src/document_core/queries/cursor_rect.rs:319-470](../../src/document_core/queries/cursor_rect.rs#L319-L470) — `hit_test_native`, `collect_runs`, meta 전파 +- [src/document_core/queries/cursor_rect.rs:520-546](../../src/document_core/queries/cursor_rect.rs#L520-L546) — TextRun 기반 cell meta 보완 (Task #717) +- [src/document_core/queries/cursor_rect.rs:592-641](../../src/document_core/queries/cursor_rect.rs#L592-L641) — inline_shape_positions 매칭 +- [src/document_core/queries/cursor_rect.rs:671-762](../../src/document_core/queries/cursor_rect.rs#L671-L762) — 셀 bbox 매칭 +- [src/renderer/layout.rs:2212-2474](../../src/renderer/layout.rs#L2212-L2474) — PageItem::Table 처리, `layout_table_item` +- [src/renderer/layout.rs:2393-2421](../../src/renderer/layout.rs#L2393-L2421) — TAC 표 inline_shape_position 미세팅 케이스 분기 (set 누락) +- [src/renderer/layout/table_layout.rs:127-360](../../src/renderer/layout/table_layout.rs#L127-L360) — `layout_table` 시그니처, TableNode 생성 +- [src/renderer/layout/paragraph_layout.rs:88-587](../../src/renderer/layout/paragraph_layout.rs#L88-L587) — `layout_inline_table_paragraph` (FullParagraph 발행 케이스에서 set 호출) +- [src/renderer/pagination/engine.rs:1032-1944](../../src/renderer/pagination/engine.rs#L1032-L1944) — PageItem::Table emit 지점들 diff --git a/mydocs/working/task_m100_857_stage1.md b/mydocs/working/task_m100_857_stage1.md new file mode 100644 index 000000000..e23fddf53 --- /dev/null +++ b/mydocs/working/task_m100_857_stage1.md @@ -0,0 +1,60 @@ +# Task m100 #857 Stage 1 완료 보고서 + +> Issue: [#857](https://github.com/edwardkim/rhwp/issues/857) +> Stage 목표: 진단 산출물 commit (RED 캡처). 소스 코드 수정 없음. +> 작성일: 2026-05-12 + +## 1. 수행 결과 + +### 1.1 추가된 파일 (4개) +- `tests/issue_table_vpos_01_page5_cell_hit_test.rs` — RED 회귀 테스트 (13 케이스) +- `mydocs/troubleshootings/table_vpos_01_page5_cell_hit_test.md` — 진단 노트 +- `mydocs/plans/task_m100_857.md` — 수행 계획서 +- `mydocs/plans/task_m100_857_impl.md` — 구현 계획서 (옵션 D 확정본) + +### 1.2 커밋 +- 브랜치: `local/task857` +- Commit: `07168934 Task #857 Stage 1 (RED): 진단 노트 + 회귀 테스트 + 계획서 추가` +- 4 files changed, 890 insertions + +### 1.3 사전 환경 정리 +- `local/devel` 이 `devel` 보다 470 commit 뒤쳐져 있던 것 sync 완료 (Task #595/#685/#689 가 PR 머지 형태로 devel 에 이미 있어 손실 없음) +- `git reset --hard devel` 로 `local/devel` 및 `local/task857` 을 devel HEAD (`2bd50a3a`) 에 정렬 + +## 2. 검증 + +### 2.1 RED 테스트 상태 확인 +``` +$ cargo test --quiet --test issue_table_vpos_01_page5_cell_hit_test +test result: FAILED. 8 passed; 5 failed +``` + +**기대대로 5 FAIL / 8 PASS** — Stage 2 fix 가 PASS 시켜야 할 케이스 명확. + +### FAIL 케이스 (5) +- `page5_inner_11x3_c2_row0_content_cell` — c=2 row=0 "국민 주도…" 클릭, cellPath 길이 1 +- `page5_inner_11x3_c2_row1_content_cell` — c=2 row=1 "대국민 소통…" +- `page5_inner_11x3_c2_row3_content_cell` — c=2 row=3 "포용과 균형…" +- `page5_inner_11x3_c2_row6_content_cell` — c=2 row=6 "성과로 신뢰…" +- `page5_inner_11x3_c2_row0_insert_lands_in_inner_cell` — insert_text 가 inner 셀에 안 들어감 (사용자 증상 직접 재현) + +### PASS 케이스 (8) +- pi=30 header c0/c1, pi=32 title, pi=34 inner 1x1 title (4) +- inner 11x3 c=0 라벨 row=0/3/6/9 (4) + +## 3. Git Tree 상태 + +``` +local/task857 ← 07168934 Task #857 Stage 1 (RED) +local/devel ← 2bd50a3a (= devel) +devel ← 2bd50a3a PR #818 처리 후속 (origin/devel) +``` + +## 4. 잔존 작업 (Stage 2-3 예정) + +- Stage 2 (GREEN): cursor_rect.rs L648-666 first-match → min area best-match 변경 (옵션 D) +- Stage 3 (회귀 sweep): 전체 cargo test + clippy + SVG 시각 회귀 + rhwp-studio E2E + 최종 보고서 + +## 5. 작업지시자 승인 요청 + +본 Stage 1 완료. **Stage 2 (cursor_rect.rs 수정) 진행 승인** 부탁드립니다. diff --git a/mydocs/working/task_m100_857_stage2.md b/mydocs/working/task_m100_857_stage2.md new file mode 100644 index 000000000..ca75d530e --- /dev/null +++ b/mydocs/working/task_m100_857_stage2.md @@ -0,0 +1,115 @@ +# Task m100 #857 Stage 2 완료 보고서 + +> Issue: [#857](https://github.com/edwardkim/rhwp/issues/857) +> Stage 목표: GREEN — cursor_rect.rs first-match → min area best-match 변경 +> 작성일: 2026-05-12 + +## 1. 수행 결과 + +### 1.1 수정 파일 +- `src/document_core/queries/cursor_rect.rs` (L643-666): cell-context TextRun selection 변경 +- `tests/issue_table_vpos_01_page5_cell_hit_test.rs`: insert_lands 테스트 검증 방식을 contains 로 정정 + +### 1.2 변경 내용 (cursor_rect.rs) + +**Before** (first-match): +```rust +if run.cell_context.is_some() { + if hit_cell.is_none() { + hit_cell = Some((i, run.char_start + char_offset)); + } +} +``` + +**After** (min area best-match): +```rust +if run.cell_context.is_some() { + let area = (run.bbox_w.max(0.0) * run.bbox_h.max(0.0) * 1000.0) as i64; + if hit_cell_area.map_or(true, |best_area| area < best_area) { + hit_cell = Some((i, run.char_start + char_offset)); + hit_cell_area = Some(area); + } +} +``` + +Task #717 의 cell_bboxes selection (cursor_rect.rs:671-675) 과 동일 best-match 패턴. + +### 1.3 커밋 +- Commit: `1135c028 Task #857 Stage 2 (GREEN): cell-hit selection first-match → min area best-match` +- 2 files changed, 15 insertions(+), 6 deletions(-) +- 커밋 메시지에 `closes #857` 포함 → merge 시 issue 자동 close + +## 2. 검증 + +### 2.1 본 RED 테스트 +``` +$ cargo test --test issue_table_vpos_01_page5_cell_hit_test +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured +``` + +**전 13 케이스 PASS** (Stage 1: 5 FAIL / 8 PASS → Stage 2: 13 PASS). + +### 2.2 변경 후 hit_test 결과 (PASS) + +inner 11×3 r=0,c=2 클릭 (442.5, 320.5) 결과: +```json +{ + "parentParaIndex": 34, + "controlIndex": 0, + "cellPath": [ + {"cellIndex":0, "cellParaIndex":1, "controlIndex":0}, + {"cellIndex":2, "cellParaIndex":0, "controlIndex":0} ← inner entry 정상 포함 + ], + "charOffset": 15, + "cursorRect": {"height":20.0, "x":444.4, "y":310.5} +} +``` + +cellPath 길이 2, inner 11×3 r=0,c=2 (cellIndex=2) 정확. cursorRect 도 텍스트 line 위치 (y=310.5, height=20). + +### 2.3 insert_text 동작 확인 +- `insert_text_in_cell_by_path(0, 34, hit_path, 15, "ZZZTEST")` 호출 +- 이후 `get_text_in_cell_by_path(0, 34, &[(0,0,1), (0,2,0)], 0, 64)` 결과에 "ZZZTEST" substring 포함 확인 +- 즉 inner 셀에 텍스트가 정상 삽입됨 — 사용자 증상 정리 + +### 2.4 clippy +``` +cargo clippy --tests +``` +- cursor_rect.rs 변경 관련 새 warning 없음 +- 기존 56 warning 은 본 변경 무관 (다른 파일) + +## 3. Stage 2 변경 영향 분석 + +| 시나리오 | Before | After | 영향 | +|---|---|---|---| +| 셀 후보 1개 | 1개 채택 | 1개 채택 (`is_none()` 분기) | 동일 | +| 셀 후보 복수 (중첩) | tree 순서 첫 매칭 | 최소 면적 매칭 | **개선** | +| 본문 TextRun | first-match | first-match (변경 없음) | 동일 | +| 셀 vs 본문 우선순위 | 셀 우선 | 셀 우선 | 동일 | + +코드베이스 일관성: L587-588 (안내문), L671-675 (Task #717), L680 (cell 안 거리) 모두 best-match → 본 분기만 first-match 였던 정책 차이 해소. + +## 4. Stage 3 예정 작업 + +다음 단계 (Stage 3) 에서 확인: +- 전체 `cargo test --release` 통과 +- 기존 핵심 회귀 테스트 (Task #717, #595, #628 등) 개별 PASS 확인 +- SVG 시각 회귀 비교 (page 4 / page 5) +- rhwp-studio E2E 수동 시연 가이드 +- 최종 결과 보고서 + +## 5. 잔존 위험 + +- 같은 셀 안 여러 줄 TextRun 이 동시 매칭되는 경우 (line bbox y 방향 겹침) → 짧은 줄 wins (기존: 첫 줄). click 좌표가 두 line 모두에 들어가는 케이스 자체가 드물어 회귀 위험 낮음 — Stage 3 전체 회귀 sweep 으로 확인. + +## 6. Git Tree 상태 + +``` +local/task857 ← 1135c028 Task #857 Stage 2 (GREEN) ← 37e7b7b0 Stage 1 보고서 ← 07168934 Stage 1 +local/devel ← 2bd50a3a (= devel) +``` + +## 7. 작업지시자 승인 요청 + +Stage 2 완료. **Stage 3 (회귀 sweep + 최종 보고서) 진행 승인** 부탁드립니다. diff --git a/mydocs/working/task_m100_857_stage3.md b/mydocs/working/task_m100_857_stage3.md new file mode 100644 index 000000000..c6bf25403 --- /dev/null +++ b/mydocs/working/task_m100_857_stage3.md @@ -0,0 +1,116 @@ +# Task m100 #857 Stage 3 완료 보고서 + +> Issue: [#857](https://github.com/edwardkim/rhwp/issues/857) +> Stage 목표: 회귀 sweep + 시각 회귀 + 수동 E2E + 최종 보고서 +> 작성일: 2026-05-12 + +## 1. 자동 회귀 검증 + +### 1.1 전체 cargo test (debug) +``` +$ cargo test 2>&1 | tee /tmp/task857_full_test.log | tail +test result: ok. 1232 passed; 0 failed; 2 ignored (unit tests) +test result: ok (35 integration test suites) +``` +**0 fail, 1232 unit tests + 35 test suites 모두 PASS** + +### 1.2 핵심 관련 테스트 개별 확인 +| 테스트 | 결과 | +|---|---| +| `tests/issue_717_table_cell_hit_test.rs` | 3 passed (Task #717 회귀 보장 유지) | +| `tests/issue_630.rs` | 1 passed | +| `tests/issue_nested_table_border.rs` | 1 passed | +| `tests/issue_table_vpos_01_page5_cell_hit_test.rs` | **13 passed** (본 RED, 모두 GREEN) | + +### 1.3 clippy +``` +$ cargo clippy --tests +``` +- 본 변경 (cursor_rect.rs:643-666, 테스트 파일) 관련 새 warning 없음 +- 기존 56 warning 은 다른 파일들 무관 사안 + +## 2. 시각 회귀 검증 + +### 2.1 page 5 (table-vpos-01.hwp) SVG diff +``` +$ diff -q /tmp/tvp01-p5/table-vpos-01_005.svg /tmp/svg-after/table-vpos-01_005.svg +$ echo $? +0 +``` +**byte-identical** — 시각 회귀 없음. fix 가 hit-test 분기만 변경하고 렌더링 단은 미터치임을 입증. + +### 2.2 추가 페이지/문서 SVG 생성 정상 확인 +- `samples/table-vpos-01.hwp` page 4 (page index 3): 정상 export +- `samples/exam_social.hwp` page 1 (page index 0): 정상 export + +## 3. 수동 E2E 검증 (rhwp-studio) + +### 3.1 WASM 재빌드 +``` +docker compose --env-file .env.docker run --rm wasm +→ Finished `release` profile [optimized] target(s) in 52.59s +→ pkg/rhwp_bg.wasm 4.35 MB (2026-05-12 22:57 갱신) +``` + +### 3.2 작업지시자 시연 확인 +- localhost:7700 에서 `samples/table-vpos-01.hwp` 5쪽 로드 +- 작업지시자 직접 확인: c=2 column 본문 셀("포용과 균형의 기본사회 구현" 등) 클릭 시 **정상 동작 확인** + +## 4. Fix 요약 + +### 4.1 수정 위치 +`src/document_core/queries/cursor_rect.rs:643-666` (1차 bbox 매칭) + +### 4.2 변경 내용 (요약) +- **Before** (first-match): `if hit_cell.is_none() { hit_cell = Some(...); }` +- **After** (min area best-match): 매칭된 cell-context TextRun 중 bbox 면적 최소 선택 +- Task #717 의 cell_bboxes selection (L671-675) 과 동일 패턴 + +### 4.3 변경 영향 +| | 변경 영향 | +|---|---| +| 셀 vs 본문 우선순위 | 동일 (셀 우선) | +| 셀 후보 단일 | 동일 | +| 셀 후보 복수 (중첩 표) | **개선 — 가장 specific 셀 선택** | +| 본문 TextRun | 동일 (first-match) | +| 코드 일관성 | L587-588 / L671-675 / L680 모두 best-match → 본 분기만 first-match 였던 정책 차이 해소 | + +## 5. 사용자 증상 해소 확인 + +| 사용자 관찰 (Issue #857) | Stage 2 fix 후 | +|---|---| +| c=2 본문 "포용과 균형의 기본사회 구현" 클릭 시 셀 진입 못함 | **정상 진입** | +| 글자 입력 시 "4 공공 AX" 행 배경에 silent 삽입 | **클릭한 inner 셀에 정상 삽입** | +| 콘솔 에러 없는 silent misroute | **misroute 해소** | +| c=0 라벨 셀 회귀 위험 | **회귀 없음** (4개 케이스 모두 PASS) | + +## 6. Git Tree 상태 + +``` +local/task857 ← b10a83f0 Stage 2 보고서 + ← 1135c028 Stage 2 (GREEN, closes #857) + ← 37e7b7b0 Stage 1 보고서 + ← 07168934 Stage 1 (RED) +local/devel ← 2bd50a3a (= devel) +``` + +본 Stage 종료 후 추가 커밋: +- Stage 3 보고서 (본 파일) +- 최종 결과 보고서 (`mydocs/report/task_m100_857_report.md`) +- `mydocs/orders/20260512.md` 갱신 + +## 7. Stage 3 종료 후 처리 (작업지시자 승인 후) + +1. `local/task857` → `local/devel` merge (`--no-ff`) +2. `local/devel` → `devel` merge + push (`origin/devel`) +3. Issue #857 자동 close (Stage 2 커밋의 `closes #857` 으로) + +## 8. 잔존 위험·미해결 + +- **HWPX 변환** (`samples/table-vpos-01.hwpx`): 미확인. 별도 task 권장. +- **#850 (exam_social/exam_science 성명 칸)**: 본 fix 와 별개 메커니즘 → 본 fix 가 #850 해결 영향 미지수. 별도 처리 필요. +- 다른 first-match 분기 (L592-641 inline shape) — 본 fix 영향 없음. + +## 9. 작업지시자 승인 요청 + +Stage 3 완료. **최종 결과보고서 작성 + orders 갱신 + merge 단계 진행 승인** 부탁드립니다. diff --git a/src/document_core/queries/cursor_rect.rs b/src/document_core/queries/cursor_rect.rs index 6e23c908e..072c484ad 100644 --- a/src/document_core/queries/cursor_rect.rs +++ b/src/document_core/queries/cursor_rect.rs @@ -643,8 +643,14 @@ impl DocumentCore { // 1. 정확한 bbox 히트 검사 // 셀/글상자 TextRun을 본문 TextRun보다 우선한다. // (본문 TextRun이 컨트롤 높이만큼 큰 bbox를 가져서 글상자 영역을 덮을 수 있음) + // 셀 후보가 여럿이면 bbox 면적이 가장 작은 것 = 가장 specific 한 셀 선택. + // (Task #717 의 cell_bboxes selection L671-675 와 동일 best-match 패턴 — closes #857. + // 중첩 표에서 외곽 셀의 빈 placeholder TextRun (bbox 가 paragraph 영역 전체) 이 + // inner cell 의 실제 TextRun (작은 bbox) 보다 트리 순서상 먼저 매칭되어 + // 외곽이 선점되던 결함 정정.) let mut hit_body: Option<(usize, usize)> = None; // (run_idx, char_offset) let mut hit_cell: Option<(usize, usize)> = None; + let mut hit_cell_area: Option = None; for (i, run) in runs.iter().enumerate() { if x >= run.bbox_x && x <= run.bbox_x + run.bbox_w && y >= run.bbox_y && y <= run.bbox_y + run.bbox_h @@ -652,8 +658,10 @@ impl DocumentCore { let local_x = x - run.bbox_x; let char_offset = find_char_at_x(&run.char_positions, local_x); if run.cell_context.is_some() { - if hit_cell.is_none() { + let area = (run.bbox_w.max(0.0) * run.bbox_h.max(0.0) * 1000.0) as i64; + if hit_cell_area.map_or(true, |best_area| area < best_area) { hit_cell = Some((i, run.char_start + char_offset)); + hit_cell_area = Some(area); } } else if hit_body.is_none() { hit_body = Some((i, run.char_start + char_offset)); diff --git a/tests/issue_table_vpos_01_page5_cell_hit_test.rs b/tests/issue_table_vpos_01_page5_cell_hit_test.rs new file mode 100644 index 000000000..86683e70a --- /dev/null +++ b/tests/issue_table_vpos_01_page5_cell_hit_test.rs @@ -0,0 +1,241 @@ +//! samples/table-vpos-01.hwp 5쪽 인라인 표 셀 클릭 진입 불가 — RED 회귀 테스트. +//! +//! 재현 문서: `samples/table-vpos-01.hwp` +//! 대상: 5쪽 (page index = 4) 의 3개 TAC inline 표: +//! - pi=30 ci=1 1x2 "참고" | "정부혁신 비전 및 추진전략" +//! - pi=32 ci=0 1x1 "국민이 주도하고 AI가 뒷받침하는 국민주권정부" +//! - pi=34 ci=0 1x1 (외곽 wrapper, 내부 1x1 title + 11x3 본문 표) +//! +//! 좌표는 `cargo run -- export-svg samples/table-vpos-01.hwp -p 4 --debug-overlay` +//! SVG 의 cell-clip 영역에서 측정 (96 DPI). +//! +//! 본 테스트는 hit_test_native 반환 검증 + 실제 cell-entry(insert_text_in_cell_by_path) +//! 검증을 모두 수행한다. 작성 시점에 page 5 의 inner 11x3 셀 케이스가 FAIL 해야 한다. + +use std::path::Path; + +use rhwp::wasm_api::HwpDocument; +use serde_json::Value; + +fn load_doc() -> HwpDocument { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/table-vpos-01.hwp"); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); + HwpDocument::from_bytes(&bytes).expect("parse table-vpos-01.hwp") +} + +fn hit_json(doc: &HwpDocument, page: u32, x: f64, y: f64) -> Value { + let json = doc + .hit_test_native(page, x, y) + .unwrap_or_else(|e| panic!("hit_test_native({page}, {x}, {y}): {e}")); + serde_json::from_str(&json).unwrap_or_else(|e| panic!("parse hit json `{json}`: {e}")) +} + +fn path_tuples(hit: &Value) -> Vec<(usize, usize, usize)> { + hit.get("cellPath") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .map(|entry| { + ( + entry["controlIndex"].as_u64().expect("controlIndex") as usize, + entry["cellIndex"].as_u64().expect("cellIndex") as usize, + entry["cellParaIndex"].as_u64().expect("cellParaIndex") as usize, + ) + }) + .collect() + }) + .unwrap_or_default() +} + +/// hit_test 결과가 (parent_para, control_index) 외곽 표에 안착했는지 + 셀 path 가 비어있지 않은지 검증. +fn assert_table_hit(hit: &Value, parent_para: u64, control: u64) { + assert_eq!(hit["sectionIndex"].as_u64(), Some(0), "section must be 0, hit={hit}"); + assert_eq!( + hit["parentParaIndex"].as_u64(), + Some(parent_para), + "click must report parentParaIndex={parent_para}, hit={hit}" + ); + assert_eq!( + hit["controlIndex"].as_u64(), + Some(control), + "click must report controlIndex={control}, hit={hit}" + ); + assert!( + hit.get("cellPath").is_some(), + "click must include cellPath, hit={hit}" + ); +} + +/// 중첩 클릭에서 cellPath 마지막 entry 의 cellIndex 가 기대 inner cell_index 와 일치하는지 검증. +fn assert_nested_inner_cell(hit: &Value, expected_inner_cell_index: usize) { + let path = path_tuples(hit); + assert!( + path.len() >= 2, + "deeply nested click must have cellPath length >= 2, got {:?}, hit={hit}", + path + ); + assert_eq!( + path.last().unwrap().1, + expected_inner_cell_index, + "inner cellPath last entry must point to inner cell_index={expected_inner_cell_index}, got {:?}, hit={hit}", + path + ); +} + +// ======================================================================= +// pi=30 / pi=32 — 비중첩 표 (정상 동작 기대) +// ======================================================================= + +#[test] +fn page5_header_cell0_center_enters_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 113.7, 113.4); + assert_table_hit(&hit, 30, 1); + assert_eq!(hit["cellIndex"].as_u64(), Some(0), "hit={hit}"); +} + +#[test] +fn page5_header_cell1_center_enters_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 433.0, 113.4); + assert_table_hit(&hit, 30, 1); + assert_eq!(hit["cellIndex"].as_u64(), Some(1), "hit={hit}"); +} + +#[test] +fn page5_title_cell_center_enters_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 396.8, 164.0); + assert_table_hit(&hit, 32, 0); + assert_eq!(hit["cellIndex"].as_u64(), Some(0), "hit={hit}"); +} + +// ======================================================================= +// pi=34 외곽 1x1 안의 inner 1x1 title — 비교 기준 (정상 동작 기대) +// ======================================================================= + +#[test] +fn page5_big_inner_title_cell_returns_nested_path() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 396.8, 260.6); + assert_table_hit(&hit, 34, 0); + let path = path_tuples(&hit); + assert!( + path.len() >= 2, + "inner 1x1 title click must have cellPath length >= 2, got {:?}, hit={hit}", + path + ); +} + +// ======================================================================= +// pi=34 inner 11x3 — c=0 column 라벨 셀들 (rowspan=2) +// ======================================================================= + +/// cell[0] r=0,c=0 "1|참여소통" — cell-clip-52 (x=86.2 y=298.0 w=83.4 h=164.9), 중심 (128, 380) +#[test] +fn page5_inner_11x3_c0_row0_label_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 128.0, 380.0); + assert_table_hit(&hit, 34, 0); + assert_nested_inner_cell(&hit, 0); +} + +/// cell[7] r=3,c=0 "2|기본사회" — cell-clip-82 (x=86.2 y=475.6 w=83.4 h=164.9), 중심 (128, 558) +#[test] +fn page5_inner_11x3_c0_row3_label_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 128.0, 558.0); + assert_table_hit(&hit, 34, 0); + assert_nested_inner_cell(&hit, 7); +} + +/// cell[14] r=6,c=0 "3|공직혁신" — cell-clip-111 (x=86.2 y=653.1 w=83.4 h=159.1), 중심 (128, 732.7) +#[test] +fn page5_inner_11x3_c0_row6_label_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 128.0, 732.7); + assert_table_hit(&hit, 34, 0); + assert_nested_inner_cell(&hit, 14); +} + +/// cell[19] r=9,c=0 "4|공공 AX" — cell-clip-136 (x=86.2 y=849.4 w=83.4 h=157.4), 중심 (128, 928.1) +#[test] +fn page5_inner_11x3_c0_row9_label_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 128.0, 928.1); + assert_table_hit(&hit, 34, 0); + assert_nested_inner_cell(&hit, 19); +} + +// ======================================================================= +// pi=34 inner 11x3 — c=2 column 본문 셀들 (외곽 placeholder x_range 안에 들어가 FAIL 예상) +// ======================================================================= + +/// cell[2] r=0,c=2 "국민 주도..." — cell-clip-61 (x=177.6 y=298.0 w=529.9 h=45.1), 중심 (442.5, 320.5) +#[test] +fn page5_inner_11x3_c2_row0_content_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 442.5, 320.5); + assert_table_hit(&hit, 34, 0); + assert_nested_inner_cell(&hit, 2); +} + +/// cell[3] r=1,c=2 "대국민 소통..." — cell-clip-65 (x=177.6 y=343.1 w=529.9 h=119.9), 중심 (442.5, 403) +#[test] +fn page5_inner_11x3_c2_row1_content_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 442.5, 403.0); + assert_table_hit(&hit, 34, 0); + assert_nested_inner_cell(&hit, 3); +} + +/// cell[9] r=3,c=2 "포용과 균형의 기본사회 구현" — cell-clip-91 (x=177.6 y=475.6 w=529.9 h=45.1), 중심 (442.5, 498.1) +#[test] +fn page5_inner_11x3_c2_row3_content_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 442.5, 498.1); + assert_table_hit(&hit, 34, 0); + assert_nested_inner_cell(&hit, 9); +} + +/// cell[16] r=6,c=2 "성과로 신뢰..." — cell-clip-120 (x=177.6 y=653.1 w=529.9 h=45.1), 중심 (442.5, 675.7) +#[test] +fn page5_inner_11x3_c2_row6_content_cell() { + let doc = load_doc(); + let hit = hit_json(&doc, 4, 442.5, 675.7); + assert_table_hit(&hit, 34, 0); + assert_nested_inner_cell(&hit, 16); +} + +// ======================================================================= +// 실제 cell-entry 검증: 클릭 결과 path 가 inner 셀에 텍스트를 삽입할 수 있는가 +// ======================================================================= +// insert_text_in_cell_by_path 는 path 가 길이 1이라도 외곽 cell paragraph 까지만 +// 진입하여 정상 반환한다. 따라서 "삽입된 텍스트가 inner 셀의 텍스트와 함께 나타나는지" +// 까지 검증해야 진짜 inner 진입 여부를 확인할 수 있다. + +/// inner 11x3 c=2 row=0 셀에 텍스트 삽입 후, 그 셀(예상 path=[(0,0,1),(0,2,0)]) 의 +/// 텍스트 첫 글자가 "X" 인지 확인. WASM hit_test_native 가 올바른 path 를 반환한다면 +/// 삽입이 inner 셀 내부에 일어나야 함. +#[test] +fn page5_inner_11x3_c2_row0_insert_lands_in_inner_cell() { + let mut doc = load_doc(); + let hit = hit_json(&doc, 4, 442.5, 320.5); + let path = path_tuples(&hit); + let parent_para = hit["parentParaIndex"].as_u64().expect("parentParaIndex") as usize; + let char_offset = hit["charOffset"].as_u64().expect("charOffset") as usize; + doc.insert_text_in_cell_by_path(0, parent_para, &path, char_offset, "ZZZTEST") + .unwrap_or_else(|e| panic!("insert failed: {e:?}, hit={hit}, path={:?}", path)); + + // inner 11x3 r=0,c=2 셀의 expected path. 이 경로 안에 "ZZZTEST" 가 보여야 한다. + // (insert 위치는 hit.charOffset 에 따라 달라지므로 cell 전체 텍스트 substring 검사) + let expected_inner_path = vec![(0usize, 0usize, 1usize), (0usize, 2usize, 0usize)]; + let inner_text = doc + .get_text_in_cell_by_path(0, 34, &expected_inner_path, 0, 64) + .unwrap_or_else(|e| panic!("get_text inner cell failed: {e:?}")); + assert!( + inner_text.contains("ZZZTEST"), + "inserted text must appear in inner 11x3 r=0,c=2 (any position), but inner cell text = {:?}, hit={hit}", + inner_text + ); +}