From f2fe7654e9d80fe0f81edb89b99077e9b4331daa Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 10:12:25 +0100 Subject: [PATCH] =?UTF-8?q?#3907:=20review=20=E2=80=94=20a=20kept=20paragr?= =?UTF-8?q?aph=20low=20in=20a=20continuous=20section=20moves=20to=20the=20?= =?UTF-8?q?next=20page;=20a=20page=20break=20before=20a=20stacked=20mark?= =?UTF-8?q?=20keeps=20the=20new=20page's=20bottom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (local): keep-lines compared its height with the room below the mid-page section start, so a paragraph that fits on a new page split instead. It now compares with the next column, or the next page's body when this is the last column. A stacked mark whose page break before starts a page no longer gets the previous page's bottom back. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/flow.rs | 9 +++++++-- crates/layout/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index 5eeab77fc..92362e545 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -42,7 +42,10 @@ pub(crate) fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, blo let (y, prev, bottom, content_bottom, at) = (pb.y, pb.prev.clone(), pb.bottom, pb.content_bottom, (pb.pages.len(), pb.col)); pb.bottom = f32::MAX / 4.0; place_para_in_flow(ctx, pb, p, block, body_top); - pb.bottom = bottom; + // A break before it starts a page with its own bottom. + if pb.pages.len() == at.0 { + pb.bottom = bottom; + } if (pb.pages.len(), pb.col) == at && pb.y > bottom + 0.01 { // At the bottom: nothing more fits on the page, even a line smaller than the mark's. (pb.y, pb.prev, pb.content_bottom) = (bottom.max(y), prev, content_bottom); @@ -176,8 +179,10 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: if pl.rp.keep_lines || (pl.rp.keep_next && pb.y + height <= pb.bottom + 0.01) { let need = height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0, &mut ctx.counters.clone()) } else { 0.0 }; // (Against the whole column: balanced columns grow to hold it.) + // Room in the next column, or on the next page when this is the last. + let next_top = if pb.col + 1 < pb.cols.len() { pb.top } else { pb.page_top }; // With the 0.01 pt of slack the other fit checks take. - if pb.y + need > pb.bottom + 0.01 && !pb.fresh() && need <= pb.uncapped_bottom() - pb.top { + if pb.y + need > pb.bottom + 0.01 && !pb.fresh() && need <= pb.uncapped_bottom() - next_top { pb.advance(block, body_top); pb.y += pl.rp.space_before.min(0.0); applied_before = pl.rp.space_before.min(0.0); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 4a5c188f5..7f3b30917 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -11681,6 +11681,34 @@ fn a_row_leaves_room_for_the_border_below_it() { } } +/// A kept-together paragraph starting a continuous section low on a page, with room there for +/// one of its lines, moves whole to the next page, where it fits (#3907 review). +#[test] +fn kept_lines_at_a_low_continuous_section_move_to_the_next_page() { + use wordcraft_doc::props::LineSpacing; + use wordcraft_doc::section::{SectionProps, SectionStart}; + let fill = 53; // 53 lines of 12 pt leave one line of a 648 pt body + let text = format!("{}{}", "x\n".repeat(fill), "word ".repeat(40)); + let mut d = Document::from_text(&text); + for i in 0..=fill { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + p.props.line_spacing = Some(LineSpacing::Exactly(12.0)); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + } + d.para_mut(StoryRef::Body, &Path::top(fill - 1)).unwrap().section = + Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() })); + d.last_section.start = SectionStart::Continuous; + let kept = d.para_mut(StoryRef::Body, &Path::top(fill)).unwrap(); + kept.props.keep_lines = Some(true); + kept.props.widow_control = Some(false); + let l = lay(&d); + let last_x = l.caret(&Pos::body(fill - 1, 0)).unwrap(); + let first = l.caret(&Pos::body(fill, 0)).unwrap(); + assert_eq!(last_x.page, 0, "the filler fits on page 1: {last_x:?}"); + assert_eq!(first.page, 1, "kept paragraph moves whole: {first:?}"); +} + /// Sections starting continuously one after another still go onto new pages: a paragraph that /// doesn't fit below the text above it moves on rather than running off the page (#3907). A /// hundred one-line sections lay out like a hundred plain paragraphs.