diff --git a/mydocs/orders/20260626.md b/mydocs/orders/20260626.md index 546edb3be..4ec4a641f 100644 --- a/mydocs/orders/20260626.md +++ b/mydocs/orders/20260626.md @@ -4,6 +4,7 @@ | Issue | 타스크 | 상태 | 비고 | |------|--------|------|------| +| #1562 | HWPX 폼 컨트롤 caption `&&` 표시 정합 보정 | 진행중 | `local/task1562`, 저장값 보존 + 표시 계층 보정 | | #1270 | HWPX 캡션 내 인라인 이미지 렌더링 누락 정정 | 완료 | 인라인 depth 1 구현·검증 완료, 플로팅은 후속. 완료: 10:58 | ## 공통 — 운영 작업 diff --git a/mydocs/plans/task_m100_1562.md b/mydocs/plans/task_m100_1562.md new file mode 100644 index 000000000..59a3eb1fa --- /dev/null +++ b/mydocs/plans/task_m100_1562.md @@ -0,0 +1,140 @@ +# 수행계획서 — Task #1562 + +> HWPX 폼 컨트롤 caption `&&`가 한컴과 다르게 `&&`로 표시됨 + +- **이슈**: [#1562](https://github.com/edwardkim/rhwp/issues/1562) +- **Parent**: [#1534](https://github.com/edwardkim/rhwp/issues/1534) +- **Review context**: [PR #1536](https://github.com/edwardkim/rhwp/pull/1536) +- **마일스톤**: v1.0.0 (M100) +- **브랜치**: `local/task1562` (base: `local/devel`) +- **작성일**: 2026-06-26 + +--- + +## 1. 배경 / 문제 + +#1534 / PR #1536에서 HWPX 폼 컨트롤 `caption` 속성의 XML escape 누적 손상은 +해결했다. 그 결과 저장 모델과 XML 직렬화는 다음 상태를 안정적으로 유지한다. + +| 계층 | 값 | +|------|----| +| XML 원문 | `caption="IP R&&D연계"` | +| XML attribute decode 후 저장값 | `IP R&&D연계` | +| roundtrip 후 XML | `caption="IP R&&D연계"` 유지 | + +남은 문제는 **표시 계층**이다. 한컴 뷰어는 동일 원본을 `IP R&D연계`로 표시하지만, +현재 rhwp 렌더러는 저장값을 그대로 출력하여 `IP R&&D연계`로 보인다. + +이 이슈는 XML escaping 문제가 아니므로 parser/serializer에서 `&&`를 `&`로 바꾸면 +안 된다. 저장값은 보존하고, 폼 컨트롤 caption을 화면에 그릴 때만 한컴 표시 결과와 +맞춘다. + +## 2. 현재 코드 조사 결과 + +폼 caption은 HWPX 파싱 후 `FormObject.caption`에 저장되고, layout 단계에서 +`FormObjectNode.caption`으로 복사된 뒤 각 렌더러가 직접 출력한다. + +| 영역 | 확인 위치 | 현재 동작 | +|------|-----------|-----------| +| HWPX parse | `src/parser/hwpx/section.rs` `parse_form_object` | `caption` 속성을 `form.caption`에 저장 | +| HWPX serialize | `src/serializer/hwpx/form.rs` | `form.caption`을 XML attribute로 재출력 | +| Layout/render tree | `src/renderer/layout/paragraph_layout.rs` | `f.caption.clone()`을 `FormObjectNode.caption`에 복사 | +| SVG | `src/renderer/svg.rs` `render_form_object` | `escape_xml(&form.caption)` 직접 출력 | +| Web Canvas | `src/renderer/web_canvas.rs` `render_form_object` | `ctx.fill_text(&form.caption, ...)` 직접 출력 | +| Skia | `src/renderer/skia/renderer.rs` `draw_form_control` | `canvas.draw_str(&form.caption, ...)` 직접 출력 | + +따라서 수정 후보는 parser/serializer가 아니라 **폼 caption 표시 문자열 생성 지점**이다. + +## 3. 해결 방향 + +### 권장 방향 + +폼 컨트롤 caption 전용 display helper를 도입한다. + +- 저장 모델: `FormObject.caption` / `FormObjectNode.caption` 값은 그대로 유지 +- 직렬화: `caption="R&&D"` 형태 유지 +- 표시: 폼 caption을 출력하기 직전에 helper를 통해 `R&&D`를 `R&D`로 변환 +- 적용 대상: PushButton, CheckBox, RadioButton의 `caption` +- 비적용 대상: 본문 텍스트, 표/그림/도형 ``, combo/edit `text`, 모든 XML attribute 전역 + +### `&` 처리 정책 + +1차 구현은 #1562에서 관측된 **double ampersand 표시 escape**를 고정한다. + +- `&&` → `&` +- 일반 본문과 저장값에는 적용하지 않음 +- 단일 `&`의 access-key prefix 제거/밑줄 표시는 한컴 실물 샘플 근거가 추가로 확인될 때 + 구현계획서에서 확장 여부 판단 + +단일 `&`까지 추정 처리하면 실제 literal `&` caption을 손상시킬 수 있으므로, 이번 이슈의 +완료 조건은 `R&&D` 계열 표시 정합으로 제한한다. + +## 4. 구현 후보 + +구현계획서에서 다음 중 하나를 확정한다. + +| 안 | 내용 | 장점 | 리스크 | +|----|------|------|--------| +| A (권장) | `FormObjectNode` 또는 renderer 공통 모듈에 `display_form_caption()` 추가 후 SVG/Web Canvas/Skia에서 사용 | 저장값 보존과 표시 변환 분리, 중복 최소화 | 호출 누락 시 렌더러 간 차이 발생 | +| B | layout 단계에서 `FormObjectNode`에 `display_caption` 필드 추가 | 렌더러는 단순해짐 | render tree JSON/API에서 저장값과 표시값 혼동 가능 | +| C | 각 렌더러에서 local replace 적용 | 변경 최소 | 중복, 향후 규칙 확장 시 누락 위험 | + +권장은 A다. `FormObjectNode.caption`은 저장/원본 의미를 유지하고, 렌더러가 그릴 때만 +공통 helper를 호출한다. + +## 5. 검증 전략 + +1. **표시 helper 단위 테스트** + - `R&&D` → `R&D` + - `IP R&&D연계` → `IP R&D연계` + - `R&&D 자율성트랙(일반)` → `R&D 자율성트랙(일반)` + - `R&D` 같은 단일 `&` 입력은 1차 구현에서 보존 +2. **SVG 렌더 회귀** + - `samples/hwpx/form-002.hwpx` page 0 SVG에 `R&D`가 있고 `R&&D`가 없는지 확인 + - `cargo test --test svg_snapshot form_002` 통과 + - 의도된 visual diff로 `tests/golden_svg/form-002/page-0.svg` 갱신 +3. **저장값 보존 회귀** + - `cargo test --test issue_1534_hwpx_form_caption_escape` 통과 유지 + - roundtrip 후 XML caption이 `R&&D` 형태를 유지하는지 확인 +4. **렌더러별 확인** + - SVG renderer + - Web Canvas renderer + - Skia native renderer + - user-visible text/HTML/Markdown export 경로가 실제 caption을 출력하는 경우 동일 정책 적용 여부 확인 +5. **기본 게이트** + - `cargo fmt --check` + - 관련 targeted tests + - 필요 시 `cargo clippy --all-targets -- -D warnings` + +## 6. 산출물 + +- 구현계획서: `mydocs/plans/task_m100_1562_impl.md` +- 소스 수정: 렌더러 공통 helper 및 SVG/Web Canvas/Skia caption 출력 경로 +- 테스트: helper 단위 테스트 또는 targeted integration test +- Golden 갱신: `tests/golden_svg/form-002/page-0.svg` +- 단계별 완료보고서: `mydocs/working/task_m100_1562_stage{N}.md` +- 최종 보고서: `mydocs/report/task_m100_1562_report.md` + +## 7. 리스크 + +- **저장값 손상**: parser/serializer에서 변환하면 #1534 해결을 되돌릴 수 있음. + 구현계획서에서 저장 계층 비수정 원칙을 다시 확인한다. +- **렌더러 누락**: SVG만 고치면 Canvas/Skia와 표시가 달라질 수 있음. 공통 helper와 + 호출 지점 목록으로 방지한다. +- **단일 `&` 추정 처리**: 근거 없이 mnemonic prefix 전체 규칙을 적용하면 literal `&` + caption을 손상시킬 수 있음. 이번 단계에서는 `&&` 표시 escape에 한정한다. +- **Golden diff 오판**: SVG golden 변경은 `R&&D` → `R&D` 텍스트 차이가 의도된 변경인지 + 확인한 뒤 반영한다. + +## 8. 단계 개략 + +1. 구현계획서 작성 — helper 위치, 호출 지점, 테스트 파일 확정 +2. Stage 1 — 표시 helper + red/green 테스트 작성 +3. Stage 2 — SVG/Web Canvas/Skia 적용 + golden 갱신 +4. Stage 3 — #1534 저장 안정성 회귀와 렌더 targeted 검증 +5. Stage 4 — 최종 보고서 작성 및 PR 준비 + +--- + +> 본 수행계획서 승인 후 구현계획서(`task_m100_1562_impl.md`)를 작성하여 재승인 요청한다. +> 소스 수정은 구현계획서 승인 전까지 착수하지 않는다. diff --git a/mydocs/plans/task_m100_1562_impl.md b/mydocs/plans/task_m100_1562_impl.md new file mode 100644 index 000000000..eb75ebd0e --- /dev/null +++ b/mydocs/plans/task_m100_1562_impl.md @@ -0,0 +1,266 @@ +# 구현계획서 — Task #1562 + +> HWPX 폼 컨트롤 caption `&&`가 한컴과 다르게 `&&`로 표시됨 — 해결 구현 + +- **이슈**: [#1562](https://github.com/edwardkim/rhwp/issues/1562) +- **브랜치**: `local/task1562` (base: `local/devel`) +- **수행계획서**: [`task_m100_1562.md`](task_m100_1562.md) +- **작성일**: 2026-06-26 + +--- + +## 1. 채택 방향 — A안, renderer 공통 display helper + +수행계획서의 A/B/C 중 **A안**을 채택한다. + +저장 모델과 표시 문자열을 분리한다. + +- `FormObject.caption` / `FormObjectNode.caption`은 저장값 그대로 둔다. +- HWPX parser/serializer는 수정하지 않는다. +- 폼 컨트롤 caption을 사용자에게 그리는 renderer 직전에서만 display helper를 호출한다. +- helper는 `&&`를 literal `&` 표시 escape로 해석해 `&` 한 글자로 접는다. + +이번 구현은 관측된 한컴 표시 결과와 #1562 완료 조건에 맞춰 **`&&` collapse에 한정**한다. +단일 `&`를 mnemonic prefix로 제거하거나 밑줄 표시하는 동작은 이번 PR 범위에서 제외한다. + +## 2. Helper 설계 + +신규 모듈: + +```text +src/renderer/form_caption.rs +``` + +`src/renderer/mod.rs`에 다음 모듈을 추가한다. + +```rust +pub(crate) mod form_caption; +``` + +API: + +```rust +pub(crate) fn display_form_caption(caption: &str) -> std::borrow::Cow<'_, str> +``` + +동작: + +| 입력 | 출력 | 비고 | +|------|------|------| +| `R&&D` | `R&D` | #1562 핵심 | +| `IP R&&D연계` | `IP R&D연계` | form-002 | +| `R&&D 자율성트랙(일반)` | `R&D 자율성트랙(일반)` | form-002 | +| `R&D` | `R&D` | 단일 `&`는 보존 | +| `&&&&` | `&&` | 좌→우 paired collapse | +| `abc` | borrowed `abc` | `&&`가 없으면 allocation 없음 | + +구현 기준: + +- `caption.contains("&&") == false`이면 `Cow::Borrowed(caption)` 반환 +- `&&`가 있으면 char 단위로 좌→우 순회하며 paired `&&`를 `&`로 접는다. +- single `&`는 그대로 둔다. + +단위 테스트는 같은 모듈의 `#[cfg(test)]` 테스트로 둔다. + +## 3. 적용 지점 + +### SVG renderer + +파일: `src/renderer/svg.rs` + +현재: + +- PushButton: `escape_xml(&form.caption)` +- CheckBox: `escape_xml(&form.caption)` +- RadioButton: `escape_xml(&form.caption)` + +변경: + +- `display_form_caption(&form.caption)` 결과를 만든 뒤 `escape_xml(display.as_ref())`로 출력 +- PushButton의 caption empty fallback인 `form.name`은 caption이 아니므로 변환하지 않는다. + +### Web Canvas renderer + +파일: `src/renderer/web_canvas.rs` + +현재: + +- `ctx.fill_text(&form.caption, ...)` + +변경: + +- PushButton/CheckBox/RadioButton caption 출력 전에 `display_form_caption(&form.caption)` 사용 +- `fill_text(display.as_ref(), ...)` 호출 + +### Skia renderer + +파일: `src/renderer/skia/renderer.rs` + +현재: + +- PushButton label 측정/출력: `form.caption` +- CheckBox/RadioButton 출력: `form.caption` + +변경: + +- caption이 있을 때만 `display_form_caption(&form.caption)` 사용 +- PushButton fallback `form.name`은 변환하지 않는다. +- `measure_str()`와 `draw_str()` 모두 display string 기준으로 수행해 text width와 실제 출력이 일치하도록 한다. + +## 4. 테스트 계획 + +### Stage 1 — red 테스트 추가 + +신규 테스트: + +```text +tests/issue_1562_hwpx_form_caption_display.rs +``` + +테스트 내용: + +1. `samples/hwpx/form-002.hwpx` page 0을 `HwpDocument::render_page_svg_native(0)`로 렌더링한다. +2. SVG 출력에 다음이 포함되는지 확인한다. + - `IP R&D연계` + - `R&D 자율성트랙(일반)` + - `R&D 자율성트랙(지정)` +3. SVG 출력에 다음이 없어야 한다. + - `R&&D` + +현재 코드에서는 `R&&D`가 남으므로 red가 되어야 한다. + +산출물: + +- `tests/issue_1562_hwpx_form_caption_display.rs` +- `mydocs/working/task_m100_1562_stage1.md` + +커밋: + +```text +test: add issue 1562 form caption display regression +``` + +### Stage 2 — helper + renderer 적용 + +변경 파일: + +- `src/renderer/mod.rs` +- `src/renderer/form_caption.rs` +- `src/renderer/svg.rs` +- `src/renderer/web_canvas.rs` +- `src/renderer/skia/renderer.rs` + +검증: + +- `cargo test --test issue_1562_hwpx_form_caption_display` +- `cargo test --lib renderer::form_caption` + +산출물: + +- `mydocs/working/task_m100_1562_stage2.md` + +커밋: + +```text +fix(renderer): display escaped form caption ampersands +``` + +### Stage 3 — SVG golden 갱신 + 회귀 검증 + +예상 변경: + +- `tests/golden_svg/form-002/page-0.svg` + +절차: + +1. `UPDATE_GOLDEN=1 cargo test --test svg_snapshot form_002` +2. `cargo test --test svg_snapshot form_002` +3. golden diff에서 `R&&D` → `R&D` 텍스트 변경만 의도된 범위인지 확인 + +저장 안정성 회귀: + +- `cargo test --test issue_1534_hwpx_form_caption_escape` + +renderer targeted: + +- `cargo test --test issue_1562_hwpx_form_caption_display` +- `cargo fmt --check` + +필요 시: + +- `cargo clippy --all-targets -- -D warnings` + +산출물: + +- `tests/golden_svg/form-002/page-0.svg` +- `mydocs/working/task_m100_1562_stage3.md` + +커밋: + +```text +test(svg): update form caption display golden +``` + +### Stage 4 — 최종 보고서 + +파일: + +```text +mydocs/report/task_m100_1562_report.md +``` + +포함 내용: + +- 원인: 저장값을 표시값으로 그대로 사용 +- 수정: form caption display helper + renderer 적용 +- 저장값 보존 확인: #1534 테스트 통과 +- 표시 확인: #1562 테스트와 SVG golden diff +- 잔여 리스크: 단일 `&` mnemonic prefix 해석은 미적용 +- #1534 parent issue close 판단: #1562 완료 후 함께 close 가능 여부 + +커밋: + +```text +docs: report task 1562 completion +``` + +## 5. 변경 파일 목록 + +| 파일 | 변경 | +|------|------| +| `src/renderer/mod.rs` | `form_caption` 모듈 추가 | +| `src/renderer/form_caption.rs` | form caption display helper + 단위 테스트 | +| `src/renderer/svg.rs` | form caption 출력 시 helper 사용 | +| `src/renderer/web_canvas.rs` | form caption 출력 시 helper 사용 | +| `src/renderer/skia/renderer.rs` | form caption 측정/출력 시 helper 사용 | +| `tests/issue_1562_hwpx_form_caption_display.rs` | SVG 표시 회귀 테스트 | +| `tests/golden_svg/form-002/page-0.svg` | 의도된 표시 diff 반영 | +| `mydocs/working/task_m100_1562_stage{1..3}.md` | 단계별 완료보고서 | +| `mydocs/report/task_m100_1562_report.md` | 최종 보고서 | + +## 6. Definition of Done + +1. `FormObject.caption` 저장값은 `R&&D` 형태로 유지된다. +2. HWPX roundtrip XML은 `R&&D` 형태를 유지한다. +3. SVG 출력은 `R&D`로 표시하고 `R&&D`를 남기지 않는다. +4. Web Canvas와 Skia도 동일 helper를 사용한다. +5. `cargo test --test issue_1562_hwpx_form_caption_display` 통과. +6. `cargo test --test issue_1534_hwpx_form_caption_escape` 통과. +7. `cargo test --test svg_snapshot form_002` 통과. +8. `cargo fmt --check` 통과. + +## 7. 제외 범위 + +- HWPX parser/serializer의 `caption` 저장값 변환 +- 모든 XML attribute의 전역 `&&` 치환 +- 본문 텍스트의 `&&` 변환 +- 표/그림/도형 `` 변환 +- 단일 `&` mnemonic prefix 제거 또는 access-key 밑줄 표시 +- 새 한컴 샘플 생성/추가 + +## 8. 승인 게이트 + +본 구현계획서 승인 전까지 소스 수정은 하지 않는다. + +승인 후 Stage 1부터 진행하며, 각 stage 완료 시 단계보고서 작성 후 다음 단계 진행 +승인을 요청한다. diff --git a/mydocs/pr/archives/pr_1565_review.md b/mydocs/pr/archives/pr_1565_review.md new file mode 100644 index 000000000..b5dd8f1ea --- /dev/null +++ b/mydocs/pr/archives/pr_1565_review.md @@ -0,0 +1,246 @@ +# PR #1565 검토 문서 — HWPX 폼 컨트롤 caption 표시 정합 보정 + +## 1. PR 메타 + +| 항목 | 내용 | +|------|------| +| PR | #1565 — Task #1562: HWPX 폼 컨트롤 caption `&&` 표시 정합 보정 | +| 작성자 | @postmelee | +| base | `devel` | +| head | `postmelee:local/task1562` | +| 검토 경로 | collaborator self-merge 후보 예외 경로 | +| 문서 작성 위치 | `mydocs/pr/archives/pr_1565_review.md` | +| 문서 작성일 | 2026-06-27 | + +작성 시점 참고값: + +| 항목 | 값 | +|------|----| +| 최신 head SHA | `ceb736b30f5482b66a52b68aabcec357cdd8a6dd` | +| draft | false | +| mergeable | MERGEABLE | +| merge state | CLEAN | +| review decision | 없음 | +| labels | `hwpx`, `rendering`, `regression` | +| milestone | `v1.0.0` | +| assignee | @postmelee | + +위 값은 문서 작성 시점 참고값이다. merge 전에는 최신 PR head, mergeable 상태, GitHub Actions 상태를 반드시 다시 확인한다. + +## 2. 경로 판정 + +이 PR은 외부 contributor PR이 아니라 collaborator 본인 PR이다. 따라서 `mydocs/manual/pr_review_workflow.md` 8장의 collaborator self-merge 후보 예외 경로를 적용한다. + +- PR 번호가 이미 확정되어 review 문서를 PR head에 포함할 수 있다. +- merge 후 별도 문서 커밋을 만들지 않기 위해 archive 경로의 review 문서를 PR diff에 포함한다. +- 작업지시자 승인 전에는 approve review, merge, issue close를 수행하지 않는다. +- 작성자와 reviewer 계정이 같으므로 별도 review request는 등록하지 않는다. + +이번 PR은 별도 `pr_1565_review_impl.md`를 만들지 않고, 이 문서에 리뷰 계획과 검토 결과를 통합한다. + +## 3. 관련 이슈 + +| 이슈 | 상태 | 비고 | +|------|------|------| +| #1562 — HWPX 폼 컨트롤 caption `&&`가 한컴과 다르게 `&&`로 표시됨 | OPEN | PR 본문에 `Closes #1562`로 연결됨 | +| #1534 — HWPX 저장 시 폼 컨트롤 속성값 XML 특수문자 이중 이스케이프 누적 | OPEN | PR 본문에 `Refs #1534`로 연결됨. #1536과 이 PR merge 후 close 판단 가능 | + +#1562는 이 PR의 직접 해결 대상이다. #1534는 PR #1536에서 저장 계층의 XML escape 누적 손상이 해결됐고, 이 PR은 남아 있던 표시 계층 호환성 문제를 해결한다. + +## 4. 변경 범위 + +핵심 변경: + +- `src/renderer/form_caption.rs`에 form caption 표시 전용 helper `display_form_caption()` 추가 +- form caption 표시 문자열에서만 `&&`를 literal `&`로 접음 +- 단일 `&`는 보존하고, `&&`가 없는 caption은 `Cow::Borrowed`로 반환 +- SVG, Web Canvas, Skia renderer의 PushButton, CheckBox, RadioButton caption 출력 경로에 helper 적용 +- HWPX parser, serializer, 저장 모델의 caption 값은 변경하지 않음 +- `samples/hwpx/form-002.hwpx` 기반 SVG 표시 회귀 테스트 추가 +- `tests/golden_svg/form-002/page-0.svg` 갱신 +- task 계획, 단계 보고, 최종 보고 문서 추가 + +변경 파일: + +| 파일 | 검토 요약 | +|------|-----------| +| `src/renderer/form_caption.rs` | 표시 전용 `&&` collapse helper와 단위 테스트 추가 | +| `src/renderer/mod.rs` | helper module 등록 | +| `src/renderer/svg.rs` | form caption SVG text 출력 직전 display helper 적용 | +| `src/renderer/web_canvas.rs` | canvas `fill_text` 직전 display helper 적용 | +| `src/renderer/skia/renderer.rs` | Skia form caption draw path에 display helper 적용 | +| `tests/issue_1562_hwpx_form_caption_display.rs` | `form-002.hwpx` page 0 SVG의 `R&D` 표시 회귀 고정 | +| `tests/golden_svg/form-002/page-0.svg` | 폼 caption 3곳의 golden을 `R&D` 표시로 갱신 | +| `mydocs/**/task_m100_1562*` | 내부 task 계획, 단계 보고, 최종 보고 | +| `mydocs/orders/20260626.md` | task 진행 기록. 최신 devel과 add/add 충돌 해소 완료 | + +## 5. 범위 밖 + +이번 PR은 다음을 변경하지 않는다. + +- HWPX 저장값 변환 (`R&&D` 저장값 유지) +- XML attribute 전역 치환 +- serializer에서 `&&`를 `&`로 바꾸는 처리 +- 일반 본문 텍스트의 `&&` 표시 처리 +- 표, 그림, 도형의 일반 `` 표시 처리 +- 단일 `&`를 mnemonic prefix로 숨기거나 access-key 밑줄을 표시하는 처리 + +단일 `&`의 mnemonic 처리까지 확장하려면 한컴 실물 샘플과 표시 근거를 추가로 확보한 뒤 별도 이슈로 다루는 편이 안전하다. + +## 6. 리뷰 계획 + +검토는 다음 순서로 진행했다. + +1. PR metadata 확인 + - base/head, 작성자, label, milestone, assignee, mergeable, check rollup 확인 +2. 경로 판정 + - 외부 contributor PR이 아니라 collaborator self-merge 후보 예외 경로로 분류 +3. 변경 코드 검토 + - display helper가 저장 계층이 아닌 표시 계층에만 적용되는지 확인 + - SVG, Web Canvas, Skia form caption 경로에만 영향이 제한되는지 확인 +4. 테스트 검토 + - 신규 SVG 표시 회귀 테스트가 `R&&D` 잔존을 막는지 확인 + - #1534 저장 escape 회귀 테스트와 충돌하지 않는지 확인 +5. 충돌 확인 + - 최신 `upstream/devel`과 최초 merge simulation에서 `mydocs/orders/20260626.md` add/add 충돌 확인 + - 작업지시자가 conflict 해소 후 최신 head `ceb736b3` 기준 `MERGEABLE/CLEAN` 확인 +6. 검증 + - 로컬 targeted test와 GitHub Actions 최신 결과 확인 + +## 7. 코드 리뷰 결과 + +### 7.1 표시값과 저장값 분리 + +`display_form_caption()`은 form caption의 사용자 표시 문자열만 변환한다. helper는 `&&`가 있을 때만 새 문자열을 만들고, 그렇지 않으면 borrowed 값을 반환한다. parser/serializer/storage 경로에는 연결되지 않는다. + +판정: 적절함. #1534의 저장값 보존 요구와 충돌하지 않는다. + +### 7.2 변환 규칙 + +현재 규칙은 `&&`를 literal `&` 한 글자로 표시하는 데 한정된다. 단일 `&`는 그대로 보존한다. + +검증 예: + +- `R&&D` -> `R&D` +- `IP R&&D연계` -> `IP R&D연계` +- `&&&&` -> `&&` +- `R&D` -> `R&D` + +판정: 이번 이슈의 한컴 뷰어 관측 결과와 UI caption escape 관례에 맞다. 단일 `&` mnemonic 처리는 근거가 부족하므로 제외한 판단이 안전하다. + +### 7.3 렌더러 적용 범위 + +helper 적용 지점은 form control caption을 사용자에게 그리는 경로로 제한되어 있다. + +- SVG: `` 출력 전 `escape_xml(display_caption)` 적용 +- Web Canvas: `fill_text(display_caption)` 적용 +- Skia: `draw_str(display_caption)` 적용 + +PushButton의 name fallback은 caption이 비어 있을 때만 사용되며, name에는 이번 caption 표시 규칙을 적용하지 않는다. CheckBox와 RadioButton은 caption이 있을 때만 helper를 거친다. + +판정: 변경 범위가 좁고 의도와 일치한다. + +### 7.4 테스트 적합성 + +신규 테스트 `tests/issue_1562_hwpx_form_caption_display.rs`는 `samples/hwpx/form-002.hwpx` page 0을 SVG로 렌더링한 뒤 다음을 직접 확인한다. + +- `IP R&D연계` 포함 +- `R&D 자율성트랙(일반)` 포함 +- `R&D 자율성트랙(지정)` 포함 +- `R&&D` 미포함 + +기존 #1534 회귀 테스트도 함께 통과해 저장 XML의 double escape 방지와 표시 계층 보정이 분리되어 있음을 확인했다. + +판정: 이번 변경 범위에 맞는 직접 회귀 테스트다. + +## 8. 검증 결과 + +### 8.1 GitHub Actions + +최신 head SHA `ceb736b30f5482b66a52b68aabcec357cdd8a6dd` 기준 GitHub check rollup: + +| Check | 상태 | +|-------|------| +| CI preflight | SUCCESS | +| CodeQL preflight | SUCCESS | +| Render Diff preflight | SUCCESS | +| Build & Test | SUCCESS | +| CodeQL | SUCCESS | +| Analyze (javascript-typescript) | SUCCESS | +| Analyze (python) | SUCCESS | +| Analyze (rust) | SUCCESS | +| Canvas visual diff | SUCCESS | +| WASM Build | SKIPPED | + +이 review 문서 커밋이 PR head에 추가되면 최신 head SHA가 다시 바뀐다. 따라서 merge 전에는 최신 PR head 기준 GitHub Actions 통과 또는 문서 전용 후속 커밋 fast-pass 조건을 다시 확인해야 한다. + +### 8.2 로컬 검증 + +초기 검토 head `f7ec8a0aebef56c846a760e52bc41ee394997b5b` 기준 로컬 검증: + +| 명령 | 결과 | +|------|------| +| `cargo test --lib renderer::form_caption` | 통과 — 3 passed | +| `cargo test --test issue_1562_hwpx_form_caption_display` | 통과 — 1 passed | +| `cargo test --test issue_1534_hwpx_form_caption_escape` | 통과 — 4 passed | +| `cargo test --test svg_snapshot form_002` | 통과 — 1 passed | +| `cargo fmt --check` | 통과 | +| `cargo clippy --all-targets -- -D warnings` | 통과 | +| `git diff --check 8d2d78c897eef937032bb982e37e044e19e96905..HEAD` | 통과 | + +conflict 해소 후 최신 head `ceb736b30f5482b66a52b68aabcec357cdd8a6dd` 기준 재확인: + +| 명령 | 결과 | +|------|------| +| `cargo test --test issue_1562_hwpx_form_caption_display` | 통과 — 1 passed | + +### 8.3 시각 검증 + +로컬 SVG export 산출물: + +```text +/private/tmp/rhwp-pr1565-review/output/poc/pr1565/form-002_001.svg +``` + +확인 결과: + +- `IP R&D연계` 포함 +- `R&D 자율성트랙(일반)` 포함 +- `R&D 자율성트랙(지정)` 포함 +- `R&&D` 미검출 + +## 9. 잔여 리스크 + +| 리스크 | 판단 | +|--------|------| +| 단일 `&` mnemonic 처리 미지원 | 의도된 범위 밖. 한컴 실물 근거 확보 후 별도 이슈로 분리하는 편이 안전 | +| Web Canvas/Skia의 별도 pixel artifact 부재 | 동일 helper를 적용했고 GitHub Canvas visual diff가 최신 head에서 SUCCESS | +| 문서 커밋 후 head SHA 변경 | merge 전 latest checks 또는 review-doc fast-pass 조건 재확인 필요 | +| #1534 수동 close 여부 | #1536과 이 PR merge 후 작업지시자 승인 하에 판단 필요 | + +현재 PR 범위에서 merge를 막는 잔여 코드 이슈는 발견하지 못했다. + +## 10. 최종 권고 + +권고: merge 준비 가능. + +단, 실제 merge 전 최종 조건은 다음을 모두 만족해야 한다. + +- 최신 PR head 기준 GitHub Actions 통과 또는 문서 전용 후속 커밋 fast-pass 조건 충족 +- `mydocs/pr/archives/pr_1565_review.md`가 PR diff에 포함됨 +- merge 직전 `mergeable` / `mergeStateStatus` 재확인 +- 작업지시자 merge 승인 +- GitHub review 또는 PR comment를 남길지 작업지시자 최종 확인 +- merge 후 #1562 auto-close 여부 확인 +- #1534는 #1536과 #1565 반영 후 작업지시자 승인 하에 close 여부 판단 + +## 11. merge 후 확인 계획 + +merge 후에는 다음을 확인한다. + +1. PR #1565 merge commit SHA와 merged timestamp 확인 +2. #1562 state 확인 +3. #1562가 자동 close되지 않았으면 작업지시자 승인 후 수동 close comment 작성 +4. #1534 state 확인 +5. #1534는 #1536과 #1565로 해결 범위가 충족됐는지 작업지시자 승인 후 close 판단 +6. 필요 시 PR merge comment에 검증 결과와 #1534 처리 계획을 남김 diff --git a/mydocs/report/task_m100_1562_report.md b/mydocs/report/task_m100_1562_report.md new file mode 100644 index 000000000..2cb8b6521 --- /dev/null +++ b/mydocs/report/task_m100_1562_report.md @@ -0,0 +1,152 @@ +# 최종 결과보고서 — Task #1562 + +> HWPX 폼 컨트롤 caption `&&`가 한컴과 다르게 `&&`로 표시됨 — 해결 + +- **이슈**: [#1562](https://github.com/edwardkim/rhwp/issues/1562) +- **Parent**: [#1534](https://github.com/edwardkim/rhwp/issues/1534) +- **마일스톤**: v1.0.0 (M100) +- **브랜치**: `local/task1562` (base: `local/devel`) +- **작성일**: 2026-06-26 +- **상태**: 구현·검증 완료 (PR 생성·이슈 클로즈 승인 대기) + +--- + +## 1. 문제 + +#1534 / PR #1536에서 HWPX 폼 컨트롤 caption의 XML escape 누적 손상은 해결했지만, +표시 계층에는 별도 문제가 남아 있었다. + +`samples/hwpx/form-002.hwpx` 원본은 다음 저장값을 가진다. + +| 계층 | 값 | +|------|----| +| XML 원문 | `caption="IP R&&D연계"` | +| 저장 모델 | `IP R&&D연계` | +| 한컴 뷰어 표시 | `IP R&D연계` | +| 기존 rhwp 표시 | `IP R&&D연계` | + +즉 XML/저장 계층에서는 `&&`가 유지되어야 하지만, 폼 UI caption 표시에서는 `&&`가 +literal `&` 한 글자로 표시되어야 한다. + +## 2. 원인 + +rhwp 렌더러가 `FormObject.caption` 저장값을 표시 문자열로 그대로 사용했다. + +| 영역 | 기존 동작 | +|------|-----------| +| SVG renderer | `escape_xml(&form.caption)` 직접 출력 | +| Web Canvas renderer | `ctx.fill_text(&form.caption, ...)` 직접 출력 | +| Skia renderer | `measure_str(&form.caption)` / `draw_str(&form.caption, ...)` 직접 사용 | + +`caption` 저장값과 사용자 표시값이 분리되어 있지 않아, 한컴 폼 caption 관례로 보이는 +`&&` 표시 escape가 적용되지 않았다. + +## 3. 해결 + +폼 컨트롤 caption 전용 표시 helper를 추가하고, 사용자에게 caption을 그리는 renderer +경로에서만 적용했다. + +신규 helper: + +```rust +pub(crate) fn display_form_caption(caption: &str) -> Cow<'_, str> +``` + +정책: + +- `&&` → `&` +- 단일 `&`는 보존 +- `&&`가 없으면 borrowed 반환으로 allocation 없음 +- 저장 모델과 HWPX serializer는 변경하지 않음 + +적용 범위: + +- PushButton caption +- CheckBox caption +- RadioButton caption +- SVG / Web Canvas / Skia renderer + +제외 범위: + +- HWPX parser/serializer +- 본문 텍스트 +- 표/그림/도형 `` +- ComboBox/Edit `text` +- 단일 `&` mnemonic prefix 제거 또는 access-key 밑줄 표시 + +## 4. 검증 + +| 검사 | 결과 | +|------|------| +| `cargo test --lib renderer::form_caption` | 3/3 통과 | +| `cargo test --test issue_1562_hwpx_form_caption_display` | 1/1 통과 | +| `env UPDATE_GOLDEN=1 cargo test --test svg_snapshot form_002` | 통과 | +| `cargo test --test svg_snapshot form_002` | 통과 | +| `cargo test --test issue_1534_hwpx_form_caption_escape` | 4/4 통과 | +| `cargo fmt --check` | 통과 | +| `cargo clippy --all-targets -- -D warnings` | 통과 | + +SVG golden 변경은 다음 3줄로 제한됐다. + +- `IP R&&D연계` → `IP R&D연계` +- `R&&D 자율성트랙(일반)` → `R&D 자율성트랙(일반)` +- `R&&D 자율성트랙(지정)` → `R&D 자율성트랙(지정)` + +좌표, 폰트, 체크박스 도형, 기타 본문 텍스트 변경은 없었다. + +## 5. 저장값 보존 + +#1534 저장 안정성 테스트가 통과했으므로 이번 변경은 XML/IR roundtrip을 변경하지 않는다. + +| 항목 | 결과 | +|------|------| +| 저장 모델 | `R&&D` 유지 | +| HWPX XML | `R&&D` 유지 | +| SVG 표시 | `R&D`로 출력 | + +따라서 #1534의 XML escape 누적 방지 수정과 충돌하지 않는다. + +## 6. 변경 파일 + +| 파일 | 변경 | +|------|------| +| `src/renderer/mod.rs` | `form_caption` 모듈 추가 | +| `src/renderer/form_caption.rs` | form caption display helper + 단위 테스트 | +| `src/renderer/svg.rs` | form caption 출력 시 helper 사용 | +| `src/renderer/web_canvas.rs` | form caption 출력 시 helper 사용 | +| `src/renderer/skia/renderer.rs` | form caption 측정/출력 시 helper 사용 | +| `tests/issue_1562_hwpx_form_caption_display.rs` | SVG 표시 회귀 테스트 | +| `tests/golden_svg/form-002/page-0.svg` | 의도된 표시 diff 반영 | +| `mydocs/orders/20260626.md` | 오늘 할일 등록 | +| `mydocs/plans/task_m100_1562.md` | 수행계획서 | +| `mydocs/plans/task_m100_1562_impl.md` | 구현계획서 | +| `mydocs/working/task_m100_1562_stage{1..3}.md` | 단계별 보고서 | +| 본 보고서 | 최종 결과 | + +## 7. 커밋 이력 + +- `4e99c722` — `test: add issue 1562 form caption display regression` +- `8155f730` — `fix(renderer): display escaped form caption ampersands` +- `1396b5d9` — `test(svg): update form caption display golden` +- Stage 4 — 본 최종 보고서 + +## 8. 잔여 리스크 + +- 단일 `&`를 mnemonic prefix로 해석해 제거하거나 access-key 밑줄을 그리는 동작은 구현하지 않았다. + 한컴 샘플과 표시 근거가 추가로 확보되면 별도 이슈로 확장하는 편이 안전하다. +- `&&` 표시 규칙은 한컴 공개 스펙에 명시된 규칙이 아니라, 한컴 뷰어 표시 결과와 + 폼 UI caption 관례를 근거로 적용했다. + +## 9. 이슈 close 판단 + +#1562가 merge되면 이 sub-issue의 완료 조건은 충족된다. + +#1534 parent issue는 #1536 merge로 XML escape 누적 손상이 해결되었고, #1562로 남긴 +표시 호환성 문제까지 본 작업에서 해결되므로, #1562 PR merge 후 작업지시자 승인 하에 +#1562와 #1534를 함께 close할 수 있다. + +## 10. 결론 + +폼 컨트롤 caption 저장값과 표시값을 분리해 `R&&D` 저장값을 보존하면서 화면/SVG/Canvas/Skia +표시는 `R&D`로 맞췄다. targeted 테스트, SVG golden, #1534 저장 안정성 회귀가 모두 통과해 +PR 생성 준비가 완료됐다. diff --git a/mydocs/working/task_m100_1562_stage1.md b/mydocs/working/task_m100_1562_stage1.md new file mode 100644 index 000000000..02590331b --- /dev/null +++ b/mydocs/working/task_m100_1562_stage1.md @@ -0,0 +1,67 @@ +# Stage 1 완료보고서 — Task #1562 + +> HWPX 폼 컨트롤 caption `&&` 표시 정합 — red 테스트 추가 + +- **이슈**: [#1562](https://github.com/edwardkim/rhwp/issues/1562) +- **브랜치**: `local/task1562` +- **작성일**: 2026-06-26 + +--- + +## 1. 수행 내용 + +폼 컨트롤 caption 표시 문제를 재현하는 targeted 회귀 테스트를 추가했다. + +추가 파일: + +- `tests/issue_1562_hwpx_form_caption_display.rs` + +테스트 대상: + +- `samples/hwpx/form-002.hwpx` +- page 0 SVG 렌더 결과 + +검사 조건: + +- `IP R&&D연계` 저장값은 SVG 표시에서 `IP R&D연계`로 보여야 한다. +- `R&&D 자율성트랙(일반)`은 `R&D 자율성트랙(일반)`로 보여야 한다. +- `R&&D 자율성트랙(지정)`은 `R&D 자율성트랙(지정)`로 보여야 한다. +- SVG 출력에 `R&&D` 표시 문자열이 그대로 남으면 실패한다. + +SVG XML 문자열 기준으로는 각각 다음 escape 형태를 검사한다. + +- 기대 포함: `IP R&D연계` +- 기대 포함: `R&D 자율성트랙(일반)` +- 기대 포함: `R&D 자율성트랙(지정)` +- 금지: `R&&D` + +## 2. 현재 기대 상태 + +현 코드에서는 SVG renderer가 `form.caption` 저장값을 그대로 `escape_xml()`에 넘긴다. +따라서 `R&&D`가 SVG에서 `R&&D`로 출력되므로 Stage 1 테스트는 red가 되어야 한다. + +실행 결과: + +```text +cargo test --test issue_1562_hwpx_form_caption_display +``` + +- 결과: 실패(red) 확인 +- 실패 지점: `IP R&D연계` 기대 문자열 미검출 +- 의미: 현재 SVG 출력이 한컴 표시 문자열이 아니라 저장값 기반 `R&&D`를 그대로 표시함 + +## 3. 다음 단계 + +Stage 2에서 renderer 공통 helper를 추가한다. + +- `src/renderer/form_caption.rs` +- `src/renderer/mod.rs` +- `src/renderer/svg.rs` +- `src/renderer/web_canvas.rs` +- `src/renderer/skia/renderer.rs` + +저장값과 serializer는 수정하지 않는다. + +## 4. 승인 요청 + +Stage 1 red 확인 후 Stage 2 구현으로 진행한다. diff --git a/mydocs/working/task_m100_1562_stage2.md b/mydocs/working/task_m100_1562_stage2.md new file mode 100644 index 000000000..856f0cea2 --- /dev/null +++ b/mydocs/working/task_m100_1562_stage2.md @@ -0,0 +1,87 @@ +# Stage 2 완료보고서 — Task #1562 + +> HWPX 폼 컨트롤 caption `&&` 표시 정합 — helper 및 renderer 적용 + +- **이슈**: [#1562](https://github.com/edwardkim/rhwp/issues/1562) +- **브랜치**: `local/task1562` +- **작성일**: 2026-06-26 + +--- + +## 1. 수행 내용 + +폼 컨트롤 caption 전용 표시 helper를 추가하고, 사용자에게 caption을 그리는 렌더러 +경로에 적용했다. + +변경 파일: + +- `src/renderer/mod.rs` +- `src/renderer/form_caption.rs` +- `src/renderer/svg.rs` +- `src/renderer/web_canvas.rs` +- `src/renderer/skia/renderer.rs` + +핵심 동작: + +- 저장값 `R&&D`는 그대로 둔다. +- 표시 문자열 생성 시에만 `&&`를 `&`로 접는다. +- 단일 `&`는 보존한다. +- PushButton caption이 비어 있을 때 fallback으로 쓰는 `form.name`은 caption이 아니므로 + 변환하지 않는다. + +## 2. 구현 상세 + +`src/renderer/form_caption.rs`에 다음 helper를 추가했다. + +```rust +pub(crate) fn display_form_caption(caption: &str) -> Cow<'_, str> +``` + +동작: + +- `caption`에 `&&`가 없으면 borrowed 반환으로 allocation을 피한다. +- `&&`가 있으면 char 단위 좌→우 순회로 paired `&&`만 `&`로 접는다. +- single `&`는 추정 처리하지 않고 그대로 둔다. + +적용 경로: + +- SVG: `escape_xml(display_form_caption(...).as_ref())` +- Web Canvas: `ctx.fill_text(display_form_caption(...).as_ref(), ...)` +- Skia: `measure_str()`와 `draw_str()` 모두 display string 기준 + +## 3. 검증 결과 + +실행: + +```text +cargo test --lib renderer::form_caption +cargo test --test issue_1562_hwpx_form_caption_display +cargo fmt +``` + +결과: + +- `renderer::form_caption` 단위 테스트 3개 통과 +- #1562 targeted SVG 표시 테스트 1개 통과 +- `cargo fmt` 완료 + +## 4. 다음 단계 + +Stage 3에서 SVG golden을 갱신하고, #1534 저장 안정성 회귀 테스트와 snapshot 테스트를 +함께 확인한다. + +예상 변경: + +- `tests/golden_svg/form-002/page-0.svg` + +필수 검증: + +- `UPDATE_GOLDEN=1 cargo test --test svg_snapshot form_002` +- `cargo test --test svg_snapshot form_002` +- `cargo test --test issue_1534_hwpx_form_caption_escape` +- `cargo test --test issue_1562_hwpx_form_caption_display` +- `cargo fmt --check` + +## 5. 승인 요청 + +Stage 2 구현은 targeted green 상태다. Stage 3 golden 갱신 및 회귀 검증으로 진행한다. diff --git a/mydocs/working/task_m100_1562_stage3.md b/mydocs/working/task_m100_1562_stage3.md new file mode 100644 index 000000000..f07345e55 --- /dev/null +++ b/mydocs/working/task_m100_1562_stage3.md @@ -0,0 +1,70 @@ +# Stage 3 완료보고서 — Task #1562 + +> HWPX 폼 컨트롤 caption `&&` 표시 정합 — SVG golden 갱신 및 회귀 검증 + +- **이슈**: [#1562](https://github.com/edwardkim/rhwp/issues/1562) +- **브랜치**: `local/task1562` +- **작성일**: 2026-06-26 + +--- + +## 1. 수행 내용 + +Stage 2에서 적용한 form caption 표시 helper 결과를 SVG snapshot golden에 반영했다. + +변경 파일: + +- `tests/golden_svg/form-002/page-0.svg` + +의도된 diff: + +- `IP R&&D연계` → `IP R&D연계` +- `R&&D 자율성트랙(일반)` → `R&D 자율성트랙(일반)` +- `R&&D 자율성트랙(지정)` → `R&D 자율성트랙(지정)` + +좌표, 폰트, 체크박스 도형, 기타 본문 텍스트 변경은 없었다. + +## 2. 검증 결과 + +실행: + +```text +env UPDATE_GOLDEN=1 cargo test --test svg_snapshot form_002 +cargo test --test svg_snapshot form_002 +cargo test --test issue_1534_hwpx_form_caption_escape +cargo test --test issue_1562_hwpx_form_caption_display +cargo fmt --check +cargo clippy --all-targets -- -D warnings +``` + +결과: + +- `svg_snapshot form_002`: 통과 +- #1534 저장/roundtrip 회귀: 4개 통과 +- #1562 SVG 표시 회귀: 1개 통과 +- `cargo fmt --check`: 통과 +- `cargo clippy --all-targets -- -D warnings`: 통과 + +## 3. 저장값 보존 확인 + +#1534 테스트가 통과했으므로 이번 변경은 parser/serializer 계층의 저장값을 변경하지 않는다. + +- 저장 모델: `R&&D` 유지 +- HWPX XML: `R&&D` 유지 +- 표시 계층: `R&D`로 출력 + +## 4. 다음 단계 + +Stage 4에서 최종 보고서를 작성한다. + +포함 예정: + +- 원인: 저장 caption을 표시 caption으로 그대로 사용 +- 수정: form caption display helper와 SVG/Web Canvas/Skia 적용 +- 검증: Stage 2/3 테스트 결과 +- 잔여 범위: 단일 `&` mnemonic prefix 제거/밑줄 표시는 미적용 +- #1534 parent issue close 판단 + +## 5. 승인 요청 + +Stage 3 회귀 검증이 완료되었다. Stage 4 최종 보고서 작성으로 진행한다. diff --git a/src/renderer/form_caption.rs b/src/renderer/form_caption.rs new file mode 100644 index 000000000..c1d7f6403 --- /dev/null +++ b/src/renderer/form_caption.rs @@ -0,0 +1,58 @@ +//! Form control caption display helpers. +//! +//! HWPX stores form captions with UI-caption escaping semantics. In observed +//! Hancom output, `&&` in a form caption displays as one literal `&`, while the +//! stored value and XML roundtrip must remain unchanged. + +use std::borrow::Cow; + +pub(crate) fn display_form_caption(caption: &str) -> Cow<'_, str> { + if !caption.contains("&&") { + return Cow::Borrowed(caption); + } + + let mut out = String::with_capacity(caption.len()); + let mut chars = caption.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '&' && chars.peek() == Some(&'&') { + chars.next(); + out.push('&'); + } else { + out.push(ch); + } + } + + Cow::Owned(out) +} + +#[cfg(test)] +mod tests { + use super::display_form_caption; + use std::borrow::Cow; + + #[test] + fn collapses_double_ampersand_for_form_caption_display() { + assert_eq!(display_form_caption("R&&D"), "R&D"); + assert_eq!(display_form_caption("IP R&&D연계"), "IP R&D연계"); + assert_eq!( + display_form_caption("R&&D 자율성트랙(일반)"), + "R&D 자율성트랙(일반)" + ); + assert_eq!(display_form_caption("&&&&"), "&&"); + } + + #[test] + fn preserves_single_ampersand() { + assert_eq!(display_form_caption("R&D"), "R&D"); + assert_eq!(display_form_caption("A&B&C"), "A&B&C"); + } + + #[test] + fn borrows_when_no_display_escape_exists() { + assert!(matches!( + display_form_caption("plain caption"), + Cow::Borrowed("plain caption") + )); + } +} diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index c97c1c80b..273f6a396 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -14,6 +14,7 @@ pub mod equation; pub(crate) mod equation_tac_flow; pub mod float_placement; pub mod font_metrics_data; +pub(crate) mod form_caption; pub mod height_cursor; pub mod height_measurer; pub mod html; diff --git a/src/renderer/skia/renderer.rs b/src/renderer/skia/renderer.rs index 1b7f66819..0d6095b3a 100644 --- a/src/renderer/skia/renderer.rs +++ b/src/renderer/skia/renderer.rs @@ -2,6 +2,7 @@ use skia_safe::{ paint, surfaces, Canvas, Color, EncodedImageFormat, Font, FontMgr, FontStyle, Paint, PathBuilder, PathEffect, RRect, Rect, Typeface, }; +use std::borrow::Cow; use std::collections::{BTreeSet, HashMap, HashSet}; use crate::error::HwpError; @@ -13,6 +14,7 @@ use crate::paint::{ LayerGlyphRunPaint, LayerNode, LayerNodeKind, LayerOutputOptions, PageLayerTree, PaintOp, PaintReplayPlane, ResourceArena, TextVariantQuality, }; +use crate::renderer::form_caption::display_form_caption; use crate::renderer::layer_renderer::{ LayerRasterRenderer, LayerRenderResult, RasterOutputFormat, RasterRenderOptions, RasterRenderOutput, @@ -1182,19 +1184,19 @@ impl SkiaLayerRenderer { canvas.draw_rrect(rrect, &stroke); let label = if form.caption.is_empty() { - &form.name + Cow::Borrowed(form.name.as_str()) } else { - &form.caption + display_form_caption(&form.caption) }; if !label.is_empty() { let font = self.make_form_font((h * 0.45).clamp(8.0, 14.0)); let mut tp = Paint::default(); tp.set_anti_alias(true); tp.set_color(fg_color); - let text_w = font.measure_str(label, Some(&tp)).0; + let text_w = font.measure_str(label.as_ref(), Some(&tp)).0; let tx = x + (w - text_w) / 2.0; let ty = y + h / 2.0 + font.size() * 0.35; - canvas.draw_str(label, (tx, ty), &font, &tp); + canvas.draw_str(label.as_ref(), (tx, ty), &font, &tp); } } FormType::CheckBox => { @@ -1238,13 +1240,14 @@ impl SkiaLayerRenderer { } if !form.caption.is_empty() { + let caption = display_form_caption(&form.caption); let font = self.make_form_font((h * 0.6).clamp(8.0, 13.0)); let mut tp = Paint::default(); tp.set_anti_alias(true); tp.set_color(fg_color); let tx = bx + box_size + 4.0; let ty = y + h / 2.0 + font.size() * 0.35; - canvas.draw_str(&form.caption, (tx, ty), &font, &tp); + canvas.draw_str(caption.as_ref(), (tx, ty), &font, &tp); } } FormType::RadioButton => { @@ -1274,13 +1277,14 @@ impl SkiaLayerRenderer { } if !form.caption.is_empty() { + let caption = display_form_caption(&form.caption); let font = self.make_form_font((h * 0.6).clamp(8.0, 13.0)); let mut tp = Paint::default(); tp.set_anti_alias(true); tp.set_color(fg_color); let tx = cx + r + 4.0; let ty = y + h / 2.0 + font.size() * 0.35; - canvas.draw_str(&form.caption, (tx, ty), &font, &tp); + canvas.draw_str(caption.as_ref(), (tx, ty), &font, &tp); } } FormType::ComboBox => { diff --git a/src/renderer/svg.rs b/src/renderer/svg.rs index bb24b8e48..1d61a730e 100644 --- a/src/renderer/svg.rs +++ b/src/renderer/svg.rs @@ -6,6 +6,7 @@ use super::composer::{ decode_pua_overlap_number, expand_pua_render_text, pua_to_display_text, CharOverlapInfo, }; +use super::form_caption::display_form_caption; pub(crate) use super::image_resolver::{ bmp_bytes_to_png_bytes, detect_image_mime_type, pcx_bytes_to_png_bytes, real_picture_watermark_bytes_to_hancom_tone_png_bytes, @@ -2286,10 +2287,11 @@ impl SvgRenderer { x, y, w, h)); // 캡션 텍스트 (회색, 중앙) if !form.caption.is_empty() { + let caption = display_form_caption(&form.caption); let font_size = (h * 0.55).min(12.0).max(7.0); self.output.push_str(&format!( "{}\n", - x + w / 2.0, y + h / 2.0, font_size, escape_xml(&form.caption))); + x + w / 2.0, y + h / 2.0, font_size, escape_xml(caption.as_ref()))); } } FormType::CheckBox => { @@ -2314,11 +2316,12 @@ impl SvgRenderer { } // 캡션 if !form.caption.is_empty() { + let caption = display_form_caption(&form.caption); let text_x = box_x + box_size + 3.0; let font_size = (h * 0.55).min(12.0).max(7.0); self.output.push_str(&format!( "{}\n", - text_x, y + h / 2.0, font_size, form.fore_color, escape_xml(&form.caption))); + text_x, y + h / 2.0, font_size, form.fore_color, escape_xml(caption.as_ref()))); } } FormType::RadioButton => { @@ -2339,11 +2342,12 @@ impl SvgRenderer { } // 캡션 if !form.caption.is_empty() { + let caption = display_form_caption(&form.caption); let text_x = cx + r + 3.0; let font_size = (h * 0.55).min(12.0).max(7.0); self.output.push_str(&format!( "{}\n", - text_x, y + h / 2.0, font_size, form.fore_color, escape_xml(&form.caption))); + text_x, y + h / 2.0, font_size, form.fore_color, escape_xml(caption.as_ref()))); } } FormType::ComboBox => { diff --git a/src/renderer/web_canvas.rs b/src/renderer/web_canvas.rs index 85a64b574..b5dbf9dc9 100644 --- a/src/renderer/web_canvas.rs +++ b/src/renderer/web_canvas.rs @@ -64,6 +64,7 @@ fn group_label_matches_replay_plane( use super::composer::{ decode_pua_overlap_number, expand_pua_render_text, pua_to_display_text, CharOverlapInfo, }; +use super::form_caption::display_form_caption; #[cfg(target_arch = "wasm32")] use super::layout::{compute_char_positions, split_into_clusters}; use crate::model::control::FormType; @@ -1844,12 +1845,15 @@ impl WebCanvasRenderer { self.ctx.stroke_rect(x, y, w, h); // 캡션 텍스트 (회색) if !form.caption.is_empty() { + let caption = display_form_caption(&form.caption); let font_size = (h * 0.5).min(12.0).max(8.0); self.ctx.set_font(&format!("{}px sans-serif", font_size)); self.ctx.set_fill_style_str("#808080"); self.ctx.set_text_align("center"); self.ctx.set_text_baseline("middle"); - let _ = self.ctx.fill_text(&form.caption, x + w / 2.0, y + h / 2.0); + let _ = self + .ctx + .fill_text(caption.as_ref(), x + w / 2.0, y + h / 2.0); self.ctx.set_text_align("left"); self.ctx.set_text_baseline("alphabetic"); } @@ -1876,13 +1880,14 @@ impl WebCanvasRenderer { } // 캡션 if !form.caption.is_empty() { + let caption = display_form_caption(&form.caption); let font_size = (h * 0.7).min(12.0).max(8.0); self.ctx.set_font(&format!("{}px sans-serif", font_size)); self.ctx.set_fill_style_str(&form.fore_color); self.ctx.set_text_baseline("middle"); let _ = self .ctx - .fill_text(&form.caption, x + box_size + 4.0, y + h / 2.0); + .fill_text(caption.as_ref(), x + box_size + 4.0, y + h / 2.0); self.ctx.set_text_baseline("alphabetic"); } } @@ -1907,13 +1912,14 @@ impl WebCanvasRenderer { } // 캡션 if !form.caption.is_empty() { + let caption = display_form_caption(&form.caption); let font_size = (h * 0.7).min(12.0).max(8.0); self.ctx.set_font(&format!("{}px sans-serif", font_size)); self.ctx.set_fill_style_str(&form.fore_color); self.ctx.set_text_baseline("middle"); let _ = self .ctx - .fill_text(&form.caption, x + r * 2.0 + 4.0, y + h / 2.0); + .fill_text(caption.as_ref(), x + r * 2.0 + 4.0, y + h / 2.0); self.ctx.set_text_baseline("alphabetic"); } } diff --git a/tests/golden_svg/form-002/page-0.svg b/tests/golden_svg/form-002/page-0.svg index 8b52e1b98..9e1381bde 100644 --- a/tests/golden_svg/form-002/page-0.svg +++ b/tests/golden_svg/form-002/page-0.svg @@ -250,7 +250,7 @@ -IP R&&D연계 +IP R&D연계 표준연계 @@ -313,9 +313,9 @@ -R&&D 자율성트랙(일반) +R&D 자율성트랙(일반) -R&&D 자율성트랙(지정) +R&D 자율성트랙(지정) diff --git a/tests/issue_1562_hwpx_form_caption_display.rs b/tests/issue_1562_hwpx_form_caption_display.rs new file mode 100644 index 000000000..af4ae0f8a --- /dev/null +++ b/tests/issue_1562_hwpx_form_caption_display.rs @@ -0,0 +1,43 @@ +//! Task #1562 회귀 테스트 — HWPX 폼 컨트롤 caption `&&` 표시 정합. +//! +//! #1534는 저장/roundtrip 계층에서 caption XML escape 누적 손상을 해결했다. +//! 이 테스트는 별도 표시 계층 문제를 고정한다. `samples/hwpx/form-002.hwpx`의 +//! 저장값은 `R&&D`를 유지해야 하지만, 사용자에게 보이는 폼 caption은 한컴처럼 +//! `R&D`로 표시되어야 한다. + +use std::path::Path; + +use rhwp::wasm_api::HwpDocument; + +const SAMPLE: &str = "samples/hwpx/form-002.hwpx"; + +fn render_form_002_page_0_svg() -> String { + let repo_root = env!("CARGO_MANIFEST_DIR"); + let path = Path::new(repo_root).join(SAMPLE); + let bytes = std::fs::read(&path) + .unwrap_or_else(|e| panic!("form-002 fixture read failed {}: {e}", path.display())); + let doc = HwpDocument::from_bytes(&bytes).expect("form-002 parse failed"); + doc.render_page_svg_native(0) + .expect("form-002 page 0 SVG render failed") +} + +#[test] +fn form_caption_double_ampersand_displays_as_single_ampersand_in_svg() { + let svg = render_form_002_page_0_svg(); + + for expected in [ + "IP R&D연계", + "R&D 자율성트랙(일반)", + "R&D 자율성트랙(지정)", + ] { + assert!( + svg.contains(expected), + "폼 caption 이 한컴 표시 문자열로 렌더링되어야 함: expected={expected}" + ); + } + + assert!( + !svg.contains("R&&D"), + "폼 caption 표시 문자열에 `R&&D`가 그대로 남아 있음" + ); +}