From daf8caada33da7c033289e3a5e69a4a35948a862 Mon Sep 17 00:00:00 2001 From: dfgodvan Date: Thu, 15 Oct 2026 04:31:10 +0300 Subject: [PATCH] Tables: repeated header rows move to the next page with the first row (#1704) (#1705) A table with repeated header rows that started near the bottom of a page left the header rows there alone when the first row after them didn't fit, then repeated them on the next page above that row. Word starts such a table on the next page. place_table now checks, before the first row, whether the row after the header rows can start under them (whole, or split when it may split). If it can't and the page already holds something, the table starts on the next page. A table at the top of a page keeps its header there as before. Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/layout/src/lib.rs | 13 +++++++++++++ crates/layout/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 596d5e7b3..a7decb564 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3678,6 +3678,19 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl pb.y += row.height; pb.content_bottom = pb.content_bottom.max(pb.y); }; + // Like Word, repeated header rows don't stay alone at the bottom of a page: when the first row + // after them can't start under them there, the table starts on the next page. + let first = header_rows.len(); + if !header_rows.is_empty() + && !pb.at_top() + && let Some(row) = tl.rows.get(first) + { + let room = pb.bottom - pb.y - tl.rows.iter().take(first).map(|r| r.height).sum::(); + let splittable = !t.rows.get(first).is_some_and(|r| r.props.cant_split); + if row.height > room + 0.01 && !(splittable && table::split_row(row, room).is_some()) { + pb.advance(block, body_top); + } + } for (ri, row) in tl.rows.iter().enumerate() { // Word never splits a row of an exact height: it moves on whole. let splittable = !header_rows.contains(&ri) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 2670952c8..fad17f1ed 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2474,6 +2474,32 @@ fn repeated_header_is_followed_by_the_next_row_even_when_it_overflows() { assert!(pages.iter().all(|p| p.first() == Some(&0)), "{pages:?}"); } +#[test] +fn repeated_header_moves_with_the_first_row_that_does_not_fit() { + // A paragraph that leaves `room` points of the letter page's 648 pt body, then a table with a + // 20 pt header row and 40 pt one-line rows. Returns the table rows on each page. + let rows_per_page = |room: f32| -> Vec> { + let mut d = Document::from_text("Text above the table"); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(648.0 - room - 14.0); + let mut t = Table::new(3, 1, 468.0); + t.rows[0].props.header = true; + for (row, h) in t.rows.iter_mut().zip([20.0, 40.0, 40.0]) { + row.props.height = Some(h); + row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast; + } + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + lay(&d) + .pages + .iter() + .map(|p| p.items.iter().filter_map(|i| if let Placed::Cell { row, cell: 0, .. } = i { Some(*row) } else { None }).collect()) + .collect() + }; + // Header and first row fit: the table starts on the first page. + assert_eq!(rows_per_page(80.0)[0], vec![0, 1]); + // Only the header fits: it goes to the next page with the first row instead of staying alone. + assert_eq!(rows_per_page(40.0), vec![vec![], vec![0, 1, 2]]); +} + /// A drop-cap paragraph in a font larger than 1000 pt used to panic in layout (`clamp` with min > max). #[test] fn drop_cap_with_a_huge_font_lays_out() {