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 <noreply@anthropic.com>
This commit is contained in:
James Parrish Coleman III
2026-10-22 15:10:32 -05:00
co-authored by Claude Opus 5.5
parent 00bc6a4b02
commit 627ded32c7
3 changed files with 77 additions and 4 deletions
+17 -1
View File
@@ -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::<f32>()
- 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
+17 -3
View File
@@ -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;
}
+43
View File
@@ -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) = (&para.lines[*l0], &para.lines[*l1 - 1], &para.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");
}
}