From 627ded32c7ea5a03d5bf81038c21553fab8bdbcf Mon Sep 17 00:00:00 2001 From: James Parrish Coleman III <288943320+jpc7k1@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:38:08 -0500 Subject: [PATCH] Layout: a multiple-spaced line stays on the page while its text fits, as in Word Multiple line spacing above single puts its extra leading below the text, and Word lets that leading of a page's last line run into the bottom margin: only the text has to fit. WordCraft required the whole line box to fit, so multiple-spaced pages ended up to a line early (one line fewer per page in many double-spaced documents). Observed black-box with synthetic pages pushed down step by step: at double spacing (14 steps), 1.15 and 1.5 (12 steps each) Word holds one more line than WordCraft did whenever the last line's leading crosses the margin; with this change all 38 page breaks match Word's. Only the bottom margin takes the leading: a footer taller than the margin, or footnotes, still keep the whole line clear (a document with a four-line footer kept Word's page breaks only with this limit). Tests: a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits (1.15, 1.5 and double; fails before this change). space_after_may_overflow_the_bottom_margin_without_notes now checks the last line's text against the margin, since the leading may run past it. Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/lib.rs | 18 ++++++++++++- crates/layout/src/tests.rs | 20 ++++++++++++--- crates/layout/src/tests_pages.rs | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 9d25e5685..af9839f41 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3901,7 +3901,23 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: - new_notes.iter().map(|n| n.h).sum::() - if pb.notes.is_empty() && !new_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 } - extra; - let bottom = pb.y + lead + (l.top + l.height - first.top); + // Multiple spacing above single puts its extra leading below the text (see + // `baseline_offset`), and Word lets that leading of a page's last line run into the + // bottom margin: only the text has to fit (observed: a double-spaced 12pt line stays on + // the page while its text ends above the margin, about half a line lower than when the + // whole line must fit). Only the bottom margin takes that leading: a footer taller than + // the margin, or footnotes, keep the whole line clear of them. + let at_margin = pb.bottom >= pb.sect.page_h - pb.sect.margin_bottom - 0.01; + let fit_h = match pl.rp.line_spacing { + wordcraft_doc::props::LineSpacing::Multiple(m) + if m > 1.0 && at_margin && pb.notes.is_empty() && new_notes.is_empty() && line_notes.is_empty() => + { + // `baseline` is from the paragraph's top; the text ends a descent below it. + (l.baseline - l.top + l.text_desc).clamp(0.0, l.height) + } + _ => l.height, + }; + let bottom = pb.y + lead + (l.top + fit_h - first.top); // On a page with footnotes the paragraph's last line keeps its space after clear of // the separator (Word): if the space doesn't fit, the line goes to the next page (and, // with widow control, one more with it). Without notes the space after may run past diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 9fe3a5106..1f9f017a3 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8551,15 +8551,29 @@ fn space_after_stays_clear_of_the_footnote_separator() { #[test] fn space_after_may_overflow_the_bottom_margin_without_notes() { // Without footnotes nothing changes: the last line only has to fit, its space after may run - // past the bottom margin (Word). + // past the bottom margin (Word). "Fit" means its text: the leading multiple spacing adds below + // the text (this paragraph's 1.15) may run past the margin too (observed in Word at 1.15, 1.5 + // and double spacing; see `a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits`). let mut tight = 0; for before in 0..=300 { let (d, t, _) = space_after_doc(before as f32, false); let l = lay(&d); let bottom_margin = d.last_section.page_h - d.last_section.margin_bottom; let (pieces, _) = para_pieces(&l, t); - let &(_, _, _, bottom) = pieces.last().unwrap(); - assert!(bottom <= bottom_margin + 0.01, "before {before}: {bottom}"); + let &(page, _, l1, bottom) = pieces.last().unwrap(); + let leading = l.pages[page] + .items + .iter() + .find_map(|it| match it { + Placed::Lines { story: StoryRef::Body, path, para, .. } if path.0 == [t as u32] => { + para.lines.get(l1 - 1).map(|ln| ln.height - (ln.baseline - ln.top + ln.text_desc)) + } + _ => None, + }) + .unwrap() + .max(0.0); + let bottom = bottom - leading; + assert!(bottom <= bottom_margin + 0.01, "before {before}: the last line's text ends at {bottom}"); if bottom > bottom_margin - 6.0 { tight += 1; } diff --git a/crates/layout/src/tests_pages.rs b/crates/layout/src/tests_pages.rs index 41f9c39f4..9d159b854 100644 --- a/crates/layout/src/tests_pages.rs +++ b/crates/layout/src/tests_pages.rs @@ -569,3 +569,46 @@ fn a_roman_page_number_in_the_footer_stays_roman_with_the_page_count_in_the_body assert_eq!(glyphs, want.chars().count(), "page {}: the footer should show {want}", p.number); } } + +#[test] +fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() { + // Multiple spacing puts its extra leading below the text, and Word lets that leading of a + // page's last line run into the bottom margin: a multiple-spaced line stays on the page while + // its text ends above the margin (observed in Word at 1.15, 1.5 and double spacing: the page + // holds one more line than when the whole line box must fit, for shifts up to about the + // leading). Each shift moves the text down; on every page the last line's text ends within + // the margin, and the next line's would not. + use wordcraft_doc::props::LineSpacing; + for multiple in [1.15, 1.5, 2.0] { + let mut past_margin = 0; + for shift in (0..28).step_by(2) { + let mut d = Document::from_text(&"Several words wrap onto many lines of a long paragraph. ".repeat(60)); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(Arc::make_mut) { + p.props.line_spacing = Some(LineSpacing::Multiple(multiple)); + p.props.space_before = Some(shift as f32); + p.props.widow_control = Some(false); + } + let bottom = d.last_section.page_h - d.last_section.margin_bottom; + let l = lay(&d); + assert!(l.pages.len() >= 2, "{multiple} shift {shift}: the paragraph should run onto a second page"); + let Some(Placed::Lines { y, para, l0, l1, .. }) = + l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { story: StoryRef::Body, .. })) + else { + panic!("{multiple} shift {shift}: no body lines on page 1") + }; + let (first, last, next) = (¶.lines[*l0], ¶.lines[*l1 - 1], ¶.lines[*l1]); + let top = y + last.top - first.top; + let text_end = top + (last.baseline - last.top) + last.text_desc; + assert!(text_end <= bottom + 0.01, "{multiple} shift {shift}: the last line's text ends at {text_end}, below the margin at {bottom}"); + let next_text_end = top + last.height + (next.baseline - next.top) + next.text_desc; + assert!( + next_text_end > bottom + 0.01, + "{multiple} shift {shift}: the next line's text would end at {next_text_end}, within the margin at {bottom}" + ); + if top + last.height > bottom + 0.01 { + past_margin += 1; + } + } + assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin"); + } +}