Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions mydocs/plans/task_m100_1535.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Task #1535 수행계획서 — co-anchored float 표 양수 vertical_offset 무시로 인한 인접 표 텍스트 겹침

## 1. 이슈

- GitHub: <https://github.com/edwardkim/rhwp/issues/1535>
- 제목: `[HWP] PR #1518 이후: 같은 문단 co-anchored float 표를 1페이지로 배치할 때 인접 셀 텍스트 겹침 (#1510 후속)`
- 상태: OPEN, 라벨 `bug`
- 관련: #1510(PR #1518 로 수정됨)

## 2. 재현 (확정)

작업지시자 제공 실파일 `나린뜰_일일작업일지.hwp` 기준 (리포터 양식과 동일 계열: 제목 "샘플식품공장일일작업일지").

| 버전 | 페이지 | 실제 cross-cell 겹침 |
|------|--------|----------------------|
| pre-#1518 (`85e16e2`, v0.7.17) | 2 | 0 |
| devel `42d7f6bc` (#1518 머지, 리포터 지목 SHA) | 1 | 1 (`원란일자`↔`일` 83%) |

- 검출: `export-pdf` → PyMuPDF span bbox 교차(≥40%, 세로쓰기 연속단일문자 오탐 제외).
- 실파일 8종 중 #1518 회귀로 새 겹침 발생: `나린뜰_일일작업일지.hwp`, `나린뜰_지단생산일지.hwp` 2종. 나머지 겹침은 양 버전 공통(회귀 아님).

## 3-FINAL. 근본 원인 (계측 확정 — 최종)

> 아래 §3 은 진단 과정의 중간 가설(layout 양수분기 → 정정, render/export 분리 → 좌표 단위 혼동으로 정정)을
> 기록으로 남긴다. 최종 확정 원인은 다음과 같다.

`src/renderer/layout.rs` 의 visible host 문단 float 표 배치(`table_y_start` 계산, `is_current_visible_para_float`)가
**활성 `visible_float_exclusions`(선행 float 표가 점유한 세로 영역)를 consult하지 않는다.** 문단 텍스트는
같은 함수 내(현 코드 ~3961-3970)에서 exclusion 영역 아래로 시작점을 밀어내지만, float 표 배치는 exclusion 을
push(현 ~5698) 만 하고 consult 하지 않는다.

계측 증거(나린뜰_일일작업일지.hwp, 렌더트리 동일 좌표):

- pi=0 ci=2 float 표 배치 → exclusion `[116,194]` 기록.
- pi=1 ci=0 float 표(원란일자 헤더 포함, offset 1536)의 자연 상단(`table_y_start`≈126.6 / 최종≈146)이 `[116,194]`
안에서 시작하는데도 194 아래로 밀리지 않아, 표 노드가 y≈151 로 배치되어 pi=0 표·날짜행(월 y≈164)과 겹친다.
- 정상(pre-#1518)은 같은 표가 y≈279 (선행 표 아래)였다.

표 렌더 치수는 #1518 전후 동일(압축 무관). #1510 단일 문단 단일 양수 표는 자연 상단이 형제 zone 밖이라 무사.

**수정 (실제 반영)**: `layout.rs` visible-host float 표 배치에서, 표의 자연 상단(`para_y + outer_margin + max(v_offset,0)`)이
활성 exclusion 영역 안에서 시작하면 그 영역 하단으로 `table_y_start` 를 끌어올린다. `compute_table_y_position` 이
`raw_y.max(y_start)` 로 클램프하므로 시작점만 올리면 표가 영역 아래로 밀린다(문단 로직과 동일 의미). ~20줄.

검증: 전체 `cargo test` 실패 0, `visual_roundtrip_baseline` 통과, `issue_1510` 4/4, 신규 `issue_1535` red→green,
나린뜰 5종 실제 겹침 0 + 1페이지 유지, fmt/clippy clean.

---

## 3. 근본 원인 (계측 확정 — 1차 진단 정정본)

`pi=1` 한 문단에 co-anchored para-relative TopAndBottom float 표 2개:

- ci=0: `vertical_offset=1536`(5.4mm), 높이 199.3px (날짜표 영역)
- ci=1: `vertical_offset=16996`(60mm), 높이 248.2px (`원란일자`/`점검자` 헤더 포함)

**핵심: 렌더 트리(`build_page_render_tree`, 웹뷰·`issue_1510` 테스트가 쓰는 경로)는 정상이다.**
임시 계측으로 확인한 표 노드 절대 y:

| 경로 | ci=1 (원란일자 표) 위치 |
|------|------------------------|
| 렌더 트리 (`build_page_render_tree`) | y=357.0 (정상, = para+60mm) |
| export-pdf / export-svg (`typeset.rs`) | 원란일자 y=124.9 (버그, para 상단) |

→ 버그는 코어 레이아웃(`layout.rs`)이 아니라 **export(typeset) 경로**에 있다. (1차 진단에서 `layout.rs` 양수 분기를 지목한 것은 오류 — #1510 단일 양수 표가 `layout.rs`/렌더 트리에서 정상(y285, 한컴 y284.5 일치)인 것으로 반증됨. `layout.rs` 의 `table_y_start` 는 흐름 기준점이고 실제 offset 적용은 `compute_table_y_position` 이 수행하여 렌더 트리는 양쪽 모두 정상.)

**export 경로 결함 위치**: `src/renderer/typeset.rs` 문단 처리(`~9596` 컨트롤 루프).

- 빈-host 문단의 para-float 표 → `try_typeset_empty_para_float_table` → `FloatLaneSet`(offset+lane stacking) 로 정상 배치.
- **visible-host 문단(텍스트 있음)의 co-anchored float 표 → `typeset_block_table`(흐름 누적 배치)** 로 빠져, vertical_offset 위치가 아닌 흐름상 para 상단 부근에 쌓여 형제 표와 겹침.
- `is_visible_para_float` 분기(`~10168`)는 페이지나눔/높이예약(`visible_float_exclusions`)만 담당하고 실제 paint 위치는 보정하지 않음.

`fit_measured_table_to_declared_height`(행 압축)은 무관 — 표 렌더 치수는 pre/devel 동일(709.6×199.3 / 708.2×248.2).
#1518 이 typeset 경로에 compression + visible-float 예약 로직을 추가하며 2→1페이지 압축은 됐으나 paint 배치 정합이 빠져 회귀.
`issue_1510` 테스트가 못 잡은 이유: 그 테스트는 **렌더 트리**(정상 경로)만 검증하고 export 출력은 검증하지 않음.

## 4. 수정 방향 (작업지시자 승인: 접근 1 — 정정본)

export(typeset) 경로의 visible-host co-anchored float 표를 렌더 트리와 동일하게 `para_top + 자기 vertical_offset`(raw_top) + 형제 lane stacking 으로 배치한다. 빈-host 가 이미 쓰는 `try_typeset_empty_para_float_table` / `FloatLaneSet::pushed_top`(`src/renderer/float_placement.rs`) 패턴을 visible-host 경로에도 적용한다.

핵심 리스크:

1. typeset 경로는 페이지나눔·`current_height` 누적·`visible_float_exclusions` 와 얽혀 있어, paint 위치만 바꾸면 page break 와 어긋날 수 있음 → 1페이지 결과(#1510 목표)와 cross-cell 비겹침을 동시에 만족하는지 export 기준으로 검증.
2. 코어 렌더 트리는 정상이므로 건드리지 않는다 (`layout.rs` 변경 금지). 변경은 `typeset.rs` 로 한정.

## 5. 구현 계획 (단계)

- **Stage 1 — 재현 회귀 테스트(실패 확인)**: 양수 offset 2개 co-anchored TopAndBottom float 표 + visible host 텍스트를 가진 단일 페이지 합성 HWPX fixture(`samples/hwpx/`) 작성. `tests/issue_1535.rs` 는 **export 경로**(SVG/PDF 좌표 또는 typeset 산출 PageItem)를 기준으로 두 표가 각자 offset 위치에 배치되고 텍스트가 겹치지 않음을 단언 — 렌더 트리만 보는 `issue_1510` 패턴으로는 회귀를 못 잡으므로 export 산출을 검증한다. 수정 전 fail 확인.
- **Stage 2 — 배치 수정**: `typeset.rs` 문단 컨트롤 루프에서 visible-host co-anchored para-float 표를 흐름 배치(`typeset_block_table`) 대신 offset+lane 배치로 보낸다. 빈-host lane 로직 재사용. `visible_float_exclusions`/page break 정합 유지.
- **Stage 3 — 회귀 검증 + 정리**: 전체 `cargo test`(1,100+) + `svg_snapshot` + `issue_1510`(4개, 렌더 트리 정상 유지) + 신규 `issue_1535` + 실파일(`나린뜰_*`) 수동 확인 + `cargo fmt --all -- --check` + `cargo clippy -- -D warnings`. 결과보고서 작성.

## 6. 검증 기준

1. 결정적: 전체 `cargo test`(회귀 0) + `cargo test --test svg_snapshot` + `cargo test --test issue_1510`(렌더 트리 정상) + `cargo test --test issue_1535`(신규 export 회귀) + `cargo clippy -- -D warnings`(전 타깃, CI 동일).
2. 시각(참고): `나린뜰_일일작업일지.hwp`·`나린뜰_지단생산일지.hwp` export-pdf/-svg 에서 cross-cell 겹침 0, `원란일자` 표가 para+60mm 위치로 복귀. PyMuPDF span bbox 교차(≥40%, 세로쓰기 단일문자 오탐 제외) 0 확인.
3. 픽스처: 실파일은 비공개 내부 양식이라 커밋 불가 → 합성 HWPX fixture 로 회귀 고정. 실파일은 로컬 수동 검증에만 사용. fixture 는 export 경로에서 겹침을 재현해야 유효(렌더 트리만 보면 정상이라 무의미).

## 7. fixture 작성 메모

`mydocs/manual/ai_sample_document_authoring_guide.md` §4.1 Clone and Narrow 준수. 기반: `samples/issue1510_coanchored_float_tables.hwpx` 구조 참고, 양수 offset 2표 + 충분한 높이로 offset 무시 시 겹침이 나도록 구성.
48 changes: 48 additions & 0 deletions mydocs/report/task_m100_1535_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Task #1535 결과보고서 — co-anchored float 표 겹침 (선행 float 점유영역 미반영)

## 이슈

- GitHub #1535 (라벨 bug, #1518 후속): visible host 문단에 co-anchored float 표 다수 시 인접 셀 텍스트 겹침.

## 근본 원인 (계측 확정)

`src/renderer/layout.rs` 의 visible host 문단 float 표 배치(`is_current_visible_para_float` 경로,
`table_y_start` 계산)가 활성 `visible_float_exclusions`(선행 float 표가 점유한 세로 영역)를 consult하지
않았다. 문단 텍스트는 같은 함수에서 exclusion 아래로 밀려나지만(현 ~3961-3970), float 표는 exclusion 을
push(현 ~5698)만 하고 consult 하지 않아, 뒤에 배치되는 float 표가 앞 float 표 위에 겹쳐 그려졌다.

- 표 렌더 치수는 #1518 전후 동일 → 행 높이 압축은 원인 아님.
- #1510 단일 문단·단일 양수 offset 표는 자연 상단이 형제 zone 밖이라 영향 없음(기존 테스트 통과 유지).
- 트리거: 같은 visible host 문단에 양수 vertical_offset co-anchored TopAndBottom float 표가 2개 이상.

## 수정

`layout.rs` visible-host float 표 배치에서, 표의 자연 상단(`para_y + outer_margin + max(v_offset, 0)`)이
활성 exclusion 영역 안에서 시작하면 그 영역 하단으로 `table_y_start` 를 끌어올린다.
`compute_table_y_position` 이 `raw_y.max(y_start)` 로 클램프하므로 시작점 상향만으로 표가 영역 아래로
밀린다(문단 텍스트의 jump 로직과 동일 의미). 약 20줄, 코어 레이아웃 외 다른 모듈 변경 없음.

## 산출물

- 수정: `src/renderer/layout.rs`
- 회귀 fixture: `samples/hwpx/issue1535_coanchored_float_exclusion.hwpx` — issue1510 HWPX 기반(Clone and
Narrow). 같은 host 문단에 양수 offset 표 A(16996)·B(18000), B 선언 위치가 A 점유영역 안.
- 회귀 테스트: `tests/issue_1535.rs` — B 표 상단이 A 표 하단 이상(겹침 금지)임을 렌더트리에서 단언.
fix 제거 시 실패(b_top≈376 ∈ A[362,437]), fix 적용 시 통과(b_top≈437).
- CHANGELOG.md / CHANGELOG_EN.md `[Unreleased]` 항목.

## 검증

- 전체 `cargo test`: 실패 0 (`test result: ok` 148 그룹).
- `tests/visual_roundtrip_baseline.rs`(시각 회귀): 통과.
- `tests/issue_1510.rs`: 4/4 통과(회귀 가드).
- `tests/issue_1535.rs`: red→green.
- `cargo fmt --all -- --check`: clean. `cargo clippy -- -D warnings`: clean.
- 작업지시자 제공 실파일(비공개) 5종: 실제 cross-cell 겹침 0 + 1페이지 압축 유지(로컬 수동 확인,
비공개라 fixture 미커밋).

## 비고

진단 과정에서 1차로 `layout.rs` 양수 분기, 2차로 export 경로를 의심했으나, 동일 문서의 내부 좌표 표현이
여럿(render-tree bbox 단위 ≠ SVG/PDF px)이라 생긴 단위 혼동이었다. 최종은 pre/devel 동일 좌표 계측 +
exclusion 활성 여부 계측으로 확정. 상세 과정은 `mydocs/plans/task_m100_1535.md`.
Binary file not shown.
24 changes: 24 additions & 0 deletions src/renderer/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5608,6 +5608,30 @@ impl LayoutEngine {
} else {
y_offset
};
// [Issue #1535] visible-host co-anchored float 표도 문단(아래 visible_float_exclusions
// 소비부)과 동일하게 선행 float 표가 점유한 세로 영역을 벗어나 배치되어야 한다.
// 표 배치는 기존에 exclusion 을 push 만 하고 consult 하지 않아, 연속 문단의
// float 표가 앞 문단 float 표 위에 겹쳐 그려졌다. 표의 자연 상단
// (para_y + outer_margin + v_offset, = compute_table_y_position 의 raw_y)이
// 활성 exclusion 영역 안에서 시작하면 그 영역 하단으로 내려 시작점을 끌어올린다.
// compute_table_y_position 이 raw_y.max(y_start) 로 클램프하므로 table_y_start
// (= y_start)를 올리면 표가 해당 영역 아래로 밀린다.
let table_y_start = if is_current_visible_para_float
&& !visible_float_exclusions.is_empty()
{
let v_off =
hwpunit_to_px(signed_hwpunit(t.common.vertical_offset), self.dpi);
let natural_top = para_y_for_table + visible_outer_top_px + v_off.max(0.0);
let mut floor = table_y_start;
for zone in visible_float_exclusions.iter() {
if natural_top + 0.5 >= zone.top && natural_top < zone.bottom {
floor = floor.max(zone.bottom);
}
}
floor
} else {
table_y_start
};
let allow_para_top_bleed = is_current_visible_para_float
&& signed_hwpunit(t.common.vertical_offset) < 0;
let table_visual_height = mt
Expand Down
61 changes: 61 additions & 0 deletions tests/issue_1535.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! Issue #1535: PR #1518 후속. visible host 문단에 co-anchored para-relative
//! TopAndBottom float 표가 여러 개 있을 때, 선행 float 표가 점유한 세로 영역을
//! 후행 float 표가 무시하고 그 위에 겹쳐 배치되던 회귀를 막는다.
//!
//! 재현 fixture(`issue1535_coanchored_float_exclusion.hwpx`)는 같은 host 문단에
//! 양수 vertical_offset 표 A(offset 16996) 와 B(offset 18000)를 둔다. B 의 선언
//! 위치(host + offset)는 A 가 차지한 영역 안에서 시작하므로, A 아래로 밀려
//! 내려가야 한다(겹침 금지). 수정 전에는 B 가 A 영역 안(y≈376, A=[362,437])에
//! 그려져 텍스트가 겹쳤다.

use rhwp::renderer::render_tree::{RenderNode, RenderNodeType};
use rhwp::wasm_api::HwpDocument;
use std::fs;
use std::path::Path;

const SAMPLE: &str = "samples/hwpx/issue1535_coanchored_float_exclusion.hwpx";
const TARGET_PI: usize = 0;
const TABLE_A: usize = 2;
const TABLE_B: usize = 3;

fn load_doc(sample: &str) -> 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))
}

fn find_table_bbox(root: &RenderNode, target_ci: usize) -> Option<(f64, f64)> {
if let RenderNodeType::Table(table) = &root.node_type {
if table.para_index == Some(TARGET_PI) && table.control_index == Some(target_ci) {
return Some((root.bbox.y, root.bbox.y + root.bbox.height));
}
}
for child in &root.children {
if let Some(found) = find_table_bbox(child, target_ci) {
return Some(found);
}
}
None
}

#[test]
fn issue_1535_later_visible_float_table_does_not_overlap_earlier_float_zone() {
let doc = load_doc(SAMPLE);
let tree = doc
.build_page_render_tree(0)
.expect("build_page_render_tree(0)");

let (a_top, a_bottom) = find_table_bbox(&tree.root, TABLE_A).expect("A table bbox");
let (b_top, _) = find_table_bbox(&tree.root, TABLE_B).expect("B table bbox");

assert!(
a_bottom > a_top,
"table A should have positive height: a_top={a_top:.1}, a_bottom={a_bottom:.1}",
);
assert!(
b_top + 0.5 >= a_bottom,
"co-anchored float table B (offset inside A's occupied zone) must be pushed below A, \
not overlapped onto it: a=[{a_top:.1},{a_bottom:.1}], b_top={b_top:.1}",
);
}