From d646287de7e2828bdc80e322933f551680501eaa Mon Sep 17 00:00:00 2001 From: kkyu8925 <64997245+kkyu8925@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:03:17 +0900 Subject: [PATCH] =?UTF-8?q?Task=20#1535:=20visible-host=20co-anchored=20fl?= =?UTF-8?q?oat=20=ED=91=9C=EA=B0=80=20=EC=84=A0=ED=96=89=20float=20?= =?UTF-8?q?=EC=A0=90=EC=9C=A0=EC=98=81=EC=97=AD=EC=9D=84=20=EC=B9=A8?= =?UTF-8?q?=EB=B2=94=ED=95=98=EB=8D=98=20=ED=9A=8C=EA=B7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 visible host 문단에 para-relative TopAndBottom float 표가 여럿일 때, 후행 float 표가 선행 float 표가 점유한 세로 영역(visible_float_exclusions)을 무시하고 그 위에 겹쳐 그려지던 회귀(#1518 후속)를 수정한다. float 표 배치도 문단 텍스트와 동일하게, 표의 자연 상단(para_y + outer_margin + max(v_offset,0))이 활성 exclusion 영역 안에서 시작하면 그 영역 하단으로 table_y_start 를 끌어올린다. compute_table_y_position 이 raw_y.max(y_start) 로 클램프하므로 시작점 상향만으로 표가 영역 아래로 밀린다. - 회귀 fixture: samples/hwpx/issue1535_coanchored_float_exclusion.hwpx - 회귀 테스트: tests/issue_1535.rs (B 표 상단 >= A 표 하단) - 검증: 전체 cargo test 통과, issue_1510 4/4 유지, fmt/clippy clean closes #1535 --- mydocs/plans/task_m100_1535.md | 100 ++++++++++++++++++ mydocs/report/task_m100_1535_report.md | 48 +++++++++ .../issue1535_coanchored_float_exclusion.hwpx | Bin 0 -> 8345 bytes src/renderer/layout.rs | 24 +++++ tests/issue_1535.rs | 61 +++++++++++ 5 files changed, 233 insertions(+) create mode 100644 mydocs/plans/task_m100_1535.md create mode 100644 mydocs/report/task_m100_1535_report.md create mode 100644 samples/hwpx/issue1535_coanchored_float_exclusion.hwpx create mode 100644 tests/issue_1535.rs diff --git a/mydocs/plans/task_m100_1535.md b/mydocs/plans/task_m100_1535.md new file mode 100644 index 000000000..73e7e0b71 --- /dev/null +++ b/mydocs/plans/task_m100_1535.md @@ -0,0 +1,100 @@ +# Task #1535 수행계획서 — co-anchored float 표 양수 vertical_offset 무시로 인한 인접 표 텍스트 겹침 + +## 1. 이슈 + +- GitHub: +- 제목: `[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 무시 시 겹침이 나도록 구성. diff --git a/mydocs/report/task_m100_1535_report.md b/mydocs/report/task_m100_1535_report.md new file mode 100644 index 000000000..2eb58cd9e --- /dev/null +++ b/mydocs/report/task_m100_1535_report.md @@ -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`. diff --git a/samples/hwpx/issue1535_coanchored_float_exclusion.hwpx b/samples/hwpx/issue1535_coanchored_float_exclusion.hwpx new file mode 100644 index 0000000000000000000000000000000000000000..3553426f660a51f877b6234e963bf03d03dca444 GIT binary patch literal 8345 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 WsK_HCJv_vM{!E~Lm4^J`?*9NKXH?Vx literal 0 HcmV?d00001 diff --git a/src/renderer/layout.rs b/src/renderer/layout.rs index b8cd30a42..d92eab6d3 100644 --- a/src/renderer/layout.rs +++ b/src/renderer/layout.rs @@ -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 diff --git a/tests/issue_1535.rs b/tests/issue_1535.rs new file mode 100644 index 000000000..5ef6d9d23 --- /dev/null +++ b/tests/issue_1535.rs @@ -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}", + ); +}