From aa786dad9f8d118789d39103a333a14d00f733d5 Mon Sep 17 00:00:00 2001 From: postmelee Date: Sat, 27 Jun 2026 13:56:18 +0900 Subject: [PATCH] =?UTF-8?q?Task=20#1585:=20HWPX=20=EC=BA=A1=EC=85=98=20?= =?UTF-8?q?=EB=82=B4=20=ED=94=8C=EB=A1=9C=ED=8C=85=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mydocs/orders/20260627.md | 6 + mydocs/plans/task_m100_1585.md | 160 ++++++++++++++ mydocs/plans/task_m100_1585_impl.md | 223 +++++++++++++++++++ mydocs/report/task_m100_1585_report.md | 218 +++++++++++++++++++ mydocs/working/task_m100_1585_stage1.md | 160 ++++++++++++++ mydocs/working/task_m100_1585_stage2.md | 93 ++++++++ mydocs/working/task_m100_1585_stage3.md | 146 +++++++++++++ src/renderer/layout/picture_footnote.rs | 98 ++++++++- src/renderer/layout/table_layout.rs | 119 ++++++++--- tests/issue_1585_caption_floating_image.rs | 236 +++++++++++++++++++++ 10 files changed, 1430 insertions(+), 29 deletions(-) create mode 100644 mydocs/plans/task_m100_1585.md create mode 100644 mydocs/plans/task_m100_1585_impl.md create mode 100644 mydocs/report/task_m100_1585_report.md create mode 100644 mydocs/working/task_m100_1585_stage1.md create mode 100644 mydocs/working/task_m100_1585_stage2.md create mode 100644 mydocs/working/task_m100_1585_stage3.md create mode 100644 tests/issue_1585_caption_floating_image.rs diff --git a/mydocs/orders/20260627.md b/mydocs/orders/20260627.md index b80c2c688..22a525d7a 100644 --- a/mydocs/orders/20260627.md +++ b/mydocs/orders/20260627.md @@ -7,6 +7,12 @@ | PR #1541, #1544, #1545, #1558, #1559, #1561, #1563, #1566, #1571 | @planet6897 연속 PR 최신 `devel` 순차 merge | 완료 | 9건 모두 branch update 또는 수동 merge 후 remote CI 통과 확인, admin merge 완료. 최종 `devel` HEAD `24c1c1d2`. #1561은 충돌 수동 해결, #1566/#1571은 opengov snapshot/visual XFAIL 승격 보정 후 재검증. 관련 이슈 #1512, #1488, #1472, #1552, #1554, #1557, #1556, #1560, #1564, #1567 모두 close 확인. 처리 보고서: `mydocs/pr/archives/pr_1541_1571_planet6897_series_report.md`. | | #1579 | 순수 review 문서 PR의 heavy CI fallback 방지 | 완료 | #1580에서 `ci.yml`/`codeql.yml` all-review-docs-only fast-pass 분기 보강 후 remote CI 통과, `ec05f73a`로 merge. 후속 문서 전용 PR #1581에서 `Build & Test`, `Analyze`, `Canvas visual diff` skipped 및 preflight fast-pass 확인. | +## M100 — v1.0.0 조판 엔진 체계화 + +| Issue | 타스크 | 상태 | 비고 | +|------|--------|------|------| +| #1585 | HWPX 캡션 내 플로팅 이미지 렌더링 지원 | 완료 | #1551 merge 기준 rebase 후 구현/검증/시각검증/최종 보고 완료. 완료: 13:39 | + ## 후속 확인 - @planet6897 open PR #1533, #1538은 이번 지시 범위 밖으로 남아 있음. diff --git a/mydocs/plans/task_m100_1585.md b/mydocs/plans/task_m100_1585.md new file mode 100644 index 000000000..d026f2f98 --- /dev/null +++ b/mydocs/plans/task_m100_1585.md @@ -0,0 +1,160 @@ +# Task M100 #1585 수행계획서 — HWPX 캡션 내 플로팅 이미지 렌더링 지원 + +## 기본 정보 + +- Issue: #1585 — HWPX: 캡션 내 플로팅 이미지 렌더링 지원 +- Parent issue: #1270 +- Related PR: #1551 — 캡션 내 인라인 이미지 렌더링 복구 +- 브랜치: `local/task1585` +- Worktree: `/private/tmp/rhwp-task1585` +- 기준: 최신 `upstream/devel` `a94e2051` +- 마일스톤: M100 / v1.0.0 + +## 배경 + +#1270은 캡션 내부 이미지가 렌더링되지 않는 문제에서 출발했다. 메인테이너는 문제를 두 범위로 분리했다. + +1. (a) 캡션 내 인라인 이미지 스레딩, depth 1 +2. (b) 캡션 내 플로팅 이미지 배치 일반화 + +#1551은 (a)를 처리하는 PR이다. #1585는 (b) 후속 작업이며, #1270 첨부 샘플의 `image2` / `SEOUL MY SOUL` 로고가 캡션 내부 `textWrap="TOP_AND_BOTTOM"` 플로팅 그림이라는 점을 직접 겨냥한다. + +## 현재 기준 브랜치 상태 + +현재 작업 브랜치는 작업지시자 요청대로 최신 `upstream/devel`에서 분기했다. + +작성 초기에는 #1551이 아직 merge되지 않았으므로 구현 단계 전 선행 PR 상태를 다시 확인하는 것을 전제로 했다. 이후 2026-06-27에 #1551이 merge되었고, Stage 3 시작 전 `local/task1585`를 최신 `upstream/devel` `a94e2051`로 rebase했다. 따라서 현재 기준에는 다음 선행 변경이 포함되어 있다. + +- `layout_caption()`의 `bin_data_content` 인자 +- caption paragraph 원본 `para` 전달 +- `tests/issue_1270_caption_inline_image.rs` + +따라서 #1585 구현은 #1551 변경을 중복 포함하지 않고, #1551 병합 후 기준 위에서 진행한다. + +## 문제 가설 + +현재 `layout_caption()`은 caption paragraph를 일반 문단 레이아웃으로 넘기지만, 플로팅 컨트롤 배치에 필요한 앵커/줄 매핑/wrap 컨텍스트는 전달하지 않는다. + +#1270 첨부 샘플의 `image2`는 다음 특성을 가진다. + +- caption 내부 `hp:pic` +- `textWrap="TOP_AND_BOTTOM"` +- 본문 표 셀 내부 캡션 영역에 표시되어야 함 +- 인라인 `treat_as_char` 경로가 아니라 플로팅 배치 경로 필요 + +따라서 단순히 `bin_data_content`를 스레딩하는 것만으로는 해결되지 않는다. caption 영역에서도 본문/셀의 플로팅 배치 로직 중 필요한 부분을 안전하게 재사용하거나 축소 적용해야 한다. + +## 목표 + +- 캡션 영역 안의 non-TAC `TopAndBottom` picture가 `ImageNode`로 방출되도록 한다. +- #1270 첨부 샘플의 `image2` / `SEOUL MY SOUL` 로고가 1페이지 상단 오른쪽에 렌더링되는지 확인한다. +- 기존 본문/표 셀/그림 caption 배치를 깨지 않는다. +- 캡션 속 그림의 caption 재귀 렌더링 depth 정책은 필요 시 별도 판단하고, 무리하게 확장하지 않는다. + +## 비범위 + +- #1270 이슈 close +- 캡션 속 그림의 caption 무제한 재귀 렌더링 +- 모든 `Square`/`TopAndBottom` 플로팅 케이스의 완전 일반화 +- HWP3 전용 분기 추가 +- rhwp-studio UI 수정 + +## 예상 조사 지점 + +| 영역 | 파일 | 확인 내용 | +|------|------|-----------| +| caption layout | `src/renderer/layout/picture_footnote.rs` | `layout_caption()`의 caption area, paragraph loop, `layout_composed_paragraph()` 호출 | +| picture layout | `src/renderer/layout/picture_footnote.rs` | `layout_picture()`, `layout_body_picture()`의 ImageNode 생성, caption 처리 | +| paragraph layout | `src/renderer/layout.rs` | 문단 controls 순회, non-TAC picture 처리, wrap anchor 전달 | +| table/cell layout | `src/renderer/layout/table_layout.rs`, `table_partial.rs`, `table_cell_content.rs` | 셀 내부 플로팅 picture 배치와 row height/wrap 처리 | +| pagination | `src/renderer/pagination/engine.rs` | TopAndBottom 객체 예약 높이와 anchor metadata | +| float helpers | `src/renderer/float_placement.rs` | non-inline floating object 판정/예약 헬퍼 재사용 가능성 | +| tests | `tests/` | 기존 #1270/#1139/#1352/#1459/#530/#1486 회귀와 신규 #1585 테스트 | + +## 단계 계획 + +### Stage 1 — 기준 정렬과 재현 확정 + +1. #1551 상태를 다시 확인한다. +2. #1551 merge 여부에 따라 다음 중 하나로 기준을 확정한다. + - merge 완료: 최신 `upstream/devel`로 rebase + - merge 전: 작업지시자 승인 후 #1551 변경 포함 방식 결정 +3. #1270 첨부 HWPX를 확보하고 XML에서 `image2` 위치/속성을 재확인한다. +4. 현재 기준에서 1페이지 SVG 또는 render tree에 `image2`가 누락되는 것을 재현한다. +5. Stage 1 완료보고서 작성 후 승인 요청. + +### Stage 2 — 구현계획서 작성 + +1. caption 영역에서 플로팅 picture를 처리할 최소 변경점을 확정한다. +2. `layout_caption()` 내부 직접 처리, 기존 `layout_picture()` 재사용, 또는 caption 전용 helper 도입 중 하나를 선택한다. +3. row height, caption height, y advance, 중복 렌더링 방지 정책을 명시한다. +4. 신규 회귀 테스트 설계를 확정한다. +5. 구현계획서 작성 후 승인 요청. + +### Stage 3 — 구현 + +작업지시자 승인 후에만 소스를 수정한다. + +예상 구현 방향은 다음 중 Stage 2에서 확정한다. + +- caption paragraph controls 중 non-TAC `TopAndBottom` picture를 탐지한다. +- caption area를 기준으로 image x/y를 계산한다. +- 기존 `bin_data_content` payload 조회/`ImageNode` 생성 경로를 재사용한다. +- caption 내부 플로팅 picture의 caption 재귀 렌더링은 기본적으로 차단하거나 depth 1 정책 안에서 제한한다. +- 기존 인라인 caption image 경로와 중복 방출되지 않도록 조건을 분리한다. + +### Stage 4 — 검증 + +필수 검증 후보: + +```bash +cargo fmt --check +cargo test --test issue_1585_caption_floating_image +cargo test --test issue_1270_caption_inline_image +cargo test --test issue_1139_inline_picture_duplicate +cargo test --test issue_1352_table_cell_tac_picture_text +cargo test --test issue_1459_topbottom_picture_reflow +cargo test --test issue_530 +cargo test --test issue_1486_hwpx_partial_tac_table +cargo test --lib +cargo clippy --lib -- -D warnings +``` + +시각/산출 검증 후보: + +```bash +cargo run --quiet --bin rhwp -- export-svg \ + '/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx' \ + -p 0 \ + -o /private/tmp/rhwp-task1585-svg +``` + +검증 목표: + +- 1페이지 SVG/render tree에서 `image2`가 방출된다. +- 기존 `image1`과 중복/소실이 발생하지 않는다. +- caption 영역의 텍스트/이미지 배치가 페이지 흐름을 과도하게 밀지 않는다. + +### Stage 5 — 최종 보고 및 PR 준비 + +1. 최종 보고서 작성 +2. 오늘 할일 완료 갱신 +3. commit/push/PR은 작업지시자 승인 후 진행 +4. #1270 close 여부는 별도 승인 전까지 보류 + +## 리스크 + +| 리스크 | 대응 | +|--------|------| +| #1551 미병합 상태에서 중복 변경 포함 | Stage 1에서 선행 PR 상태 확인 후 기준 재정렬 | +| caption 내부 플로팅이 기존 table/cell 플로팅 로직과 충돌 | 신규 테스트와 기존 TAC/TopAndBottom 회귀 테스트 동시 실행 | +| caption height 산정과 실제 image 배치 불일치 | Stage 2에서 height/advance 정책을 별도 확정 | +| 재귀 caption 렌더링 폭증 | depth 정책을 제한하고 이번 범위에서는 필요 최소만 처리 | +| 첨부 샘플 한 건에 과적합 | fixture 조작 테스트와 실샘플 보조 검증을 병행 | + +## 완료 기준 + +- #1585 신규 회귀 테스트가 실패에서 통과로 전환된다. +- #1270 첨부 샘플의 `image2`가 SVG/render tree에 방출된다. +- 관련 기존 회귀 테스트와 `cargo test --lib`, `clippy -D warnings`가 통과한다. +- 문서에 #1551과 #1585의 역할 분리가 명확히 남는다. diff --git a/mydocs/plans/task_m100_1585_impl.md b/mydocs/plans/task_m100_1585_impl.md new file mode 100644 index 000000000..ae7b78ee7 --- /dev/null +++ b/mydocs/plans/task_m100_1585_impl.md @@ -0,0 +1,223 @@ +# Task M100 #1585 구현계획서 — 캡션 내 플로팅 이미지 렌더링 + +## 기준 상태 + +- 작업 브랜치: `local/task1585` +- Worktree: `/private/tmp/rhwp-task1585` +- 기준 커밋: 최신 `upstream/devel` `a94e2051` +- 선행 PR: #1551 `MERGED`, `mergedAt=2026-06-27T04:12:17Z` + +작성 초기에는 #1551이 아직 merge되지 않았으므로 이 구현계획서는 소스 수정 전 설계 확정 문서로 작성했다. 이후 Stage 3 시작 시 #1551 병합을 확인했고, `local/task1585`를 최신 `upstream/devel` `a94e2051`로 rebase한 뒤 구현했다. + +## 결론 + +#1585는 #1551이 merge된 뒤 최신 `upstream/devel`로 재기준화해서 구현한다. + +그 이유는 #1585 구현이 #1551의 다음 변경을 전제로 하기 때문이다. + +- `layout_caption()`이 caption paragraph 원본 `para`를 `layout_composed_paragraph()`에 전달 +- `layout_caption()`이 `bin_data_content`를 받아 caption 내부 그림의 바이너리 데이터를 조회 +- #1270 인라인 caption image 회귀 테스트 + +#1551이 merge되기 전에 Stage 3을 진행하는 stacked 변경은 피했고, 현재 브랜치는 #1551 merge commit 이후의 `upstream/devel`을 기준으로 한다. + +## 구현 범위 + +대상은 caption 내부 `Control::Picture` 중 `pic.common.text_wrap == TextWrap::TopAndBottom`인 그림이다. + +주의할 점은 #1270 첨부 샘플의 `image2`가 `hp:pos@treatAsChar="1"`도 가지고 있다는 점이다. 이번 후속 작업에서는 메인테이너의 분리 기준에 맞춰 `treat_as_char` 값이 아니라 `textWrap="TOP_AND_BOTTOM"`을 플로팅 caption image 판정 기준으로 삼는다. + +## 비범위 + +- #1270 이슈 close +- caption 속 그림의 caption 재귀 렌더링 일반화 +- 모든 `Square`, `BehindText`, `InFrontOfText` caption object 일반화 +- HWP3 전용 분기 +- rhwp-studio UI 수정 + +## 설계 방향 + +전역 paragraph/table 플로팅 로직을 확장하지 않고, `layout_caption()` 내부에서 caption 전용 후처리 helper를 호출한다. + +이유: + +- caption 영역은 이미 `layout_caption()`에서 `content_x`, `content_width`, `y_start`, `cell_ctx`를 알고 있다. +- 표 caption은 `cell_index=65534` 센티널로 식별된다. +- 전역 `paragraph_layout.rs`의 빈 문단/TAC 처리 조건을 바꾸면 본문·셀·글상자 회귀 범위가 커진다. +- #1585는 caption 내부 `TopAndBottom` 그림에 한정된 후속 작업이다. + +## 구현 단계 + +### 1. Stage 3 preflight + +소스 수정 직전에 다음을 다시 확인한다. + +```bash +gh pr view 1551 --repo edwardkim/rhwp --json state,mergedAt,mergeCommit,baseRefName,headRefName +git fetch upstream devel +git rev-parse upstream/devel +``` + +판정: + +- #1551 merge 완료: `local/task1585`를 최신 `upstream/devel`로 rebase한 뒤 구현한다. +- #1551 미merge: 소스 수정하지 않고 작업지시자에게 stacked 진행 여부를 확인한다. + +### 2. caption helper 추가 + +`src/renderer/layout/picture_footnote.rs`에 caption 전용 helper를 추가한다. + +예상 형태: + +```rust +fn layout_caption_topbottom_pictures( + &self, + tree: &mut PageRenderTree, + parent_node: &mut RenderNode, + para: &Paragraph, + caption_area: &LayoutRect, + para_y_before_layout: f64, + bin_data_content: &[BinDataContent], + section_index: usize, + para_index: usize, + cell_ctx: Option<&CellContext>, +) +``` + +helper는 `layout_caption()`에서 각 caption paragraph 처리 직후 호출한다. + +### 3. 후보 탐지 + +caption paragraph의 controls를 순회한다. + +대상: + +```rust +matches!(ctrl, Control::Picture(pic) if matches!(pic.common.text_wrap, TextWrap::TopAndBottom)) +``` + +제외: + +- 이미 같은 `(section, para, control, cell_ctx)`로 inline position이 등록된 그림 +- `bin_data_content`에서 데이터를 찾을 수 없고 외부 path도 없는 그림은 placeholder 정책 확인 후 현행 `layout_picture()` 동작에 맡김 + +중복 방지는 `PageRenderTree::get_inline_shape_position()`으로 한다. #1551 인라인 경로가 이미 같은 control을 렌더한 경우 caption floating helper는 추가 emit하지 않는다. + +### 4. 좌표 계산 + +caption local area를 기준으로 계산한다. + +- 기준 rect: `LayoutRect { x: content_x, y: para_y_before_layout, width: content_width, height: ... }` +- `VertRelTo::Para`인 경우 첫 `LineSeg.vertical_pos`가 있으면 `caption_area.y + vertical_pos`를 anchor로 사용한다. +- `HorzRelTo::Column`/`Para`는 caption content box를 기준으로 처리한다. +- `horz_align`, `vert_align`, `horizontal_offset`, `vertical_offset`은 기존 `compute_object_position()`의 의미와 맞춘다. + +#1270 첨부 샘플은 `horzRelTo="COLUMN"`, `vertRelTo="PARA"`, `horzAlign="LEFT"`, `vertAlign="TOP"`, offset 0이다. 이 케이스가 우선 검증 기준이다. + +### 5. emit 방식 + +기존 `layout_picture()`/`layout_picture_full()`을 재사용한다. + +다만 caption floating helper는 `TextWrap::TopAndBottom`을 우선하는 경로이므로, 배치용 clone에서는 다음을 적용한다. + +- `horizontal_offset`/`vertical_offset`은 사전 좌표 계산에 반영한 뒤 0으로 정규화 +- `horz_align`/`vert_align`은 `Left`/`Top`으로 정규화 +- sample처럼 `treat_as_char=true`이더라도 floating helper에서 배치한 그림은 inline 위치 등록으로 다시 처리하지 않도록 clone의 `treat_as_char`를 `false`로 둔다. + +이렇게 해야 `layout_picture_full()`의 TAC 분기로 되돌아가 caption floating 배치가 무력화되거나 inline 중복 등록되는 것을 피할 수 있다. + +### 6. caption height / flow 정책 + +이번 구현은 기존 caption 높이 계산을 크게 바꾸지 않는다. + +- `calculate_caption_height()`의 전체 정책은 유지한다. +- floating image가 caption text와 겹치는 경우에만 최소 높이 보정이 필요한지 Stage 3에서 실제 테스트로 확인한다. +- 보정이 필요하면 caption paragraph별 `TopAndBottom` 그림 높이를 `calculate_caption_height()`에 포함하는 별도 작은 helper를 추가한다. + +처음부터 table row height나 pagination 예약 높이까지 확장하지 않는다. + +### 7. 재귀 caption 차단 + +caption 속 picture가 다시 caption을 갖는 경우 이번 범위에서는 재귀 렌더링하지 않는다. + +구현은 `layout_body_picture()`가 아니라 `layout_picture()` 또는 caption 전용 emit helper를 통해 진행한다. 이로써 caption 속 그림의 caption까지 다시 내려가는 depth 확장을 피한다. + +## 테스트 계획 + +### 신규 회귀 테스트 + +파일 후보: + +```text +tests/issue_1585_caption_floating_image.rs +``` + +테스트 방식: + +1. 저장소에 이미 있는 작은 HWPX/HWP 샘플에서 문서와 BinData를 로드한다. +2. 기존 picture control 하나를 clone해 caption paragraph 내부로 넣는다. +3. clone한 picture를 다음 속성으로 조정한다. + - `text_wrap = TextWrap::TopAndBottom` + - `treat_as_char = true` + - `vert_rel_to = VertRelTo::Para` + - `horz_rel_to = HorzRelTo::Column` + - `vert_align = VertAlign::Top` + - `horz_align = HorzAlign::Left` + - offsets 0 +4. 표 top caption의 빈 paragraph 또는 image-only paragraph에 해당 picture control을 배치한다. +5. page 0 render tree를 생성한다. +6. `ImageNode` 중 `cell_context.path[0].cell_index == 65534`이고 `text_wrap == Some(TextWrap::TopAndBottom)`인 노드를 찾는다. +7. caption floating image가 정확히 1개 방출되는지, 동일 control 중복 방출이 없는지 검증한다. + +이 테스트는 현재 기준에서는 실패해야 하고, Stage 3 구현 후 통과해야 한다. + +### 실샘플 보조 검증 + +Git에 첨부 샘플을 추가하지 않고, 로컬 첨부 파일로 SVG 산출을 확인한다. + +```bash +cargo run --quiet --bin rhwp -- export-svg \ + '/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx' \ + -p 0 \ + -o /private/tmp/rhwp-task1585-svg +``` + +검증: + +- 1페이지 SVG의 `` 개수가 기존 1개에서 `image2` 포함 2개 이상으로 증가 +- `BinData/image2.png`에 해당하는 `SEOUL MY SOUL` 로고가 page 0 render tree 또는 SVG에 방출 +- 기존 `image1` 누락 없음 + +### 회귀 테스트 + +```bash +cargo fmt --check +cargo test --test issue_1585_caption_floating_image +cargo test --test issue_1270_caption_inline_image +cargo test --test issue_1139_inline_picture_duplicate +cargo test --test issue_1352_table_cell_tac_picture_text +cargo test --test issue_1459_topbottom_picture_reflow +cargo test --test issue_530 +cargo test --test issue_1486_hwpx_partial_tac_table +cargo test --lib +cargo clippy --lib -- -D warnings +``` + +## 리스크와 대응 + +| 리스크 | 대응 | +|--------|------| +| #1551 미merge 상태에서 중복 변경 포함 | Stage 3 preflight에서 merge 상태 확인 후, 미merge면 소스 수정 중단 | +| `treat_as_char=true`와 `TopAndBottom`이 충돌 | #1585에서는 `TopAndBottom`을 caption floating 판정 기준으로 삼고, emit clone은 non-TAC 배치로 정규화 | +| 기존 inline caption image와 중복 방출 | `get_inline_shape_position()` 기반 중복 가드 | +| caption height가 이미지 높이를 반영하지 못함 | 우선 최소 emit을 구현하고, 겹침이 재현되면 caption height 보정 helper 추가 | +| table/cell 플로팅 회귀 | caption 전용 helper로 범위 제한, 기존 TopAndBottom 회귀 테스트 동시 실행 | + +## 승인 후 진행 조건 + +Stage 3 소스 수정은 다음 중 하나가 충족될 때만 시작한다. + +1. #1551이 merge되어 최신 `upstream/devel`에 포함됨 +2. 작업지시자가 #1551 미merge 상태에서 stacked 변경으로 진행하라고 명시 승인함 + +권장 조건은 1번이다. diff --git a/mydocs/report/task_m100_1585_report.md b/mydocs/report/task_m100_1585_report.md new file mode 100644 index 000000000..bb8c2cfab --- /dev/null +++ b/mydocs/report/task_m100_1585_report.md @@ -0,0 +1,218 @@ +# Task #1585 최종 보고서 — HWPX 캡션 내 플로팅 이미지 렌더링 지원 + +## 개요 + +- Issue: #1585 +- Parent issue: #1270 +- 선행 PR: #1551 +- 브랜치: `local/task1585` +- Worktree: `/private/tmp/rhwp-task1585` +- 기준 커밋: `upstream/devel` `a94e2051` + +#1585는 #1270에서 분리된 후속 작업이다. #1551은 캡션 내 인라인 이미지 스레딩을 복구했고, 이번 작업은 캡션 내부 `textWrap="TOP_AND_BOTTOM"` 그림이 화면에 방출되지 않는 문제를 처리한다. + +## 기준 정렬 + +Stage 3 시작 전에 #1551 merge 상태를 확인했다. + +```text +state=MERGED +mergedAt=2026-06-27T04:12:17Z +mergeCommit=a94e20518b486caabd82de824784bdf25a0e1c87 +``` + +이후 `git fetch upstream devel` 및 `git rebase upstream/devel`을 수행했고, 현재 브랜치는 `a94e2051` 기준이다. 따라서 이번 변경은 #1551 변경을 중복 포함하지 않는다. + +## 구현 내용 + +### 캡션 내부 `TopAndBottom` 그림 렌더링 + +`src/renderer/layout/picture_footnote.rs`의 `layout_caption()`에서 caption paragraph 조판 직후 `TopAndBottom` picture 전용 helper를 호출하도록 했다. + +핵심 정책: + +- `Control::Picture` 중 `pic.common.text_wrap == TextWrap::TopAndBottom`인 그림만 대상으로 한다. +- #1551 인라인 경로에서 이미 같은 control이 등록된 경우 중복 방출하지 않는다. +- caption content box와 paragraph line segment를 기준으로 좌표를 계산한다. +- 이미지 payload와 `ImageNode` 생성은 기존 `layout_picture()` 경로를 재사용한다. +- 샘플처럼 `treat_as_char=true`와 `TopAndBottom`이 함께 있는 경우에도, 플로팅 배치용 clone은 non-TAC로 정규화해 렌더링한다. + +### depth 1 중첩 표 caption 보강 + +#1270 첨부 실샘플의 `image2`는 top-level 표 caption이 아니라 내부 표의 TOP caption 안에 있었다. 기존 `table_layout.rs`는 table caption 렌더링을 `depth == 0`으로 제한하고 있었기 때문에, caption picture helper만 추가해도 실샘플의 caption 자체가 렌더링되지 않았다. + +이번 변경은 범위를 좁혀 다음 경우만 caption 렌더링을 허용했다. + +- `depth == 0`: 기존 동작 유지 +- `depth == 1`이고 caption 안에 `TopAndBottom` picture가 있는 경우: caption 렌더링 + +중첩 caption의 `CellContext`는 기존 caption 식별 센티널인 `cell_index=65534`를 유지하도록 조정했다. + +## 테스트 + +신규 테스트: + +```text +tests/issue_1585_caption_floating_image.rs +``` + +검증 내용: + +- top-level table caption 내부 `TopAndBottom` picture가 payload 포함 `ImageNode`로 1회 방출된다. +- depth 1 nested table caption 내부 `TopAndBottom` picture가 caption context와 함께 1회 방출된다. +- caption image가 중복 방출되지 않는다. + +## 실샘플 확인 + +대상: + +```text +/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx +``` + +#1551 merge 직후, #1585 변경 전 page 0 SVG: + +```text + count: 1 +``` + +#1585 변경 후 page 0 SVG: + +```text + count: 2 +``` + +추가된 두 번째 image는 `image2` / `SEOUL MY SOUL` 로고에 해당한다. + +```xml + +``` + +기존 `image1`도 유지됐다. + +## rhwp-studio 시각 검증 + +작업지시자가 로컬 `rhwp-studio` 웹서버에서 #1270 첨부 샘플을 직접 로드해 시각 검증을 완료했다. + +검증 서버: + +```text +http://127.0.0.1:7701/ +``` + +검증 시각: + +```text +2026-06-27 13:52 KST +``` + +## 검증 결과 + +다음 명령이 통과했다. + +```text +cargo fmt --check +cargo test --test issue_1585_caption_floating_image +cargo test --test issue_1270_caption_inline_image +cargo test --test issue_530 +cargo test --test issue_1459_topbottom_picture_reflow +cargo test --test issue_1139_inline_picture_duplicate +cargo test --test issue_1352_table_cell_tac_picture_text +cargo test --test issue_1486_hwpx_partial_tac_table +cargo test --lib +cargo clippy --lib -- -D warnings +git diff --check +``` + +`cargo test --lib` 결과: + +```text +1959 passed; 0 failed; 6 ignored +``` + +## 변경 파일 + +소스: + +```text +src/renderer/layout/picture_footnote.rs +src/renderer/layout/table_layout.rs +tests/issue_1585_caption_floating_image.rs +``` + +문서: + +```text +mydocs/orders/20260627.md +mydocs/plans/task_m100_1585.md +mydocs/plans/task_m100_1585_impl.md +mydocs/working/task_m100_1585_stage1.md +mydocs/working/task_m100_1585_stage2.md +mydocs/working/task_m100_1585_stage3.md +mydocs/report/task_m100_1585_report.md +``` + +## PR 초안 + +제목: + +```text +Task #1585: HWPX 캡션 내 플로팅 이미지 렌더링 지원 +``` + +본문: + +```markdown +## 요약 + +- 캡션 paragraph 내부 `TopAndBottom` picture를 caption 영역 기준으로 렌더링합니다. +- depth 1 중첩 표 caption 중 `TopAndBottom` picture가 있는 경우만 caption 렌더링을 허용합니다. +- top-level / nested table caption 플로팅 이미지 회귀 테스트를 추가했습니다. + +## 배경 + +#1551은 #1270의 (a) 범위였던 캡션 내 인라인 이미지 스레딩을 복구했습니다. + +이번 PR은 후속 이슈 #1585의 범위인 (b) 캡션 내 플로팅 이미지 렌더링을 다룹니다. #1270 첨부 샘플의 `image2` / `SEOUL MY SOUL` 로고는 내부 표의 TOP caption 안에 있는 `textWrap="TOP_AND_BOTTOM"` 그림이므로, #1551 merge 이후에도 별도 처리가 필요했습니다. + +## 구현 + +- `layout_caption()`에서 caption paragraph 조판 후 `TopAndBottom` picture 전용 렌더링 helper를 호출합니다. +- #1551 인라인 경로에서 이미 등록된 control은 중복 방출하지 않습니다. +- caption 배치용 picture clone은 non-TAC로 정규화해 기존 `layout_picture()` 경로를 재사용합니다. +- nested table caption은 `depth == 1`이고 caption 안에 `TopAndBottom` picture가 있는 경우에만 렌더링합니다. +- 중첩 caption도 기존 caption 센티널인 `cell_index=65534`를 유지합니다. + +## 검증 + +- `cargo fmt --check` +- `cargo test --test issue_1585_caption_floating_image` +- `cargo test --test issue_1270_caption_inline_image` +- `cargo test --test issue_530` +- `cargo test --test issue_1459_topbottom_picture_reflow` +- `cargo test --test issue_1139_inline_picture_duplicate` +- `cargo test --test issue_1352_table_cell_tac_picture_text` +- `cargo test --test issue_1486_hwpx_partial_tac_table` +- `cargo test --lib` +- `cargo clippy --lib -- -D warnings` + +## 실샘플 확인 + +로컬 #1270 첨부 샘플의 page 0 SVG에서 `` 개수가 #1551 merge 직후 1개에서 이번 변경 후 2개로 증가하는 것을 확인했습니다. 추가 방출된 image는 `image2` / `SEOUL MY SOUL` 로고입니다. + +## 시각 검증 + +로컬 `rhwp-studio` 웹서버에서 작업지시자가 #1270 첨부 샘플을 직접 로드해 시각 검증을 완료했습니다. + +## 범위 + +이번 PR은 #1585의 캡션 내부 `TopAndBottom` 플로팅 이미지 렌더링에 집중합니다. caption 내부 모든 floating object의 일반화나 caption 재귀 렌더링 확장은 포함하지 않습니다. +``` + +## 남은 작업 + +커밋, push, PR 생성은 작업지시자 승인 후 진행한다. 이슈 close는 별도 승인 전까지 수행하지 않는다. diff --git a/mydocs/working/task_m100_1585_stage1.md b/mydocs/working/task_m100_1585_stage1.md new file mode 100644 index 000000000..b112858de --- /dev/null +++ b/mydocs/working/task_m100_1585_stage1.md @@ -0,0 +1,160 @@ +# Task #1585 Stage 1 완료보고서 — 기준 정렬과 재현 확정 + +## 범위 + +- 선행 PR #1551 상태 확인 +- 최신 `upstream/devel` 기준 코드 상태 확인 +- #1270 첨부 HWPX의 `image2` 위치/속성 재확인 +- 현재 기준에서 1페이지 SVG `image2` 누락 재현 +- Stage 2 전제 조건 정리 + +## 기준 상태 + +작업 브랜치: + +```text +local/task1585...upstream/devel +``` + +기준 커밋: + +```text +d8e792fe Merge pull request #1538 from planet6897/pr-task1537 +``` + +선행 PR #1551 상태: + +```text +state=OPEN +mergedAt=null +mergeCommit=null +base=devel +head=local/task1270 +``` + +따라서 2026-06-27 현재 최신 `upstream/devel`에는 #1551 변경이 아직 포함되어 있지 않다. + +## 선행 PR 미반영 확인 + +현재 기준의 `layout_caption()`은 여전히 #1551 이전 형태다. + +확인 위치: + +```text +src/renderer/layout/picture_footnote.rs:610-670 +``` + +관찰: + +- `layout_caption()` 시그니처에 `bin_data_content` 인자가 없다. +- `layout_composed_paragraph()` 호출의 마지막 인자들이 `None`으로 남아 있다. +- `tests/issue_1270_caption_inline_image.rs`도 현재 기준에 없다. + +이는 #1585 구현을 바로 시작하면 #1551 인라인 스레딩 변경을 중복 포함할 수 있음을 의미한다. + +## 첨부 HWPX 구조 확인 + +대상 파일: + +```text +/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx +``` + +`image2` 바이너리: + +```text +BinData/image2.png +size: 7272 bytes +PNG image data, 232 x 37, 8-bit/color RGBA, non-interlaced +``` + +`Contents/section0.xml`에서 `image2`는 내부 표의 TOP caption 안에 존재한다. + +핵심 XML: + +```xml + +... + +... + +... + +... + +... +그림입니다. +원본 그림의 이름: 26_SMS_BKCOLOR_KOR.png +원본 그림의 크기: 가로 3192pixel, 세로 526pixel +``` + +주의: + +- `hp:pic@textWrap="TOP_AND_BOTTOM"`이므로 이슈 #1585의 플로팅 배치 대상이다. +- `hp:pos@treatAsChar="1"`도 존재하지만, 메인테이너가 분류한 문제 범위는 `hp:pic@textWrap` 기준의 플로팅 캡션 이미지 후속이다. + +## 현재 기준 렌더 재현 + +실행: + +```bash +cargo run --quiet --bin rhwp -- export-svg \ + /private/tmp/issue1270/*.hwpx \ + -p 0 \ + -o /private/tmp/rhwp-task1585-stage1-svg +``` + +결과: + +```text +문서 로드 완료: /private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx (56페이지) + → /private/tmp/rhwp-task1585-stage1-svg/서울 문화예술단체 지원사업_001.svg +내보내기 완료: 1개 SVG 파일 → /private/tmp/rhwp-task1585-stage1-svg/ +``` + +SVG `` 개수: + +```text +1 +``` + +SVG에 방출된 유일한 image: + +```xml + +``` + +이는 기존 `image1`에 해당한다. `image2`, `26_SMS`, `SEOUL`, `MY SOUL` 관련 문자열은 SVG에서 발견되지 않았다. + +## 판정 + +Stage 1 재현은 성공이다. + +- 첨부 HWPX에는 `image2`가 정상 PNG로 존재한다. +- `image2`는 caption 내부 `hp:pic@textWrap="TOP_AND_BOTTOM"` 그림이다. +- 현재 최신 `upstream/devel` 기준 SVG 1페이지에는 `image1`만 방출되고 `image2`는 방출되지 않는다. +- 동시에 #1551은 아직 merge되지 않아, 구현 단계에서는 기준 정렬을 먼저 해결해야 한다. + +## Stage 2 전제 조건 + +Stage 2 구현계획서 작성 전에 다음을 먼저 결정해야 한다. + +1. #1551이 merge되었는지 다시 확인한다. +2. merge 완료라면 최신 `upstream/devel`로 rebase한 뒤 #1585 설계를 작성한다. +3. merge 전이라면 다음 중 하나를 작업지시자가 승인해야 한다. + - #1551 merge 대기 + - #1551 변경을 #1585 브랜치에 포함해 통합 설계 + - #1551 브랜치를 base로 임시 구현계획 작성 + +권장 방향은 #1551 merge 후 최신 `upstream/devel`로 재기준화한 다음 #1585 구현계획서를 작성하는 것이다. 그래야 (a) 인라인 스레딩과 (b) 플로팅 캡션 이미지 변경이 PR 단위로 분리된다. + +## 다음 단계 + +작업지시자 승인 후 Stage 2 구현계획서를 작성한다. 단, Stage 2 시작 시 #1551 상태를 다시 확인하고 기준 정렬 방식을 먼저 확정한다. diff --git a/mydocs/working/task_m100_1585_stage2.md b/mydocs/working/task_m100_1585_stage2.md new file mode 100644 index 000000000..d695617aa --- /dev/null +++ b/mydocs/working/task_m100_1585_stage2.md @@ -0,0 +1,93 @@ +# Task #1585 Stage 2 완료보고서 — 구현계획서 작성 + +## 범위 + +- #1551 merge 상태 재확인 +- 최신 `upstream/devel` 기준과 선행 변경 포함 여부 확인 +- caption floating image 구현 위치 검토 +- 구현계획서 작성 +- Stage 3 진입 조건 정리 + +## 확인 결과 + +선행 PR #1551은 Stage 2 시작 시점에도 아직 merge되지 않았다. + +```text +state=OPEN +mergedAt=null +base=devel +head=local/task1270 +``` + +따라서 현재 `local/task1585` 기준에는 #1551의 caption inline image 스레딩 변경이 없다. + +## 코드 관찰 요약 + +현재 기준의 `layout_caption()`은 다음 상태다. + +- 위치: `src/renderer/layout/picture_footnote.rs` +- caption paragraph를 `compose_paragraph()`로 조합한다. +- `layout_composed_paragraph()`를 호출하지만, 원본 `para`와 `bin_data_content`를 넘기지 않는다. +- caption context는 `CellContext`로 전달되고, 표 caption은 `cell_index=65534` 센티널을 사용한다. + +셀 내부 `TopAndBottom` picture 처리는 `src/renderer/layout/table_layout.rs`에 이미 존재하지만, 일반 셀 흐름용 로직이다. 이를 전역으로 확장하기보다 caption 전용 helper를 `layout_caption()`에 두는 것이 영향 범위가 가장 작다. + +## 설계 결정 + +구현계획서는 다음 파일로 작성했다. + +```text +mydocs/plans/task_m100_1585_impl.md +``` + +핵심 방향: + +- #1551 merge 후 최신 `upstream/devel`로 rebase한 뒤 구현한다. +- `layout_caption()` 내부에서 caption 전용 `TopAndBottom` picture helper를 호출한다. +- 후보 판정은 `pic.common.text_wrap == TextWrap::TopAndBottom` 기준으로 한다. +- #1270 첨부 샘플처럼 `treat_as_char=true`가 함께 있어도 #1585에서는 floating caption image로 처리한다. +- 기존 `layout_picture()`/`layout_picture_full()`을 재사용하되, placement clone을 non-TAC 배치로 정규화해 inline 중복 등록을 피한다. +- caption 속 picture의 caption 재귀 렌더링은 이번 범위에서 확장하지 않는다. + +## 테스트 계획 + +신규 테스트 후보: + +```text +tests/issue_1585_caption_floating_image.rs +``` + +검증 목표: + +- caption 내부 `TopAndBottom` picture가 `ImageNode`로 1회 방출된다. +- 표 caption context의 `cell_index=65534`가 유지된다. +- 기존 #1270 inline caption image와 중복 방출되지 않는다. +- 로컬 #1270 첨부 샘플 page 0 SVG에서 `image2`가 방출된다. + +회귀 테스트 후보는 구현계획서에 정리했다. + +## Stage 3 진입 조건 + +현재 상태에서는 바로 소스 수정하지 않는 것이 맞다. + +Stage 3 소스 수정은 다음 중 하나가 충족될 때만 진행한다. + +1. #1551이 merge되어 최신 `upstream/devel`에 포함됨 +2. 작업지시자가 #1551 미merge 상태에서 stacked 변경으로 진행하라고 명시 승인함 + +권장 경로는 1번이다. 그래야 #1270의 (a) 인라인 caption image PR과 #1585의 (b) floating caption image PR이 분리된다. + +## 변경 파일 + +문서만 추가했다. + +```text +mydocs/plans/task_m100_1585_impl.md +mydocs/working/task_m100_1585_stage2.md +``` + +소스 파일은 수정하지 않았다. + +## 다음 단계 + +작업지시자 승인 후 Stage 3로 진행한다. Stage 3 시작 시 #1551 상태를 다시 확인하고, 아직 merge되지 않았다면 stacked 진행 여부를 먼저 확정해야 한다. diff --git a/mydocs/working/task_m100_1585_stage3.md b/mydocs/working/task_m100_1585_stage3.md new file mode 100644 index 000000000..06dc6ace8 --- /dev/null +++ b/mydocs/working/task_m100_1585_stage3.md @@ -0,0 +1,146 @@ +# Task #1585 Stage 3 완료보고서 — 구현 및 검증 + +## 범위 + +- #1551 merge 상태 확인 +- 최신 `upstream/devel` 기준 rebase +- 캡션 내부 `TopAndBottom` 그림 렌더링 구현 +- depth 1 중첩 표 caption의 플로팅 이미지 방출 보강 +- 신규 회귀 테스트와 기존 관련 회귀 테스트 실행 +- #1270 첨부 실샘플 SVG 보조 검증 + +## 기준 정렬 + +#1551은 Stage 3 시작 시점에 merge 완료 상태였다. + +```text +state=MERGED +mergedAt=2026-06-27T04:12:17Z +mergeCommit=a94e20518b486caabd82de824784bdf25a0e1c87 +base=devel +``` + +이후 `upstream/devel`을 fetch하고 `local/task1585`를 rebase했다. + +```text +upstream/devel HEAD: a94e2051 Merge pull request #1551 from postmelee/local/task1270 +작업 브랜치: local/task1585...upstream/devel +``` + +따라서 #1585 변경은 #1551의 인라인 caption image 스레딩 변경을 중복 포함하지 않고, merge된 기준 위에 얹힌다. + +## 구현 요약 + +### `src/renderer/layout/picture_footnote.rs` + +`layout_caption()`에서 caption paragraph 조판 직후 caption 전용 helper를 호출하도록 했다. + +핵심 처리: + +- caption paragraph의 `Control::Picture` 중 `text_wrap == TextWrap::TopAndBottom`인 그림을 탐지한다. +- #1551 인라인 경로에서 이미 같은 control 위치가 등록된 경우 중복 방출하지 않는다. +- caption content box와 paragraph line segment를 기준으로 배치 좌표를 계산한다. +- 기존 `layout_picture()` 경로를 재사용해 `ImageNode`와 payload 처리를 유지한다. +- HWPX 샘플처럼 `treat_as_char=true`가 함께 있어도, `TopAndBottom` caption floating 배치용 clone은 non-TAC로 정규화해 렌더링한다. + +### `src/renderer/layout/table_layout.rs` + +#1270 첨부 실샘플의 `image2`는 top-level 표가 아니라 내부 표의 TOP caption 안에 있었다. 기존 구현은 표 caption 렌더링을 `depth == 0`으로 제한해, helper를 추가해도 해당 caption 자체가 렌더링되지 않았다. + +이번 변경은 범위를 좁혀 다음 경우만 추가 허용했다. + +- `depth == 0`: 기존처럼 caption 렌더링 +- `depth == 1`이고 caption 안에 `TopAndBottom` picture가 있는 경우: caption 렌더링 + +중첩 caption은 기존 표 caption 센티널인 `cell_index=65534`를 유지하도록 `CellContext` 마지막 path entry를 caption context로 치환한다. + +## 신규 테스트 + +추가 파일: + +```text +tests/issue_1585_caption_floating_image.rs +``` + +테스트 케이스: + +1. top-level table caption 내부 `TopAndBottom` picture가 payload 포함 `ImageNode`로 1회 방출되는지 확인 +2. depth 1 nested table caption 내부 `TopAndBottom` picture가 caption context와 함께 1회 방출되는지 확인 + +## 실샘플 보조 검증 + +대상: + +```text +/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx +``` + +#1551 merge 직후, #1585 변경 전 page 0 SVG의 `` 개수는 1개였다. + +```text +/private/tmp/rhwp-task1585-after1551-svg + count: 1 +``` + +#1585 변경 후 page 0 SVG의 `` 개수는 2개로 증가했다. + +```text +/private/tmp/rhwp-task1585-stage3-svg + count: 2 +``` + +추가 방출된 image는 `image2` / `SEOUL MY SOUL` 로고에 해당한다. + +```xml + +``` + +기존 `image1`도 유지된다. + +```xml + +``` + +## 검증 결과 + +다음 검증을 통과했다. + +```text +cargo fmt --check +cargo test --test issue_1585_caption_floating_image +cargo test --test issue_1270_caption_inline_image +cargo test --test issue_530 +cargo test --test issue_1459_topbottom_picture_reflow +cargo test --test issue_1139_inline_picture_duplicate +cargo test --test issue_1352_table_cell_tac_picture_text +cargo test --test issue_1486_hwpx_partial_tac_table +cargo test --lib +cargo clippy --lib -- -D warnings +``` + +`cargo test --lib` 결과: + +```text +1959 passed; 0 failed; 6 ignored +``` + +## 판정 + +Stage 3 구현 및 검증은 완료됐다. + +- #1551 merge 기준으로 재기준화했다. +- #1270 첨부 실샘플의 `image2`가 SVG에 방출된다. +- 신규 #1585 회귀 테스트와 관련 기존 회귀 테스트가 통과한다. +- 변경 범위는 caption 내부 `TopAndBottom` picture와 depth 1 nested table caption 보강으로 제한했다. + +## 다음 단계 + +작업지시자 승인 후 Stage 4 최종 보고 및 PR 준비 단계로 진행한다. diff --git a/src/renderer/layout/picture_footnote.rs b/src/renderer/layout/picture_footnote.rs index 42ea1929a..19f938fb9 100644 --- a/src/renderer/layout/picture_footnote.rs +++ b/src/renderer/layout/picture_footnote.rs @@ -635,6 +635,7 @@ impl LayoutEngine { let mut para_y = y_start; for (pi, para) in caption.paragraphs.iter().enumerate() { + let para_y_before_layout = para_y; // 먼저 문단을 조합 let mut composed = compose_paragraph(para); @@ -661,7 +662,7 @@ impl LayoutEngine { composed.lines.len(), 0, 0, - ctx, + ctx.clone(), false, false, 0.0, @@ -670,6 +671,101 @@ impl LayoutEngine { Some(bin_data_content), None, // 캡션 컨텍스트 — wrap zone 무관 ); + + self.layout_caption_topbottom_pictures( + tree, + parent_node, + para, + &caption_area, + para_y_before_layout, + bin_data_content, + ctx.as_ref(), + ); + } + } + + fn layout_caption_topbottom_pictures( + &self, + tree: &mut PageRenderTree, + parent_node: &mut RenderNode, + para: &Paragraph, + caption_area: &LayoutRect, + para_y: f64, + bin_data_content: &[BinDataContent], + cell_ctx: Option<&super::CellContext>, + ) { + let anchor_y = para + .line_segs + .first() + .filter(|seg| seg.vertical_pos >= 0) + .map(|seg| caption_area.y + hwpunit_to_px(seg.vertical_pos, self.dpi)) + .unwrap_or(para_y); + + for (ctrl_idx, ctrl) in para.controls.iter().enumerate() { + let Control::Picture(pic) = ctrl else { + continue; + }; + if !matches!(pic.common.text_wrap, TextWrap::TopAndBottom) { + continue; + } + if tree + .get_inline_shape_position(0, 0, ctrl_idx, cell_ctx) + .is_some() + { + continue; + } + + let (pic_width_hu, pic_height_hu) = picture_display_size_hu(pic); + let pic_width = hwpunit_to_px(pic_width_hu, self.dpi); + let pic_height = hwpunit_to_px(pic_height_hu, self.dpi); + let placement_area = LayoutRect { + x: caption_area.x, + y: anchor_y, + width: caption_area.width, + height: pic_height.max(caption_area.height), + }; + let mut placement_common = pic.common.clone(); + placement_common.treat_as_char = false; + let (pic_x, pic_y) = self.compute_object_position( + &placement_common, + pic_width, + pic_height, + &placement_area, + &placement_area, + &placement_area, + &placement_area, + anchor_y, + Alignment::Left, + ); + let pic_area = LayoutRect { + x: pic_x, + y: pic_y, + width: pic_width, + height: pic_height, + }; + let mut pic_for_layout = (**pic).clone(); + pic_for_layout.common.treat_as_char = false; + pic_for_layout.common.horizontal_offset = 0; + pic_for_layout.common.vertical_offset = 0; + pic_for_layout.common.horz_align = HorzAlign::Left; + pic_for_layout.common.vert_align = VertAlign::Top; + + self.layout_picture( + tree, + parent_node, + &pic_for_layout, + &pic_area, + bin_data_content, + Alignment::Left, + Some(0), + Some(0), + Some(ctrl_idx), + cell_ctx, + ); + + if pic.common.treat_as_char { + tree.set_inline_shape_position(0, 0, ctrl_idx, cell_ctx, pic_x, pic_y); + } } } diff --git a/src/renderer/layout/table_layout.rs b/src/renderer/layout/table_layout.rs index 0b264ced2..b6a3477fc 100644 --- a/src/renderer/layout/table_layout.rs +++ b/src/renderer/layout/table_layout.rs @@ -46,7 +46,58 @@ use super::utils::find_bin_data; use super::{CellContext, CellPathEntry, LayoutEngine}; // 표 수평 정렬: model::shape 타입 사용 -use crate::model::shape::{CommonObjAttr, HorzAlign, HorzRelTo, TextWrap, VertRelTo}; +use crate::model::shape::{ + Caption, CaptionDirection, CommonObjAttr, HorzAlign, HorzRelTo, TextWrap, VertRelTo, +}; + +fn caption_has_topbottom_picture(caption: &Caption) -> bool { + caption.paragraphs.iter().any(|para| { + para.controls.iter().any(|ctrl| { + matches!( + ctrl, + Control::Picture(pic) if matches!(pic.common.text_wrap, TextWrap::TopAndBottom) + ) + }) + }) +} + +fn should_render_table_caption(table: &crate::model::table::Table, depth: usize) -> bool { + depth == 0 + || (depth == 1 + && table + .caption + .as_ref() + .is_some_and(caption_has_topbottom_picture)) +} + +fn caption_flow_extra(caption: &Option, caption_height: f64, caption_spacing: f64) -> f64 { + let is_lr_caption = caption.as_ref().is_some_and(|cap| { + matches!( + cap.direction, + CaptionDirection::Left | CaptionDirection::Right + ) + }); + if is_lr_caption || caption_height <= 0.0 { + 0.0 + } else { + caption_height + caption_spacing + } +} + +fn top_caption_flow_extra( + caption: &Option, + caption_height: f64, + caption_spacing: f64, +) -> f64 { + if caption + .as_ref() + .is_some_and(|cap| matches!(cap.direction, CaptionDirection::Top)) + { + caption_flow_extra(caption, caption_height, caption_spacing) + } else { + 0.0 + } +} fn build_col_row_y_from_cell_heights( table: &crate::model::table::Table, @@ -684,7 +735,8 @@ impl LayoutEngine { paper_w, ); - let (caption_height, caption_spacing) = if depth == 0 { + let render_caption = should_render_table_caption(table, depth); + let (caption_height, caption_spacing) = if render_caption { let ch = self.calculate_caption_height(&table.caption, styles); let cs = table .caption @@ -697,7 +749,7 @@ impl LayoutEngine { }; // Left 캡션: 표를 캡션 크기만큼 오른쪽으로 이동 - if depth == 0 { + if render_caption { if let Some(ref cap) = table.caption { if matches!(cap.direction, crate::model::shape::CaptionDirection::Left) { let cap_w = hwpunit_to_px(cap.width as i32, self.dpi); @@ -711,17 +763,8 @@ impl LayoutEngine { } else { crate::model::shape::TextWrap::Square }; - let inline_top_caption_offset = if inline_x_override.is_some() && depth == 0 { - if let Some(ref caption) = table.caption { - use crate::model::shape::CaptionDirection; - if matches!(caption.direction, CaptionDirection::Top) { - caption_height + caption_spacing - } else { - 0.0 - } - } else { - 0.0 - } + let inline_top_caption_offset = if inline_x_override.is_some() && render_caption { + top_caption_flow_extra(&table.caption, caption_height, caption_spacing) } else { 0.0 }; @@ -731,7 +774,7 @@ impl LayoutEngine { let table_y = if inline_x_override.is_some() { y_start + inline_top_caption_offset } else { - self.compute_table_y_position( + let computed_y = self.compute_table_y_position( table, table_height, y_start, @@ -741,7 +784,12 @@ impl LayoutEngine { caption_spacing, para_y, allow_para_top_bleed, - ) + ); + if depth > 0 && render_caption { + computed_y + top_caption_flow_extra(&table.caption, caption_height, caption_spacing) + } else { + computed_y + } }; let inline_table_flow_y_shift = if inline_x_override.is_some() { para_y @@ -870,7 +918,7 @@ impl LayoutEngine { bin_data_content, depth, table_meta, - enclosing_cell_ctx, + enclosing_cell_ctx.clone(), &row_col_x, &row_y, independent_col_row_y.as_deref(), @@ -981,7 +1029,7 @@ impl LayoutEngine { col_node.children.push(table_node); // ── 7. 캡션 렌더링 ── - if depth == 0 { + if render_caption { if let Some(ref caption) = table.caption { use crate::model::shape::{CaptionDirection, CaptionVertAlign}; let (cap_x, cap_w, cap_y) = match caption.direction { @@ -1010,15 +1058,26 @@ impl LayoutEngine { (cx, cw, cy) } }; - let cap_cell_ctx = table_meta.map(|(pi, ci)| CellContext { - parent_para_index: pi, - path: vec![CellPathEntry { - control_index: ci, - cell_index: 65534, // 캡션 식별 센티널 - cell_para_index: 0, - text_direction: 0, - }], - }); + let cap_cell_ctx = table_meta + .map(|(pi, ci)| CellContext { + parent_para_index: pi, + path: vec![CellPathEntry { + control_index: ci, + cell_index: 65534, // 캡션 식별 센티널 + cell_para_index: 0, + text_direction: 0, + }], + }) + .or_else(|| { + enclosing_cell_ctx.as_ref().map(|ctx| { + let mut cc = ctx.clone(); + if let Some(last) = cc.path.last_mut() { + last.cell_index = 65534; + last.cell_para_index = 0; + } + cc + }) + }); self.layout_caption( tree, col_node, @@ -1076,7 +1135,11 @@ impl LayoutEngine { // 중첩 표: outer_margin 포함 높이 반환 let om_top = hwpunit_to_px(table.outer_margin_top as i32, self.dpi); let om_bottom = hwpunit_to_px(table.outer_margin_bottom as i32, self.dpi); - (table_height + om_top + om_bottom).max(0.0) + (table_height + + caption_flow_extra(&table.caption, caption_height, caption_spacing) + + om_top + + om_bottom) + .max(0.0) } } diff --git a/tests/issue_1585_caption_floating_image.rs b/tests/issue_1585_caption_floating_image.rs new file mode 100644 index 000000000..dc5e057c5 --- /dev/null +++ b/tests/issue_1585_caption_floating_image.rs @@ -0,0 +1,236 @@ +//! Issue #1585: caption paragraphs must render TopAndBottom picture controls. +//! +//! #1551 restored inline TAC picture payload threading for caption paragraphs. +//! This follow-up covers caption pictures whose wrap mode is TopAndBottom, +//! including the HWPX case where a caption-local logo is stored in a nested +//! table caption. + +use std::fs; +use std::path::Path; + +use rhwp::model::control::Control; +use rhwp::model::paragraph::Paragraph; +use rhwp::model::shape::{ + Caption, CaptionDirection, HorzAlign, HorzRelTo, TextWrap, VertAlign, VertRelTo, +}; +use rhwp::renderer::render_tree::{RenderNode, RenderNodeType}; +use rhwp::wasm_api::HwpDocument; + +const SAMPLE: &str = "samples/hwpx/hy-001.hwpx"; +const CAPTION_CELL_SENTINEL: usize = 65534; + +fn first_picture_para(paragraphs: &[Paragraph]) -> Option { + for para in paragraphs { + if para + .controls + .iter() + .any(|ctrl| matches!(ctrl, Control::Picture(pic) if pic.image_attr.bin_data_id > 0)) + { + return Some(para.clone()); + } + for ctrl in ¶.controls { + if let Control::Table(table) = ctrl { + for cell in &table.cells { + if let Some(found) = first_picture_para(&cell.paragraphs) { + return Some(found); + } + } + } + } + } + None +} + +fn make_caption_floating_picture_para(mut para: Paragraph) -> (Paragraph, u16) { + para.text.clear(); + para.char_offsets.clear(); + para.char_count = 0; + let mut bin_id = None; + for ctrl in &mut para.controls { + if let Control::Picture(pic) = ctrl { + pic.common.treat_as_char = false; + pic.common.text_wrap = TextWrap::TopAndBottom; + pic.common.vert_rel_to = VertRelTo::Para; + pic.common.horz_rel_to = HorzRelTo::Column; + pic.common.vert_align = VertAlign::Top; + pic.common.horz_align = HorzAlign::Left; + pic.common.vertical_offset = 0; + pic.common.horizontal_offset = 0; + bin_id = Some(pic.image_attr.bin_data_id); + break; + } + } + ( + para, + bin_id.expect("fixture paragraph must contain a picture"), + ) +} + +fn top_caption(caption_para: Paragraph) -> Caption { + Caption { + direction: CaptionDirection::Top, + width: 10_000, + spacing: 0, + max_width: 50_000, + paragraphs: vec![caption_para], + ..Default::default() + } +} + +fn attach_top_caption_to_first_table(paragraphs: &mut [Paragraph], caption: Caption) -> bool { + for para in paragraphs { + for ctrl in &mut para.controls { + if let Control::Table(table) = ctrl { + table.caption = Some(caption); + return true; + } + } + } + false +} + +fn clone_first_table(paragraphs: &[Paragraph]) -> Option { + for para in paragraphs { + for ctrl in ¶.controls { + if let Control::Table(table) = ctrl { + return Some((**table).clone()); + } + } + } + None +} + +fn attach_nested_caption_table_to_first_table( + paragraphs: &mut [Paragraph], + mut nested_table: rhwp::model::table::Table, + caption: Caption, +) -> bool { + nested_table.caption = Some(caption); + nested_table.common.treat_as_char = true; + nested_table.common.text_wrap = TextWrap::TopAndBottom; + nested_table.common.vert_rel_to = VertRelTo::Para; + nested_table.common.horz_rel_to = HorzRelTo::Para; + nested_table.common.vert_align = VertAlign::Top; + nested_table.common.horz_align = HorzAlign::Left; + nested_table.common.vertical_offset = 0; + nested_table.common.horizontal_offset = 0; + + for para in paragraphs { + for ctrl in &mut para.controls { + if let Control::Table(table) = ctrl { + let Some(cell) = table.cells.first_mut() else { + return false; + }; + let Some(cell_para) = cell.paragraphs.first_mut() else { + return false; + }; + cell_para.text.clear(); + cell_para.char_offsets.clear(); + cell_para.char_count = 0; + cell_para.controls.clear(); + cell_para + .controls + .push(Control::Table(Box::new(nested_table))); + return true; + } + } + } + false +} + +fn collect_caption_images(node: &RenderNode, out: &mut Vec<(u16, Option, bool)>) { + if let RenderNodeType::Image(img) = &node.node_type { + let is_caption_image = img.cell_index == Some(CAPTION_CELL_SENTINEL) + || img.cell_context.as_ref().is_some_and(|ctx| { + ctx.path + .last() + .is_some_and(|entry| entry.cell_index == CAPTION_CELL_SENTINEL) + }); + if is_caption_image { + out.push((img.bin_data_id, img.text_wrap, img.data.is_some())); + } + } + for child in &node.children { + collect_caption_images(child, out); + } +} + +fn load_doc() -> HwpDocument { + let repo_root = env!("CARGO_MANIFEST_DIR"); + let path = Path::new(repo_root).join(SAMPLE); + let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {}", SAMPLE, e)); + HwpDocument::from_bytes(&bytes).unwrap_or_else(|e| panic!("parse {}: {}", SAMPLE, e)) +} + +#[test] +fn table_caption_topbottom_picture_emits_image_node() { + let mut doc = load_doc(); + let source_para = first_picture_para(&doc.document().sections[0].paragraphs) + .expect("fixture must contain a picture paragraph"); + let (caption_para, bin_id) = make_caption_floating_picture_para(source_para); + assert!( + doc.document() + .bin_data_content + .iter() + .any(|content| content.id == bin_id), + "fixture must contain BinData payload for bin_id={bin_id}" + ); + + assert!( + attach_top_caption_to_first_table( + &mut doc.document_mut().sections[0].paragraphs, + top_caption(caption_para), + ), + "fixture must contain a top-level table" + ); + + let tree = doc.build_page_render_tree(0).expect("render page 1"); + let mut caption_images = Vec::new(); + collect_caption_images(&tree.root, &mut caption_images); + + assert_eq!( + caption_images, + vec![(bin_id, Some(TextWrap::TopAndBottom), true)], + "caption TopAndBottom picture must be emitted exactly once with payload" + ); +} + +#[test] +fn nested_table_caption_topbottom_picture_emits_image_node() { + let mut doc = load_doc(); + let source_para = first_picture_para(&doc.document().sections[0].paragraphs) + .expect("fixture must contain a picture paragraph"); + let (caption_para, bin_id) = make_caption_floating_picture_para(source_para); + let nested_table = clone_first_table(&doc.document().sections[0].paragraphs) + .expect("fixture must contain a cloneable table"); + + assert!( + attach_nested_caption_table_to_first_table( + &mut doc.document_mut().sections[0].paragraphs, + nested_table, + top_caption(caption_para), + ), + "fixture must allow inserting a nested caption table" + ); + + let tree = doc.build_page_render_tree(0).expect("render page 1"); + let mut caption_images = Vec::new(); + collect_caption_images(&tree.root, &mut caption_images); + + assert!( + caption_images + .iter() + .any(|entry| *entry == (bin_id, Some(TextWrap::TopAndBottom), true)), + "nested table caption TopAndBottom image must be emitted with payload: {caption_images:?}" + ); + assert_eq!( + caption_images + .iter() + .filter(|(seen_bin_id, wrap, _)| { + *seen_bin_id == bin_id && *wrap == Some(TextWrap::TopAndBottom) + }) + .count(), + 1, + "nested table caption image must not be duplicated: {caption_images:?}" + ); +}