diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index dbd888bc9..2b3cd1c08 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -841,11 +841,15 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { .collect(); // A cell that widow/orphan control leaves with nothing on this page, though its first line // would fit there, takes the whole row on with it: Word doesn't leave the other cells behind - // beside an empty one (observed with synthetic documents). + // beside an empty one (observed with synthetic documents). Trailing space alone can move + // the last paragraph without deferring the whole row. for (items, splits) in cells.iter().zip(&splits) { let first = items.iter().zip(splits).find_map(|(it, &split)| text_lines(it).map(|t| (t, split))); - if let Some(((para, l0, _, y), split)) = first + if let Some(((para, l0, l1, y), split)) = first && split == l0 + && para.rp.widow_control + && l1.saturating_sub(l0) > 1 + && crate::item_bottom(y, para, l0, l1).is_some_and(|bottom| !fits(bottom)) && para.lines.get(l0).is_some_and(|l| fits(y + l.top - para.lines.get(l0).map_or(0.0, |f| f.top) + l.height)) { return None; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 0b6fd3d3d..4aa08f3bf 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2567,6 +2567,71 @@ fn a_cell_emptied_by_widow_control_takes_its_row_to_the_next_page() { assert_eq!(pages_of(45, 4, true)[..2], [(0, 0), (1, 4)], "an orphan: the whole row moves"); } +// Use real paragraph and table layout to distinguish content from trailing spacing. +fn row_content_pieces(text: &str, props: ParaProps, room: f32) -> Vec<(usize, usize)> { + use wordcraft_doc::props::LineSpacing; + + let exact = ParaProps { line_spacing: Some(LineSpacing::Exactly(14.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() }; + let mut d = Document::from_text("Filler"); + *para_props(&mut d, 0) = ParaProps { line_spacing: Some(LineSpacing::Exactly(d.last_section.text_height() - room)), ..exact.clone() }; + let mut table = Table::new(1, 2, 400.0); + table.props.style = None; + table.props.cell_margins = Some([0.0; 4]); + let neighbor = ["First", "Second", "Third", "Fourth"].join(&wordcraft_doc::para::LINE_BREAK.to_string()); + for (cell, text) in [text, neighbor.as_str()].into_iter().enumerate() { + let mut p = Paragraph::with_text(text, Default::default()); + p.props = exact.clone(); + if cell == 0 { + p.props.space_after = props.space_after; + p.props.widow_control = props.widow_control; + } else { + p.props.widow_control = Some(false); + } + table.rows[0].cells[cell].blocks = vec![wordcraft_doc::para_block(p)]; + } + d.body.push(std::sync::Arc::new(Block::Table(table))); + lay(&d) + .pages + .iter() + .map(|page| { + let count = |cell| { + page.items + .iter() + .filter_map(|item| match item { + Placed::Lines { path, l0, l1, .. } if path.0 == [1, 0, cell, 0] => Some(l1 - l0), + _ => None, + }) + .sum::() + }; + (count(0), count(1)) + }) + .collect() +} + +#[test] +fn row_split_ignores_afterspace() { + let two = ["Top", "Bottom"].join(&wordcraft_doc::para::LINE_BREAK.to_string()); + for (text, room, expected) in [("Label", 18.0, vec![(0, 1), (1, 3)]), (two.as_str(), 32.0, vec![(0, 2), (2, 2)])] { + let props = ParaProps { space_after: Some(8.0), widow_control: Some(true), ..Default::default() }; + assert_eq!(row_content_pieces(text, props, room), expected, "text={text:?}, room={room}"); + } +} + +#[test] +fn row_split_respects_widows() { + let two = ["Top", "Bottom"].join(&wordcraft_doc::para::LINE_BREAK.to_string()); + let three = ["Top", "Middle", "Bottom"].join(&wordcraft_doc::para::LINE_BREAK.to_string()); + for (text, widow, room, expected) in [ + (two.as_str(), true, 18.0, vec![(0, 0), (2, 4)]), + (two.as_str(), false, 18.0, vec![(1, 1), (1, 3)]), + (three.as_str(), true, 32.0, vec![(0, 0), (3, 4)]), + (three.as_str(), false, 32.0, vec![(2, 2), (1, 2)]), + ] { + let props = ParaProps { space_after: Some(0.0), widow_control: Some(widow), ..Default::default() }; + assert_eq!(row_content_pieces(text, props, room), expected, "text={text:?}, widow={widow}, room={room}"); + } +} + /// The cell's last paragraph needs its space after on the page too: a row ending in an empty /// paragraph with 8pt after, whose text fits but whose space after doesn't, splits before it /// (Word keeps the "Remarks:" line on the page) instead of moving whole.