Skip to content

Commit 81db203

Browse files
planet6897edwardkim
authored andcommitted
Task #398 Stage 2: 분할 호출부에 rowspan 블록 정책 적용
- pagination/engine.rs::split_table_rows: pre-loop first_block_h, snap_to_block_boundary, cur/next 블록 단일성 가드 - typeset.rs::paginate_table: 동일 패턴 적용 (실제 SVG 내보내기 경로) - 다중 행 블록이 페이지에 들어가지 않으면 블록 전체를 한 단위로 배치 본 샘플 검증: 1쪽에서 표 분할 사라지고 2쪽에 표 전체 시작. cargo test --lib: 1023 passed.
1 parent 0c7b6dc commit 81db203

3 files changed

Lines changed: 133 additions & 20 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Task #398 Stage 2 — 분할 호출부에 블록 정책 적용
2+
3+
## 발견 사항
4+
5+
`split_table_rows` 와 동일한 분할 로직이 **두 곳에 중복 존재**한다는 사실을 작업 중 확인:
6+
7+
1. `src/renderer/pagination/engine.rs::split_table_rows` (1445~)
8+
2. `src/renderer/typeset.rs``paginate_table` 계열 (1378~) — **실제 SVG 내보내기 경로가 사용**
9+
10+
엔진 한쪽만 수정 시 회귀 검증에서 변화 없음을 발견하여 두 파일 모두 동일 패턴으로 수정.
11+
12+
## 변경 사항
13+
14+
### `src/renderer/pagination/engine.rs::split_table_rows`
15+
16+
1. **Pre-loop 분할 판정 (line 1492~)**: `first_row_h = mt.row_heights[0]``first_block_h = mt.row_block_for(0).2` 로 교체.
17+
- `first_block_is_single_row` 플래그 추가.
18+
- 인트라-로우 분할은 단일 행 블록에서만 활성.
19+
20+
2. **Loop body 분할 결정 (line ~1606)**:
21+
- `find_break_row` 결과에 `snap_to_block_boundary` 적용 → rowspan 묶음 중간 분할 차단.
22+
- `cursor_row` 가 속한 블록 (`cur_b_*`) / 분할 후보 행 `r` 의 블록 (`next_b_*`) 단일 여부 검사.
23+
- 인트라-로우 분할 / 강제 split 모두 단일 행 블록에서만 시도.
24+
- 다중 행 블록이 들어가지 않으면 `end_row = cur_b_end` (블록 전체 한 단위로 배치).
25+
26+
### `src/renderer/typeset.rs::paginate_table` (실제 호출 경로)
27+
28+
위와 동일한 패턴 적용 (변수 이름·구조 동일).
29+
30+
### 디버그 출력 정리
31+
32+
조사 중 추가했던 `eprintln!("DBG_T398 ...")` 제거.
33+
34+
## 검증
35+
36+
### 본 샘플 (Task #398 회귀)
37+
38+
`samples/2025년 기부·답례품 실적 지자체 보고서_양식.hwpx` 재내보내기:
39+
40+
**페이지 1 (`dump-pages -p 0`)** — 표 사라짐:
41+
```
42+
... pi=21 (빈) vpos=66830 (마지막 항목)
43+
```
44+
45+
**페이지 2 (`dump-pages -p 1`)**:
46+
```
47+
Table pi=22 ci=0 3x3 635.0x924.5px wrap=TopAndBottom tac=false vpos=68590
48+
```
49+
→ PartialTable 분할 없이 표 전체가 페이지 2에 시작 (PDF/한글과 동일).
50+
51+
**페이지 1 SVG 텍스트 (제목 부재 확인)**:
52+
| 글자 | 페이지 1 | 페이지 2 |
53+
|------|----------|----------|
54+
|| 0 | 1 |
55+
|| 0 | 1 |
56+
|| 0 | 1 |
57+
|| 0 | 1 |
58+
59+
(페이지 1의 "기:1 보:2" 는 "본 보고서는...주시기 바랍니다." 본문 텍스트.)
60+
61+
### 전체 단위 테스트
62+
63+
```
64+
cargo test --lib
65+
→ 1023 passed; 0 failed; 1 ignored
66+
```
67+
68+
기존 표 분할 테스트(`pagination/tests.rs::test_typeset_page_break` 외 17개) + 신규 단계 1 테스트 7개 모두 통과.
69+
70+
## 다음 단계
71+
72+
- 단계 3: 골든 샘플 회귀 검증 (`re_sample_gen` 또는 수동 SVG diff) + 최종 보고서 + orders 갱신.

