From eb0b6c32a477737acf6c321c68e46b4baf872010 Mon Sep 17 00:00:00 2001 From: postmelee Date: Tue, 12 May 2026 18:03:03 +0900 Subject: [PATCH 1/3] Task #850: fix nested answer sheet hit-test path --- mydocs/orders/20260512.md | 1 + mydocs/plans/task_m100_850.md | 124 +++++++++++ mydocs/plans/task_m100_850_impl.md | 205 ++++++++++++++++++ mydocs/report/task_m100_850_report.md | 162 ++++++++++++++ mydocs/working/task_m100_850_stage1.md | 109 ++++++++++ mydocs/working/task_m100_850_stage2.md | 123 +++++++++++ mydocs/working/task_m100_850_stage3.md | 139 ++++++++++++ mydocs/working/task_m100_850_stage4.md | 140 ++++++++++++ src/document_core/queries/cursor_rect.rs | 135 +++++++++++- tests/issue_850_answer_sheet_name_hit_test.rs | 108 +++++++++ 10 files changed, 1238 insertions(+), 8 deletions(-) create mode 100644 mydocs/plans/task_m100_850.md create mode 100644 mydocs/plans/task_m100_850_impl.md create mode 100644 mydocs/report/task_m100_850_report.md create mode 100644 mydocs/working/task_m100_850_stage1.md create mode 100644 mydocs/working/task_m100_850_stage2.md create mode 100644 mydocs/working/task_m100_850_stage3.md create mode 100644 mydocs/working/task_m100_850_stage4.md create mode 100644 tests/issue_850_answer_sheet_name_hit_test.rs diff --git a/mydocs/orders/20260512.md b/mydocs/orders/20260512.md index e0e3690d5..1743a1311 100644 --- a/mydocs/orders/20260512.md +++ b/mydocs/orders/20260512.md @@ -6,6 +6,7 @@ |------|--------|------|------| | **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) 패턴. | +| #850 | rhwp-studio 성명 칸 입력 회귀 수정 | 완료 | 최종보고서 작성 + `cargo test` 통과, 완료: 17:58 | ## 작업 메모 diff --git a/mydocs/plans/task_m100_850.md b/mydocs/plans/task_m100_850.md new file mode 100644 index 000000000..94487d6ee --- /dev/null +++ b/mydocs/plans/task_m100_850.md @@ -0,0 +1,124 @@ +# Task #850 수행 계획서 + +## 이슈 + +- 이슈 번호: #850 +- 제목: `[rhwp-studio] v0.7.11 회귀: exam_social/exam_science 성명 칸 입력 시 컨트롤 인덱스 0 범위 초과` +- URL: https://github.com/edwardkim/rhwp/issues/850 +- 마일스톤: v1.0.0 (M100) + +## 결함 요약 + +`rhwp-studio`에서 수능형 샘플 문서 1쪽 상단 답안지 영역의 `성명` 입력칸에 이름을 입력하면 콘솔에 다음 오류가 발생하고 입력이 반영되지 않는다. + +```text +Uncaught 렌더링 오류: 컨트롤 인덱스 0 범위 초과 +``` + +확인 대상 문서: + +- `samples/exam_social.hwp` +- `samples/exam_science.hwp` + +사용자 확인 기준으로 `v0.7.10`에서는 같은 입력이 정상 동작했으나 `v0.7.11`부터 실패하므로 신규 기능 미구현이 아니라 회귀로 판단한다. + +## 회귀 진원지 + +의심 커밋: + +- `ef67efa1` — `Task #717: Fix table cell whitespace hit test` +- 머지 커밋: `bbf01424` — `Merge PR #725 (Task #717): rhwp-studio 표 셀 빈 영역 hitTest 컨텍스트 이탈 정정` +- 주요 파일: `src/document_core/queries/cursor_rect.rs` + +Task #717 변경은 표 셀 빈 영역 hit-test의 컨텍스트 이탈을 보정했지만, 상단 답안지처럼 첫 문단의 큰 표 내부에 중첩 표가 들어 있는 경우에는 외곽 표 경로가 보존되지 않는 것으로 보인다. + +현재 최신 `upstream/devel` 기준으로 `exam_science.hwp` 성명 칸 근처 hit-test는 다음처럼 내부 중첩 표의 로컬 컨텍스트만 반환한다. + +```json +{ + "sectionIndex": 0, + "paragraphIndex": 0, + "parentParaIndex": 3, + "controlIndex": 0, + "cellIndex": 1, + "cellParaIndex": 0, + "cellPath": [ + { "controlIndex": 0, "cellIndex": 1, "cellParaIndex": 0 } + ] +} +``` + +이 결과가 `insertTextInCell(0, 3, 0, 1, 0, 0, text)` 경로로 전달되면서, 실제 루트 문단 3에는 해당 표 컨트롤이 없어 `컨트롤 인덱스 0 범위 초과`가 발생한다. + +## 실제 문서 구조 + +상단 답안지의 `성명` 입력칸은 문서 루트 문단 3의 표가 아니라, 첫 문단의 상단 표 안에 들어 있는 중첩 표이다. + +`exam_social.hwp`: + +- 바깥 상단 표: `section 0 / paragraph 0 / control 4` +- 성명 입력칸: 바깥 표의 `cellParaIndex=3` 내부 1x2 중첩 표의 두 번째 셀 +- 기대 경로: `controlIndex=4`를 루트로 하는 2단계 `cellPath` + +`exam_science.hwp`: + +- 바깥 상단 표: `section 0 / paragraph 0 / control 6` +- 성명 입력칸: 바깥 표의 `cellParaIndex=3` 내부 1x2 중첩 표의 두 번째 셀 +- 기대 경로: `controlIndex=6`을 루트로 하는 2단계 `cellPath` + +## 수정 목표 + +1. `hit_test_native()`가 중첩 표 내부 빈 입력칸을 hit-test할 때도 루트 문서 기준 `parentParaIndex/controlIndex`와 전체 `cellPath`를 반환한다. +2. `rhwp-studio` 입력 경로가 내부 표 로컬 컨텍스트를 문서 루트 컨텍스트로 오인하지 않는다. +3. `exam_social.hwp`, `exam_science.hwp`의 상단 `성명` 칸 입력이 `v0.7.10`처럼 정상 동작한다. +4. Task #717의 기존 목적이었던 일반 표 셀 빈 영역 hit-test 회귀 테스트는 계속 통과한다. + +## 의심 영역 + +`src/document_core/queries/cursor_rect.rs`: + +- `hit_test_native()` +- `collect_runs()` +- `CellContext` 및 `cellPath` 구성 로직 +- `cell_bboxes` 보정 로직 +- `format_hit()` 반환 필드 구성 + +특히 `RenderNodeType::Table`이 자체 `section_index/para_index/control_index` 메타를 갖지 않을 때 `current_table_meta` 또는 기존 `CellContext` 경로를 끊는 분기를 점검한다. + +## 수용 기준 + +1. `exam_social.hwp` 상단 `성명` 칸 hit-test 결과가 다음 조건을 만족한다. + - `parentParaIndex == 0` + - `controlIndex == 4` + - `cellPath.length == 2` + - 첫 `cellPath` 요소가 외곽 상단 표를 가리킨다. +2. `exam_science.hwp` 상단 `성명` 칸 hit-test 결과가 다음 조건을 만족한다. + - `parentParaIndex == 0` + - `controlIndex == 6` + - `cellPath.length == 2` + - 첫 `cellPath` 요소가 외곽 상단 표를 가리킨다. +3. 두 문서 모두 `insert_text_in_cell_by_path()` 경로로 이름 텍스트 삽입이 성공한다. +4. `tests/issue_717_table_cell_hit_test.rs` 기존 테스트가 모두 통과한다. +5. 새 회귀 테스트가 RED → GREEN으로 전환된다. +6. 관련 단위/통합 테스트와 `cargo test` 범위 검증을 통과한다. + +## 회귀 위험 + +- Task #717에서 고친 일반 표 셀 빈 영역 hit-test가 다시 깨질 수 있다. +- 중첩 표 hit-test 반환 필드 변경이 Studio의 기존 입력 분기와 충돌할 수 있다. +- 외곽 표 기준 `controlIndex`와 내부 표 기준 `cellPath`를 동시에 보존해야 하므로, 반환 필드와 path 필드의 의미가 섞이면 다른 표 편집 기능에 부작용이 생길 수 있다. + +## 진행 절차 + +1. 본 수행계획서 승인 요청 +2. 구현 계획서 (`mydocs/plans/task_m100_850_impl.md`) 작성 → 승인 요청 +3. Stage 1: RED 회귀 테스트 작성 및 실패 확인 +4. Stage 2: `cursor_rect.rs` hit-test 경로 보존 로직 정정 +5. Stage 3: Studio 입력 경로 기준 삽입 성공 검증 +6. Stage 4: 기존 Task #717 테스트 및 관련 회귀 검증 +7. Stage 5: 단계별 완료보고서와 최종 결과보고서 작성 + +## 브랜치 + +- `local/task850` (`upstream/devel` 최신 기준 분기) + diff --git a/mydocs/plans/task_m100_850_impl.md b/mydocs/plans/task_m100_850_impl.md new file mode 100644 index 000000000..a2d8588e8 --- /dev/null +++ b/mydocs/plans/task_m100_850_impl.md @@ -0,0 +1,205 @@ +# Task #850 구현 계획서 + +수행 계획서: [`task_m100_850.md`](./task_m100_850.md) + +## 목표 + +`rhwp-studio`에서 `samples/exam_social.hwp`, `samples/exam_science.hwp` 1쪽 상단 답안지 `성명` 칸을 클릭한 뒤 이름을 입력할 때 발생하는 `컨트롤 인덱스 0 범위 초과` 회귀를 정정한다. + +핵심 목표는 `hit_test_native()`가 중첩 표 내부 빈 입력칸에서도 문서 루트 기준의 외곽 표 컨텍스트와 전체 `cellPath`를 유지하도록 만드는 것이다. + +## 변경 후보 + +### 옵션 A — `collect_runs()`의 table meta 전파 보정 + +메타가 없는 중첩 `RenderNodeType::Table`을 만났을 때 `current_table_meta`를 `None`으로 끊지 않고 유지한다. + +- 장점: 변경량이 작고 회귀 지점과 직접 대응한다. +- 단점: 중첩 표 자체의 table_id와 외곽 표 meta가 섞일 수 있어 `cell_bboxes` 보정까지 함께 확인해야 한다. + +### 옵션 B — `cell_bboxes` 보정 기준을 `CellContext` 전체 경로 기준으로 정밀화 + +현재 보정은 같은 table_id 안에서 `ctx.innermost().cell_index == cb.cell_index`만 확인한다. 중첩 표에서 같은 cell index가 반복될 수 있으므로 `table_id`, 외곽 path, innermost path를 함께 사용해 `CellBboxInfo.cell_context`를 채운다. + +- 장점: `cellPath`를 public 반환 필드의 권위 데이터로 삼을 수 있다. +- 단점: 빈 셀처럼 TextRun이 없는 셀은 같은 표의 다른 run 템플릿에 의존하므로 보정 규칙을 신중히 유지해야 한다. + +### 옵션 C — `format_hit()` 직전 public 필드 정규화 helper 추가 + +`CellContext`가 있으면 항상 `ctx.parent_para_index`와 `ctx.path[0]`을 public `parentParaIndex/controlIndex/cellIndex/cellParaIndex`로 반환하도록 helper를 만들고, TextRun hit와 cell bbox hit 양쪽에서 같은 helper를 사용한다. + +- 장점: Studio 입력 분기와 WASM API가 기대하는 public 필드 의미를 한 곳에서 고정할 수 있다. +- 단점: 근본 원인인 잘못된 `CellContext` 생성은 별도 정정이 필요하다. + +## 권장 방향 + +**옵션 A + B를 최소 범위로 적용하고, 옵션 C는 중복 문자열 정리 수준에서만 사용한다.** + +이번 결함은 중첩 표의 실제 경로가 `CellContext.path`에 보존되어야 하는 문제다. 따라서 Studio TypeScript 입력 경로를 우회 수정하지 않고, Rust `hit_test_native()` 반환값을 문서 구조와 맞추는 방향이 맞다. + +## 단계별 구현 + +### Stage 1 — RED 회귀 테스트 작성 + +신규 테스트 파일: + +- `tests/issue_850_answer_sheet_name_hit_test.rs` + +검증 항목: + +1. `exam_social.hwp` 1쪽 상단 `성명` 입력칸 hit-test + - 클릭 좌표: 상단 답안지 `성명` 오른쪽 빈칸 내부 좌표 + - 현재 기대 실패: `parentParaIndex=3`, `controlIndex=0`, `cellPath.length=1` + - 최종 기대: `parentParaIndex=0`, `controlIndex=4`, `cellPath.length=2` +2. `exam_science.hwp` 1쪽 상단 `성명` 입력칸 hit-test + - 클릭 좌표: 상단 답안지 `성명` 오른쪽 빈칸 내부 좌표 + - 현재 기대 실패: `parentParaIndex=3`, `controlIndex=0`, `cellPath.length=1` + - 최종 기대: `parentParaIndex=0`, `controlIndex=6`, `cellPath.length=2` +3. 두 문서 모두 `insert_text_in_cell_by_path()`로 `"홍"` 삽입 성공 + +실행 명령: + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +``` + +완료 조건: + +- 새 테스트가 현재 `upstream/devel` 기준으로 실패한다. +- 실패 메시지가 #850의 실제 결함인 잘못된 `parentParaIndex/controlIndex/cellPath` 또는 삽입 실패를 가리킨다. +- Stage 1 완료보고서 `mydocs/working/task_m100_850_stage1.md` 작성 후 승인 요청. + +### Stage 2 — hit-test 경로 보존 로직 정정 + +대상 파일: + +- `src/document_core/queries/cursor_rect.rs` + +작업 내용: + +1. `collect_runs()`에서 메타 없는 중첩 `Table` 진입 시 외곽 표의 루트 메타와 `table_id` 관계를 끊지 않도록 보정한다. +2. `cell_bboxes` 보정에서 같은 table_id의 TextRun 템플릿을 사용할 때, 내부 표 로컬 셀 인덱스만 public 필드로 승격하지 않도록 한다. +3. `CellContext.path[0]`이 public `controlIndex/cellIndex/cellParaIndex`의 권위가 되도록 `format_hit()` 및 빈 셀 bbox 반환 경로를 확인한다. +4. 기존 `issue_717_table_cell_hit_test`의 nested header 기대값 `[(0,0,0), (1,1,0)]`은 유지한다. + +검증 명령: + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +cargo test --test issue_717_table_cell_hit_test -- --nocapture +``` + +완료 조건: + +- #850 신규 테스트 GREEN +- #717 기존 테스트 GREEN +- Stage 2 완료보고서 `mydocs/working/task_m100_850_stage2.md` 작성 후 승인 요청. + +### Stage 3 — Studio 입력 경로 기준 검증 + +대상: + +- Rust WASM API의 `insert_text_in_cell_by_path()` +- `rhwp-studio/src/engine/input-handler-text.ts` +- `rhwp-studio/src/engine/command.ts` + +작업 내용: + +1. Rust hit-test 반환값이 Studio의 기존 분기 조건 `(pos.cellPath?.length ?? 0) > 1`을 만족하는지 확인한다. +2. TypeScript 입력 경로 수정 없이 해결되는지 확인한다. +3. 필요 시에만 Studio 쪽 방어 로직을 추가한다. 단, 우선순위는 Rust hit-test 정정이다. + +검증 명령: + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +cargo test --lib wasm_api::tests -- --nocapture +``` + +필요 시 rhwp-studio E2E: + +```bash +cd rhwp-studio +npx vite --host 0.0.0.0 --port 7700 +node e2e/text-flow.test.mjs +``` + +완료 조건: + +- `insert_text_in_cell_by_path()` 삽입 성공이 테스트로 고정된다. +- Studio TypeScript 변경이 필요 없으면 변경하지 않는다. +- 변경이 필요한 경우 별도 근거를 완료보고서에 명시한다. +- Stage 3 완료보고서 `mydocs/working/task_m100_850_stage3.md` 작성 후 승인 요청. + +### Stage 4 — 회귀 검증 + +검증 범위: + +1. #850 신규 테스트 +2. #717 기존 hit-test 테스트 +3. 관련 WASM API 테스트 +4. 전체 cargo 테스트 + +실행 명령: + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +cargo test --test issue_717_table_cell_hit_test -- --nocapture +cargo test +``` + +추가 확인: + +- `samples/exam_social.hwp`의 본문 자료 표 빈 영역 hit-test가 기존 기대값을 유지한다. +- `samples/exam_social.hwp`, `samples/exam_science.hwp` 상단 답안지 영역의 public `controlIndex`가 각각 `4`, `6`으로 유지된다. + +완료 조건: + +- 관련 테스트 전체 GREEN +- 전체 `cargo test` GREEN 또는 실패 시 #850과 무관한 기존 실패로 분류 +- Stage 4 완료보고서 `mydocs/working/task_m100_850_stage4.md` 작성 후 승인 요청. + +### Stage 5 — 최종 정리 + +작업 내용: + +1. 최종 결과보고서 작성: `mydocs/report/task_m100_850_report.md` +2. 오늘할일 문서 갱신: `mydocs/orders/20260512.md` +3. 최종 `git status` 확인 +4. 필요 시 커밋 준비 범위 정리 + +최종 보고서 포함 내용: + +- 회귀 원인 +- 수정 방식 +- 테스트 결과 +- #717 보존 여부 +- 남은 위험 또는 후속 권장 사항 + +완료 조건: + +- 최종 보고서 승인 요청 +- 작업 브랜치 `local/task850`에 커밋 가능한 상태 정리 + +## 예상 변경 파일 + +| 파일 | 변경 종류 | 목적 | +|------|----------|------| +| `src/document_core/queries/cursor_rect.rs` | 수정 | hit-test 중첩 표 경로 보존 | +| `tests/issue_850_answer_sheet_name_hit_test.rs` | 신규 | #850 RED/GREEN 회귀 테스트 | +| `mydocs/plans/task_m100_850.md` | 신규 | 수행 계획서 | +| `mydocs/plans/task_m100_850_impl.md` | 신규 | 구현 계획서 | +| `mydocs/working/task_m100_850_stage{1..4}.md` | 신규 | 단계별 완료보고서 | +| `mydocs/report/task_m100_850_report.md` | 신규 | 최종 결과보고서 | +| `mydocs/orders/20260512.md` | 수정 | 작업 상태 갱신 | + +## 위험 관리 + +- `hit_test_native()`는 본문, 표, 글상자, inline shape 클릭을 모두 다루므로 변경 범위를 `CellContext`와 `cell_bboxes` 경로 보정에 한정한다. +- Studio TypeScript는 Rust 반환값이 올바르면 기존 `cellPath.length > 1` 경로로 동작하므로, TypeScript 수정은 최후 수단으로 둔다. +- #717 테스트를 Stage 2부터 매번 같이 실행해 기존 빈 셀 hit-test 정정을 되돌리지 않는다. + +## 진행 조건 + +본 구현 계획서 승인 후 Stage 1 RED 회귀 테스트 작성을 시작한다. + diff --git a/mydocs/report/task_m100_850_report.md b/mydocs/report/task_m100_850_report.md new file mode 100644 index 000000000..e4ae4e2cd --- /dev/null +++ b/mydocs/report/task_m100_850_report.md @@ -0,0 +1,162 @@ +# Task #850 최종 결과 보고서 + +**이슈**: [#850 — rhwp-studio v0.7.11 회귀: exam_social/exam_science 성명 칸 입력 시 컨트롤 인덱스 0 범위 초과](https://github.com/edwardkim/rhwp/issues/850) +**브랜치**: `local/task850` (`upstream/devel` 기준) +**마일스톤**: v1.0.0 (M100) + +## 1. 결함 요약 + +`rhwp-studio`에서 수능형 샘플 문서 1쪽 상단 답안지 영역의 `성명` 입력칸에 이름을 입력하면 다음 오류가 발생하고 입력이 반영되지 않았다. + +```text +Uncaught 렌더링 오류: 컨트롤 인덱스 0 범위 초과 +``` + +확인 문서: + +- `samples/exam_social.hwp` +- `samples/exam_science.hwp` + +사용자 확인 기준으로 `v0.7.10`에서는 정상 입력되었고 `v0.7.11`부터 실패한 회귀다. + +## 2. 원인 + +상단 답안지의 `성명` 칸은 문서 루트 문단 3의 표가 아니라 첫 문단의 상단 표 내부에 있는 중첩 표다. + +`exam_social.hwp` 구조: + +- 외곽 상단 표: `section 0 / paragraph 0 / control 4` +- 성명 입력칸: 외곽 표 `cellIndex=0`, `cellParaIndex=3` 내부 1x2 중첩 표의 두 번째 셀 +- 기대 경로: `[(4, 0, 3), (0, 1, 0)]` + +`exam_science.hwp` 구조: + +- 외곽 상단 표: `section 0 / paragraph 0 / control 6` +- 성명 입력칸: 외곽 표 `cellIndex=0`, `cellParaIndex=3` 내부 1x2 중첩 표의 두 번째 셀 +- 기대 경로: `[(6, 0, 3), (0, 1, 0)]` + +하지만 기존 `hit_test_native()`는 내부 TAC 표의 로컬 메타를 문서 루트 기준처럼 반환했다. + +```json +{ + "parentParaIndex": 3, + "controlIndex": 0, + "cellPath": [ + { "controlIndex": 0, "cellIndex": 1, "cellParaIndex": 0 } + ] +} +``` + +이 값이 Studio 입력 경로로 전달되면 `insertTextInCell(0, 3, 0, 1, 0, 0, text)`가 호출되어 루트 문단 3의 컨트롤 0을 찾다가 `컨트롤 인덱스 0 범위 초과`가 발생했다. + +## 3. 수정 + +수정 파일: + +- `src/document_core/queries/cursor_rect.rs` + +`hit_test_native()`의 렌더 트리 수집 단계에서 조상 표/셀 컨텍스트를 함께 전파하도록 보정했다. + +핵심 보정: + +1. `table_ctx_from_node()` + - Table 노드가 셀 내부에 있고 `para_index/control_index`를 가진 경우, 이를 문서 루트 메타가 아니라 현재 셀의 `cellParaIndex`와 내부 표 `controlIndex`로 해석한다. +2. `cell_ctx_for_table_cell()` + - TableCell 진입 시 현재 표 경로에 `cellIndex`, `cellParaIndex`, `textDirection`을 반영한다. +3. `effective_cell_context()` + - TextRun 자체가 내부 표 로컬 경로만 가진 경우, 조상 traversal context가 더 깊으면 전체 경로를 보존한 traversal context를 우선 사용한다. + +Studio TypeScript는 수정하지 않았다. Studio는 이미 `cellPath.length > 1`이면 `insertTextInCellByPath`를 호출하므로, Rust hit-test 반환값만 정상화하면 기존 경로로 해결된다. + +## 4. 회귀 테스트 + +신규 테스트: + +- `tests/issue_850_answer_sheet_name_hit_test.rs` + +검증 항목: + +- `exam_social.hwp` 성명 칸 hit-test가 `parentParaIndex=0`, `controlIndex=4`, `cellPath=[(4,0,3),(0,1,0)]`를 반환 +- `exam_science.hwp` 성명 칸 hit-test가 `parentParaIndex=0`, `controlIndex=6`, `cellPath=[(6,0,3),(0,1,0)]`를 반환 +- 두 문서 모두 `insert_text_in_cell_by_path()`로 `"홍"` 삽입 후 `get_text_in_cell_by_path()`로 확인 + +## 5. 검증 + +### #850 신규 테스트 + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +``` + +```text +test result: ok. 2 passed; 0 failed +``` + +### #717 기존 테스트 보존 + +```bash +cargo test --test issue_717_table_cell_hit_test -- --nocapture +``` + +```text +test result: ok. 3 passed; 0 failed +``` + +### 기존 중첩 표 path API + +```bash +cargo test --lib test_task105_nested_table_path_api -- --nocapture +``` + +```text +test result: ok. 1 passed; 0 failed +``` + +### 전체 테스트 + +```bash +cargo test +``` + +첫 실행은 sandbox 네트워크 제한으로 `static.crates.io` DNS 조회에 실패했다. 승인 후 `web-sys v0.3.95`를 다운로드하여 재실행했고 전체 테스트가 통과했다. + +```text +test result: ok. 1232 passed; 0 failed; 2 ignored +``` + +통합 테스트와 doc-test까지 모두 통과했다. + +## 6. 기존 경고 + +전체 테스트 중 기존 warning 6건이 출력되었다. + +- `src/renderer/equation/parser.rs`: duplicated attribute +- `src/renderer/layout/integration_tests.rs`: unnecessary parentheses +- `src/serializer/hwpx/field.rs`: non-snake-case test name +- `src/wasm_api/tests.rs`: non-snake-case test name 1건 +- `src/wasm_api/tests.rs`: unused Result 2건 + +#850 수정과 직접 관련된 실패는 없다. + +## 7. 산출물 + +| 영역 | 파일 | +|------|------| +| 수행 계획서 | `mydocs/plans/task_m100_850.md` | +| 구현 계획서 | `mydocs/plans/task_m100_850_impl.md` | +| Stage 1 보고서 | `mydocs/working/task_m100_850_stage1.md` | +| Stage 2 보고서 | `mydocs/working/task_m100_850_stage2.md` | +| Stage 3 보고서 | `mydocs/working/task_m100_850_stage3.md` | +| Stage 4 보고서 | `mydocs/working/task_m100_850_stage4.md` | +| 최종 보고서 | `mydocs/report/task_m100_850_report.md` | +| 본질 정정 | `src/document_core/queries/cursor_rect.rs` | +| 회귀 가드 | `tests/issue_850_answer_sheet_name_hit_test.rs` | + +## 8. 결론 + +#850 회귀는 Studio 입력 라우터 문제가 아니라 Rust `hit_test_native()`가 상단 답안지 내부 TAC 표의 로컬 메타를 문서 루트 컨텍스트처럼 반환한 문제였다. + +조상 표/셀 컨텍스트를 수집 단계에서 전파해 외곽 표 기준 `parentParaIndex/controlIndex`와 전체 `cellPath`를 복원했다. 이로써 `exam_social.hwp`, `exam_science.hwp`의 `성명` 입력칸이 기존 Studio path 기반 입력 API로 정상 처리된다. + +기존 #717 hit-test 회귀 테스트와 전체 `cargo test` 모두 통과했다. + diff --git a/mydocs/working/task_m100_850_stage1.md b/mydocs/working/task_m100_850_stage1.md new file mode 100644 index 000000000..c319ebee6 --- /dev/null +++ b/mydocs/working/task_m100_850_stage1.md @@ -0,0 +1,109 @@ +# Task #850 Stage 1 완료보고서 + +## 단계 + +Stage 1 — RED 회귀 테스트 작성 및 실패 확인 + +## 작업 내용 + +신규 회귀 테스트를 추가했다. + +- `tests/issue_850_answer_sheet_name_hit_test.rs` + +테스트 대상: + +- `samples/exam_social.hwp` 1쪽 상단 답안지 `성명` 오른쪽 빈 입력칸 +- `samples/exam_science.hwp` 1쪽 상단 답안지 `성명` 오른쪽 빈 입력칸 + +검증 의도: + +- hit-test 결과가 문서 루트 기준 외곽 표 컨텍스트를 유지해야 한다. +- `cellPath`가 외곽 상단 표와 내부 1x2 중첩 표를 모두 포함해야 한다. +- `insert_text_in_cell_by_path()`로 실제 이름 입력이 가능해야 한다. + +## 기대값 + +`exam_social.hwp`: + +- `parentParaIndex == 0` +- `controlIndex == 4` +- `cellPath == [(4, 0, 3), (0, 1, 0)]` + +`exam_science.hwp`: + +- `parentParaIndex == 0` +- `controlIndex == 6` +- `cellPath == [(6, 0, 3), (0, 1, 0)]` + +## 실행 결과 + +명령: + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +``` + +결과: + +```text +test result: FAILED. 0 passed; 2 failed; 0 ignored +``` + +`exam_social.hwp` 실제 hit-test 결과: + +```json +{ + "sectionIndex": 0, + "paragraphIndex": 0, + "charOffset": 0, + "parentParaIndex": 3, + "controlIndex": 0, + "cellIndex": 1, + "cellParaIndex": 0, + "cellPath": [ + { "controlIndex": 0, "cellIndex": 1, "cellParaIndex": 0 } + ], + "cursorRect": { "pageIndex": 0, "x": 212.7, "y": 211.8, "height": 15.3 } +} +``` + +`exam_science.hwp` 실제 hit-test 결과: + +```json +{ + "sectionIndex": 0, + "paragraphIndex": 0, + "charOffset": 0, + "parentParaIndex": 3, + "controlIndex": 0, + "cellIndex": 1, + "cellParaIndex": 0, + "cellPath": [ + { "controlIndex": 0, "cellIndex": 1, "cellParaIndex": 0 } + ], + "cursorRect": { "pageIndex": 0, "x": 206.7, "y": 212.8, "height": 13.3 } +} +``` + +## 판정 + +RED 확인 완료. + +두 문서 모두 현재 `upstream/devel` 기준으로 외곽 상단 표 경로를 잃고, 내부 중첩 표의 로컬 컨텍스트만 문서 루트 컨텍스트처럼 반환한다. + +이는 이슈 #850의 재현 증상인 `insertTextInCell(0, 3, 0, 1, 0, 0, text)` 잘못된 입력 경로와 일치한다. 따라서 테스트 실패는 좌표 오차가 아니라 실제 회귀를 정확히 포착한 실패다. + +## 다음 단계 + +Stage 2에서 `src/document_core/queries/cursor_rect.rs`의 hit-test 경로 보존 로직을 정정한다. + +우선 확인할 영역: + +- `collect_runs()`의 메타 없는 중첩 `Table` 처리 +- `cell_bboxes` 보정의 TextRun 템플릿 선택 조건 +- `CellContext.path[0]` 기반 public `parentParaIndex/controlIndex` 반환 유지 + +## 승인 요청 + +Stage 2 진행 승인 요청. + diff --git a/mydocs/working/task_m100_850_stage2.md b/mydocs/working/task_m100_850_stage2.md new file mode 100644 index 000000000..2b63a6751 --- /dev/null +++ b/mydocs/working/task_m100_850_stage2.md @@ -0,0 +1,123 @@ +# Task #850 Stage 2 완료보고서 + +## 단계 + +Stage 2 — `cursor_rect.rs` hit-test 경로 보존 로직 정정 + +## 작업 내용 + +수정 파일: + +- `src/document_core/queries/cursor_rect.rs` + +신규 테스트: + +- `tests/issue_850_answer_sheet_name_hit_test.rs` + +## 원인 확인 + +`exam_social.hwp` 상단 답안지 영역의 렌더 트리를 확인한 결과, 외곽 상단 표 안의 TAC 중첩 표가 다음처럼 렌더된다. + +- 외곽 상단 표: `Table pi=0 ci=4` +- 외곽 셀: `cellIndex=0` +- 내부 `성명` 표: `Table pi=3 ci=0` +- 내부 `성명` 빈 입력칸 TextRun: `CellContext { parent_para_index: 3, path: [(0, 1, 0)] }` + +즉 내부 TAC 표의 `pi=3, ci=0`은 문서 루트 문단/컨트롤이 아니라, 외곽 표 셀 내부의 `cellParaIndex=3`, 내부 표 `controlIndex=0`이다. 그러나 기존 `hit_test_native()`는 이 로컬 메타를 문서 루트 기준처럼 그대로 사용해서 다음 잘못된 경로를 반환했다. + +```json +{ + "parentParaIndex": 3, + "controlIndex": 0, + "cellPath": [ + { "controlIndex": 0, "cellIndex": 1, "cellParaIndex": 0 } + ] +} +``` + +이 값이 Studio 입력 경로로 전달되면 `insertTextInCell(0, 3, 0, 1, 0, 0, text)`가 호출되어 루트 문단 3의 컨트롤 0을 찾다가 `컨트롤 인덱스 0 범위 초과`가 발생한다. + +## 수정 방식 + +`hit_test_native()`의 렌더 트리 수집 단계에서 조상 표/셀 컨텍스트를 함께 전파하도록 보정했다. + +추가된 보정: + +1. `table_ctx_from_node()` + - Table 노드가 셀 내부에 있고 `para_index/control_index`를 가진 경우, 이를 루트 메타가 아니라 `현재 셀의 cellParaIndex + 내부 표 controlIndex`로 해석한다. + - 예: 외곽 `[(4, 0, 0)]` + 내부 `pi=3, ci=0` → `[(4, 0, 3), (0, 0, 0)]` +2. `cell_ctx_for_table_cell()` + - 현재 TableCell 진입 시 해당 표의 `cellIndex`, `cellParaIndex`, `textDirection`을 반영한 `CellContext`를 만든다. +3. `effective_cell_context()` + - TextRun 자체의 `cell_context`가 내부 표 로컬 경로만 가진 경우, 조상 traversal context가 더 깊으면 traversal context를 우선 사용한다. + - 기존처럼 TextRun 경로가 더 깊은 정상 중첩 표 케이스는 TextRun 경로를 유지한다. + +이 방식은 Studio TypeScript 입력 경로를 수정하지 않고, Rust hit-test 반환값을 문서 구조에 맞게 바로잡는다. + +## 검증 결과 + +### #850 신규 회귀 테스트 + +명령: + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +``` + +결과: + +```text +running 2 tests +test issue_850_exam_science_answer_sheet_name_cell_keeps_outer_path ... ok +test issue_850_exam_social_answer_sheet_name_cell_keeps_outer_path ... ok + +test result: ok. 2 passed; 0 failed +``` + +검증된 기대값: + +- `exam_social.hwp`: `parentParaIndex=0`, `controlIndex=4`, `cellPath=[(4,0,3),(0,1,0)]` +- `exam_science.hwp`: `parentParaIndex=0`, `controlIndex=6`, `cellPath=[(6,0,3),(0,1,0)]` +- 두 문서 모두 `insert_text_in_cell_by_path()` 삽입 성공 + +### #717 기존 회귀 테스트 + +명령: + +```bash +cargo test --test issue_717_table_cell_hit_test -- --nocapture +``` + +결과: + +```text +running 3 tests +test issue_717_exam_social_view_table_empty_area_stays_in_clicked_table ... ok +test issue_717_exam_social_title_empty_area_stays_in_clicked_table ... ok +test issue_717_exam_social_nested_header_empty_area_returns_editable_path ... ok + +test result: ok. 3 passed; 0 failed +``` + +## 판정 + +Stage 2 목표 달성. + +- #850 신규 테스트 GREEN +- #717 기존 테스트 GREEN +- `rhwp-studio` TypeScript 입력 경로 수정 없이 Rust hit-test 반환값 정정으로 해결 + +## 남은 확인 + +Stage 3에서 Studio 입력 경로 기준 검증을 진행한다. + +확인 항목: + +- 반환된 `cellPath.length == 2`가 Studio의 `insertTextInCellByPath` 분기를 타는지 확인 +- 관련 WASM API 테스트 범위 확인 +- TypeScript 수정이 불필요하다는 근거 정리 + +## 승인 요청 + +Stage 3 진행 승인 요청. + diff --git a/mydocs/working/task_m100_850_stage3.md b/mydocs/working/task_m100_850_stage3.md new file mode 100644 index 000000000..496c19d75 --- /dev/null +++ b/mydocs/working/task_m100_850_stage3.md @@ -0,0 +1,139 @@ +# Task #850 Stage 3 완료보고서 + +## 단계 + +Stage 3 — Studio 입력 경로 기준 검증 + +## 확인 대상 + +Studio 입력 경로: + +- `rhwp-studio/src/engine/command.ts` +- `rhwp-studio/src/engine/input-handler-text.ts` +- `rhwp-studio/src/core/wasm-bridge.ts` + +WASM path 기반 API: + +- `src/wasm_api.rs` +- `src/wasm_api/tests.rs` + +## Studio 입력 분기 확인 + +Studio 텍스트 입력 경로는 중첩 표 여부를 다음 조건으로 판단한다. + +```ts +(pos.cellPath?.length ?? 0) > 1 +``` + +해당 조건이 참이면 일반 셀 입력 API가 아니라 path 기반 API를 호출한다. + +```ts +wasm.insertTextInCellByPath( + pos.sectionIndex, + pos.parentParaIndex!, + JSON.stringify(pos.cellPath), + pos.charOffset, + text, +); +``` + +Stage 2 수정 후 #850 대상 hit-test 결과는 다음 조건을 만족한다. + +- `exam_social.hwp`: `parentParaIndex=0`, `cellPath=[(4,0,3),(0,1,0)]` +- `exam_science.hwp`: `parentParaIndex=0`, `cellPath=[(6,0,3),(0,1,0)]` +- 두 문서 모두 `cellPath.length == 2` + +따라서 Studio는 기존 코드 그대로 `insertTextInCellByPath` 분기를 탄다. + +## WASM API 확인 + +`src/wasm_api.rs`의 `insertTextInCellByPath`는 JSON `cellPath`를 `DocumentCore::parse_cell_path()`로 파싱한 뒤 `insert_text_in_cell_by_path()`를 호출한다. + +```rust +let path = DocumentCore::parse_cell_path(path_json)?; +self.insert_text_in_cell_by_path( + section_idx as usize, + parent_para_idx as usize, + &path, + char_offset as usize, + text, +) +``` + +#850 신규 테스트는 이 Rust path 기반 삽입 경로를 직접 검증한다. + +- hit-test 결과에서 `path_tuples()` 추출 +- `insert_text_in_cell_by_path(0, 0, &path, char_offset, "홍")` +- `get_text_in_cell_by_path(0, 0, &path, char_offset, 1)`로 `"홍"` 확인 + +## 실행 결과 + +### #850 회귀 테스트 + +명령: + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +``` + +결과: + +```text +running 2 tests +test issue_850_exam_science_answer_sheet_name_cell_keeps_outer_path ... ok +test issue_850_exam_social_answer_sheet_name_cell_keeps_outer_path ... ok + +test result: ok. 2 passed; 0 failed +``` + +### 기존 중첩 표 path API 테스트 + +명령: + +```bash +cargo test --lib test_task105_nested_table_path_api -- --nocapture +``` + +결과: + +```text +test wasm_api::tests::test_task105_nested_table_path_api ... ok + +test result: ok. 1 passed; 0 failed +``` + +## TypeScript 수정 여부 + +수정 불필요. + +이유: + +- Studio는 이미 `cellPath.length > 1`이면 path 기반 API를 사용한다. +- #850 수정 후 hit-test 반환값이 `cellPath.length == 2`를 만족한다. +- Rust path 기반 삽입 API가 #850 신규 테스트에서 실제 삽입/읽기까지 통과했다. + +이번 결함은 Studio 입력 라우터 문제가 아니라 Rust `hit_test_native()`가 중첩 표 경로를 잘못 반환한 문제다. 따라서 Studio TypeScript를 우회 수정하지 않는 것이 맞다. + +## 판정 + +Stage 3 목표 달성. + +- Studio 기존 입력 분기와 Stage 2 반환값 정합 확인 +- WASM path 기반 API 정상 확인 +- TypeScript 변경 없음 + +## 다음 단계 + +Stage 4에서 회귀 검증을 진행한다. + +예정 검증: + +- #850 신규 테스트 +- #717 기존 테스트 +- 관련 WASM API 테스트 +- 전체 `cargo test` + +## 승인 요청 + +Stage 4 진행 승인 요청. + diff --git a/mydocs/working/task_m100_850_stage4.md b/mydocs/working/task_m100_850_stage4.md new file mode 100644 index 000000000..910958e1d --- /dev/null +++ b/mydocs/working/task_m100_850_stage4.md @@ -0,0 +1,140 @@ +# Task #850 Stage 4 완료보고서 + +## 단계 + +Stage 4 — 회귀 검증 + +## 검증 범위 + +1. #850 신규 회귀 테스트 +2. #717 기존 hit-test 회귀 테스트 +3. 기존 중첩 표 path 기반 WASM API 테스트 +4. 전체 `cargo test` + +## 실행 결과 + +### #850 신규 회귀 테스트 + +명령: + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +``` + +결과: + +```text +running 2 tests +test issue_850_exam_science_answer_sheet_name_cell_keeps_outer_path ... ok +test issue_850_exam_social_answer_sheet_name_cell_keeps_outer_path ... ok + +test result: ok. 2 passed; 0 failed +``` + +### #717 기존 hit-test 테스트 + +명령: + +```bash +cargo test --test issue_717_table_cell_hit_test -- --nocapture +``` + +결과: + +```text +running 3 tests +test issue_717_exam_social_title_empty_area_stays_in_clicked_table ... ok +test issue_717_exam_social_view_table_empty_area_stays_in_clicked_table ... ok +test issue_717_exam_social_nested_header_empty_area_returns_editable_path ... ok + +test result: ok. 3 passed; 0 failed +``` + +### 기존 중첩 표 path API 테스트 + +명령: + +```bash +cargo test --lib test_task105_nested_table_path_api -- --nocapture +``` + +결과: + +```text +test wasm_api::tests::test_task105_nested_table_path_api ... ok + +test result: ok. 1 passed; 0 failed +``` + +### 전체 cargo test + +명령: + +```bash +cargo test +``` + +첫 실행은 sandbox 네트워크 제한으로 `static.crates.io` DNS 조회에 실패했다. + +```text +failed to download from `https://static.crates.io/crates/web-sys/0.3.95/download` +Could not resolve host: static.crates.io +``` + +승인 후 같은 명령을 재실행하여 `web-sys v0.3.95` 다운로드와 전체 테스트를 완료했다. + +주요 결과: + +```text +test result: ok. 1232 passed; 0 failed; 2 ignored +``` + +통합 테스트 및 doc-test까지 모두 통과했다. + +전체 테스트 중 #717, #850도 다시 통과했다. + +```text +Running tests/issue_717_table_cell_hit_test.rs +test result: ok. 3 passed; 0 failed + +Running tests/issue_850_answer_sheet_name_hit_test.rs +test result: ok. 2 passed; 0 failed +``` + +## 경고 + +전체 테스트 중 기존 warning 6건이 출력되었다. + +- `src/renderer/equation/parser.rs`: duplicated attribute +- `src/renderer/layout/integration_tests.rs`: unnecessary parentheses +- `src/serializer/hwpx/field.rs`: non-snake-case test name +- `src/wasm_api/tests.rs`: non-snake-case test name 1건 +- `src/wasm_api/tests.rs`: unused Result 2건 + +모두 기존 테스트 경고이며 #850 수정과 직접 관련된 실패는 아니다. + +## 판정 + +Stage 4 목표 달성. + +- #850 신규 회귀 테스트 GREEN +- #717 기존 회귀 테스트 GREEN +- 기존 중첩 표 path 기반 API 테스트 GREEN +- 전체 `cargo test` GREEN +- Studio TypeScript 변경 없이 Rust hit-test 경로 보정만으로 해결 확인 + +## 다음 단계 + +Stage 5에서 최종 정리를 진행한다. + +예정 작업: + +- 최종 결과보고서 작성: `mydocs/report/task_m100_850_report.md` +- 오늘할일 문서 상태 갱신 +- 최종 `git status` 확인 +- 커밋 가능한 변경 범위 정리 + +## 승인 요청 + +Stage 5 진행 승인 요청. + diff --git a/src/document_core/queries/cursor_rect.rs b/src/document_core/queries/cursor_rect.rs index 6e23c908e..40c436d58 100644 --- a/src/document_core/queries/cursor_rect.rs +++ b/src/document_core/queries/cursor_rect.rs @@ -318,7 +318,7 @@ impl DocumentCore { /// 페이지 좌표에서 문서 위치 찾기 (네이티브) pub fn hit_test_native(&self, page_num: u32, x: f64, y: f64) -> Result { use crate::renderer::render_tree::{RenderNode, RenderNodeType}; - use crate::renderer::layout::{compute_char_positions, CellContext}; + use crate::renderer::layout::{compute_char_positions, CellContext, CellPathEntry}; let tree = self.build_page_tree_cached(page_num)?; @@ -371,6 +371,82 @@ impl DocumentCore { cell_context: Option, } + fn table_ctx_from_node( + node: &RenderNode, + current_table_ctx: Option<&CellContext>, + current_cell_ctx: Option<&CellContext>, + ) -> Option { + if let RenderNodeType::Table(ref tn) = node.node_type { + match (tn.para_index, tn.control_index) { + (Some(pi), Some(ci)) => { + if let Some(parent_ctx) = current_cell_ctx { + let mut ctx = parent_ctx.clone(); + if let Some(last) = ctx.path.last_mut() { + last.cell_para_index = pi; + } + ctx.path.push(CellPathEntry { + control_index: ci, + cell_index: 0, + cell_para_index: 0, + text_direction: 0, + }); + Some(ctx) + } else { + Some(CellContext { + parent_para_index: pi, + path: vec![CellPathEntry { + control_index: ci, + cell_index: 0, + cell_para_index: 0, + text_direction: 0, + }], + }) + } + } + _ => current_table_ctx.cloned(), + } + } else { + current_table_ctx.cloned() + } + } + + fn cell_ctx_for_table_cell( + table_ctx: Option<&CellContext>, + cell_index: usize, + cell_para_index: usize, + text_direction: u8, + ) -> Option { + table_ctx.map(|ctx| { + let mut cell_ctx = ctx.clone(); + if let Some(last) = cell_ctx.path.last_mut() { + last.cell_index = cell_index; + last.cell_para_index = cell_para_index; + last.text_direction = text_direction; + } + cell_ctx + }) + } + + fn effective_cell_context( + text_ctx: &Option, + traversal_ctx: &Option, + ) -> Option { + match (text_ctx, traversal_ctx) { + (Some(text_ctx), Some(traversal_ctx)) + if traversal_ctx.path.len() >= text_ctx.path.len() => + { + let mut ctx = traversal_ctx.clone(); + if let (Some(dst), Some(src)) = (ctx.path.last_mut(), text_ctx.path.last()) { + dst.cell_para_index = src.cell_para_index; + dst.text_direction = src.text_direction; + } + Some(ctx) + } + (Some(text_ctx), _) => Some(text_ctx.clone()), + (None, _) => None, + } + } + fn collect_runs( node: &RenderNode, runs: &mut Vec, @@ -380,6 +456,8 @@ impl DocumentCore { current_table_id: Option, // Table 노드에서 전파되는 (section_index, parent_para_index, control_index) current_table_meta: Option<(usize, usize, usize)>, + current_table_ctx: Option, + current_cell_ctx: Option, ) { // Column 노드 진입 시 칼럼 인덱스 전파 let col = if let RenderNodeType::Column(col_idx) = node.node_type { @@ -393,17 +471,37 @@ impl DocumentCore { } else { current_table_id }; - let table_meta = if let RenderNodeType::Table(ref tn) = node.node_type { + let table_ctx = table_ctx_from_node( + node, + current_table_ctx.as_ref(), + current_cell_ctx.as_ref(), + ); + let table_section_index = if let RenderNodeType::Table(ref tn) = node.node_type { + tn.section_index.or_else(|| current_table_meta.map(|(si, _, _)| si)) + } else { + current_table_meta.map(|(si, _, _)| si) + }; + let table_meta = if let Some(ref ctx) = table_ctx { + table_section_index.map(|si| (si, ctx.parent_para_index, ctx.path[0].control_index)) + } else 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, + _ => current_table_meta, } } else { current_table_meta }; + let mut child_cell_ctx = current_cell_ctx.clone(); // TableCell 노드의 bbox 수집 if let RenderNodeType::TableCell(ref tc) = node.node_type { if let Some(cell_idx) = tc.model_cell_index { + let cell_ctx = cell_ctx_for_table_cell( + table_ctx.as_ref(), + cell_idx as usize, + 0, + tc.text_direction, + ); + child_cell_ctx = cell_ctx.clone(); // table_meta가 있으면 즉시 보완, 없으면 자식 TextRun에서 보완 let (si, ppi, ci, has_meta) = table_meta .map(|(si, ppi, ci)| (si, ppi, ci, true)) @@ -420,12 +518,13 @@ impl DocumentCore { w: node.bbox.width, h: node.bbox.height, has_meta, - cell_context: None, + cell_context: cell_ctx, }); } } if let RenderNodeType::TextRun(ref text_run) = node.node_type { if let (Some(si), Some(pi)) = (text_run.section_index, text_run.para_index) { + let cell_context = effective_cell_context(&text_run.cell_context, ¤t_cell_ctx); // 머리말/꼬리말·각주 마커 TextRun 건너뛰기 if pi >= (usize::MAX - 3000) { /* skip marker runs */ } else if let Some(cs) = text_run.char_start { @@ -445,7 +544,7 @@ impl DocumentCore { bbox_y: node.bbox.y, bbox_w: node.bbox.width, bbox_h: node.bbox.height, - cell_context: text_run.cell_context.clone(), + cell_context, is_textbox: false, column_index: col, table_id: current_table_id, @@ -459,13 +558,23 @@ impl DocumentCore { bbox_y: node.bbox.y, bbox_w: node.bbox.width, bbox_h: node.bbox.height, - cell_context: text_run.cell_context.clone(), + cell_context, }); } } } for child in &node.children { - collect_runs(child, runs, guide_runs, cell_bboxes, col, current_table_id, table_meta); + collect_runs( + child, + runs, + guide_runs, + cell_bboxes, + col, + current_table_id, + table_meta, + table_ctx.clone(), + child_cell_ctx.clone(), + ); } } @@ -511,7 +620,17 @@ impl DocumentCore { let mut runs: Vec = Vec::new(); let mut guide_runs: Vec = Vec::new(); let mut cell_bboxes: Vec = Vec::new(); - collect_runs(&tree.root, &mut runs, &mut guide_runs, &mut cell_bboxes, None, None, None); + collect_runs( + &tree.root, + &mut runs, + &mut guide_runs, + &mut cell_bboxes, + None, + None, + None, + None, + None, + ); // cell_bboxes의 section_index/parent_para_index/control_index/cellPath를 runs로 보완. // Table 노드에서 이미 채워진 최외곽 메타는 유지하되, 중첩 표의 Table 노드에는 diff --git a/tests/issue_850_answer_sheet_name_hit_test.rs b/tests/issue_850_answer_sheet_name_hit_test.rs new file mode 100644 index 000000000..cec225ebc --- /dev/null +++ b/tests/issue_850_answer_sheet_name_hit_test.rs @@ -0,0 +1,108 @@ +//! Issue #850: rhwp-studio 상단 답안지 `성명` 칸 입력 회귀. +//! +//! 재현 문서: `samples/exam_social.hwp`, `samples/exam_science.hwp` +//! 대상: 1쪽 상단 답안지 영역의 `성명` 오른쪽 빈 입력칸. + +use std::path::Path; + +use rhwp::wasm_api::HwpDocument; +use serde_json::Value; + +fn load_sample(name: &str) -> HwpDocument { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("samples") + .join(name); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); + HwpDocument::from_bytes(&bytes).unwrap_or_else(|e| panic!("parse {name}: {e}")) +} + +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["cellPath"] + .as_array() + .expect("cellPath array") + .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() +} + +fn assert_answer_sheet_name_hit( + hit: &Value, + outer_control_index: u64, + expected_path: &[(usize, usize, usize)], +) { + assert_eq!(hit["sectionIndex"].as_u64(), Some(0), "hit={hit}"); + assert_eq!( + hit["parentParaIndex"].as_u64(), + Some(0), + "answer sheet name field must keep the outer table parent paragraph, hit={hit}" + ); + assert_eq!( + hit["controlIndex"].as_u64(), + Some(outer_control_index), + "answer sheet name field must expose the outer table control index, hit={hit}" + ); + assert_eq!( + hit["cellIndex"].as_u64(), + Some(expected_path[0].1 as u64), + "public cellIndex must remain the outer table cell, hit={hit}" + ); + assert_eq!( + hit["cellParaIndex"].as_u64(), + Some(expected_path[0].2 as u64), + "public cellParaIndex must remain the outer table cell paragraph, hit={hit}" + ); + assert_eq!( + path_tuples(hit), + expected_path, + "answer sheet name field must preserve the full nested table path, hit={hit}" + ); +} + +fn assert_name_insert_by_path( + doc: &mut HwpDocument, + sample_name: &str, + outer_control_index: u64, + expected_path: &[(usize, usize, usize)], +) { + // 1쪽 상단 답안지 `성명` 오른쪽 빈 입력칸 내부 좌표. + let hit = hit_json(doc, 0, 250.0, 210.0); + assert_answer_sheet_name_hit(&hit, outer_control_index, expected_path); + + let path = path_tuples(&hit); + let char_offset = hit["charOffset"].as_u64().unwrap_or(0) as usize; + doc.insert_text_in_cell_by_path(0, 0, &path, char_offset, "홍") + .unwrap_or_else(|e| panic!("{sample_name}: insert_text_in_cell_by_path failed: {e}")); + + let inserted = doc + .get_text_in_cell_by_path(0, 0, &path, char_offset, 1) + .unwrap_or_else(|e| panic!("{sample_name}: get_text_in_cell_by_path failed: {e}")); + assert_eq!( + inserted, "홍", + "{sample_name}: inserted text must be readable by path" + ); +} + +#[test] +fn issue_850_exam_social_answer_sheet_name_cell_keeps_outer_path() { + let mut doc = load_sample("exam_social.hwp"); + assert_name_insert_by_path(&mut doc, "exam_social.hwp", 4, &[(4, 0, 3), (0, 1, 0)]); +} + +#[test] +fn issue_850_exam_science_answer_sheet_name_cell_keeps_outer_path() { + let mut doc = load_sample("exam_science.hwp"); + assert_name_insert_by_path(&mut doc, "exam_science.hwp", 6, &[(6, 0, 3), (0, 1, 0)]); +} From 41dd2b01b4cdddf17e70fcdf7c37887d9d24e465 Mon Sep 17 00:00:00 2001 From: postmelee Date: Tue, 12 May 2026 18:31:43 +0900 Subject: [PATCH 2/3] Task #850: fix nested path cursor rect in studio --- mydocs/orders/20260512.md | 2 +- mydocs/report/task_m100_850_report.md | 36 +++- mydocs/working/task_m100_850_stage5.md | 144 +++++++++++++ src/document_core/queries/cursor_rect.rs | 195 +++++++++++++++--- tests/issue_850_answer_sheet_name_hit_test.rs | 12 ++ 5 files changed, 356 insertions(+), 33 deletions(-) create mode 100644 mydocs/working/task_m100_850_stage5.md diff --git a/mydocs/orders/20260512.md b/mydocs/orders/20260512.md index 1743a1311..e21afda32 100644 --- a/mydocs/orders/20260512.md +++ b/mydocs/orders/20260512.md @@ -6,7 +6,7 @@ |------|--------|------|------| | **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) 패턴. | -| #850 | rhwp-studio 성명 칸 입력 회귀 수정 | 완료 | 최종보고서 작성 + `cargo test` 통과, 완료: 17:58 | +| #850 | rhwp-studio 성명 칸 입력 회귀 수정 | 완료 | WASM 재빌드 + 브라우저 입력 검증 + `cargo test` 통과, 완료: 18:29 | ## 작업 메모 diff --git a/mydocs/report/task_m100_850_report.md b/mydocs/report/task_m100_850_report.md index e4ae4e2cd..e5edfc52f 100644 --- a/mydocs/report/task_m100_850_report.md +++ b/mydocs/report/task_m100_850_report.md @@ -55,7 +55,7 @@ Uncaught 렌더링 오류: 컨트롤 인덱스 0 범위 초과 - `src/document_core/queries/cursor_rect.rs` -`hit_test_native()`의 렌더 트리 수집 단계에서 조상 표/셀 컨텍스트를 함께 전파하도록 보정했다. +`hit_test_native()`와 `get_cursor_rect_by_path_native()`의 렌더 트리 수집 단계에서 조상 표/셀 컨텍스트를 함께 전파하도록 보정했다. 핵심 보정: @@ -65,6 +65,8 @@ Uncaught 렌더링 오류: 컨트롤 인덱스 0 범위 초과 - TableCell 진입 시 현재 표 경로에 `cellIndex`, `cellParaIndex`, `textDirection`을 반영한다. 3. `effective_cell_context()` - TextRun 자체가 내부 표 로컬 경로만 가진 경우, 조상 traversal context가 더 깊으면 전체 경로를 보존한 traversal context를 우선 사용한다. +4. `get_cursor_rect_by_path_native()` 후속 보정 + - path 기반 삽입 후 커서 좌표 조회도 동일한 전체 `cellPath` 기준으로 TextRun을 찾도록 수정했다. Studio TypeScript는 수정하지 않았다. Studio는 이미 `cellPath.length > 1`이면 `insertTextInCellByPath`를 호출하므로, Rust hit-test 반환값만 정상화하면 기존 경로로 해결된다. @@ -79,6 +81,7 @@ Studio TypeScript는 수정하지 않았다. Studio는 이미 `cellPath.length > - `exam_social.hwp` 성명 칸 hit-test가 `parentParaIndex=0`, `controlIndex=4`, `cellPath=[(4,0,3),(0,1,0)]`를 반환 - `exam_science.hwp` 성명 칸 hit-test가 `parentParaIndex=0`, `controlIndex=6`, `cellPath=[(6,0,3),(0,1,0)]`를 반환 - 두 문서 모두 `insert_text_in_cell_by_path()`로 `"홍"` 삽입 후 `get_text_in_cell_by_path()`로 확인 +- 삽입 후 `get_cursor_rect_by_path()`가 전체 `cellPath`로 정상 좌표를 반환하는지 확인 ## 5. 검증 @@ -118,7 +121,7 @@ test result: ok. 1 passed; 0 failed cargo test ``` -첫 실행은 sandbox 네트워크 제한으로 `static.crates.io` DNS 조회에 실패했다. 승인 후 `web-sys v0.3.95`를 다운로드하여 재실행했고 전체 테스트가 통과했다. +첫 실행은 sandbox 네트워크 제한으로 `static.crates.io` DNS 조회에 실패했다. 승인 후 `web-sys v0.3.95`를 다운로드하여 재실행했고 전체 테스트가 통과했다. Stage 5 후속 수정 뒤에도 다시 전체 테스트를 실행했다. ```text test result: ok. 1232 passed; 0 failed; 2 ignored @@ -126,6 +129,29 @@ test result: ok. 1232 passed; 0 failed; 2 ignored 통합 테스트와 doc-test까지 모두 통과했다. +### WASM/브라우저 검증 + +`rhwp-studio`는 `../pkg/rhwp.js`, `../pkg/rhwp_bg.wasm`을 사용하므로 Rust 수정 후 WASM 산출물 재빌드가 필요했다. 기존 `pkg/`는 2026-05-08 빌드본이었다. + +```bash +colima start +docker-compose run --rm wasm +``` + +최종 브라우저 검증 URL: + +```text +http://localhost:7700/?url=/samples/exam_social.hwp&filename=exam_social.hwp&t=8502 +``` + +검증 결과: + +```json +{ "newLogs": [] } +``` + +2차 수정 후 `성명` 칸 입력에서 `컨트롤 인덱스 0 범위 초과`와 `getCursorRectByPath` warning이 새로 발생하지 않았다. + ## 6. 기존 경고 전체 테스트 중 기존 warning 6건이 출력되었다. @@ -148,6 +174,7 @@ test result: ok. 1232 passed; 0 failed; 2 ignored | Stage 2 보고서 | `mydocs/working/task_m100_850_stage2.md` | | Stage 3 보고서 | `mydocs/working/task_m100_850_stage3.md` | | Stage 4 보고서 | `mydocs/working/task_m100_850_stage4.md` | +| Stage 5 보고서 | `mydocs/working/task_m100_850_stage5.md` | | 최종 보고서 | `mydocs/report/task_m100_850_report.md` | | 본질 정정 | `src/document_core/queries/cursor_rect.rs` | | 회귀 가드 | `tests/issue_850_answer_sheet_name_hit_test.rs` | @@ -156,7 +183,6 @@ test result: ok. 1232 passed; 0 failed; 2 ignored #850 회귀는 Studio 입력 라우터 문제가 아니라 Rust `hit_test_native()`가 상단 답안지 내부 TAC 표의 로컬 메타를 문서 루트 컨텍스트처럼 반환한 문제였다. -조상 표/셀 컨텍스트를 수집 단계에서 전파해 외곽 표 기준 `parentParaIndex/controlIndex`와 전체 `cellPath`를 복원했다. 이로써 `exam_social.hwp`, `exam_science.hwp`의 `성명` 입력칸이 기존 Studio path 기반 입력 API로 정상 처리된다. - -기존 #717 hit-test 회귀 테스트와 전체 `cargo test` 모두 통과했다. +조상 표/셀 컨텍스트를 수집 단계에서 전파해 외곽 표 기준 `parentParaIndex/controlIndex`와 전체 `cellPath`를 복원했다. 또한 path 기반 삽입 후 커서 좌표 조회도 같은 경로 기준으로 동작하도록 보정했다. 이로써 `exam_social.hwp`, `exam_science.hwp`의 `성명` 입력칸이 기존 Studio path 기반 입력 API로 정상 처리된다. +기존 #717 hit-test 회귀 테스트, WASM 빌드, 브라우저 입력 검증, 전체 `cargo test` 모두 통과했다. diff --git a/mydocs/working/task_m100_850_stage5.md b/mydocs/working/task_m100_850_stage5.md new file mode 100644 index 000000000..7f58c16c1 --- /dev/null +++ b/mydocs/working/task_m100_850_stage5.md @@ -0,0 +1,144 @@ +# Task #850 Stage 5 완료 보고서 + +## 단계 목표 + +웹서버에서 `samples/exam_social.hwp` 성명 칸 입력을 직접 검증하고, 네이티브 테스트와 브라우저 동작 사이의 차이를 확인한다. + +## 추가 확인된 현상 + +초기 커밋 후 `rhwp-studio`를 실행했을 때 브라우저에서는 여전히 다음 오류가 재현되었다. + +```text +Uncaught 렌더링 오류: 컨트롤 인덱스 0 범위 초과 +``` + +원인은 코드 수정 누락이 아니라 `rhwp-studio`가 사용하는 `pkg/rhwp.js`, `pkg/rhwp_bg.wasm`이 2026-05-08 빌드본으로 남아 있었기 때문이다. 네이티브 테스트는 최신 Rust 코드를 사용했지만, 웹서버는 `rhwp-studio/vite.config.ts`의 `@wasm -> ../pkg` 별칭을 통해 기존 WASM 산출물을 계속 사용했다. + +## WASM 재빌드 + +저장소 지침에 따라 WASM은 Docker 경로로 빌드했다. + +현재 로컬 환경은 `docker compose` 플러그인이 없고 `docker-compose` 명령만 사용 가능했다. 또한 Colima가 정지되어 있어 먼저 Colima를 시작한 뒤 빌드했다. + +```bash +colima start +docker-compose run --rm wasm +``` + +1차 WASM 재빌드 후 `pkg/` 산출물은 다음 시각으로 갱신되었다. + +```text +pkg/rhwp.js 2026-05-12 18:14 +pkg/rhwp_bg.wasm 2026-05-12 18:14 +``` + +## 2차 결함 + +최신 WASM으로 브라우저에서 다시 확인하니 기존 `컨트롤 인덱스 0 범위 초과`는 사라졌다. 다만 입력 직후 다음 warning이 새로 확인되었다. + +```text +[CursorState] updateRect 실패 → hitTest 폴백 +렌더링 오류: 경로 기반 커서 위치를 찾을 수 없습니다 +path=[{"controlIndex":4,"cellIndex":0,"cellParaIndex":3},{"controlIndex":0,"cellIndex":1,"cellParaIndex":0}] +``` + +원인은 `getCursorRectByPath`가 렌더 트리 `TextRun.cell_context`를 직접 비교했기 때문이다. `hitTest`는 Stage 2에서 traversal context로 전체 중첩 경로를 복원했지만, `getCursorRectByPath`는 동일 보정을 하지 않아 내부 표 TextRun의 로컬 경로만 보고 전체 path 매칭에 실패했다. + +## 수정 + +수정 파일: + +- `src/document_core/queries/cursor_rect.rs` +- `tests/issue_850_answer_sheet_name_hit_test.rs` + +수정 내용: + +- `get_cursor_rect_by_path_native()`에도 `hit_test_native()`와 같은 조상 표/셀 traversal context 보정을 적용했다. +- TextRun 자체가 로컬 `cell_context`만 가진 경우에도 traversal context가 더 깊으면 전체 `cellPath`를 기준으로 매칭한다. +- #850 회귀 테스트에 `insert_text_in_cell_by_path()` 이후 `get_cursor_rect_by_path()` 성공 검증을 추가했다. + +## 검증 + +### 네이티브 회귀 테스트 + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +``` + +결과: + +```text +test result: ok. 2 passed; 0 failed +``` + +### 기존 회귀 테스트 + +```bash +cargo test --test issue_717_table_cell_hit_test -- --nocapture +cargo test --lib test_task105_nested_table_path_api -- --nocapture +``` + +결과: + +```text +#717: 3 passed +task105: 1 passed +``` + +### WASM 재빌드 + +```bash +docker-compose run --rm wasm +``` + +결과: + +```text +Finished `release` profile [optimized] +Your wasm pkg is ready to publish at /app/pkg. +``` + +### 브라우저 검증 + +URL: + +```text +http://localhost:7700/?url=/samples/exam_social.hwp&filename=exam_social.hwp&t=8502 +``` + +검증 플로우: + +1. `exam_social.hwp` 로드 +2. 1쪽 상단 답안지 `성명` 입력칸 클릭 +3. 한글 1자 입력 +4. 브라우저 console error/warn 신규 발생 확인 + +결과: + +```json +{ + "newLogs": [] +} +``` + +기존 탭에 남아 있던 warning 1건은 1차 WASM 빌드 전 검증 로그였고, 2차 수정 후 새 입력에서는 `컨트롤 인덱스 0 범위 초과`와 `getCursorRectByPath` warning 모두 새로 발생하지 않았다. + +### 전체 테스트 + +```bash +cargo test +``` + +결과: + +```text +test result: ok. 1232 passed; 0 failed; 2 ignored +``` + +통합 테스트와 doc-test까지 모두 통과했다. + +## 결론 + +웹서버에서 남아 있던 최초 오류는 stale WASM 산출물 사용이 원인이었다. WASM 갱신 후 원래 오류는 사라졌고, 이어 드러난 path 기반 커서 좌표 조회 실패까지 같은 중첩 경로 보정 방식으로 수정했다. + +최종적으로 네이티브 테스트, WASM 빌드, 브라우저 입력 검증, 전체 `cargo test`가 모두 통과했다. diff --git a/src/document_core/queries/cursor_rect.rs b/src/document_core/queries/cursor_rect.rs index 40c436d58..e69788aa4 100644 --- a/src/document_core/queries/cursor_rect.rs +++ b/src/document_core/queries/cursor_rect.rs @@ -1291,19 +1291,110 @@ impl DocumentCore { char_offset: usize, ) -> Result { use crate::renderer::render_tree::{RenderNode, RenderNodeType}; - use crate::renderer::layout::compute_char_positions; + use crate::renderer::layout::{compute_char_positions, CellContext, CellPathEntry}; let path = Self::parse_cell_path(path_json)?; if path.is_empty() { return Err(HwpError::RenderError("경로가 비어있습니다".to_string())); } - let last = path.last().unwrap(); - let para = self.resolve_paragraph_by_path(section_idx, parent_para_idx, &path)?; + let _para = self.resolve_paragraph_by_path(section_idx, parent_para_idx, &path)?; // 커서 좌표를 렌더 트리에서 찾기 let pages = self.find_pages_for_paragraph(section_idx, parent_para_idx)?; + fn table_ctx_from_node( + node: &RenderNode, + current_table_ctx: Option<&CellContext>, + current_cell_ctx: Option<&CellContext>, + ) -> Option { + if let RenderNodeType::Table(ref tn) = node.node_type { + match (tn.para_index, tn.control_index) { + (Some(pi), Some(ci)) => { + if let Some(parent_ctx) = current_cell_ctx { + let mut ctx = parent_ctx.clone(); + if let Some(last) = ctx.path.last_mut() { + last.cell_para_index = pi; + } + ctx.path.push(CellPathEntry { + control_index: ci, + cell_index: 0, + cell_para_index: 0, + text_direction: 0, + }); + Some(ctx) + } else { + Some(CellContext { + parent_para_index: pi, + path: vec![CellPathEntry { + control_index: ci, + cell_index: 0, + cell_para_index: 0, + text_direction: 0, + }], + }) + } + } + _ => current_table_ctx.cloned(), + } + } else { + current_table_ctx.cloned() + } + } + + fn cell_ctx_for_table_cell( + table_ctx: Option<&CellContext>, + cell_index: usize, + cell_para_index: usize, + text_direction: u8, + ) -> Option { + table_ctx.map(|ctx| { + let mut cell_ctx = ctx.clone(); + if let Some(last) = cell_ctx.path.last_mut() { + last.cell_index = cell_index; + last.cell_para_index = cell_para_index; + last.text_direction = text_direction; + } + cell_ctx + }) + } + + fn effective_cell_context( + text_ctx: &Option, + traversal_ctx: &Option, + ) -> Option { + match (text_ctx, traversal_ctx) { + (Some(text_ctx), Some(traversal_ctx)) + if traversal_ctx.path.len() >= text_ctx.path.len() => + { + let mut ctx = traversal_ctx.clone(); + if let (Some(dst), Some(src)) = (ctx.path.last_mut(), text_ctx.path.last()) { + dst.cell_para_index = src.cell_para_index; + dst.text_direction = src.text_direction; + } + Some(ctx) + } + (Some(text_ctx), _) => Some(text_ctx.clone()), + (None, _) => None, + } + } + + fn cell_context_matches( + ctx: &Option, + parent_para: usize, + path: &[(usize, usize, usize)], + ) -> bool { + ctx.as_ref().map_or(false, |ctx| { + ctx.parent_para_index == parent_para + && ctx.path.len() == path.len() + && ctx.path.iter().zip(path.iter()).all(|(a, b)| { + a.control_index == b.0 + && a.cell_index == b.1 + && a.cell_para_index == b.2 + }) + }) + } + // 렌더 트리에서 경로가 일치하는 TextRun 찾기 fn find_cursor_by_path( node: &RenderNode, @@ -1311,18 +1402,28 @@ impl DocumentCore { path: &[(usize, usize, usize)], offset: usize, page: u32, + current_table_ctx: Option, + current_cell_ctx: Option, ) -> Option<(u32, f64, f64, f64)> { + let table_ctx = table_ctx_from_node( + node, + current_table_ctx.as_ref(), + current_cell_ctx.as_ref(), + ); + let mut child_cell_ctx = current_cell_ctx.clone(); + if let RenderNodeType::TableCell(ref tc) = node.node_type { + if let Some(cell_idx) = tc.model_cell_index { + child_cell_ctx = cell_ctx_for_table_cell( + table_ctx.as_ref(), + cell_idx as usize, + 0, + tc.text_direction, + ); + } + } if let RenderNodeType::TextRun(ref tr) = node.node_type { - let matches = tr.cell_context.as_ref().map_or(false, |ctx| { - ctx.parent_para_index == parent_para - && ctx.path.len() == path.len() - && ctx.path.iter().zip(path.iter()).all(|(a, b)| { - a.control_index == b.0 - && a.cell_index == b.1 - && a.cell_para_index == b.2 - }) - }); - if matches { + let cell_context = effective_cell_context(&tr.cell_context, ¤t_cell_ctx); + if cell_context_matches(&cell_context, parent_para, path) { let cs = tr.char_start.unwrap_or(0); let cc = tr.text.chars().count(); if offset >= cs && offset <= cs + cc { @@ -1336,7 +1437,15 @@ impl DocumentCore { } } for child in &node.children { - if let Some(hit) = find_cursor_by_path(child, parent_para, path, offset, page) { + if let Some(hit) = find_cursor_by_path( + child, + parent_para, + path, + offset, + page, + table_ctx.clone(), + child_cell_ctx.clone(), + ) { return Some(hit); } } @@ -1345,7 +1454,15 @@ impl DocumentCore { for &page_num in &pages { let tree = self.build_page_tree(page_num)?; - if let Some((pi, x, y, h)) = find_cursor_by_path(&tree.root, parent_para_idx, &path, char_offset, page_num) { + if let Some((pi, x, y, h)) = find_cursor_by_path( + &tree.root, + parent_para_idx, + &path, + char_offset, + page_num, + None, + None, + ) { return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", pi, x, y, h @@ -1361,29 +1478,53 @@ impl DocumentCore { parent_para: usize, path: &[(usize, usize, usize)], page: u32, + current_table_ctx: Option, + current_cell_ctx: Option, ) -> Option<(u32, f64, f64, f64)> { + let table_ctx = table_ctx_from_node( + node, + current_table_ctx.as_ref(), + current_cell_ctx.as_ref(), + ); + let mut child_cell_ctx = current_cell_ctx.clone(); + if let RenderNodeType::TableCell(ref tc) = node.node_type { + if let Some(cell_idx) = tc.model_cell_index { + child_cell_ctx = cell_ctx_for_table_cell( + table_ctx.as_ref(), + cell_idx as usize, + 0, + tc.text_direction, + ); + } + } if let RenderNodeType::TextRun(ref tr) = node.node_type { - let matches = tr.cell_context.as_ref().map_or(false, |ctx| { - ctx.parent_para_index == parent_para - && ctx.path.len() == path.len() - && ctx.path.iter().zip(path.iter()).all(|(a, b)| { - a.control_index == b.0 - && a.cell_index == b.1 - && a.cell_para_index == b.2 - }) - }); - if matches { + let cell_context = effective_cell_context(&tr.cell_context, ¤t_cell_ctx); + if cell_context_matches(&cell_context, parent_para, path) { return Some((page, node.bbox.x, node.bbox.y, node.bbox.height)); } } for child in &node.children { - if let Some(hit) = find_any_run(child, parent_para, path, page) { + if let Some(hit) = find_any_run( + child, + parent_para, + path, + page, + table_ctx.clone(), + child_cell_ctx.clone(), + ) { return Some(hit); } } None } - if let Some((pi, x, y, h)) = find_any_run(&tree.root, parent_para_idx, &path, page_num) { + if let Some((pi, x, y, h)) = find_any_run( + &tree.root, + parent_para_idx, + &path, + page_num, + None, + None, + ) { return Ok(format!( "{{\"pageIndex\":{},\"x\":{:.1},\"y\":{:.1},\"height\":{:.1}}}", pi, x, y, h diff --git a/tests/issue_850_answer_sheet_name_hit_test.rs b/tests/issue_850_answer_sheet_name_hit_test.rs index cec225ebc..5be1f24c1 100644 --- a/tests/issue_850_answer_sheet_name_hit_test.rs +++ b/tests/issue_850_answer_sheet_name_hit_test.rs @@ -82,6 +82,7 @@ fn assert_name_insert_by_path( assert_answer_sheet_name_hit(&hit, outer_control_index, expected_path); let path = path_tuples(&hit); + let path_json = serde_json::to_string(&hit["cellPath"]).expect("cellPath json"); let char_offset = hit["charOffset"].as_u64().unwrap_or(0) as usize; doc.insert_text_in_cell_by_path(0, 0, &path, char_offset, "홍") .unwrap_or_else(|e| panic!("{sample_name}: insert_text_in_cell_by_path failed: {e}")); @@ -93,6 +94,17 @@ fn assert_name_insert_by_path( inserted, "홍", "{sample_name}: inserted text must be readable by path" ); + + let rect_json = doc + .get_cursor_rect_by_path(0, 0, &path_json, (char_offset + 1) as u32) + .unwrap_or_else(|e| panic!("{sample_name}: get_cursor_rect_by_path failed: {e:?}")); + let rect: Value = serde_json::from_str(&rect_json) + .unwrap_or_else(|e| panic!("{sample_name}: parse cursor rect `{rect_json}`: {e}")); + assert_eq!(rect["pageIndex"].as_u64(), Some(0), "{sample_name}: rect={rect}"); + assert!( + rect["height"].as_f64().unwrap_or(0.0) > 0.0, + "{sample_name}: cursor rect height must be positive, rect={rect}" + ); } #[test] From 4b07628ba8cd72fe30ad6c0e8ed8968998817b61 Mon Sep 17 00:00:00 2001 From: postmelee Date: Wed, 13 May 2026 09:41:12 +0900 Subject: [PATCH 3/3] Task #850: reduce nested path input render cost --- mydocs/orders/20260513.md | 7 + mydocs/plans/task_m100_850_impl.md | 26 ++- mydocs/report/task_m100_850_report.md | 38 ++++- mydocs/working/task_m100_850_stage6.md | 126 ++++++++++++++ rhwp-studio/src/core/wasm-bridge.ts | 9 + rhwp-studio/src/view/canvas-view.ts | 1 + rhwp-studio/src/view/page-renderer.ts | 63 +++++-- src/document_core/queries/cursor_rect.rs | 4 +- src/document_core/queries/rendering.rs | 156 +++++++++++++++++- src/wasm_api.rs | 7 + tests/issue_850_answer_sheet_name_hit_test.rs | 30 ++++ 11 files changed, 446 insertions(+), 21 deletions(-) create mode 100644 mydocs/orders/20260513.md create mode 100644 mydocs/working/task_m100_850_stage6.md diff --git a/mydocs/orders/20260513.md b/mydocs/orders/20260513.md new file mode 100644 index 000000000..61bdc2dbf --- /dev/null +++ b/mydocs/orders/20260513.md @@ -0,0 +1,7 @@ +# 오늘 할일 - 2026년 5월 13일 + +## M100 — v1.0.0 편집 기반 + +| Issue | 타스크 | 상태 | 비고 | +|------|--------|------|------| +| #850 | rhwp-studio 성명 칸 입력 성능 후속 정리 | 완료 | nested path 입력 루프 compact overlay/cached cursor 적용, 완료: 09:37 | diff --git a/mydocs/plans/task_m100_850_impl.md b/mydocs/plans/task_m100_850_impl.md index a2d8588e8..06b85335a 100644 --- a/mydocs/plans/task_m100_850_impl.md +++ b/mydocs/plans/task_m100_850_impl.md @@ -181,6 +181,31 @@ cargo test - 최종 보고서 승인 요청 - 작업 브랜치 `local/task850`에 커밋 가능한 상태 정리 +### Stage 6 — 성능 후속 정리 + +작업 내용: + +1. `getCursorRectByPath`가 입력마다 uncached page tree를 다시 만드는지 확인한다. +2. `rhwp-studio` 입력 렌더 루프가 매 입력마다 전체 `getPageLayerTree` JSON을 요청하는지 확인한다. +3. 불필요한 지연 재렌더 예약 또는 취소가 발생하지 않도록 `PageRenderer` 재시도 조건을 좁힌다. +4. WASM API와 Studio bridge에 입력 루프용 compact overlay image 조회 경로를 추가한다. + +검증 명령: + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +cargo test --test issue_717_table_cell_hit_test -- --nocapture +cargo test --lib test_task105_nested_table_path_api -- --nocapture +cd rhwp-studio && npm run build +docker-compose run --rm wasm +``` + +완료 조건: + +- `getCursorRectByPath`가 캐시된 page tree 경로를 사용한다. +- 입력 루프에서 `getPageLayerTree` 1.4MB JSON 대신 compact overlay JSON을 사용한다. +- 성명 칸 입력 브라우저 검증에서 `홍길동`이 반영되고 `컨트롤 인덱스 0 범위 초과`가 새로 발생하지 않는다. + ## 예상 변경 파일 | 파일 | 변경 종류 | 목적 | @@ -202,4 +227,3 @@ cargo test ## 진행 조건 본 구현 계획서 승인 후 Stage 1 RED 회귀 테스트 작성을 시작한다. - diff --git a/mydocs/report/task_m100_850_report.md b/mydocs/report/task_m100_850_report.md index e5edfc52f..6add326ec 100644 --- a/mydocs/report/task_m100_850_report.md +++ b/mydocs/report/task_m100_850_report.md @@ -82,6 +82,7 @@ Studio TypeScript는 수정하지 않았다. Studio는 이미 `cellPath.length > - `exam_science.hwp` 성명 칸 hit-test가 `parentParaIndex=0`, `controlIndex=6`, `cellPath=[(6,0,3),(0,1,0)]`를 반환 - 두 문서 모두 `insert_text_in_cell_by_path()`로 `"홍"` 삽입 후 `get_text_in_cell_by_path()`로 확인 - 삽입 후 `get_cursor_rect_by_path()`가 전체 `cellPath`로 정상 좌표를 반환하는지 확인 +- `exam_social.hwp` page 0에서 입력 루프용 `getPageOverlayImages()` JSON이 전체 `getPageLayerTree()` JSON으로 회귀하지 않도록 compact size 가드 ## 5. 검증 @@ -92,7 +93,7 @@ cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture ``` ```text -test result: ok. 2 passed; 0 failed +test result: ok. 3 passed; 0 failed ``` ### #717 기존 테스트 보존 @@ -175,14 +176,45 @@ http://localhost:7700/?url=/samples/exam_social.hwp&filename=exam_social.hwp&t=8 | Stage 3 보고서 | `mydocs/working/task_m100_850_stage3.md` | | Stage 4 보고서 | `mydocs/working/task_m100_850_stage4.md` | | Stage 5 보고서 | `mydocs/working/task_m100_850_stage5.md` | +| Stage 6 보고서 | `mydocs/working/task_m100_850_stage6.md` | | 최종 보고서 | `mydocs/report/task_m100_850_report.md` | | 본질 정정 | `src/document_core/queries/cursor_rect.rs` | | 회귀 가드 | `tests/issue_850_answer_sheet_name_hit_test.rs` | -## 8. 결론 +## 8. 성능 후속 + +Stage 5 이후 사용자 검증에서 입력 지연이 확인되어 Stage 6에서 별도 분석했다. + +결론은 #850 정확성 수정이 새 비용을 직접 만든 것이 아니라, `v0.7.11`의 #717 이후 성명 칸이 nested path 입력 경로를 타게 되면서 기존 느린 경로가 노출된 것이다. `v0.7.10`에서는 해당 칸이 이 경로로 진입하지 않아 같은 성능 문제가 체감되지 않았다. + +성능 병목: + +- `getCursorRectByPath`가 매 입력마다 uncached page tree를 구성했다. +- Studio `PageRenderer`가 입력 렌더마다 `getPageLayerTree()`로 약 1.4MB JSON을 생성/파싱했다. +- 이미지 지연 재렌더 조건이 넓어 같은 image count에서도 타이머 상태를 불필요하게 건드렸다. + +추가 수정: + +- `getCursorRectByPath`와 `buildPageLayerTree`를 cached page tree 경로로 전환했다. +- 입력 루프용 `getPageOverlayImages(pageNum)` WASM API를 추가해 overlay image만 compact JSON으로 반환한다. +- Studio `PageRenderer`가 새 API를 우선 사용하고, 구버전 WASM에서만 `getPageLayerTree()` fallback을 사용하도록 했다. +- flow 렌더와 동일 image count에서는 지연 재렌더를 새로 예약하지 않도록 조건을 좁혔다. + +측정 결과: + +```text +getPageOverlayImages(0): 0.54~0.91ms, len=39 +getPageLayerTree(0): 16.81ms, len=1,402,745 +getCursorRectByPath: cached 이후 0.15~0.18ms +insertTextInCellByPath: 0.12~0.35ms +``` + +브라우저 검증에서 `samples/exam_social.hwp` 성명 칸에 `홍길동`이 실제 셀 텍스트로 반영되었고, `컨트롤 인덱스 0 범위 초과`는 재현되지 않았다. + +## 9. 결론 #850 회귀는 Studio 입력 라우터 문제가 아니라 Rust `hit_test_native()`가 상단 답안지 내부 TAC 표의 로컬 메타를 문서 루트 컨텍스트처럼 반환한 문제였다. 조상 표/셀 컨텍스트를 수집 단계에서 전파해 외곽 표 기준 `parentParaIndex/controlIndex`와 전체 `cellPath`를 복원했다. 또한 path 기반 삽입 후 커서 좌표 조회도 같은 경로 기준으로 동작하도록 보정했다. 이로써 `exam_social.hwp`, `exam_science.hwp`의 `성명` 입력칸이 기존 Studio path 기반 입력 API로 정상 처리된다. -기존 #717 hit-test 회귀 테스트, WASM 빌드, 브라우저 입력 검증, 전체 `cargo test` 모두 통과했다. +후속 성능 정리로 nested path 입력 루프의 cached cursor 조회와 compact overlay 조회도 적용했다. 기존 #717 hit-test 회귀 테스트, WASM 빌드, 브라우저 입력 검증, 전체 `cargo test` 모두 통과했다. diff --git a/mydocs/working/task_m100_850_stage6.md b/mydocs/working/task_m100_850_stage6.md new file mode 100644 index 000000000..159e00252 --- /dev/null +++ b/mydocs/working/task_m100_850_stage6.md @@ -0,0 +1,126 @@ +# Task #850 Stage 6 완료 보고서 + +## 단계 목표 + +`samples/exam_social.hwp` 성명 칸 입력은 Stage 5에서 정상화되었지만, 사용자 검증에서 입력 지연이 확인되었다. 이번 단계의 목표는 #850 수정이 성능 문제를 새로 만든 것인지 분리하고, 입력 루프에서 불필요한 page tree 재구성, 대형 JSON 생성, 지연 재렌더 예약을 제거하는 것이다. + +## 원인 분석 + +`v0.7.10`에서는 성명 칸 hit-test가 중첩 표 path로 진입하지 않았기 때문에 느린 path 기반 입력 경로가 노출되지 않았다. `v0.7.11`의 #717 이후 빈 셀 hit-test가 `cellPath`를 반환하면서 성명 칸 입력도 `insertTextInCellByPath`와 `getCursorRectByPath` 경로를 사용하게 되었고, 이때 다음 비용이 드러났다. + +- `getCursorRectByPath`: 매 입력마다 `build_page_tree()`를 새로 호출했다. +- `PageRenderer.applyOverlays`: 매 렌더마다 `getPageLayerTree()`를 호출해 page 0 기준 약 1.4MB JSON을 생성하고 파싱했다. +- `scheduleReRender`: 이미지가 없는 flow 렌더까지 지연 재렌더 후보로 취급할 수 있는 구조였고, 같은 이미지 수인 경우에도 기존 타이머를 먼저 취소했다. + +따라서 #850의 정확성 수정이 직접 지연을 만든 것은 아니다. 다만 #850 수정으로 입력이 실제로 성공하게 되면서, #717 이후 노출된 nested path 입력 경로의 기존 비용이 사용자에게 체감된 것이다. + +## 수정 + +수정 파일: + +- `src/document_core/queries/cursor_rect.rs` +- `src/document_core/queries/rendering.rs` +- `src/wasm_api.rs` +- `rhwp-studio/src/core/wasm-bridge.ts` +- `rhwp-studio/src/view/page-renderer.ts` +- `rhwp-studio/src/view/canvas-view.ts` + +수정 내용: + +1. `get_cursor_rect_by_path_native()`가 `build_page_tree_cached()`를 사용하도록 변경했다. +2. `build_page_layer_tree()`도 캐시된 page tree를 사용하도록 변경했다. +3. 입력 렌더 루프용 `getPageOverlayImages(pageNum)` WASM API를 추가했다. + - behind/front overlay image만 JSON으로 반환한다. + - flow image는 base64 payload를 포함하지 않고 `imageCount`만 계산한다. +4. Studio `PageRenderer`가 우선 `getPageOverlayImages()`를 사용하고, 구버전 WASM일 때만 `getPageLayerTree()`로 fallback하도록 했다. +5. flow 렌더에서는 image retry를 예약하지 않도록 `imageCount=0`을 전달했다. +6. 같은 `imageCount`면 지연 재렌더 타이머를 건드리지 않고 반환하도록 `scheduleReRender()` 조건 순서를 정리했다. +7. 문서 reset 시 overlay image retry 상태도 같이 초기화하도록 했다. + +## 성능 측정 + +WASM 직접 측정 (`samples/exam_social.hwp`, 성명 칸 path): + +```text +getPageOverlayImages(0) first 0.91ms, len=39 +getPageOverlayImages(0) second 0.54ms, len=39 +getPageLayerTree(0) 16.81ms, len=1,402,745 +getCursorRectByPath first 0.60ms +getCursorRectByPath second 0.15ms +insertTextInCellByPath 0.12~0.35ms +입력 후 overlay 조회 3.33~3.59ms, len=39 +입력 후 cursor 조회 0.15~0.18ms +``` + +브라우저 입력 검증 (`홍길동` 입력): + +```json +{ + "afterText": "홍길동", + "overlayLength": 39, + "layerLength": 1403612, + "renderPageCalls": 6, + "scheduleReRenderImageCounts": [5, 1, 5, 1, 5, 1] +} +``` + +입력 3자 동안 보이는 2개 페이지 렌더만 발생했다. 위 `scheduleReRender` 호출은 각 visible page 렌더의 image count 확인이며, 같은 image count에서는 새 지연 재렌더 타이머를 만들지 않는다. + +브라우저 검증에서는 폰트 요청을 차단해 초기화 지연을 제거했다. console의 `Failed to load resource: net::ERR_FAILED`는 이 폰트 차단으로 발생한 검증 환경 로그이며, #850 오류인 `컨트롤 인덱스 0 범위 초과`는 새로 발생하지 않았다. + +## 검증 + +```bash +cargo test --test issue_850_answer_sheet_name_hit_test -- --nocapture +``` + +```text +test result: ok. 3 passed; 0 failed +``` + +```bash +cargo test --test issue_717_table_cell_hit_test -- --nocapture +``` + +```text +test result: ok. 3 passed; 0 failed +``` + +```bash +cargo test --lib test_task105_nested_table_path_api -- --nocapture +``` + +```text +test result: ok. 1 passed; 0 failed +``` + +```bash +cd rhwp-studio && npm run build +``` + +```text +tsc && vite build 통과 +``` + +```bash +docker-compose run --rm wasm +``` + +```text +Your wasm pkg is ready to publish at /app/pkg. +``` + +```bash +cargo test +``` + +```text +test result: ok. 1232 passed; 0 failed; 2 ignored +integration/doc tests 통과 +``` + +## 결론 + +성능 문제의 본질은 #850 정확성 수정 자체가 아니라, `v0.7.11` 이후 성명 칸이 nested path 입력 경로를 타면서 캐시 미사용 cursor 조회와 대형 page layer JSON 생성이 입력 루프에 들어온 것이다. + +이번 단계에서 cursor 조회를 캐시 경로로 바꾸고, Studio 렌더가 입력마다 1.4MB page layer JSON을 만들지 않도록 compact overlay image API로 분리했다. 브라우저 검증에서 성명 칸에 `홍길동`이 정상 입력되었고, 기존 `컨트롤 인덱스 0 범위 초과` 오류는 재현되지 않았다. diff --git a/rhwp-studio/src/core/wasm-bridge.ts b/rhwp-studio/src/core/wasm-bridge.ts index b3a14e784..b71cda684 100644 --- a/rhwp-studio/src/core/wasm-bridge.ts +++ b/rhwp-studio/src/core/wasm-bridge.ts @@ -319,6 +319,15 @@ export class WasmBridge { return '{"layers":[]}'; } + getPageOverlayImages(pageNum: number): string { + if (!this.doc) throw new Error('문서가 로드되지 않았습니다'); + const d = this.doc as unknown as { getPageOverlayImages?: (p: number) => string }; + if (typeof d.getPageOverlayImages === 'function') { + return d.getPageOverlayImages(pageNum); + } + return ''; + } + renderPageSvg(pageNum: number): string { if (!this.doc) throw new Error('문서가 로드되지 않았습니다'); return this.doc.renderPageSvg(pageNum); diff --git a/rhwp-studio/src/view/canvas-view.ts b/rhwp-studio/src/view/canvas-view.ts index ea42ccdf0..5ec75e7e1 100644 --- a/rhwp-studio/src/view/canvas-view.ts +++ b/rhwp-studio/src/view/canvas-view.ts @@ -255,6 +255,7 @@ export class CanvasView { /** 리소스를 정리한다 */ private reset(): void { this.pageRenderer.cancelAll(); + this.pageRenderer.resetImageRetryState(); this.canvasPool.releaseAll(); this.currentVisiblePages = []; this.pages = []; diff --git a/rhwp-studio/src/view/page-renderer.ts b/rhwp-studio/src/view/page-renderer.ts index 541313105..8ab3749d4 100644 --- a/rhwp-studio/src/view/page-renderer.ts +++ b/rhwp-studio/src/view/page-renderer.ts @@ -16,8 +16,15 @@ export interface OverlayImageInfo { transform?: { rotation: number; horzFlip: boolean; vertFlip: boolean }; } +interface OverlayImagesResult { + behind: OverlayImageInfo[]; + front: OverlayImageInfo[]; + imageCount: number; +} + export class PageRenderer { private reRenderTimers = new Map[]>(); + private imageRetryCounts = new Map(); constructor(private wasm: WasmBridge) {} @@ -28,8 +35,8 @@ export class PageRenderer { // 2) overlay (BehindText / InFrontOfText) 는 같은 부모 컨테이너에 로 추가 this.wasm.renderPageToCanvasFiltered(pageIdx, canvas, scale, 'flow'); this.drawMarginGuides(pageIdx, canvas, scale); - this.applyOverlays(pageIdx, canvas, scale); - this.scheduleReRender(pageIdx, canvas, scale); + const overlays = this.applyOverlays(pageIdx, canvas, scale); + this.scheduleReRender(pageIdx, canvas, scale, overlays.imageCount); } /** @@ -40,9 +47,9 @@ export class PageRenderer { * - mix-blend-mode 로 워터마크 효과 (multiply 등) 적용 * - pointer-events: none — hit-test 는 Canvas (텍스트) 가 받음 */ - private applyOverlays(pageIdx: number, canvas: HTMLCanvasElement, scale: number): void { + private applyOverlays(pageIdx: number, canvas: HTMLCanvasElement, scale: number): OverlayImagesResult { const parent = canvas.parentElement; - if (!parent) return; + if (!parent) return { behind: [], front: [], imageCount: 0 }; // 페이지 단위 overlay 컨테이너를 Canvas 의 sibling 으로 관리. // data-rhwp-overlay-page 속성으로 식별, 페이지 재렌더링 시 갱신. @@ -55,8 +62,8 @@ export class PageRenderer { if (existingBehind) existingBehind.remove(); if (existingFront) existingFront.remove(); - const { behind, front } = this.getOverlayImages(pageIdx); - if (behind.length === 0 && front.length === 0) return; + const overlays = this.getOverlayImages(pageIdx); + if (overlays.behind.length === 0 && overlays.front.length === 0) return overlays; // 위치/크기 정합용 공통 정보 const dpr = scale; // scale = zoom × DPR. CSS 표시 크기 = canvas / dpr @@ -67,8 +74,8 @@ export class PageRenderer { const transform = canvas.style.transform; // BehindText overlay (Canvas 뒤) - if (behind.length > 0) { - const layer = this.createOverlayLayer(behind, cssWidth, cssHeight); + if (overlays.behind.length > 0) { + const layer = this.createOverlayLayer(overlays.behind, cssWidth, cssHeight); layer.dataset.rhwpOverlay = `behind-${pageIdx}`; layer.style.position = 'absolute'; layer.style.top = top; @@ -83,8 +90,8 @@ export class PageRenderer { } // InFrontOfText overlay (Canvas 앞) - if (front.length > 0) { - const layer = this.createOverlayLayer(front, cssWidth, cssHeight); + if (overlays.front.length > 0) { + const layer = this.createOverlayLayer(overlays.front, cssWidth, cssHeight); layer.dataset.rhwpOverlay = `front-${pageIdx}`; layer.style.position = 'absolute'; layer.style.top = top; @@ -96,6 +103,7 @@ export class PageRenderer { layer.style.zIndex = '2'; // Canvas (z=auto) 보다 앞 parent.appendChild(layer); } + return overlays; } /** overlay 레이어 div 를 생성하고 그림 들을 추가 */ @@ -151,17 +159,32 @@ export class PageRenderer { renderPageFlow(pageIdx: number, canvas: HTMLCanvasElement, scale: number): void { this.wasm.renderPageToCanvasFiltered(pageIdx, canvas, scale, 'flow'); this.drawMarginGuides(pageIdx, canvas, scale); - this.scheduleReRender(pageIdx, canvas, scale); + this.scheduleReRender(pageIdx, canvas, scale, 0); } /** * 페이지의 BehindText / InFrontOfText 그림 overlay 정보를 추출한다 (Task #516, Stage 5.2). * PageLayerTree JSON 을 파싱하여 wrap = behindText / inFrontOfText 인 image op 만 반환. */ - getOverlayImages(pageIdx: number): { behind: OverlayImageInfo[]; front: OverlayImageInfo[] } { + getOverlayImages(pageIdx: number): OverlayImagesResult { + const overlayJson = this.wasm.getPageOverlayImages(pageIdx); + if (overlayJson) { + try { + const parsed = JSON.parse(overlayJson); + return { + behind: Array.isArray(parsed?.behind) ? parsed.behind : [], + front: Array.isArray(parsed?.front) ? parsed.front : [], + imageCount: typeof parsed?.imageCount === 'number' ? parsed.imageCount : 0, + }; + } catch (e) { + console.warn('[PageRenderer] overlay image JSON parse 실패:', e); + } + } + const json = this.wasm.getPageLayerTree(pageIdx); const behind: OverlayImageInfo[] = []; const front: OverlayImageInfo[] = []; + const imageCount = (json.match(/"type":"image"/g) || []).length; try { const wrapper = JSON.parse(json); // PageLayerTree JSON 의 트리는 wrapper.root 안에 있음. @@ -173,7 +196,7 @@ export class PageRenderer { } catch (e) { console.warn('[PageRenderer] PageLayerTree JSON parse 실패:', e); } - return { behind, front }; + return { behind, front, imageCount }; } /** 편집 용지 여백 가이드라인을 캔버스에 그린다 (4모서리 L자 표시) */ @@ -227,8 +250,16 @@ export class PageRenderer { * 아직 디코딩되지 않았을 수 있으므로 점진적 재렌더링한다. * 200ms, 600ms 두 번 재시도하여 대부분의 이미지 로드를 커버한다. */ - private scheduleReRender(pageIdx: number, canvas: HTMLCanvasElement, scale: number): void { + private scheduleReRender(pageIdx: number, canvas: HTMLCanvasElement, scale: number, imageCount: number): void { + if (imageCount <= 0) { + this.cancelReRender(pageIdx); + this.imageRetryCounts.delete(pageIdx); + return; + } + if (this.imageRetryCounts.get(pageIdx) === imageCount) return; + this.cancelReRender(pageIdx); + this.imageRetryCounts.set(pageIdx, imageCount); const delays = [200, 600]; const timers: ReturnType[] = []; @@ -261,6 +292,10 @@ export class PageRenderer { } this.reRenderTimers.clear(); } + + resetImageRetryState(): void { + this.imageRetryCounts.clear(); + } } /** diff --git a/src/document_core/queries/cursor_rect.rs b/src/document_core/queries/cursor_rect.rs index e69788aa4..6e71c8280 100644 --- a/src/document_core/queries/cursor_rect.rs +++ b/src/document_core/queries/cursor_rect.rs @@ -1453,7 +1453,7 @@ impl DocumentCore { } for &page_num in &pages { - let tree = self.build_page_tree(page_num)?; + let tree = self.build_page_tree_cached(page_num)?; if let Some((pi, x, y, h)) = find_cursor_by_path( &tree.root, parent_para_idx, @@ -1472,7 +1472,7 @@ impl DocumentCore { // fallback: 아무 TextRun이라도 찾기 for &page_num in &pages { - let tree = self.build_page_tree(page_num)?; + let tree = self.build_page_tree_cached(page_num)?; fn find_any_run( node: &RenderNode, parent_para: usize, diff --git a/src/document_core/queries/rendering.rs b/src/document_core/queries/rendering.rs index 7aa38da45..63f2b89c7 100644 --- a/src/document_core/queries/rendering.rs +++ b/src/document_core/queries/rendering.rs @@ -1,6 +1,7 @@ //! 렌더링/페이지 정보/구성/페이지네이션/페이지 트리 관련 native 메서드 use std::cell::RefCell; +use std::fmt::Write as _; use crate::model::document::Section; use crate::model::control::Control; use crate::model::paragraph::Paragraph; @@ -32,7 +33,7 @@ impl DocumentCore { /// 페이지 레이어 트리를 생성하여 반환한다 (native bridge / backend replay용). pub fn build_page_layer_tree(&self, page_num: u32) -> Result { - let tree = self.build_page_tree(page_num)?; + let tree = self.build_page_tree_cached(page_num)?; let _overflows = self.layout_engine.take_overflows(); let output_options = LayerOutputOptions { show_paragraph_marks: self.show_paragraph_marks, @@ -393,6 +394,159 @@ impl DocumentCore { Ok(self.build_page_layer_tree(page_num)?.to_json()) } + /// 페이지 overlay 이미지 정보만 작은 JSON으로 반환한다. + /// + /// Studio는 BehindText/InFrontOfText 그림 overlay 계산을 위해 전체 PageLayerTree JSON을 + /// 파싱할 필요가 없다. 특히 그림이 본문 layer에만 있는 페이지에서는 빈 overlay 배열과 + /// imageCount만 반환하여 입력 중 대용량 JSON 직렬화/파싱을 피한다. + pub fn get_page_overlay_images_native(&self, page_num: u32) -> Result { + use base64::Engine; + use crate::model::image::ImageEffect; + use crate::model::shape::TextWrap; + use crate::paint::{LayerNode, LayerNodeKind, PaintOp}; + use crate::renderer::render_tree::{BoundingBox, ImageNode}; + + fn effect_str(value: ImageEffect) -> &'static str { + match value { + ImageEffect::RealPic => "realPic", + ImageEffect::GrayScale => "grayScale", + ImageEffect::BlackWhite => "blackWhite", + ImageEffect::Pattern8x8 => "pattern8x8", + } + } + + fn wrap_str(value: TextWrap) -> &'static str { + match value { + TextWrap::BehindText => "behindText", + TextWrap::InFrontOfText => "inFrontOfText", + _ => "flow", + } + } + + fn write_json_str(buf: &mut String, value: &str) { + buf.push('"'); + buf.push_str(&crate::document_core::helpers::json_escape(value)); + buf.push('"'); + } + + fn write_bbox(buf: &mut String, bbox: BoundingBox) { + let _ = write!( + buf, + "{{\"x\":{:.3},\"y\":{:.3},\"width\":{:.3},\"height\":{:.3}}}", + bbox.x, bbox.y, bbox.width, bbox.height + ); + } + + fn write_overlay_image( + buf: &mut String, + bbox: BoundingBox, + image: &ImageNode, + wrap: TextWrap, + ) { + if !buf.is_empty() { + buf.push(','); + } + + let mut mime = "application/octet-stream"; + let mut base64_data = String::new(); + if let Some(data) = &image.data { + let detected = crate::renderer::svg::detect_image_mime_type(data); + let (final_mime, final_data): (&str, std::borrow::Cow<[u8]>) = + if detected == "image/x-pcx" { + match crate::renderer::svg::pcx_bytes_to_png_bytes(data) { + Some(png) => ("image/png", std::borrow::Cow::Owned(png)), + None => (detected, std::borrow::Cow::Borrowed(data.as_slice())), + } + } else if detected == "image/bmp" { + match crate::renderer::svg::bmp_bytes_to_png_bytes(data) { + Some(png) => ("image/png", std::borrow::Cow::Owned(png)), + None => (detected, std::borrow::Cow::Borrowed(data.as_slice())), + } + } else { + (detected, std::borrow::Cow::Borrowed(data.as_slice())) + }; + mime = final_mime; + base64_data = base64::engine::general_purpose::STANDARD.encode(&*final_data); + } + + buf.push('{'); + buf.push_str("\"bbox\":"); + write_bbox(buf, bbox); + buf.push_str(",\"mime\":"); + write_json_str(buf, mime); + buf.push_str(",\"base64\":"); + write_json_str(buf, &base64_data); + buf.push_str(",\"effect\":"); + write_json_str(buf, effect_str(image.effect)); + let _ = write!( + buf, + ",\"brightness\":{},\"contrast\":{},\"wrap\":", + image.brightness, image.contrast + ); + write_json_str(buf, wrap_str(wrap)); + + let attr = crate::model::image::ImageAttr { + brightness: image.brightness, + contrast: image.contrast, + effect: image.effect, + bin_data_id: image.bin_data_id, + external_path: None, + }; + if let Some(preset) = attr.watermark_preset() { + let _ = write!(buf, ",\"watermark\":{{\"preset\":\"{}\"}}", preset); + } + + let _ = write!( + buf, + ",\"transform\":{{\"rotation\":{:.3},\"horzFlip\":{},\"vertFlip\":{}}}}}", + image.transform.rotation, image.transform.horz_flip, image.transform.vert_flip + ); + } + + fn collect( + node: &LayerNode, + behind: &mut String, + front: &mut String, + image_count: &mut usize, + ) { + match &node.kind { + LayerNodeKind::Group { children, .. } => { + for child in children { + collect(child, behind, front, image_count); + } + } + LayerNodeKind::ClipRect { child, .. } => collect(child, behind, front, image_count), + LayerNodeKind::Leaf { ops } => { + for op in ops { + if let PaintOp::Image { bbox, image } = op { + *image_count += 1; + match image.text_wrap { + Some(TextWrap::BehindText) => { + write_overlay_image(behind, *bbox, image, TextWrap::BehindText); + } + Some(TextWrap::InFrontOfText) => { + write_overlay_image(front, *bbox, image, TextWrap::InFrontOfText); + } + _ => {} + } + } + } + } + } + } + + let tree = self.build_page_layer_tree(page_num)?; + let mut behind = String::new(); + let mut front = String::new(); + let mut image_count = 0usize; + collect(&tree.root, &mut behind, &mut front, &mut image_count); + + Ok(format!( + "{{\"behind\":[{}],\"front\":[{}],\"imageCount\":{}}}", + behind, front, image_count + )) + } + /// 페이지 정보 (네이티브 에러 타입) pub fn get_page_info_native(&self, page_num: u32) -> Result { use crate::renderer::hwpunit_to_px; diff --git a/src/wasm_api.rs b/src/wasm_api.rs index 41657566c..09d223369 100644 --- a/src/wasm_api.rs +++ b/src/wasm_api.rs @@ -371,6 +371,13 @@ impl HwpDocument { .map_err(|e| e.into()) } + /// 페이지 overlay 이미지 정보만 JSON 문자열로 반환한다. + #[wasm_bindgen(js_name = getPageOverlayImages)] + pub fn get_page_overlay_images(&self, page_num: u32) -> Result { + self.get_page_overlay_images_native(page_num) + .map_err(|e| e.into()) + } + /// 페이지 정보를 JSON 문자열로 반환한다. #[wasm_bindgen(js_name = getPageInfo)] pub fn get_page_info(&self, page_num: u32) -> Result { diff --git a/tests/issue_850_answer_sheet_name_hit_test.rs b/tests/issue_850_answer_sheet_name_hit_test.rs index 5be1f24c1..a903696f6 100644 --- a/tests/issue_850_answer_sheet_name_hit_test.rs +++ b/tests/issue_850_answer_sheet_name_hit_test.rs @@ -118,3 +118,33 @@ fn issue_850_exam_science_answer_sheet_name_cell_keeps_outer_path() { let mut doc = load_sample("exam_science.hwp"); assert_name_insert_by_path(&mut doc, "exam_science.hwp", 6, &[(6, 0, 3), (0, 1, 0)]); } + +#[test] +fn issue_850_exam_social_overlay_images_api_stays_compact_for_input_loop() { + let doc = load_sample("exam_social.hwp"); + let overlay_json = doc + .get_page_overlay_images_native(0) + .expect("overlay image json"); + let layer_json = doc + .get_page_layer_tree_native(0) + .expect("full page layer tree json"); + let overlay: Value = serde_json::from_str(&overlay_json) + .unwrap_or_else(|e| panic!("parse overlay json `{overlay_json}`: {e}")); + + assert_eq!(overlay["behind"].as_array().map(Vec::len), Some(0)); + assert_eq!(overlay["front"].as_array().map(Vec::len), Some(0)); + assert!( + overlay["imageCount"].as_u64().unwrap_or(0) > 0, + "flow images must still be counted for decode retry scheduling: {overlay_json}" + ); + assert!( + overlay_json.len() < 128, + "input loop overlay JSON must remain compact: len={}, json={overlay_json}", + overlay_json.len() + ); + assert!( + layer_json.len() > 1_000_000, + "test fixture should demonstrate the avoided full layer JSON cost: len={}", + layer_json.len() + ); +}