diff --git a/mydocs/orders/20260626.md b/mydocs/orders/20260626.md index c0a2bbc5e..546edb3be 100644 --- a/mydocs/orders/20260626.md +++ b/mydocs/orders/20260626.md @@ -1,5 +1,10 @@ # 오늘 할일 - 2026년 6월 26일 +## M100 — v1.0.0 조판 엔진 체계화 + +| Issue | 타스크 | 상태 | 비고 | +|------|--------|------|------| +| #1270 | HWPX 캡션 내 인라인 이미지 렌더링 누락 정정 | 완료 | 인라인 depth 1 구현·검증 완료, 플로팅은 후속. 완료: 10:58 | ## 공통 — 운영 작업 | Issue | 타스크 | 상태 | 비고 | diff --git a/mydocs/plans/task_m100_1270.md b/mydocs/plans/task_m100_1270.md new file mode 100644 index 000000000..ee21920f1 --- /dev/null +++ b/mydocs/plans/task_m100_1270.md @@ -0,0 +1,188 @@ +# Task #1270 수행 계획서 — HWPX 캡션 내 인라인 이미지 렌더링 누락 정정 + +## 이슈 정합 + +- **Issue**: [#1270](https://github.com/edwardkim/rhwp/issues/1270) — HWPX: 온라인 데모에서 HWPX 1페이지 상단 이미지가 표시되지 않음 +- **마일스톤**: M100 — v1.0.0 조판 엔진 체계화 +- **작업 브랜치**: `local/task1270` +- **작업 worktree**: `/private/tmp/rhwp-task1270` +- **기준 커밋**: `upstream/devel` `48e96704` +- **대표 첨부**: issue #1270 첨부 HWPX (`서울 문화예술단체 지원사업.hwpx`) +- **메인테이너 확정 방향**: 이번 범위는 캡션 내 `treat_as_char` 인라인 이미지 스레딩만 처리하고, 플로팅 캡션 이미지는 후속 이슈로 분리한다. + +## 현상 + +첨부 HWPX를 rhwp로 SVG 렌더링하면 1페이지 제목 영역의 작은 이미지가 누락된다. + +원본 HWPX 구조상 누락 이미지는 다음 위치에 있다. + +- 외부 표 첫 번째 페이지 영역 내부의 내부표 캡션 +- `` 내부 문단의 `` +- `binaryItemIDRef="image2"` +- `BinData/image2.png`는 정상 PNG + +현재 로컬 재현 결과, 1페이지 SVG에는 `` 요소가 1개만 생성된다. 이는 첫 셀의 `image1`에 해당하며, 캡션 내부 `image2`는 `ImageNode`로 방출되지 않는다. + +## 사전 분석 + +### 원인 후보 확정 + +`src/renderer/layout/picture_footnote.rs::layout_caption()`은 캡션 문단을 `compose_paragraph()` 한 뒤 `layout_composed_paragraph()`로 넘긴다. + +그러나 현재 호출은 마지막 인자들인 원본 문단 `para`와 이미지 데이터 `bin_data_content`를 `None`으로 고정 전달한다. + +`src/renderer/layout/paragraph_layout.rs::layout_composed_paragraph()`의 주석과 구현에 따르면: + +- `para`: treat_as_char 이미지 인라인 렌더링에 필요한 원본 문단 +- `bin_data_content`: 해당 이미지 payload 조회에 필요한 바이너리 데이터 + +따라서 캡션 문단 안의 컨트롤은 파싱되어도 paragraph layout의 인라인 이미지 렌더링 분기가 활성화되지 않는다. + +### 메인테이너 결정 범위 + +메인테이너 코멘트 기준으로 이번 PR은 다음만 수행한다. + +- `layout_caption()`에 원본 캡션 문단 `para`와 `bin_data_content`를 스레딩한다. +- 캡션 내 `treat_as_char` 인라인 이미지를 depth 1로 렌더링한다. +- 인라인 경로는 `layout_caption()`을 재호출하지 않으므로 별도 재귀 가드 없이 depth 1로 제한된다. +- 캡션 속 그림의 캡션은 이번 범위에서 렌더링하지 않는 것이 의도된 동작이다. +- 플로팅 캡션 이미지, 셀 플로팅 배치 로직의 캡션 영역 일반화는 후속 이슈 범위다. + +## 작업 목표 + +캡션 문단 안에 있는 `treat_as_char=true` 이미지가 SVG/렌더 트리에서 누락되지 않도록 한다. + +정정 후 기대 상태: + +- 캡션 문단의 인라인 그림이 `ImageNode`로 방출된다. +- 기존 본문/셀/글상자 문단의 인라인 그림 렌더링 경로는 유지된다. +- 캡션 안 플로팅 그림은 이번 정정으로 해결하려 하지 않는다. +- 이슈 #1270은 플로팅 후속 범위가 남으므로 이번 작업 완료 후에도 close하지 않는다. + +## 범위 + +### 포함 + +- `layout_caption()`의 인자 스레딩 구조 진단 확정 +- 캡션 문단의 `para` / `bin_data_content` 전달 경로 설계 +- `layout_caption()` 호출부 전체 정합화 +- 캡션 내 인라인 이미지 회귀 테스트 또는 렌더 산출 비교 추가 +- SVG/렌더 트리 기반 집중 검증 +- 기존 그림/표/캡션 회귀 테스트 확인 + +### 제외 + +- 캡션 내 플로팅 이미지 배치 일반화 +- 셀 플로팅 배치 로직의 캡션 영역 적용 +- 캡션 속 그림의 캡션 재귀 렌더링 +- 첨부 샘플 `image2`의 완전 시각 해소를 이번 PR의 성공 조건으로 삼는 것 +- HWPX 파서 구조 변경 +- 공통 문서 모델 또는 HWP3 전용 로직 변경 + +## 의심 코드 영역 + +| 파일 | 역할 | 확인 포인트 | +|------|------|-------------| +| `src/renderer/layout/picture_footnote.rs` | `layout_caption()` 정의 | `layout_composed_paragraph()`에 `para` / `bin_data_content`를 `None`으로 전달 | +| `src/renderer/layout/paragraph_layout.rs` | 인라인 TAC 이미지 렌더링 | `para` / `bin_data_content`가 Some일 때 `ImageNode` 생성 | +| `src/renderer/layout/table_layout.rs` | 표 캡션 호출 | 표 캡션 경로에서 바이너리 데이터 전달 필요 | +| `src/renderer/layout/table_partial.rs` | 분할 표 캡션 호출 | partial table 캡션 경로 호출부 정합 필요 | +| `src/renderer/layout.rs` | 본문 그림/표 캡션 호출 | 기존 layout entry 경로 호출부 정합 필요 | +| `src/renderer/layout/shape_layout.rs` | 도형 캡션 호출 | 도형/그림형 shape 캡션 호출부 정합 필요 | + +## 단계 분리 + +### Stage 1 — 진단 확정 + +- 첨부 HWPX의 `image2` XML 위치와 `BinData/image2.png` 정상 여부 기록 +- 현재 SVG 1페이지 `` 개수와 누락 상태 기록 +- `layout_caption()`의 `None` 전달 지점과 `layout_composed_paragraph()` 인라인 이미지 의존성 확인 +- 테스트 fixture 후보와 성공 조건 확정 + +**산출물**: `mydocs/working/task_m100_1270_stage1.md` + +### Stage 2 — 구현 계획서 작성 + +- `layout_caption()` 시그니처 확장 방식 결정 +- 모든 호출부별 `bin_data_content` 전달 가능성 정리 +- depth 1 제한이 자연스럽게 유지되는지 확인 +- 회귀 테스트 방식 확정 +- 검증 명령과 위험 회피책 확정 + +**산출물**: `mydocs/plans/task_m100_1270_impl.md` + +### Stage 3 — 구현 + 집중 테스트 + +- 승인된 구현 계획에 따라 `layout_caption()` 및 호출부 수정 +- 캡션 내 인라인 이미지 회귀 테스트 추가 +- 신규 테스트와 대상 경로 집중 테스트 실행 +- 첨부 샘플은 “이번 범위로 완전 해소되지 않을 수 있음”을 명시하고 결과를 분리 기록 + +**산출물**: `mydocs/working/task_m100_1270_stage3.md` + +### Stage 4 — 회귀 검증 + +- 신규 #1270 테스트 실행 +- `cargo test --lib` 또는 영향 범위에 맞는 lib/layout 테스트 실행 +- 관련 이슈 테스트 선별 실행 + - 인라인 그림 중복 방지 + - 표 셀 TAC 그림 + - 캡션/표 렌더링 경로 +- 필요 시 `cargo clippy --lib -- -D warnings` +- SVG 출력에서 기존 1페이지 이미지 방출 상태와 변경 범위 확인 + +**산출물**: `mydocs/working/task_m100_1270_stage4.md` + +### Stage 5 — 최종 보고 + +- 최종 결과 보고서 작성 +- 오늘 할일 문서 완료 상태 갱신 +- PR 설명용 요약 작성 + - depth 1 인라인 한정 + - 플로팅 캡션 이미지는 후속 + - #1270은 이번 PR merge 후에도 close하지 않음 +- 작업지시자 승인 후 merge/PR 절차 진행 + +**산출물**: `mydocs/report/task_m100_1270_report.md` + `mydocs/orders/20260626.md` 갱신 + +## 검증 게이트 + +| 검증 | 기준 | +|------|------| +| 첨부 HWPX XML 확인 | `image2`가 캡션 문단 안의 그림으로 존재함 | +| 현재 SVG 재현 | 1페이지 기존 출력에서 `image2` 누락 상태 확인 | +| 신규 회귀 테스트 | 캡션 내 `treat_as_char` 이미지가 `ImageNode`로 방출됨 | +| 기존 인라인 이미지 테스트 | 중복 렌더링 또는 위치 등록 회귀 없음 | +| 기존 표/캡션 테스트 | 표 캡션 y/height 회귀 없음 | +| clippy | warning 0 | + +## 위험 영역 + +| 위험 | 가능성 | 회피책 | +|------|--------|--------| +| 캡션 내 TAC 이미지가 기존 shape pass와 중복 렌더링 | 중간 | `inline_shape_position` 등록 여부와 기존 중복 방지 테스트 확인 | +| `cell_ctx` 센티널 변경으로 hit-test/cursor metadata 회귀 | 중간 | 기존 cell context 전달 방식 유지, 인자만 추가 | +| 빈 캡션/텍스트 캡션의 y advance 변화 | 낮음~중간 | `layout_composed_paragraph()`의 기존 y 반환을 유지하고 입력 데이터만 추가 | +| 플로팅 이미지까지 의도치 않게 방출 | 중간 | 테스트와 코드 리뷰에서 `treat_as_char` 경로만 성공 조건으로 제한 | +| 첨부 샘플 완전 해소 여부 혼동 | 높음 | 보고서와 PR 설명에 “플로팅 후속”을 반복 명시 | + +## 처리 정합 + +- 코드 수정은 수행계획서와 구현 계획서 승인 후 진행한다. +- 단계별 완료보고서는 해당 단계 소스 변경과 함께 task 브랜치에서 커밋한다. +- 최종 결과보고서와 오늘 할일 갱신도 task 브랜치에서 커밋한다. +- 이슈 #1270 close는 이번 범위에서 수행하지 않는다. +- PR base는 `devel`로 한다. + +## 승인 게이트 + +1. 본 수행계획서 승인 → Stage 1 진단 확정 진행 +2. Stage 1 완료보고서 승인 → Stage 2 구현 계획서 작성 +3. 구현 계획서 승인 → Stage 3 구현 시작 +4. Stage 3 완료보고서 승인 → Stage 4 회귀 검증 +5. Stage 4 완료보고서 승인 → Stage 5 최종 보고 +6. 최종 보고서 승인 → PR/merge 후속 절차 진행 + +## 다음 단계 + +작업지시자 승인 후 Stage 1 진단 확정을 진행한다. diff --git a/mydocs/plans/task_m100_1270_impl.md b/mydocs/plans/task_m100_1270_impl.md new file mode 100644 index 000000000..b50e6904e --- /dev/null +++ b/mydocs/plans/task_m100_1270_impl.md @@ -0,0 +1,228 @@ +# Task #1270 구현 계획서 — 캡션 문단 인라인 이미지 스레딩 + +## 기준 + +- **수행 계획서**: `mydocs/plans/task_m100_1270.md` +- **Stage 1 보고서**: `mydocs/working/task_m100_1270_stage1.md` +- **브랜치/worktree**: `local/task1270` / `/private/tmp/rhwp-task1270` +- **base**: `upstream/devel` `48e96704` +- **메인테이너 지시 범위**: 캡션 내 `treat_as_char` 인라인 이미지 스레딩, depth 1 한정. 플로팅 캡션 이미지는 후속. + +## 구현 원칙 + +1. `layout_caption()`에서 이미 순회 중인 캡션 문단 `para`를 `layout_composed_paragraph()`에 넘긴다. +2. `layout_caption()` 호출부에서 이미 보유한 `bin_data_content`를 캡션 경로로 전달한다. +3. 기존 캡션 배치 좌표, caption height 계산, y advance 계산은 변경하지 않는다. +4. 캡션 속 그림의 caption 재귀 렌더링은 구현하지 않는다. +5. 플로팅 캡션 이미지 배치 일반화는 구현하지 않는다. + +## 수정 대상 + +### 1. `src/renderer/layout/picture_footnote.rs` + +`layout_caption()` 시그니처에 이미지 바이너리 인자를 추가한다. + +```rust +pub(crate) fn layout_caption( + &self, + tree: &mut PageRenderTree, + parent_node: &mut RenderNode, + caption: &Caption, + styles: &ResolvedStyleSet, + _col_area: &LayoutRect, + content_x: f64, + content_width: f64, + y_start: f64, + auto_counter: &mut AutoNumberCounter, + bin_data_content: &[BinDataContent], + cell_ctx: Option, +) +``` + +`layout_composed_paragraph()` 호출의 마지막 인자 묶음을 다음처럼 바꾼다. + +```rust +0.0, +None, // multi_col_width_hu +Some(para), // 원본 캡션 문단 +Some(bin_data_content), // 이미지 바이너리 +None, // wrap_anchor +``` + +현재 `section_index` / `para_index` 인자는 기존처럼 `0, 0`을 유지한다. 캡션 이미지의 실제 소유 위치는 기존 `cell_ctx` 또는 호출부 context가 보존하는 범위 안에서만 다룬다. 이번 작업에서 캡션 문단 identity 체계를 새로 설계하지 않는다. + +### 2. `layout_caption()` 호출부 + +모든 호출부에 `bin_data_content`를 추가 전달한다. + +| 파일 | 호출 위치 | 전달 근거 | +|------|-----------|-----------| +| `src/renderer/layout/picture_footnote.rs` | `layout_body_picture()` 내부 | 함수 인자로 이미 `bin_data_content: &[BinDataContent]` 보유 | +| `src/renderer/layout/table_layout.rs` | `layout_table()` 캡션 렌더링 | table layout 함수 인자로 이미 `bin_data_content` 보유 | +| `src/renderer/layout/table_partial.rs` | top/bottom/left/right partial caption | partial table layout 함수 인자로 이미 `bin_data_content` 보유 | +| `src/renderer/layout/shape_layout.rs` | `layout_shape()` 캡션 렌더링 | 함수 인자로 이미 `bin_data_content` 보유 | +| `src/renderer/layout.rs` | 본문 picture caption 경로 | 해당 렌더 함수 scope에 이미 `bin_data_content` 보유 | + +호출부의 `cell_ctx` 생성 방식은 그대로 둔다. + +### 3. 빈 문단 TAC 캡션 주의점 + +`paragraph_layout.rs`에는 runs가 없는 빈 줄에서 TAC picture를 렌더링하는 분기가 있다. + +현재 조건은 다음 의도를 가진다. + +```rust +// 테이블 셀 내부에서는 table_layout.rs가 layout_picture로 이미 처리하므로 스킵. +// 셀 외부에서 해당 줄 범위에 걸린 TAC만 여기서 렌더링. +if cell_ctx.is_none() && !line_tac_offsets.is_empty() { + ... +} +``` + +캡션은 실제 표 셀이 아니지만 hit-test/context 보존을 위해 `cell_ctx`를 사용한다. 특히 표 캡션은 `cell_index = 65534` 센티널을 쓴다. + +따라서 Stage 3 구현 검증은 두 단계로 판정한다. + +1. 우선 `layout_caption()` 스레딩만 적용한다. +2. 신규 테스트 또는 첨부 샘플 관찰에서 빈 문단 caption TAC picture가 여전히 누락되고, 해당 컨트롤이 `treat_as_char=true`로 분류되는 경우에만 `paragraph_layout.rs`의 빈 줄 TAC picture 조건을 캡션 센티널에 한해 완화한다. + +조건 완화가 필요할 경우의 후보: + +```rust +let is_caption_ctx = cell_ctx + .as_ref() + .and_then(|ctx| ctx.path.last()) + .is_some_and(|entry| entry.cell_index == 65534); + +if (cell_ctx.is_none() || is_caption_ctx) && !line_tac_offsets.is_empty() { + ... +} +``` + +이 완화는 실제 표 셀 내부 중복 렌더링 방지 조건을 보존하면서, 캡션 센티널만 셀 외부 흐름으로 취급하기 위한 좁은 예외다. 단, 메인테이너 지시의 1차 범위는 스레딩이므로 이 예외는 Stage 3 증거가 있을 때만 적용한다. + +## 회귀 테스트 계획 + +### 신규 테스트 파일 + +```text +tests/issue_1270_caption_inline_image.rs +``` + +### 테스트 방식 + +1. 기존 작은 HWPX fixture를 로드한다. + - 1차 후보: `samples/hwpx/hy-001.hwpx` + - 이유: 이미 표 셀 내부 TAC picture + text 렌더링 회귀 테스트에서 사용 중이고, fixture 크기/렌더 비용이 작다. +2. 테스트 안에서 문서 IR을 조작해 첫 번째 표에 TOP caption을 부여한다. +3. caption paragraphs에는 기존 문서에서 파싱된 TAC picture 포함 문단을 복제하거나, 최소 `Paragraph` + `Picture`를 구성한다. +4. `build_page_render_tree(0)` 실행 후 `RenderNodeType::Image`를 순회한다. +5. caption sentinel context를 가진 이미지가 방출되는지 확인한다. + +검증 기준: + +```rust +matches!(node.node_type, RenderNodeType::Image(img) + if img.cell_index == Some(65534) + && img.cell_context.as_ref().is_some_and(|ctx| ...) +) +``` + +가능하면 다음도 확인한다. + +- `img.data.is_some()` 또는 `img.bin_data_id`가 문서 `bin_data_content`에 존재 +- 이미지가 1개만 방출되어 중복 렌더링되지 않음 +- 기존 셀 내부 이미지는 기존 context로 유지됨 + +### 첨부 샘플 검증 + +첨부 HWPX는 Stage 3/4에서 보조로 확인한다. + +```bash +cargo run --quiet --bin rhwp -- export-svg \ + '/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx' \ + -p 0 \ + -o /private/tmp/rhwp-task1270-stage3-svg +``` + +단, 첨부 샘플의 `image2`는 `textWrap="TOP_AND_BOTTOM"` 특성과 메인테이너의 플로팅 후속 범위 언급이 있으므로, 이번 PR의 통과 조건은 신규 인라인 캡션 테스트로 둔다. 첨부 샘플 결과는 “보조 관찰”로만 보고한다. + +## 구현 단계 + +### Stage 3-1 — 캡션 스레딩 구현 + +- `layout_caption()` 시그니처에 `bin_data_content` 추가 +- `layout_composed_paragraph()` 호출에 `Some(para)` / `Some(bin_data_content)` 전달 +- 모든 호출부 컴파일 정합화 +- `cargo check` 또는 신규 테스트 컴파일로 타입 오류 확인 + +### Stage 3-2 — 신규 회귀 테스트 추가 + +- `tests/issue_1270_caption_inline_image.rs` 작성 +- 작은 fixture + IR 조작 방식으로 캡션 내 인라인 이미지 방출 검증 +- 수정 전 실패, 수정 후 성공하는 조건 확인 + +### Stage 3-3 — 필요 시 빈 문단 caption sentinel 예외 적용 + +- 스레딩만으로 신규 테스트가 통과하면 이 단계는 생략한다. +- 빈 문단 caption TAC picture만 실패할 경우, `paragraph_layout.rs`의 빈 줄 TAC picture 조건에 `cell_index == 65534` 예외를 추가한다. +- 실제 표 셀 내부 TAC picture 중복 방지 조건은 유지한다. + +### Stage 3-4 — 집중 검증 및 보고 + +- 신규 #1270 테스트 실행 +- 관련 기존 테스트 선별 실행 +- 첨부 HWPX SVG 1페이지 보조 관찰 +- `mydocs/working/task_m100_1270_stage3.md` 작성 + +## Stage 4 회귀 검증 계획 + +Stage 3 승인 후 다음 검증을 실행한다. + +```bash +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 --lib +cargo clippy --lib -- -D warnings +``` + +필요 시 표/캡션 경로 추가 검증: + +```bash +cargo test --test issue_530 +cargo test --test issue_1486_hwpx_partial_tac_table +``` + +## 성공 기준 + +- 신규 #1270 테스트가 통과한다. +- 캡션 내 인라인 `treat_as_char` picture가 `ImageNode`로 방출된다. +- 실제 표 셀 내부 TAC picture가 중복 렌더링되지 않는다. +- 기존 인라인 picture 중복 방지 테스트가 통과한다. +- `cargo test --lib`와 clippy가 통과한다. +- 보고서에 depth 1 인라인 한정과 플로팅 후속 범위를 명시한다. + +## 비성공/후속 기준 + +다음은 이번 작업의 실패가 아니라 후속 범위로 기록한다. + +- 캡션 안 `treat_as_char=false` picture 누락 +- 캡션 안 `textWrap=TOP_AND_BOTTOM` 플로팅 배치 불완전 +- 캡션 속 그림의 caption 미렌더링 +- 첨부 샘플 `image2`가 메인테이너가 정의한 플로팅 후속 범위 때문에 여전히 누락되는 경우 + +## 위험 및 대응 + +| 위험 | 대응 | +|------|------| +| `layout_caption()` 호출부 누락으로 컴파일 실패 | `rg "layout_caption\\(" src/renderer`로 모든 호출부 확인 후 컴파일 | +| 실제 표 셀 내부 TAC picture 중복 렌더링 | 빈 줄 TAC 조건은 기본적으로 유지하고, 필요 시 caption sentinel만 예외 | +| 캡션 이미지 metadata가 기존 문단 index `0,0`으로 남음 | 이번 범위에서는 기존 caption text 경로와 동일하게 유지, cell context로만 식별 | +| 첨부 샘플 완전 해소 여부 혼동 | 신규 인라인 테스트를 성공 기준으로 삼고 첨부 샘플은 보조 관찰로 분리 | +| 플로팅 일반화로 범위 확장 | `treat_as_char` 인라인 렌더링 외 로직 변경 금지 | + +## 승인 후 다음 작업 + +작업지시자 승인 후 Stage 3 구현을 시작한다. diff --git a/mydocs/pr/archives/pr_1551_review.md b/mydocs/pr/archives/pr_1551_review.md new file mode 100644 index 000000000..45942f18e --- /dev/null +++ b/mydocs/pr/archives/pr_1551_review.md @@ -0,0 +1,253 @@ +# PR #1551 검토 문서 — 캡션 내 인라인 이미지 렌더링 복구 + +## 1. PR 메타 + +| 항목 | 내용 | +|------|------| +| PR | #1551 — Task #1270: 캡션 내 인라인 이미지 렌더링 복구 | +| 작성자 | @postmelee | +| base | `devel` | +| head | `postmelee:local/task1270` | +| 검토 경로 | collaborator self-merge 후보 예외 경로 | +| 문서 작성 위치 | `mydocs/pr/archives/pr_1551_review.md` | +| 문서 작성일 | 2026-06-27 | + +작성 시점 참고값: + +| 항목 | 값 | +|------|----| +| base SHA | `d8e792fe41f50f9e8977db9b73df3d1cf5c53c95` | +| code 검증 대상 head SHA | `fdfe568863095d42145709077bd1f2fe55f85e0a` | +| draft | false | +| mergeable | MERGEABLE | +| merge state | CLEAN | +| review decision | 없음 | +| review request | 없음 | + +위 값은 문서 작성 시점 참고값이다. 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를 수행하지 않는다. +- 작업지시자 요청에 따라 review request는 등록하지 않는다. + +이번 PR은 규모와 성격상 별도 `pr_1551_review_impl.md`를 만들지 않고, 이 문서에 리뷰 계획과 검토 결과를 통합한다. + +## 3. 관련 이슈 + +| 이슈 | 상태 | 비고 | +|------|------|------| +| #1270 — HWPX: 온라인 데모에서 HWPX 1페이지 상단 이미지가 표시되지 않음 | OPEN | 이 PR은 `Refs #1270`만 사용한다. merge 후에도 닫지 않는다. | +| #1585 — HWPX: 캡션 내 플로팅 이미지 렌더링 지원 | OPEN | 이번 범위 밖인 플로팅 캡션 이미지를 후속 추적한다. | + +PR 본문은 #1270을 자동 close하지 않도록 `Refs #1270`으로 연결한다. #1270의 첨부 샘플에서 오른쪽 상단 `SEOUL MY SOUL` 로고는 캡션 내부 그림이지만 `textWrap="TOP_AND_BOTTOM"`인 플로팅 배치이므로 이번 PR의 해결 범위가 아니다. + +## 4. 변경 범위 + +핵심 변경: + +- `layout_caption()`이 캡션 문단을 `layout_composed_paragraph()`에 넘길 때 원본 `para`와 `bin_data_content`를 함께 전달한다. +- 본문 그림, 표, 분할 표, 도형 캡션 호출부에 `bin_data_content` 인자를 추가한다. +- 표 캡션의 picture-only TAC 문단을 위해 `paragraph_layout.rs`의 빈 줄 TAC picture 분기에 `cell_index == 65534` 캡션 센티널 예외를 추가한다. +- `tests/issue_1270_caption_inline_image.rs`를 추가해 텍스트 포함 캡션과 picture-only 캡션의 인라인 TAC picture 방출을 검증한다. + +변경 파일: + +| 파일 | 검토 요약 | +|------|-----------| +| `src/renderer/layout/picture_footnote.rs` | `layout_caption()`에 `bin_data_content` 추가, 캡션 문단의 실제 `para` 전달 | +| `src/renderer/layout.rs` | 본문 picture caption 호출부 인자 정합화 | +| `src/renderer/layout/shape_layout.rs` | shape caption 호출부 인자 정합화 | +| `src/renderer/layout/table_layout.rs` | table caption 호출부 인자 정합화, caption sentinel context 유지 | +| `src/renderer/layout/table_partial.rs` | partial table caption 호출부 인자 정합화 | +| `src/renderer/layout/paragraph_layout.rs` | picture-only 표 캡션 TAC 방출을 위한 caption sentinel 예외 추가 | +| `tests/issue_1270_caption_inline_image.rs` | 캡션 인라인 TAC picture 회귀 테스트 2건 추가 | +| `mydocs/**/task_m100_1270*` | 내부 task 계획, 단계 보고, 최종 보고 | + +## 5. 범위 밖 + +이번 PR은 다음을 해결하지 않는다. + +- 캡션 안 `textWrap="TOP_AND_BOTTOM"` 플로팅 그림 배치 +- 캡션 내부 플로팅 그림의 앵커, 줄 매핑, wrap 배치 일반화 +- 캡션 속 그림의 caption 재귀 렌더링 +- #1270 issue close + +이 범위 제한은 PR 본문과 task 보고서에 명시되어 있다. + +## 6. 리뷰 계획 + +검토는 다음 순서로 진행했다. + +1. PR metadata 확인 + - base/head, 작성자, label, milestone, assignee, review request, mergeable, check rollup 확인 +2. 변경 코드 검토 + - `layout_caption()` 인자 스레딩 경로 확인 + - 모든 호출부의 `bin_data_content` 보유 여부 확인 + - 표 캡션 sentinel context와 실제 표 셀 중복 렌더 방지 조건 확인 +3. 테스트 검토 + - 신규 테스트가 렌더 트리의 `ImageNode`와 payload 존재를 직접 확인하는지 검토 + - 텍스트 포함 캡션과 picture-only 캡션 양쪽을 고정하는지 확인 +4. 문서 검토 + - task 계획/보고서가 PR 범위와 검증 결과를 일관되게 설명하는지 확인 + - #1270 유지와 #1585 후속 추적 조건 확인 +5. 검증 + - PR head 기준 GitHub Actions 통과 확인 + - 로컬 targeted test와 diff hygiene 확인 + +## 7. 코드 리뷰 결과 + +### 7.1 `layout_caption()` 스레딩 + +기존 결함은 캡션 문단을 compose한 뒤 paragraph layout에 넘길 때 원본 `Paragraph`와 `BinDataContent`가 `None`으로 들어가 인라인 picture 방출 경로가 활성화되지 않는 점이었다. + +수정 후 `layout_caption()`은 캡션 문단 루프 안의 실제 `para`와 상위 렌더 경로의 `bin_data_content`를 `layout_composed_paragraph()`에 전달한다. 이 변경은 기존 paragraph layout의 TAC picture 방출 경로를 재사용하므로, 별도 캡션 전용 이미지 렌더러를 추가하지 않는다. + +판정: 적절함. + +### 7.2 호출부 정합성 + +`layout_caption()` 호출부는 다음 경로에 존재한다. + +- `src/renderer/layout.rs` +- `src/renderer/layout/picture_footnote.rs` +- `src/renderer/layout/shape_layout.rs` +- `src/renderer/layout/table_layout.rs` +- `src/renderer/layout/table_partial.rs` + +각 호출부는 이미 상위 함수 scope에서 `bin_data_content`를 보유하고 있어 신규 인자 전달만으로 정합화된다. 캡션 좌표, 높이 계산, y advance 정책은 변경하지 않는다. + +판정: 적절함. + +### 7.3 picture-only 표 캡션 보완 + +초기 리뷰에서 표 캡션의 picture-only TAC 문단 경계 조건을 확인했다. 표 캡션은 실제 표 셀이 아니지만 `cell_ctx`에 `cell_index = 65534` 센티널을 사용한다. 기존 빈 줄 TAC picture 분기는 `cell_ctx.is_none()`일 때만 동작하므로, 텍스트 없이 TAC picture만 있는 표 캡션 문단은 여전히 누락될 수 있었다. + +보완 커밋 `fdfe568863095d42145709077bd1f2fe55f85e0a`는 다음을 반영했다. + +- `CAPTION_CELL_SENTINEL = 65534` 상수 추가 +- `is_caption_cell_context()` helper 추가 +- 빈 줄 TAC picture 렌더링 조건을 `cell_ctx.is_none() || is_caption_cell_context(cell_ctx.as_ref())`로 좁게 확장 +- 실제 표 셀 내부 중복 렌더링 방지 조건은 유지 +- picture-only 캡션 TAC 회귀 테스트 추가 + +판정: 이전 P2급 경계 조건은 해소됨. + +### 7.4 테스트 적합성 + +신규 테스트는 기존 fixture `samples/hwpx/hy-001.hwpx`에서 실제 파싱된 TAC picture 문단을 찾은 뒤 첫 top-level 표의 TOP caption으로 복제한다. 이후 `build_page_render_tree(0)` 결과를 순회해 caption sentinel context의 `ImageNode`가 정확히 1개 방출되고, `bin_data_id`와 image payload가 존재하는지 확인한다. + +검증 케이스: + +- 텍스트가 함께 있는 캡션 TAC picture 문단 +- 텍스트 없이 TAC picture만 있는 캡션 문단 + +판정: 이번 변경 범위에 맞는 직접 회귀 테스트다. + +## 8. 검증 결과 + +### 8.1 GitHub Actions + +작성 시점 참고값으로, code 검증 대상 head SHA `fdfe568863095d42145709077bd1f2fe55f85e0a` 기준 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 | + +이 문서 커밋이 PR head에 추가되면 최신 head SHA가 바뀐다. 따라서 merge 전에는 최신 PR head 기준으로 GitHub Actions 통과 또는 문서 전용 후속 커밋 fast-pass 조건을 다시 확인해야 한다. + +### 8.2 로컬 검증 + +리뷰 중 확인한 로컬 검증: + +| 명령 | 결과 | +|------|------| +| `cargo fmt --check` | 통과 | +| `cargo test --test issue_1270_caption_inline_image` | 통과 — 2 passed | +| `cargo test --test issue_1139_inline_picture_duplicate` | 통과 — 85 passed | +| `cargo test --test issue_1352_table_cell_tac_picture_text` | 통과 — 1 passed | +| `cargo test --test issue_1459_topbottom_picture_reflow` | 통과 — 3 passed | +| `cargo test --lib` | 통과 — 1959 passed, 6 ignored | +| `cargo clippy --lib -- -D warnings` | 통과 | +| `cargo test --test issue_530` | 통과 — 1 passed | +| `cargo test --test issue_1486_hwpx_partial_tac_table` | 통과 — 6 passed | +| `git diff --check upstream/devel...HEAD` | 통과 | + +문서 작성 직전 재확인: + +| 명령 | 결과 | +|------|------| +| `cargo test --test issue_1270_caption_inline_image` | 통과 — 2 passed | + +## 9. 잔여 리스크 + +| 리스크 | 판단 | +|--------|------| +| 플로팅 캡션 이미지가 여전히 보이지 않음 | 의도된 범위 밖. #1585에서 추적 | +| #1270이 merge 후에도 OPEN 상태로 남음 | 의도된 상태. 플로팅 후속 범위가 남아 있으므로 닫지 않음 | +| 이 review 문서 커밋 후 최신 head CI가 다시 필요함 | merge 전 최신 head 기준 check 또는 문서 전용 fast-pass 확인 필요 | +| 그림 자체의 picture-only caption은 별도 sentinel 예외 대상이 아님 | 이번 회귀 테스트는 표 캡션을 겨냥한다. 그림 캡션의 picture-only TAC는 후속 확장 검토 여지 있음 | + +현재 PR 범위에서 merge를 막는 잔여 코드 이슈는 발견하지 못했다. + +## 10. 최종 권고 + +권고: merge 준비 가능. + +단, 실제 merge 전 최종 조건은 다음을 모두 만족해야 한다. + +- 최신 PR head 기준 GitHub Actions 통과 또는 문서 전용 후속 커밋 fast-pass 조건 충족 +- `mydocs/pr/archives/pr_1551_review.md`가 PR diff에 포함됨 +- merge 직전 `mergeable` / `mergeStateStatus` 재확인 +- 작업지시자 merge 승인 +- GitHub review 또는 PR comment를 남길지 작업지시자 최종 확인 +- #1270은 merge 후에도 닫지 않음 +- #1585는 플로팅 캡션 이미지 후속 이슈로 유지 + +## 11. merge 후 확인 계획 + +merge가 승인되고 완료되면 다음을 확인한다. + +1. PR merge commit SHA와 merged timestamp 확인 +2. #1270 상태 확인 +3. #1270이 open이면 그대로 유지 +4. #1585 상태 확인 +5. 필요 시 PR merge comment 초안을 작성하되, 게시는 작업지시자 승인 후 수행 +6. 로컬/원격 PR 작업 브랜치 정리 여부는 작업지시자 승인 후 결정 + +## 12. 리뷰 코멘트 초안 + +작업지시자가 직접 게시할 수 있는 코멘트 초안: + +```md +리뷰 결과, 최신 코드 검증 대상 head `fdfe5688` 기준으로 merge를 막는 추가 이슈는 발견하지 못했습니다. + +확인한 내용: +- `layout_caption()`이 캡션 문단의 실제 `para`와 `bin_data_content`를 paragraph layout에 전달해 인라인 TAC picture 방출 경로를 사용할 수 있음 +- 표 캡션의 picture-only TAC 문단 경계 조건은 `cell_index == 65534` 캡션 센티널 예외와 회귀 테스트로 보완됨 +- 실제 표 셀 내부 TAC picture 중복 방지 조건은 유지됨 +- 신규 `issue_1270_caption_inline_image` 테스트가 텍스트 포함 캡션과 picture-only 캡션을 모두 검증함 + +검증: +- GitHub Actions: Build & Test / CodeQL / Render Diff 계열 성공 +- 로컬: `cargo test --test issue_1270_caption_inline_image` 2 passed +- 기존 관련 테스트, `cargo test --lib`, `cargo clippy --lib -- -D warnings` 통과 확인 + +주의: +- 이 PR은 캡션 내 `treat_as_char` 인라인 이미지 복구만 다룹니다. +- 첨부 샘플의 `textWrap="TOP_AND_BOTTOM"` 플로팅 캡션 이미지는 #1585 후속 범위입니다. +- 따라서 #1270은 이 PR merge 후에도 닫지 않는 것이 맞습니다. +``` diff --git a/mydocs/report/task_m100_1270_report.md b/mydocs/report/task_m100_1270_report.md new file mode 100644 index 000000000..ca143ca10 --- /dev/null +++ b/mydocs/report/task_m100_1270_report.md @@ -0,0 +1,103 @@ +# Task #1270 최종 보고서 — 캡션 내 인라인 이미지 렌더링 스레딩 + +## 개요 + +- 이슈: #1270 — HWPX 온라인 데모에서 1페이지 상단 이미지가 표시되지 않는 문제 +- 브랜치: `local/task1270` +- 기준: `upstream/devel` `48e96704` +- 범위: 캡션 문단 안의 `treat_as_char` 인라인 이미지 렌더링 복구, depth 1 한정 + +## 메인테이너 지시 반영 + +메인테이너는 (a) 인라인 캡션 이미지와 (b) 플로팅 캡션 이미지를 분리하고, 이번 PR은 (a)만 진행하도록 지시했다. + +- (a) 인라인 캡션 이미지: 이번 작업 범위 +- (b) 플로팅 캡션 이미지: 후속 이슈 범위 +- 캡션 속 그림의 캡션 재귀 렌더링: 이번 범위 제외 +- 이슈 #1270 close: 이번 PR merge 후에도 보류 + +## 구현 내용 + +`layout_caption()`이 캡션 문단을 `layout_composed_paragraph()`에 넘길 때 기존에는 인라인 이미지 렌더링에 필요한 원본 `para`와 `bin_data_content`를 `None`으로 전달했다. + +이번 작업에서는 다음을 반영했다. + +- `src/renderer/layout/picture_footnote.rs` + - `layout_caption()` 시그니처에 `bin_data_content: &[BinDataContent]` 추가 + - 캡션 문단 루프의 실제 `para`와 `bin_data_content`를 `layout_composed_paragraph()`에 전달 +- `src/renderer/layout.rs` +- `src/renderer/layout/shape_layout.rs` +- `src/renderer/layout/table_layout.rs` +- `src/renderer/layout/table_partial.rs` + - `layout_caption()` 호출부에 `bin_data_content` 전달 +- `src/renderer/layout/paragraph_layout.rs` + - 텍스트 없이 TAC picture만 있는 표 캡션 문단을 위해 빈 줄 TAC picture 렌더링 조건에 `cell_index == 65534` 캡션 센티널 예외 추가 + - 실제 표 셀 내부 중복 렌더링 방지 조건은 유지 + +기존 좌표 계산, 캡션 높이 계산, caption y advance, 표/그림 본문 배치 정책은 변경하지 않았다. + +## 테스트 + +신규 회귀 테스트를 추가했다. + +- `tests/issue_1270_caption_inline_image.rs` + - 기존 fixture에서 실제 파싱된 TAC picture 문단을 찾아 첫 top-level 표의 TOP caption으로 복제 + - `build_page_render_tree(0)` 후 caption sentinel context의 `ImageNode`가 정확히 1개 방출되는지 검증 + - 이미지 payload 존재 여부도 함께 확인 + - 텍스트가 함께 있는 캡션 문단과 picture-only 캡션 문단을 모두 검증 + +## 검증 결과 + +다음 검증을 통과했다. + +| 검증 | 결과 | +|------|------| +| `cargo fmt --check` | 통과 | +| `cargo test --test issue_1270_caption_inline_image` | 통과 — 2 passed | +| `cargo test --test issue_1139_inline_picture_duplicate` | 통과 — 85 passed | +| `cargo test --test issue_1352_table_cell_tac_picture_text` | 통과 — 1 passed | +| `cargo test --test issue_1459_topbottom_picture_reflow` | 통과 — 3 passed | +| `cargo test --lib` | 통과 — 1959 passed, 6 ignored | +| `cargo clippy --lib -- -D warnings` | 통과 | +| `cargo test --test issue_530` | 통과 — 1 passed | +| `cargo test --test issue_1486_hwpx_partial_tac_table` | 통과 — 6 passed | + +## 첨부 샘플 시각검증 해석 + +작업지시자가 `rhwp-studio`에서 첨부 HWPX를 직접 로드해 확인한 결과, 이슈 기대 이미지의 오른쪽 상단 `SEOUL MY SOUL` 로고는 여전히 표시되지 않았다. + +이는 이번 구현 실패가 아니라 범위 차이에 따른 기대 결과다. + +- 첨부 샘플의 누락 대상 `image2`는 캡션 내부 그림이지만 `textWrap="TOP_AND_BOTTOM"`인 플로팅 배치다. +- 이번 작업은 `treat_as_char` 인라인 캡션 이미지만 복구한다. +- 플로팅 캡션 이미지는 caption 영역에 앵커, 줄 매핑, wrap 배치 로직을 일반화해야 하므로 후속 작업으로 남긴다. + +PR description에는 이 내용을 명시해야 한다. + +## PR 설명 필수 문구 + +PR 본문에는 다음 범위 제한을 포함한다. + +```md +Scope: +- Fixes caption inline images where the picture is `treat_as_char`. +- `layout_caption` now threads the caption paragraph and `bin_data_content` into paragraph layout. +- This is intentionally depth 1 only. + +Out of scope: +- The attached issue sample's `image2` / `SEOUL MY SOUL` logo is a floating caption image with `textWrap="TOP_AND_BOTTOM"`. +- It is expected to still not render in this PR. +- Floating caption image layout needs a follow-up because it requires generalizing anchor/line mapping/wrap logic into caption layout. +- Therefore #1270 should remain open after this PR. +``` + +## 잔여 작업 + +- PR 생성 +- PR description에 depth 1 인라인 한정과 플로팅 후속 범위 명시 +- #1270은 PR merge 후에도 close하지 않음 +- 플로팅 캡션 이미지 일반화는 후속 이슈 또는 후속 작업으로 분리 + +## 결론 + +메인테이너가 지시한 (a) 인라인 스레딩 범위는 완료했다. 신규 회귀 테스트는 텍스트 포함 캡션과 picture-only 캡션을 모두 검증하며, 관련 회귀 검증도 통과했다. 첨부 샘플의 완전 시각 해소는 (b) 플로팅 캡션 이미지 후속 범위로 남긴다. diff --git a/mydocs/working/task_m100_1270_stage1.md b/mydocs/working/task_m100_1270_stage1.md new file mode 100644 index 000000000..7f523821f --- /dev/null +++ b/mydocs/working/task_m100_1270_stage1.md @@ -0,0 +1,214 @@ +# Task #1270 Stage 1 완료보고서 — 진단 확정 + +## 범위 + +- 첨부 HWPX 내부에서 누락 이미지 `image2`의 위치 확인 +- `BinData/image2.png` 정상 여부 확인 +- 현재 rhwp SVG 출력에서 `image2`가 방출되지 않는 현상 재현 +- 렌더 코드에서 캡션 문단의 인라인 이미지 경로가 끊기는 지점 확인 +- Stage 2 구현 계획서에서 다룰 테스트 후보 정리 + +소스 코드는 수정하지 않았다. + +## 첨부 HWPX 구조 확인 + +대상 파일: + +```text +/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx +``` + +`Contents/section0.xml`에서 `image2`는 표의 TOP 캡션 내부 문단에 존재한다. + +```text + + + + + + + ... + + +``` + +확인 명령: + +```bash +unzip -p '/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx' Contents/section0.xml \ + | xmllint --format - \ + | rg -n -C 8 'binaryItemIDRef="image2"|hp:caption side="TOP"|id="1490368362"' +``` + +`Contents/content.hpf`에도 `image2`는 `BinData/image2.png`로 선언되어 있다. + +```text + +``` + +`BinData/image2.png` 자체도 정상 PNG다. + +```text +Length: 7272 bytes +file: PNG image data, 232 x 37, 8-bit/color RGBA, non-interlaced +``` + +## 현재 렌더 출력 재현 + +현재 `local/task1270` 기준에서 1페이지만 SVG로 내보냈다. + +```bash +cargo run --quiet --bin rhwp -- export-svg \ + '/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx' \ + -p 0 \ + -o /private/tmp/rhwp-task1270-stage1-svg +``` + +결과: + +```text +문서 로드 완료: ... (56페이지) +내보내기 완료: 1개 SVG 파일 → /private/tmp/rhwp-task1270-stage1-svg/ +``` + +생성된 1페이지 SVG의 `` 요소 수는 1개다. + +```bash +awk 'BEGIN{c=0} {s=$0; while (match(s, / +``` + +이는 첫 셀의 `image1` 크기와 대응한다. 캡션 내부의 `image2` 크기(`hp:curSz width="16100" height="2645"`, 약 214.7px × 35.3px)는 SVG에 별도 ``로 방출되지 않는다. + +## 코드 원인 확인 + +`src/renderer/layout/picture_footnote.rs::layout_caption()`은 캡션 문단을 순회하며 `compose_paragraph()`를 호출한다. + +```rust +for (pi, para) in caption.paragraphs.iter().enumerate() { + let mut composed = compose_paragraph(para); + ... + para_y = self.layout_composed_paragraph( + ... + None, + None, + None, + None, // 캡션 컨텍스트 — wrap zone 무관 + ); +} +``` + +현재 문제 지점: + +- `layout_caption()`은 실제 캡션 문단 `para`를 가지고 있다. +- 하지만 `layout_composed_paragraph()`의 마지막 인자 중 원본 문단 `para`와 `bin_data_content`를 `None`으로 전달한다. + +반면 `src/renderer/layout/paragraph_layout.rs::layout_composed_paragraph()`는 이 두 인자를 인라인 이미지 렌더링에 사용한다고 명시한다. + +```rust +/// `para`: 원본 문단 (treat_as_char 이미지 인라인 렌더링에 사용) +/// `bin_data_content`: 이미지 데이터 (treat_as_char 이미지 인라인 렌더링에 사용) +``` + +실제 인라인 이미지 방출 분기도 두 값이 모두 `Some`일 때만 실행된다. + +```rust +if let (Some(p), Some(bdc)) = (para, bin_data_content) { + if let Some(ctrl) = p.controls.get(tac_ci) { + if let Control::Picture(pic) = ctrl { + ... + let image_data = find_bin_data(bdc, bin_data_id).map(|c| c.data.clone()); + let img_node = make_picture_image_node(...); + line_node.children.push(img_node); + tree.set_inline_shape_position(...); + } + } +} +``` + +따라서 현재 구조에서는 캡션 문단의 `treat_as_char` 그림 컨트롤이 `compose_paragraph()`에 의해 TAC 후보로 인식되어도, 실제 `ImageNode` 생성 단계에서 원본 컨트롤과 바이너리 데이터에 접근할 수 없어 렌더링되지 않는다. + +## 호출부 범위 + +`layout_caption()` 호출부는 다음과 같다. + +```text +src/renderer/layout.rs:6752 +src/renderer/layout/table_layout.rs:1022 +src/renderer/layout/picture_footnote.rs:528 +src/renderer/layout/table_partial.rs:1628 +src/renderer/layout/table_partial.rs:1651 +src/renderer/layout/table_partial.rs:1682 +src/renderer/layout/shape_layout.rs:535 +``` + +Stage 2에서는 `layout_caption()` 시그니처에 `bin_data_content`를 추가하고, 각 호출부에서 이미 보유한 바이너리 데이터 slice를 넘길 수 있는지 확인한다. + +## 메인테이너 방향과 정합 + +메인테이너 코멘트에서 확정한 이번 범위는 다음이다. + +- 캡션 내 `treat_as_char` 인라인 이미지 스레딩 +- depth 1 한정 +- 캡션 속 그림의 캡션은 렌더링하지 않음 +- 플로팅 캡션 이미지는 후속 이슈 범위 +- #1270은 이번 PR merge 후에도 close하지 않음 + +현 코드 진단은 이 방향과 정합한다. + +`layout_composed_paragraph()`의 인라인 이미지 경로는 `ImageNode`를 직접 만들고 끝나며, 해당 그림의 caption을 다시 `layout_caption()`으로 넘기지 않는다. 따라서 `para` / `bin_data_content` 스레딩만 적용하면 자연스럽게 depth 1 한정이 유지된다. + +## 테스트 후보 + +### 후보 A — 작은 HWPX fixture 추가 + +작은 HWPX 샘플을 추가해 다음 구조를 갖게 한다. + +- 표 또는 그림에 TOP caption 존재 +- caption 문단 안에 `treatAsChar="1"` 그림 1개 +- `BinData/image1.png` 포함 + +테스트는 `HwpDocument::from_bytes()` → `build_page_render_tree(0)` → `RenderNodeType::Image` 수집으로 검증한다. + +기대: + +- 수정 전: 캡션 이미지 `ImageNode` 누락 +- 수정 후: 캡션 이미지 `ImageNode` 1개 방출 + +### 후보 B — 기존 fixture 기반 보강 + +기존 `samples/hwpx` 중 캡션 내부 문단을 포함하는 샘플을 찾아 인라인 이미지 포함 여부를 확인한 뒤, 렌더 트리 검사만 추가한다. + +장점은 새 샘플 추가가 없다는 점이다. 단점은 캡션 내 인라인 그림이라는 조건을 만족하는 기존 샘플이 없으면 사용할 수 없다. + +### 후보 C — 첨부 HWPX 직접 추가 + +이슈 첨부 HWPX를 fixture로 추가해 재현 테스트를 작성한다. + +이 파일은 크기가 크고 본 이슈의 대표 `image2`가 메인테이너 코멘트상 후속 플로팅 범위와 혼동될 수 있으므로 1차 후보로는 부적합하다. + +## Stage 1 결론 + +원인은 `layout_caption()`이 캡션 문단을 `layout_composed_paragraph()`로 넘길 때 `para` / `bin_data_content`를 `None`으로 고정 전달하는 구조로 확정한다. + +이번 작업의 구현 방향은 메인테이너 지시대로 다음이 적절하다. + +1. `layout_caption()`에 `bin_data_content`를 전달할 수 있도록 시그니처를 확장한다. +2. 캡션 문단 루프 내부의 현재 `para`를 `layout_composed_paragraph()`에 `Some(para)`로 넘긴다. +3. `bin_data_content`도 `Some(bin_data_content)`로 넘긴다. +4. 플로팅 캡션 이미지는 이번 구현/테스트 성공 조건에서 제외한다. +5. 회귀 테스트는 첨부 대형 문서보다 작은 fixture 또는 기존 fixture 기반으로 `ImageNode` 방출을 검증하는 방식이 적절하다. + +## 다음 단계 + +작업지시자 승인 후 `mydocs/plans/task_m100_1270_impl.md` 구현 계획서를 작성한다. diff --git a/mydocs/working/task_m100_1270_stage3.md b/mydocs/working/task_m100_1270_stage3.md new file mode 100644 index 000000000..4ab876fd9 --- /dev/null +++ b/mydocs/working/task_m100_1270_stage3.md @@ -0,0 +1,209 @@ +# Task #1270 Stage 3 완료보고서 — 구현 + 집중 테스트 + +## 범위 + +- `layout_caption()`에 캡션 문단 원본 `para`와 `bin_data_content` 스레딩 적용 +- `layout_caption()` 호출부 전체 컴파일 정합화 +- 캡션 내 인라인 TAC picture 회귀 테스트 추가 +- 신규 테스트와 관련 기존 TAC picture 테스트 실행 +- 첨부 HWPX 1페이지 SVG 보조 관찰 + +## 구현 요약 + +### 1. 캡션 문단 원본/바이너리 스레딩 + +수정 파일: + +- `src/renderer/layout/picture_footnote.rs` + +`layout_caption()` 시그니처에 `bin_data_content: &[BinDataContent]`를 추가했다. + +기존에는 `layout_composed_paragraph()` 호출 시 인라인 이미지 렌더링에 필요한 마지막 인자들을 `None`으로 고정 전달했다. + +```rust +None, +None, +None, +None, +``` + +수정 후 캡션 문단 루프의 실제 `para`와 상위 렌더 경로의 `bin_data_content`를 전달한다. + +```rust +None, +Some(para), +Some(bin_data_content), +None, +``` + +이에 따라 캡션 문단의 `treat_as_char` picture가 `paragraph_layout.rs`의 기존 인라인 이미지 방출 경로를 사용할 수 있게 되었다. + +### 2. 호출부 정합화 + +수정 파일: + +- `src/renderer/layout.rs` +- `src/renderer/layout/picture_footnote.rs` +- `src/renderer/layout/shape_layout.rs` +- `src/renderer/layout/table_layout.rs` +- `src/renderer/layout/table_partial.rs` + +각 호출부는 이미 상위 함수 scope에서 `bin_data_content`를 보유하고 있었다. 기존 좌표 계산, `cell_ctx`, caption y/height 처리 방식은 변경하지 않고 인자만 추가했다. + +변경된 호출부는 다음 7곳이다. + +```text +src/renderer/layout.rs:6752 +src/renderer/layout/picture_footnote.rs:528 +src/renderer/layout/shape_layout.rs:535 +src/renderer/layout/table_layout.rs:1022 +src/renderer/layout/table_partial.rs:1628 +src/renderer/layout/table_partial.rs:1652 +src/renderer/layout/table_partial.rs:1684 +``` + +### 3. 신규 회귀 테스트 + +추가 파일: + +- `tests/issue_1270_caption_inline_image.rs` + +테스트 방식: + +1. 작은 기존 fixture `samples/hwpx/hy-001.hwpx`를 로드한다. +2. 문서 안에서 실제 파싱된 TAC picture 포함 문단을 찾는다. +3. 해당 문단을 첫 번째 top-level 표의 TOP caption paragraph로 복제한다. +4. `build_page_render_tree(0)`를 실행한다. +5. caption sentinel context(`cell_index = 65534`)를 가진 `ImageNode`가 정확히 1개 방출되고, 이미지 payload가 존재하는지 확인한다. + +검증 케이스: + +- 텍스트가 함께 있는 캡션 TAC picture 문단 +- 텍스트 없이 TAC picture만 있는 캡션 문단 + +검증 기준: + +```rust +caption_images == vec![(caption_bin_id, true)] +``` + +이 테스트는 수정 전 구조에서는 `layout_caption()`이 `para` / `bin_data_content`를 넘기지 않아 caption 이미지가 방출되지 않는 조건을 직접 겨냥한다. + +## 리뷰 보완 + +리뷰 중 표 캡션의 picture-only 문단 경계 조건을 추가 확인했다. 표 캡션은 실제 표 셀이 아니지만 `cell_ctx`에 `cell_index = 65534` 센티널을 사용한다. 기존 빈 줄 TAC picture 렌더링 분기는 `cell_ctx.is_none()`일 때만 동작하므로, 텍스트 없이 TAC picture만 있는 표 캡션 문단은 여전히 누락될 수 있었다. + +보완 내용: + +- `src/renderer/layout/paragraph_layout.rs` + - 빈 줄 TAC picture 렌더링 허용 조건에 `cell_index == 65534` 캡션 센티널 예외 추가 + - 실제 표 셀 내부 중복 렌더링 방지 조건은 유지 +- `tests/issue_1270_caption_inline_image.rs` + - picture-only 캡션 TAC 문단 회귀 테스트 추가 + +이 보완은 구현 계획서에서 “필요 시”로 둔 좁은 예외 적용이다. + +## 검증 결과 + +### 신규 테스트 + +```bash +cargo test --test issue_1270_caption_inline_image +``` + +결과: + +```text +running 2 tests +test table_caption_inline_tac_picture_emits_image_node ... ok +test table_caption_picture_only_tac_paragraph_emits_image_node ... ok + +test result: ok. 2 passed; 0 failed +``` + +### 관련 기존 테스트 + +```bash +cargo test --test issue_1352_table_cell_tac_picture_text +``` + +결과: + +```text +running 1 test +test hy001_first_cell_tac_picture_stays_inside_center_aligned_cell ... ok + +test result: ok. 1 passed; 0 failed +``` + +```bash +cargo test --test issue_1459_topbottom_picture_reflow +``` + +결과: + +```text +running 3 tests +test turning_first_picture_off_reflows_remaining_tac_picture ... ok +test topbottom_second_picture_flows_before_tac_picture ... ok +test non_tac_topbottom_picture_is_not_caret_stop ... ok + +test result: ok. 3 passed; 0 failed +``` + +### 포맷 체크 + +```bash +cargo fmt --check +``` + +결과: 통과. + +## 첨부 샘플 보조 관찰 + +첨부 HWPX를 수정 후 다시 SVG로 내보냈다. + +```bash +cargo run --quiet --bin rhwp -- export-svg \ + '/private/tmp/issue1270/서울 문화예술단체 지원사업.hwpx' \ + -p 0 \ + -o /private/tmp/rhwp-task1270-stage3-svg +``` + +1페이지 SVG의 `` 요소 수: + +```text +1 +``` + +이미지 태그: + +```text + +``` + +따라서 첨부 샘플의 `image2`는 이번 Stage 3 수정 후에도 1페이지 SVG에 나타나지 않는다. 이는 구현 계획서에서 보조 관찰로 분리한 항목이며, 메인테이너 코멘트의 “플로팅 캡션 이미지는 후속” 범위와 충돌하지 않도록 이번 Stage 3 성공 기준에서 제외했다. + +## 변경 파일 + +```text +src/renderer/layout.rs +src/renderer/layout/picture_footnote.rs +src/renderer/layout/shape_layout.rs +src/renderer/layout/table_layout.rs +src/renderer/layout/table_partial.rs +src/renderer/layout/paragraph_layout.rs +tests/issue_1270_caption_inline_image.rs +``` + +## Stage 3 결론 + +- 캡션 문단의 인라인 TAC picture 렌더링에 필요한 `para` / `bin_data_content` 스레딩을 구현했다. +- 신규 회귀 테스트로 텍스트 포함 캡션 및 picture-only 캡션의 인라인 TAC picture가 `ImageNode`로 방출되는 것을 확인했다. +- 기존 셀 TAC picture / TopAndBottom picture 흐름 테스트는 통과했다. +- `paragraph_layout.rs`의 빈 줄 TAC 조건은 캡션 센티널에 한해서만 완화했고, 기존 표 셀 중복 방지 범위는 보존했다. +- 첨부 샘플의 완전 해소는 후속 플로팅 캡션 이미지 작업 범위로 유지한다. + +## 다음 단계 + +작업지시자 승인 후 Stage 4 회귀 검증을 진행한다. diff --git a/mydocs/working/task_m100_1270_stage4.md b/mydocs/working/task_m100_1270_stage4.md new file mode 100644 index 000000000..d8be02277 --- /dev/null +++ b/mydocs/working/task_m100_1270_stage4.md @@ -0,0 +1,151 @@ +# Task #1270 Stage 4 완료보고서 — 회귀 검증 + +## 범위 + +- 신규 #1270 회귀 테스트 실행 +- 인라인 picture 중복 방지 회귀 테스트 실행 +- 표 셀 TAC picture 및 TopAndBottom picture 흐름 회귀 테스트 실행 +- lib 전체 테스트 실행 +- clippy warning 게이트 확인 +- 표/캡션 추가 회귀 테스트 실행 + +## 검증 결과 요약 + +| 검증 | 결과 | +|------|------| +| `cargo test --test issue_1270_caption_inline_image` | 통과 — 2 passed | +| `cargo test --test issue_1139_inline_picture_duplicate` | 통과 — 85 passed | +| `cargo test --test issue_1352_table_cell_tac_picture_text` | 통과 — 1 passed | +| `cargo test --test issue_1459_topbottom_picture_reflow` | 통과 — 3 passed | +| `cargo test --lib` | 통과 — 1959 passed, 6 ignored | +| `cargo clippy --lib -- -D warnings` | 통과 | +| `cargo test --test issue_530` | 통과 — 1 passed | +| `cargo test --test issue_1486_hwpx_partial_tac_table` | 통과 — 6 passed | + +## 상세 결과 + +### 신규 #1270 테스트 + +```bash +cargo test --test issue_1270_caption_inline_image +``` + +```text +running 2 tests +test table_caption_inline_tac_picture_emits_image_node ... ok +test table_caption_picture_only_tac_paragraph_emits_image_node ... ok + +test result: ok. 2 passed; 0 failed +``` + +### 인라인 picture 중복 방지 + +```bash +cargo test --test issue_1139_inline_picture_duplicate +``` + +```text +running 85 tests +... +test result: ok. 85 passed; 0 failed +``` + +### 표 셀 TAC picture + +```bash +cargo test --test issue_1352_table_cell_tac_picture_text +``` + +```text +running 1 test +test hy001_first_cell_tac_picture_stays_inside_center_aligned_cell ... ok + +test result: ok. 1 passed; 0 failed +``` + +### TopAndBottom picture 흐름 + +```bash +cargo test --test issue_1459_topbottom_picture_reflow +``` + +```text +running 3 tests +test turning_first_picture_off_reflows_remaining_tac_picture ... ok +test topbottom_second_picture_flows_before_tac_picture ... ok +test non_tac_topbottom_picture_is_not_caret_stop ... ok + +test result: ok. 3 passed; 0 failed +``` + +### lib 전체 테스트 + +```bash +cargo test --lib +``` + +```text +test result: ok. 1959 passed; 0 failed; 6 ignored; 0 measured; 0 filtered out +``` + +### clippy + +```bash +cargo clippy --lib -- -D warnings +``` + +```text +Finished `dev` profile [unoptimized + debuginfo] target(s) in 20.64s +``` + +### 표/캡션 추가 회귀 + +```bash +cargo test --test issue_530 +``` + +```text +running 1 test +test issue_530_tac_top_caption_does_not_overlap_header_row ... ok + +test result: ok. 1 passed; 0 failed +``` + +```bash +cargo test --test issue_1486_hwpx_partial_tac_table +``` + +```text +running 6 tests +test issue_1486_page29_tac_logo_aligns_with_text_line ... ok +test issue_1486_page13_page_number_keeps_footer_gap ... ok +test issue_1486_rowspan_block_tail_stays_on_pdf_page14 ... ok +test issue_1486_partial_table_tac_nested_table_stays_inside_page_body ... ok +test issue_1486_page19_nested_square_picture_is_not_page_clipped ... ok +test issue_1486_terminal_rowbreak_sliver_does_not_push_pdf_page22_content ... ok + +test result: ok. 6 passed; 0 failed +``` + +## 판정 + +Stage 4 회귀 검증은 통과로 판정한다. + +- 신규 테스트가 텍스트 포함 캡션 및 picture-only 캡션의 인라인 TAC picture `ImageNode` 방출을 검증한다. +- 기존 인라인 picture 중복 방지 테스트가 통과했다. +- 기존 표 셀 TAC picture와 TopAndBottom picture 흐름 테스트가 통과했다. +- 표 Top caption 및 partial TAC table 경로 테스트도 통과했다. +- lib 전체 테스트와 clippy warning 게이트가 통과했다. + +## 잔여 범위 + +메인테이너 지시대로 다음은 후속 범위로 남긴다. + +- 캡션 내 플로팅 picture 배치 일반화 +- 첨부 샘플의 `image2` 완전 시각 해소 +- 캡션 속 그림의 caption 재귀 렌더링 +- 이슈 #1270 close + +## 다음 단계 + +작업지시자 승인 후 Stage 5 최종 보고서를 작성하고 오늘 할일 문서를 완료 상태로 갱신한다. diff --git a/src/renderer/layout.rs b/src/renderer/layout.rs index 55b3aeccc..5e6cf44a4 100644 --- a/src/renderer/layout.rs +++ b/src/renderer/layout.rs @@ -6836,6 +6836,7 @@ impl LayoutEngine { pic_w, cap_y, &mut self.auto_counter.borrow_mut(), + bin_data_content, Some(cell_ctx), ); // [Task #864 Stage F] caption 이 차지한 영역까지 result_y 진행. diff --git a/src/renderer/layout/paragraph_layout.rs b/src/renderer/layout/paragraph_layout.rs index 1455fe7b0..98b2da3e1 100644 --- a/src/renderer/layout/paragraph_layout.rs +++ b/src/renderer/layout/paragraph_layout.rs @@ -27,6 +27,8 @@ use crate::model::paragraph::Paragraph; use crate::model::shape::{CommonObjAttr, HorzAlign, HorzRelTo, TextWrap, VertRelTo}; use crate::model::style::{Alignment, HeadType, LineSpacingType, Numbering, UnderlineType}; +const CAPTION_CELL_SENTINEL: usize = 65534; + /// `RHWP_LAYOUT_DEBUG=1` 로 활성화되는 layout 디버그 로깅 여부. /// Phase 1 (#517) — 본질 정정 (#467/#491/#496) 시 결함 측정·재현 자동화에 사용. #[inline] @@ -157,6 +159,12 @@ fn is_treat_as_char_equation_control(ctrl: Option<&Control>) -> bool { matches!(ctrl, Some(Control::Equation(eq)) if eq.common.treat_as_char) } +fn is_caption_cell_context(cell_ctx: Option<&CellContext>) -> bool { + cell_ctx + .and_then(|ctx| ctx.path.last()) + .is_some_and(|entry| entry.cell_index == CAPTION_CELL_SENTINEL) +} + fn composed_line_char_end(comp: &ComposedParagraph, line_idx: usize) -> usize { if let Some(next) = comp.lines.get(line_idx + 1) { return next.char_start; @@ -4130,7 +4138,9 @@ impl LayoutEngine { // runs가 없는 빈 줄에서 treat_as_char 이미지 렌더링 // 테이블 셀 내부에서는 table_layout.rs가 layout_picture로 이미 처리하므로 스킵. // 셀 외부에서 해당 줄 범위에 걸린 TAC만 여기서 렌더링. - if cell_ctx.is_none() && !line_tac_offsets.is_empty() { + let empty_line_tac_allowed = + cell_ctx.is_none() || is_caption_cell_context(cell_ctx.as_ref()); + if empty_line_tac_allowed && !line_tac_offsets.is_empty() { if let (Some(p), Some(bdc)) = (para, bin_data_content) { // TAC 이미지 전체 폭 계산 후 문단 정렬 적용 let total_tac_width: f64 = diff --git a/src/renderer/layout/picture_footnote.rs b/src/renderer/layout/picture_footnote.rs index 2a1810f32..42ea1929a 100644 --- a/src/renderer/layout/picture_footnote.rs +++ b/src/renderer/layout/picture_footnote.rs @@ -535,6 +535,7 @@ impl LayoutEngine { cap_w, cap_y, &mut self.auto_counter.borrow_mut(), + bin_data_content, Some(cell_ctx), ); } @@ -618,6 +619,7 @@ impl LayoutEngine { content_width: f64, y_start: f64, auto_counter: &mut AutoNumberCounter, + bin_data_content: &[BinDataContent], cell_ctx: Option, ) { if caption.paragraphs.is_empty() { @@ -664,8 +666,8 @@ impl LayoutEngine { false, 0.0, None, - None, - None, + Some(para), + Some(bin_data_content), None, // 캡션 컨텍스트 — wrap zone 무관 ); } diff --git a/src/renderer/layout/shape_layout.rs b/src/renderer/layout/shape_layout.rs index 67dfe1ac6..709544765 100644 --- a/src/renderer/layout/shape_layout.rs +++ b/src/renderer/layout/shape_layout.rs @@ -542,6 +542,7 @@ impl LayoutEngine { cap_w, cap_y, &mut self.auto_counter.borrow_mut(), + bin_data_content, None, ); } diff --git a/src/renderer/layout/table_layout.rs b/src/renderer/layout/table_layout.rs index b05082cda..0b264ced2 100644 --- a/src/renderer/layout/table_layout.rs +++ b/src/renderer/layout/table_layout.rs @@ -1029,6 +1029,7 @@ impl LayoutEngine { cap_w, cap_y, &mut self.auto_counter.borrow_mut(), + bin_data_content, cap_cell_ctx, ); } diff --git a/src/renderer/layout/table_partial.rs b/src/renderer/layout/table_partial.rs index ee7fdc89f..0262c4cce 100644 --- a/src/renderer/layout/table_partial.rs +++ b/src/renderer/layout/table_partial.rs @@ -1635,6 +1635,7 @@ impl LayoutEngine { table_width, y_start, &mut self.auto_counter.borrow_mut(), + bin_data_content, cap_cell_ctx.clone(), ); } @@ -1658,6 +1659,7 @@ impl LayoutEngine { table_width, caption_y, &mut self.auto_counter.borrow_mut(), + bin_data_content, cap_cell_ctx.clone(), ); } @@ -1689,6 +1691,7 @@ impl LayoutEngine { cap_width_px, cap_y, &mut self.auto_counter.borrow_mut(), + bin_data_content, cap_cell_ctx.clone(), ); } diff --git a/tests/issue_1270_caption_inline_image.rs b/tests/issue_1270_caption_inline_image.rs new file mode 100644 index 000000000..4c1a23cef --- /dev/null +++ b/tests/issue_1270_caption_inline_image.rs @@ -0,0 +1,171 @@ +//! Issue #1270: caption paragraphs must render inline TAC pictures. +//! +//! The renderer already composes caption paragraphs, but the caption path used +//! to call `layout_composed_paragraph` without the source `Paragraph` and +//! `BinDataContent`. That made inline `treat_as_char` pictures inside captions +//! disappear because paragraph layout could not resolve the source control or +//! image payload. + +use std::fs; +use std::path::Path; + +use rhwp::model::control::Control; +use rhwp::model::paragraph::Paragraph; +use rhwp::model::shape::{Caption, CaptionDirection}; +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 has_tac_picture(para: &Paragraph) -> bool { + para.controls.iter().any(|ctrl| { + matches!( + ctrl, + Control::Picture(pic) if pic.common.treat_as_char && pic.image_attr.bin_data_id > 0 + ) + }) +} + +fn first_tac_picture_para(paragraphs: &[Paragraph]) -> Option { + for para in paragraphs { + if has_tac_picture(para) { + return Some(para.clone()); + } + for ctrl in ¶.controls { + if let Control::Table(table) = ctrl { + for cell in &table.cells { + if let Some(found) = first_tac_picture_para(&cell.paragraphs) { + return Some(found); + } + } + } + } + } + None +} + +fn tac_picture_bin_id(para: &Paragraph) -> Option { + para.controls.iter().find_map(|ctrl| match ctrl { + Control::Picture(pic) if pic.common.treat_as_char => Some(pic.image_attr.bin_data_id), + _ => None, + }) +} + +fn picture_only_caption_para(mut para: Paragraph) -> Paragraph { + para.text.clear(); + para.char_offsets.clear(); + para.char_count = 0; + para +} + +fn attach_top_caption_to_first_table( + paragraphs: &mut [Paragraph], + caption_para: Paragraph, +) -> bool { + for para in paragraphs { + for ctrl in &mut para.controls { + if let Control::Table(table) = ctrl { + table.caption = Some(Caption { + direction: CaptionDirection::Top, + width: 10_000, + spacing: 0, + max_width: 50_000, + paragraphs: vec![caption_para], + ..Default::default() + }); + return true; + } + } + } + false +} + +fn collect_caption_images(node: &RenderNode, out: &mut Vec<(u16, 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.data.is_some())); + } + } + for child in &node.children { + collect_caption_images(child, out); + } +} + +#[test] +fn table_caption_inline_tac_picture_emits_image_node() { + 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)); + let mut doc = + HwpDocument::from_bytes(&bytes).unwrap_or_else(|e| panic!("parse {}: {}", SAMPLE, e)); + + let caption_para = first_tac_picture_para(&doc.document().sections[0].paragraphs) + .expect("fixture must contain a TAC picture paragraph"); + let caption_bin_id = + tac_picture_bin_id(&caption_para).expect("caption paragraph must have a TAC picture"); + assert!( + doc.document() + .bin_data_content + .iter() + .any(|content| content.id == caption_bin_id), + "fixture must contain BinData payload for caption bin_id={caption_bin_id}" + ); + + assert!( + attach_top_caption_to_first_table( + &mut doc.document_mut().sections[0].paragraphs, + 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![(caption_bin_id, true)], + "caption inline TAC picture must be emitted exactly once with image payload" + ); +} + +#[test] +fn table_caption_picture_only_tac_paragraph_emits_image_node() { + 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)); + let mut doc = + HwpDocument::from_bytes(&bytes).unwrap_or_else(|e| panic!("parse {}: {}", SAMPLE, e)); + + let source_para = first_tac_picture_para(&doc.document().sections[0].paragraphs) + .expect("fixture must contain a TAC picture paragraph"); + let caption_bin_id = + tac_picture_bin_id(&source_para).expect("caption paragraph must have a TAC picture"); + let caption_para = picture_only_caption_para(source_para); + + assert!( + attach_top_caption_to_first_table( + &mut doc.document_mut().sections[0].paragraphs, + 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![(caption_bin_id, true)], + "picture-only caption TAC paragraph must emit exactly one caption image" + ); +}