src/renderer/pagination/engine.rs

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1489,13 +1489,20 @@ impl Paginator {
14891489
};
14901490
let remaining_on_page = table_available_height - st.current_height - host_text_height - v_offset_px;
14911491

1492-
let first_row_h = if row_count > 0 { mt.row_heights[0] } else { 0.0 };
1492+
// Task #398: rowspan 묶음 블록 단위로 분할 판정.
1493+
// 행 0이 rowspan>1 셀의 시작점이면 블록 전체 높이를 분할 단위로 사용해야
1494+
// rowspan 셀이 페이지 경계에서 잘리지 않는다.
1495+
let (first_block_start, first_block_end, first_block_h) = if row_count > 0 {
1496+
mt.row_block_for(0)
1497+
} else { (0, 0, 0.0) };
1498+
let first_block_is_single_row = first_block_end == first_block_start + 1;
14931499
let can_intra_split_early = !mt.cells.is_empty();
14941500

1495-
if remaining_on_page < first_row_h && !st.current_items.is_empty() {
1496-
// 첫 행이 인트라-로우 분할 가능하고 남은 공간에 최소 콘텐츠가 들어갈 수 있으면
1497-
// 현재 페이지에서 분할 시도 (새 페이지로 밀지 않음)
1498-
let first_row_splittable = can_intra_split_early && mt.is_row_splittable(0);
1501+
if remaining_on_page < first_block_h && !st.current_items.is_empty() {
1502+
// 인트라-로우 분할은 단일 행 블록에서만 시도 (rowspan 묶음은 분할 불가)
1503+
let first_row_splittable = first_block_is_single_row
1504+
&& can_intra_split_early
1505+
&& mt.is_row_splittable(0);
14991506
let min_content = if first_row_splittable {
15001507
mt.min_first_line_height_for_row(0, 0.0) + mt.max_padding_for_row(0)
15011508
} else {
@@ -1603,11 +1610,18 @@ impl Paginator {
16031610
{
16041611
const MIN_SPLIT_CONTENT_PX: f64 = 10.0;
16051612

1606-
let approx_end = mt.find_break_row(avail_for_rows, cursor_row, effective_first_row_h);
1613+
let approx_end_raw = mt.find_break_row(avail_for_rows, cursor_row, effective_first_row_h);
1614+
// Task #398: rowspan 묶음 중간에서 잘리지 않도록 블록 경계로 스냅
1615+
let approx_end = mt.snap_to_block_boundary(approx_end_raw);
1616+
1617+
// cursor_row가 속한 블록 정보 (인트라-로우 분할 가드)
1618+
let (cur_b_start, cur_b_end, _) = mt.row_block_for(cursor_row);
1619+
let cur_block_single = cur_b_end == cur_b_start + 1;
16071620

16081621
if approx_end <= cursor_row {
16091622
let r = cursor_row;
1610-
let splittable = can_intra_split && mt.is_row_splittable(r);
1623+
// 인트라-로우 분할은 단일 행 블록에서만 허용 (rowspan 묶음 보호)
1624+
let splittable = cur_block_single && can_intra_split && mt.is_row_splittable(r);
16111625
if splittable {
16121626
let padding = mt.max_padding_for_row(r);
16131627
let avail_content = (avail_for_rows - padding).max(0.0);
@@ -1623,7 +1637,7 @@ impl Paginator {
16231637
} else {
16241638
end_row = r + 1;
16251639
}
1626-
} else if can_intra_split && effective_first_row_h > avail_for_rows {
1640+
} else if cur_block_single && can_intra_split && effective_first_row_h > avail_for_rows {
16271641
// 행이 분할 불가능하지만 페이지보다 클 때: 가용 높이에 맞춰 강제 분할
16281642
let padding = mt.max_padding_for_row(r);
16291643
let avail_content = (avail_for_rows - padding).max(0.0);
@@ -1633,6 +1647,12 @@ impl Paginator {
16331647
} else {
16341648
end_row = r + 1;
16351649
}
1650+
} else if !cur_block_single {
1651+
// Task #398: 다중 행 블록(rowspan 묶음)이 들어가지 않으면
1652+
// 블록 전체를 한 단위로 배치 (페이지 초과 가능, 시각적 잘림 방지).
1653+
// 일반적으로 pre-loop에서 advance되어 fresh page에서는 들어가지만,
1654+
// 블록이 페이지 전체보다 큰 경우 통째로 배치.
1655+
end_row = cur_b_end;
16361656
} else {
16371657
end_row = r + 1;
16381658
}
@@ -1646,7 +1666,10 @@ impl Paginator {
16461666
};
16471667
let range_h = mt.range_height(cursor_row, approx_end) - delta;
16481668
let remaining_avail = avail_for_rows - range_h;
1649-
if can_intra_split && mt.is_row_splittable(r) {
1669+
// Task #398: 분할 후보 r의 블록 단일성 검사
1670+
let (next_b_start, next_b_end, _) = mt.row_block_for(r);
1671+
let next_block_single = next_b_end == next_b_start + 1;
1672+
if next_block_single && can_intra_split && mt.is_row_splittable(r) {
16501673
let row_cs = cs;
16511674
let padding = mt.max_padding_for_row(r);
16521675
let avail_content_for_r = (remaining_avail - row_cs - padding).max(0.0);
@@ -1660,9 +1683,10 @@ impl Paginator {
16601683
end_row = r + 1;
16611684
split_end_limit = avail_content_for_r;
16621685
}
1663-
} else if can_intra_split && mt.row_heights[r] > base_available_height {
1686+
} else if next_block_single && can_intra_split && mt.row_heights[r] > base_available_height {
16641687
// 행이 splittable=false이지만 전체 페이지 가용높이보다 큰 경우:
1665-
// 다음 페이지로 넘겨도 들어가지 않으므로 가용 공간에 맞춰 강제 intra-row split
1688+
// 다음 페이지로 넘겨도 들어가지 않으므로 가용 공간에 맞춰 강제 intra-row split.
1689+
// Task #398: 단일 행 블록에서만 적용 (rowspan 묶음 보호).
16661690
let row_cs = cs;
16671691
let padding = mt.max_padding_for_row(r);
16681692
let avail_content_for_r = (remaining_avail - row_cs - padding).max(0.0);

src/renderer/typeset.rs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,11 +1379,17 @@ impl TypesetEngine {
13791379
let base_available = st.base_available_height();
13801380
let table_available = available; // 각주/존 오프셋 차감된 가용 높이
13811381

1382-
// 첫 행이 남은 공간보다 크면 다음 페이지로 (인트라-로우 분할 가능성 확인)
1382+
// 첫 행이 남은 공간보다 크면 다음 페이지로 (인트라-로우 분할 가능성 확인).
1383+
// Task #398: rowspan>1 셀이 행 0의 시작점이면 블록 전체 높이로 판정.
13831384
let remaining_on_page = (table_available - st.current_height).max(0.0);
1384-
let first_row_h = mt.row_heights[0];
1385-
if remaining_on_page < first_row_h && !st.current_items.is_empty() {
1386-
let first_row_splittable = can_intra_split && mt.is_row_splittable(0);
1385+
let (first_block_start, first_block_end, first_block_h) = if row_count > 0 {
1386+
mt.row_block_for(0)
1387+
} else { (0, 0, 0.0) };
1388+
let first_block_is_single_row = first_block_end == first_block_start + 1;
1389+
if remaining_on_page < first_block_h && !st.current_items.is_empty() {
1390+
let first_row_splittable = first_block_is_single_row
1391+
&& can_intra_split
1392+
&& mt.is_row_splittable(0);
13871393
let min_content = if first_row_splittable {
13881394
mt.min_first_line_height_for_row(0, 0.0) + mt.max_padding_for_row(0)
13891395
} else {
@@ -1470,11 +1476,16 @@ impl TypesetEngine {
14701476
{
14711477
const MIN_SPLIT_CONTENT_PX: f64 = 10.0;
14721478

1473-
let approx_end = mt.find_break_row(avail_for_rows, cursor_row, effective_first_row_h);
1479+
let approx_end_raw = mt.find_break_row(avail_for_rows, cursor_row, effective_first_row_h);
1480+
// Task #398: rowspan 묶음 중간에서 잘리지 않도록 블록 경계로 스냅
1481+
let approx_end = mt.snap_to_block_boundary(approx_end_raw);
1482+
1483+
let (cur_b_start, cur_b_end, _) = mt.row_block_for(cursor_row);
1484+
let cur_block_single = cur_b_end == cur_b_start + 1;
14741485

14751486
if approx_end <= cursor_row {
14761487
let r = cursor_row;
1477-
let splittable = can_intra_split && mt.is_row_splittable(r);
1488+
let splittable = cur_block_single && can_intra_split && mt.is_row_splittable(r);
14781489
if splittable {
14791490
let padding = mt.max_padding_for_row(r);
14801491
let avail_content = (avail_for_rows - padding).max(0.0);
@@ -1490,7 +1501,7 @@ impl TypesetEngine {
14901501
} else {
14911502
end_row = r + 1;
14921503
}
1493-
} else if can_intra_split && effective_first_row_h > avail_for_rows {
1504+
} else if cur_block_single && can_intra_split && effective_first_row_h > avail_for_rows {
14941505
let padding = mt.max_padding_for_row(r);
14951506
let avail_content = (avail_for_rows - padding).max(0.0);
14961507
if avail_content >= MIN_SPLIT_CONTENT_PX {
@@ -1499,6 +1510,10 @@ impl TypesetEngine {
14991510
} else {
15001511
end_row = r + 1;
15011512
}
1513+
} else if !cur_block_single {
1514+
// Task #398: 다중 행 블록(rowspan 묶음)이 들어가지 않으면
1515+
// 블록 전체를 한 단위로 배치 (페이지 초과 가능, 시각적 잘림 방지).
1516+
end_row = cur_b_end;
15021517
} else {
15031518
end_row = r + 1;
15041519
}
@@ -1512,7 +1527,9 @@ impl TypesetEngine {
15121527
};
15131528
let range_h = mt.range_height(cursor_row, approx_end) - delta;
15141529
let remaining_avail = avail_for_rows - range_h;
1515-
if can_intra_split && mt.is_row_splittable(r) {
1530+
let (next_b_start, next_b_end, _) = mt.row_block_for(r);
1531+
let next_block_single = next_b_end == next_b_start + 1;
1532+
if next_block_single && can_intra_split && mt.is_row_splittable(r) {
15161533
let row_cs = cs;
15171534
let padding = mt.max_padding_for_row(r);
15181535
let avail_content_for_r = (remaining_avail - row_cs - padding).max(0.0);
@@ -1526,7 +1543,7 @@ impl TypesetEngine {
15261543
end_row = r + 1;
15271544
split_end_limit = avail_content_for_r;
15281545
}
1529-
} else if can_intra_split && mt.row_heights[r] > base_available {
1546+
} else if next_block_single && can_intra_split && mt.row_heights[r] > base_available {
15301547
let row_cs = cs;
15311548
let padding = mt.max_padding_for_row(r);
15321549
let avail_content_for_r = (remaining_avail - row_cs - padding).max(0.0);

0 commit comments

Comments
 (0)