Tables: a row isn't split to leave a picture cell empty beside its text; it moves on whole, as in Word (#4272) (#5161)

When a row's first part on a page that already holds something would leave
a cell empty because that cell's inline picture (one line, never split) or
first line runs past the room, the row moves on whole instead of leaving the
other cells' text beside the empty cell. An at-least row grown taller than its
minimum by its picture used to pass the #1742 check (room >= declared minimum)
and then split, the picture going on alone. At the top of a page such a row
still splits.

Co-authored-by: Harrison Smith <harrison@hsmith.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Harrison Smith
2026-11-01 14:23:01 -05:00
committed by GitHub
co-authored by Harrison Smith Claude Opus 5.5 Brandon Thomas
parent c395760959
commit a64d9748f2
5 changed files with 135 additions and 12 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+35 -6
View File
@@ -901,6 +901,20 @@ fn finish_table_rows(ctx: &mut Ctx, input: TableRows<'_>) -> TableLayout {
/// continuation is as tall as the cell needing the most room, and at least the rest of the row
/// (a minimum height holds over the pages).
pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
split_row_with(row, cut, false)
}
/// [`split_row`] for the first part of a row on a page that already holds something: `None`
/// also when a cell would be left empty on this page because its first line, starting above the
/// cut, runs below it (#4272). A line is never split, and an inline picture is one line, so a
/// cell holding only a picture taller than the room keeps nothing there; rather than leave the
/// other cells' text beside an empty cell, the row moves on whole (see `docs/layout-parity.md`,
/// Tables). Content of a cell aligned below the cut doesn't count.
pub fn split_row_keeping_cells(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
split_row_with(row, cut, true)
}
fn split_row_with(row: &RowLayout, cut: f32, whole_cells: bool) -> Option<(RowLayout, RowLayout)> {
// Also rejects a NaN cut.
if !(cut > 0.0 && cut < row.height) {
return None;
@@ -949,30 +963,40 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
}
align_in_part(row, &mut cells, &splits, &realign, &mut ends, cut);
// The top of the first thing that moves on in each cell.
let mut kept_any = false;
let mut moved = vec![f32::MAX; cells.len()];
for ((items, splits), m) in cells.iter().zip(&splits).zip(&mut moved) {
// Whether each cell keeps anything on this page, and whether a line or picture of it that
// moves on starts above the cut but runs below it (its space after aside).
let mut kept_cells = vec![false; cells.len()];
let mut cut_through = vec![false; cells.len()];
for ((((items, splits), m), kept), through) in cells.iter().zip(&splits).zip(&mut moved).zip(&mut kept_cells).zip(&mut cut_through) {
for (it, &split) in items.iter().zip(splits) {
let mut runs_over = |top: f32, bottom: f32| *through |= top < limit - 0.01 && bottom > limit + 0.01;
let top = if let Some(r) = it.turned_bounds() {
// Turned text moves as a whole, like a picture.
if fits(r.bottom()) {
kept_any = true;
*kept = true;
None
} else {
runs_over(r.y, r.bottom());
Some(r.y)
}
} else {
match it {
Placed::Lines { para, l0, l1, y, .. } => {
kept_any |= split > *l0;
*kept |= split > *l0;
let base = para.lines.get(*l0).map(|l| l.top).unwrap_or(0.0);
(split < *l1).then(|| para.lines.get(split).map_or(*y, |l| y + l.top - base))
(split < *l1).then(|| {
let Some(l) = para.lines.get(split) else { return *y };
runs_over(y + l.top - base, y + l.top - base + l.height);
y + l.top - base
})
}
Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. } => {
if fits(rect.bottom()) {
kept_any = true;
*kept = true;
None
} else {
runs_over(rect.y, rect.bottom());
Some(rect.y)
}
}
@@ -990,10 +1014,15 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
}
}
}
let kept_any = kept_cells.iter().any(|&k| k);
let first_moved = moved.iter().copied().fold(f32::MAX, f32::min);
if !kept_any || first_moved == f32::MAX {
return None;
}
// A cell left with nothing here because its first line or picture runs below the cut.
if whole_cells && kept_cells.iter().zip(&cut_through).any(|(&kept, &through)| !kept && through) {
return None;
}
// Each cell resumes below its own top margin and border band.
let shifts: Vec<f32> = moved.iter().zip(&row.cells).map(|(&m, c)| ((if m < f32::MAX { m } else { first_moved }) - c.top).max(0.0)).collect();
// The continuation is as tall as the cell needing the most room (what it moves on and the
+12 -4
View File
@@ -104,7 +104,7 @@ fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, p
if row.props.height_rule == HeightRule::AtLeast && row.props.height.is_some_and(|h| h > room + 0.01 && h <= page_height + 0.01) {
return false;
}
table::split_row(laid, room).is_some()
table::split_row_keeping_cells(laid, room).is_some()
}
pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
@@ -171,7 +171,8 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
{
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 + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && table::split_row(row, room).is_some())) {
if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && table::split_row_keeping_cells(row, room).is_some()))
{
pb.advance(block, body_top);
}
}
@@ -215,8 +216,15 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
// An at-least row with less room than its minimum moves on whole, unless nothing
// is above it on this page (moving on would gain nothing).
let room = pb.bottom - pb.y;
let moves_on = rest.is_none() && !pb.at_top() && !fresh && !under_top_headers && short_of_min(ri, room);
let split = if splittable && !moves_on && !defer_kept { table::split_row(cur, room) } else { None };
// Its first part on a page that already holds something leaves no cell empty
// (#4272): moving on can give the cell room there.
let first_part = rest.is_none() && !pb.at_top() && !fresh && !under_top_headers;
let moves_on = first_part && short_of_min(ri, room);
let split = match (splittable && !moves_on && !defer_kept, first_part) {
(false, _) => None,
(true, true) => table::split_row_keeping_cells(cur, room),
(true, false) => table::split_row(cur, room),
};
match split {
Some((a, b)) => {
place(pb, &a);
+82
View File
@@ -7015,6 +7015,88 @@ fn repeated_header_moves_with_a_first_row_that_moves_on_whole() {
assert_eq!(pages, vec![vec![], vec![0, 1]]);
}
/// #4272: a filler leaving `room` points, then a three-column table: a one-line heading row and
/// a row at least `min` points tall (no minimum when 0) whose first cell holds only an inline picture `pic_h` points
/// tall (one line, single spacing) and whose other cells hold `lines` short lines each. Returns
/// the layout and, per page, the rows with a first cell there.
fn picture_row(room: f32, min: f32, pic_h: f32, lines: usize) -> (DocLayout, Vec<Vec<usize>>) {
use wordcraft_doc::props::{HeightRule, LineSpacing};
let (mut d, at) = at_least_rows_filler(room);
let mut t = Table::new(2, 3, 468.0);
if min > 0.0 {
t.rows[1].props.height = Some(min);
t.rows[1].props.height_rule = HeightRule::AtLeast;
}
for c in 1..3 {
t.rows[1].cells[c].blocks = lines_in_cell(lines, "N/A");
}
set_at_least_row_spacing(&mut t);
let mut p = wordcraft_doc::Paragraph::with_text("", Default::default());
p.props.line_spacing = Some(LineSpacing::Multiple(1.0));
p.props.space_before = Some(0.0);
p.props.space_after = Some(0.0);
p.insert_object(0, picture(114.0, pic_h, Default::default()), &Default::default()).unwrap();
t.rows[1].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
d.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let pages = at_least_rows_pages(&l);
(l, pages)
}
/// The height of row `row`'s first cell on each page it has a part on.
fn picture_row_parts(l: &DocLayout, row: usize) -> Vec<f32> {
l.pages
.iter()
.filter_map(|p| {
p.items.iter().find_map(|i| if let Placed::Cell { rect, row: r, cell: 0, .. } = i { (*r == row).then_some(rect.h) } else { None })
})
.collect()
}
#[test]
fn an_at_least_row_grown_by_its_picture_moves_on_whole_rather_than_leave_the_picture_behind() {
// The report in #4272: a row at least 193.45 pt (3869 twips) with a 203 pt inline picture in
// its first cell and "N/A" in the others, so the picture makes the row taller than its
// minimum. With room for the minimum but not for the picture, the row used to split: the
// "N/A" cells stayed beside an empty picture cell and the picture went on alone. The picture
// is one line, which Word never splits, so the row moves on whole. The same goes for a row
// with no minimum height.
for min in [193.45, 0.0] {
let (l, pages) = picture_row(214.0, min, 203.0, 1);
assert_eq!(pages, vec![vec![0], vec![1]], "min {min}{}", pagination_fixture_diagnostics("picture_row/grown", None, &l));
assert_eq!(l.pages.len(), 2, "min {min}");
let parts = picture_row_parts(&l, 1);
assert_eq!(parts.len(), 1, "min {min}: the picture row is in one piece: {parts:?}");
assert!(parts[0] >= 203.0, "min {min}: the row holds its picture: {parts:?}");
}
}
#[test]
fn a_picture_row_too_tall_for_any_page_splits_from_the_top_of_the_next_one() {
// A row of 60 lines beside a 700 pt picture (the body is 648 pt), under a heading row at the
// top of page 1: like an at-least row taller than a page (#1742), it starts page 2 rather
// than leave its picture cell empty under the heading, then splits from there, its text going
// on over the pages (at the top of a page moving on would gain nothing).
let (l, pages) = picture_row(648.0, 0.0, 700.0, 60);
let rows: Vec<&Vec<usize>> = pages.iter().filter(|p| !p.is_empty()).collect();
assert!(
rows.len() >= 3 && rows[0] == &vec![0] && rows[1..].iter().all(|p| p == &&vec![1]),
"{pages:?}{}",
pagination_fixture_diagnostics("picture_row/too_tall", None, &l)
);
}
#[test]
fn a_picture_row_that_fits_stays_and_text_rows_beside_a_short_picture_still_split() {
// The same row with room for all of it stays on page 1.
let (_, pages) = picture_row(260.0, 193.45, 203.0, 1);
assert_eq!(pages, vec![vec![0, 1]]);
// A row of 30 lines beside a 40 pt picture, at least 100 pt: with 225 pt left the picture
// and the first lines fit, so the row still splits as before.
let (l, pages) = picture_row(225.0, 100.0, 40.0, 30);
assert_eq!(pages, vec![vec![0, 1], vec![1]], "{}", pagination_fixture_diagnostics("picture_row/text_splits", None, &l));
}
#[test]
fn text_overflowing_an_exact_row_is_clipped_to_it() {
use wordcraft_doc::props::HeightRule;
File diff suppressed because one or more lines are too long