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"); + } +}