From fc52079f587abfb82a45970075bc92f0f6464c6c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 09:58:26 -0400 Subject: [PATCH] R19: keep with next splits a paragraph that doesn't fit anyway (#933) Word moves a "keep with next" paragraph to the next page only when it would fit on this one by itself; one that doesn't fit anyway breaks as usual, its last lines going over with the next paragraph (with keep lines together it moves whole). A chain of kept paragraphs moves together. Measured in Word for Windows: 20 synthetic cases. NIH's five-line kept paragraph no longer leaves a third of a page empty. Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 1 + crates/layout/src/lib.rs | 19 +++++++++++----- crates/layout/src/tests.rs | 46 ++++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 1 + 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7d379ea..8e568c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -151,6 +151,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Hidden end-of-cell marks (`w:hideMark`) read, written and laid out like Word (9 cases measured in Word for Windows): a cell's empty last paragraph takes no room, so rows of such empty cells shrink to their borders | | 2026-10-12 | Text frames (`w:framePr`) in headers, footers, cells and text boxes: placed apart with text wrapping around them, and kept on save (a framed footer page number took a line of its own and was lost on save) | | 2026-10-12 | List indents like Word's when a numbered style has indents of its own: the style giving the list (and styles based on it) beat the level's indents, and a paragraph with its list switched off drops the styles' indents (27 cases measured in Word for Windows); NIH's Heading 3s now sit at their 0.9" | +| 2026-10-12 | Keep with next like Word (20 cases measured in Word for Windows): a kept paragraph moves to the next page only when it would fit on this one by itself, otherwise it breaks as usual; chains of kept paragraphs move together | | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 799077d..4d47286 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -2615,9 +2615,11 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, } } pb.y += before; - // Keep lines together: if it doesn't fit but would on an empty column, move it. - if pl.rp.keep_lines || pl.rp.keep_next { - let need = pl.height + if pl.rp.keep_next { next_first_line(ctx, block, width) } else { 0.0 }; + // Keep lines together: if it doesn't fit but would on an empty column, move it. Keep with + // next alone moves a paragraph only when it would fit here by itself (Word for Windows): one + // that doesn't breaks as usual, its last lines going over with the next paragraph. + if pl.rp.keep_lines || (pl.rp.keep_next && pb.y + pl.height <= pb.bottom + 0.01) { + let need = pl.height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0) } else { 0.0 }; // (Against the whole column: balanced columns grow to hold it.) if pb.y + need > pb.bottom && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top { pb.advance(block, body_top); @@ -2883,7 +2885,10 @@ fn mirror_shift(rp: &wordcraft_doc::resolve::ResolvedPara, number: u32, web: boo } /// Height of the first line of the block after `block` (for keep-with-next). -fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { +/// The room the paragraphs after `block` need on its page for keep with next: the next one's +/// first line, or all of it and what it keeps with in turn when it is kept with the next too +/// (a chain moves together in Word). +fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32 { match ctx.doc.body.get(block + 1).map(|b| &**b) { Some(Block::Para(p)) => { // Don't advance list counters for a lookahead: lay out without the label. @@ -2907,7 +2912,11 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { }; let _ = rp; let pl = para::layout_para(p, &env); - pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0) + if pl.rp.keep_next && depth < 8 { + pl.rp.space_before + pl.height + pl.rp.space_after + next_first_line(ctx, block + 1, width, depth + 1) + } else { + pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0) + } } Some(Block::Table(_)) => 24.0, None => 0.0, diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 65a5b49..975045a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4341,3 +4341,49 @@ fn list_indents_and_style_indents_combine_like_word() { assert!((got - want).abs() < 0.5, "{what}: {got} vs {want}"); } } + +/// Keep with next at a page bottom, measured in Word for Windows (`plan/word` R19, 20 cases): a +/// paragraph that would fit on the page by itself moves over with the next one, but one that +/// doesn't fit anyway breaks as usual, its last lines going over with the next paragraph (with +/// Keep lines together it moves whole); a chain of kept paragraphs moves together. NIH's +/// five-line kept paragraph left a third of a page empty. +#[test] +fn keep_with_next_moves_a_paragraph_only_when_it_would_fit() { + // Page of each of the kept paragraph's 5 lines and of the first paragraph after it. + let pages = |fillers: usize, kept: ParaProps, next: ParaProps| { + let mut d = Document::new(); + let plain = ParaProps { + space_before: Some(0.0), + space_after: Some(0.0), + line_spacing: Some(wordcraft_doc::props::LineSpacing::Multiple(1.0)), + ..Default::default() + }; + let para = |text: &str, p: &ParaProps| { + let mut x = wordcraft_doc::Paragraph::with_text(text, Default::default()); + x.props = ParaProps { keep_next: p.keep_next, keep_lines: p.keep_lines, ..plain.clone() }; + wordcraft_doc::para_block(x) + }; + d.body = (0..fillers).map(|i| para(&format!("F{i}"), &plain)).collect(); + d.body.push(para("Ka\nKb\nKc\nKd\nKe", &kept)); + d.body.push(para("NX", &next)); + d.body.push(para("NY", &plain)); + let l = lay(&d); + let k = fillers; + [0, 3, 6, 9, 12] + .map(|off| l.caret(&Pos::body(k, off)).unwrap().page) + .into_iter() + .chain([l.caret(&Pos::body(k + 1, 0)).unwrap().page]) + .collect::>() + }; + let none = ParaProps::default(); + let kn = ParaProps { keep_next: Some(true), ..Default::default() }; + let kl = ParaProps { keep_lines: Some(true), ..kn.clone() }; + // Find where the kept paragraph just fits: its last line on page 1, the next one's on page 2. + let fit = (20..80).find(|n| pages(*n, none.clone(), none.clone()) == [0, 0, 0, 0, 0, 1]).expect("a filler count that fills the page"); + assert_eq!(pages(fit, kn.clone(), none.clone()), [1, 1, 1, 1, 1, 1], "fits by itself: moves with the next"); + let split = pages(fit + 1, none.clone(), none.clone()); + assert!(split[0] == 0 && split[4] == 1, "one more line splits it: {split:?}"); + assert_eq!(pages(fit + 1, kn.clone(), none.clone()), split, "doesn't fit anyway: breaks as usual"); + assert_eq!(pages(fit + 1, kl.clone(), none.clone()), [1, 1, 1, 1, 1, 1], "with keep lines together it moves whole"); + assert_eq!(pages(fit - 1, kn.clone(), kn.clone()), [1, 1, 1, 1, 1, 1], "a kept chain moves together"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 6c9f68a..7b3e133 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -80,6 +80,7 @@ font can't match Word's metrics. | 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders | | 2026-10-12 | trivial | Text frames in headers, footers, cells and text boxes are placed like floating objects with the text wrapping around them; body frames still lay out in line | | 2026-10-12 | trivial | List indents and style indents combine like Word's: a numbered style's own indents (and those of styles based on it) beat the list level's, its base styles' don't, the paragraph's own list beats every style; a paragraph that switches its list off (`numId` 0) drops the styles' indents | +| 2026-10-12 | trivial | Keep with next: a paragraph that doesn't fit on the page anyway breaks as usual (its last lines go over with the next paragraph) instead of moving whole; a chain of kept paragraphs moves together | | 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page | | 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) | | 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |