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 <noreply@anthropic.com>
This commit is contained in:
dfgodvan
2026-10-14 21:31:10 -04:00
committed by GitHub
co-authored by dfgodvan Claude Opus 5.5
parent babb331528
commit daf8caada3
2 changed files with 39 additions and 0 deletions
+13
View File
@@ -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::<f32>();
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)
+26
View File
@@ -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<Vec<usize>> {
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() {