From 85a3a53a8ef94ce2d65dbc89522100a62b88006b Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Wed, 14 Oct 2026 08:03:30 +0200 Subject: [PATCH] Keep a paragraph's space after clear of the footnote separator (#1116) (#1140) * #1116: keep a paragraph's space after clear of the footnote separator On a page with footnotes (already there or brought by the line), a paragraph's last line only fits if its space after fits above the notes area too; otherwise it goes to the next page with its note (and, with widow control, one more line). Contextual spacing and automatic spacing between list items drop the requirement; pages without notes are unchanged (space after may still run past the bottom margin). Co-Authored-By: Claude Opus 5.5 * #1116: space after vs footnote separator uses the spacing code's tests, only on pages with notes Contextual spacing drops a paragraph's space after only when that paragraph asks for it, and the next paragraph counts as a list item only when it gets a label (Numbering::shows_label), as in the spacing between paragraphs. The kept space after is worked out only for a last line on a page with notes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 +- crates/doc/src/numbering.rs | 5 ++ crates/layout/src/lib.rs | 47 ++++++++++++- crates/layout/src/tests.rs | 129 ++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +- 5 files changed, 184 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1cccc17d2..fdf508fee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: dragged shapes wrap in their new column, #884; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: footnote pagination, #1116; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -144,6 +144,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | The caret is black or white, whichever shows against what lies under it — the paper, a page colour, a dark page, shading or a highlight — whatever the UI theme, as Word's inverted caret does; before, it stayed black on dark page colours and highlights and could vanish (#299) | | 2026-10-13 | Clicking the ribbon's tab names, empty space, gallery tiles or View check boxes, or the status bar's view buttons and language menu, no longer makes the caret vanish: typing goes on in the document as in Word, without another click on the page (#1093) | | 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) | +| 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index 655937da3..052893450 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -208,6 +208,11 @@ impl Numbering { let n = self.num(num)?; self.abstracts.iter().find(|a| a.id == n.abstract_id) } + /// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives + /// one), without counting it. + pub fn shows_label(&self, num: u32, level: u8) -> bool { + self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None) + } pub fn level(&self, num: u32, level: u8) -> Option<&Level> { if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) { return Some(l); diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 727cff1fe..883011b10 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -660,6 +660,14 @@ impl Ctx<'_> { /// The list label of `p`, advancing the list counters: call it once per paragraph, in /// document order (only for paragraphs that will show). + /// Whether `p` gets a list label ([`Ctx::next_label`] is `Some`), without counting it. + fn has_label(&self, p: &Paragraph) -> bool { + p.props + .numbering + .or_else(|| self.doc.styles.resolve_para(&p.props).numbering) + .filter(|n| n.num != 0) + .is_some_and(|n| self.doc.numbering.shows_label(n.num, n.level)) + } fn next_label(&mut self, p: &Paragraph) -> Option<(String, Level)> { let n = p.props.numbering.or_else(|| self.doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0)?; self.counters.next_label(&self.doc.numbering, n.num, n.level) @@ -2777,6 +2785,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: anchor_y = y0 - applied_before; } let mut n = pl.lines.len(); + // Worked out only for a last line on a page with notes. + let mut after_on_page: Option = None; let mut l0 = 0; while l0 < n { // How many lines fit? @@ -2815,6 +2825,17 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: - 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); + // 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 + // the bottom margin as usual. + let has_notes = !pb.notes.is_empty() || !new_notes.is_empty() || !line_notes.is_empty(); + let tail = if has_notes && l1 + 1 == n { + *after_on_page.get_or_insert_with(|| space_after_kept(ctx, block, &pl.rp, label.is_some())) + } else { + 0.0 + }; + let over = bottom + tail > limit + 0.01; // Notes too long for the rest of the page continue on the next one: the line still // fits if the notes before its last one, and that one's first line, fit below it. // The notes then take the rest of the page. @@ -2824,7 +2845,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: let sep = pb.seps.sep_h(pb.notes.first().is_some_and(|n| n.cont)); pb.body_floor().max(bottom) + sep + before + last.first_piece() + pb.seps.notice_h() <= pb.orig_bottom + 0.01 }); - if bottom > limit + 0.01 && l1 > l0 && !split { + if over && l1 > l0 && !split { break; } // A line holding nothing but a page or column break stays at the bottom of the page @@ -2834,7 +2855,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: && p.text .get(l.start..l.stop) .is_some_and(|t| t.chars().all(|c| matches!(c, wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK))); - if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split && !only_break { + if over && l1 == l0 && !pb.at_top() && !split && !only_break { break; } new_notes.extend(line_notes); @@ -3063,6 +3084,28 @@ fn mirror_shift(rp: &wordcraft_doc::resolve::ResolvedPara, number: u32, web: boo right - left } +/// The space after body paragraph `block` (resolved as `rp`) that is really left below it: none +/// when contextual spacing or automatic spacing between list items drops it before the next +/// paragraph. Same tests as the spacing between paragraphs in `place_para_in_flow`: contextual +/// spacing removes this paragraph's space after only when this one asks for it, and the next +/// counts as a list item when it gets a label. +fn space_after_kept(ctx: &Ctx, block: usize, rp: &wordcraft_doc::resolve::ResolvedPara, listed: bool) -> f32 { + let after = rp.space_after.max(0.0); + if after <= 0.0 { + return 0.0; + } + if let Some(Block::Para(next)) = ctx.doc.body.get(block + 1).map(|b| &**b) { + let nrp = ctx.doc.styles.resolve_para(&next.props); + if nrp.style == rp.style && rp.contextual_spacing { + return 0.0; + } + if listed && rp.after_auto && ctx.has_label(next) { + return 0.0; + } + } + after +} + /// Height of the first line of the block after `block` (for keep-with-next). /// 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 diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index de4b5d19f..afdf9d0e3 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4990,3 +4990,132 @@ fn automatic_spacing_drops_between_list_items() { ); } } + +/// #1116: a document of a top paragraph with `before` points of space before, filler lines, a +/// paragraph of several lines (block `t`) with 6 pt after and, when `note`, a footnote on its last line. +fn space_after_doc(before: f32, note: bool) -> (Document, usize, Option) { + let text = format!("Top.\n{}{}\nAfter.", "Body text line.\n".repeat(14), "Words in a paragraph that wraps. ".repeat(14)); + let mut d = Document::from_text(&text); + let t = 15; + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.props.space_before = Some(before); + } + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(t).map(std::sync::Arc::make_mut) { + p.props.space_after = Some(6.0); + p.props.widow_control = Some(true); + } + let len = d.para(StoryRef::Body, &Path(vec![t as u32])).map(|p| p.text.len()).unwrap(); + let id = note.then(|| footnote(&mut d, &Pos::body(t, len), "The note.")); + (d, t, id) +} + +/// The pieces of body paragraph `t` as (page, l0, l1, bottom), and its line count. +fn para_pieces(l: &DocLayout, t: usize) -> (Vec<(usize, usize, usize, f32)>, usize) { + let mut v = Vec::new(); + let mut n = 0; + for (pi, p) in l.pages.iter().enumerate() { + for it in &p.items { + if let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, y, .. } = it + && path.0 == [t as u32] + { + n = para.lines.len(); + v.push((pi, *l0, *l1, item_bottom(*y, para, *l0, *l1).unwrap())); + } + } + } + (v, n) +} + +#[test] +fn space_after_stays_clear_of_the_footnote_separator() { + // #1116, measured in Word: on a page with footnotes a paragraph ending there keeps its space + // after above the separator, or its last lines (two, with widow control) go to the next page + // with the note. + let mut moved = 0; + for before in 0..=300 { + let (d, t, id) = space_after_doc(before as f32, true); + let l = lay(&d); + let (pieces, n) = para_pieces(&l, t); + assert!(n >= 4, "a paragraph of several lines: {n}"); + let &(page, _, l1, bottom) = pieces.last().unwrap(); + assert_eq!(l1, n); + let note = note_pieces(&l, id.unwrap()); + assert_eq!(note.first().map(|p| p.0), Some(page), "before {before}: the note is on its reference's page"); + // The separator's area (default separator: NOTE_SEP high, its rule half way down) sits + // right above the note. + let sep_top = note[0].3 - NOTE_SEP; + assert!(sep_top - bottom >= 6.0 - 0.01, "before {before}: last line ends at {bottom}, the separator starts at {sep_top}"); + if pieces.len() > 1 { + assert_eq!(pieces.len(), 2, "{pieces:?}"); + assert!(pieces[0].2 - pieces[0].1 >= 2 && pieces[1].2 - pieces[1].1 >= 2, "widow/orphan control: {pieces:?}"); + moved += 1; + } + } + assert!(moved > 0, "the sweep crosses the page bottom"); +} + +#[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). + 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}"); + if bottom > bottom_margin - 6.0 { + tight += 1; + } + } + assert!(tight > 0, "some step leaves less than the space after below the last line"); +} + +/// #1116: the sweep of `space_after_stays_clear_of_the_footnote_separator` with paragraphs `t` +/// and `t + 1` changed by `tweak`: the last line of `t` stays `need` points clear of the separator. +fn space_after_clear_with(tweak: impl Fn(&mut Document, usize), need: f32) { + for before in 0..=300 { + let (mut d, t, id) = space_after_doc(before as f32, true); + tweak(&mut d, t); + let l = lay(&d); + let (pieces, _) = para_pieces(&l, t); + let &(page, _, _, bottom) = pieces.last().unwrap(); + let note = note_pieces(&l, id.unwrap()); + assert_eq!(note.first().map(|p| p.0), Some(page), "before {before}: the note is on its reference's page"); + let sep_top = note[0].3 - NOTE_SEP; + assert!(sep_top - bottom >= need - 0.01, "before {before}: last line ends at {bottom}, the separator starts at {sep_top}"); + } +} + +fn para_props(d: &mut Document, i: usize) -> &mut ParaProps { + match d.body.get_mut(i).map(std::sync::Arc::make_mut) { + Some(wordcraft_doc::Block::Para(p)) => &mut p.props, + _ => panic!("no paragraph {i}"), + } +} + +#[test] +fn space_after_stays_clear_when_only_the_next_paragraph_has_contextual_spacing() { + // Contextual spacing on the next paragraph alone drops its space before, not this one's space + // after (as the spacing between them): the space after still stays clear of the separator. + space_after_clear_with(|d, t| para_props(d, t + 1).contextual_spacing = Some(true), 6.0); +} + +#[test] +fn space_after_stays_clear_before_a_numbered_paragraph_without_a_label() { + // Automatic spacing drops only between two list items with labels: before a paragraph whose + // list doesn't exist (no label) a list item's automatic space after stays clear. + use wordcraft_doc::props::NumRef; + space_after_clear_with( + |d, t| { + let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered); + let p = para_props(d, t); + p.numbering = Some(NumRef { num, level: 0 }); + p.space_after_auto = Some(true); + para_props(d, t + 1).numbering = Some(NumRef { num: 9999, level: 0 }); + }, + wordcraft_doc::resolve::AUTO_SPACING, + ); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index d4d455e24..2a8bc2bf2 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (a dragged floating object anchors in the column it lands in, #884; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -37,7 +37,7 @@ font can't match Word's metrics. | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852) | 1–2 | | List labels: number formats (decimal, letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `layout::tests::chinese_counting_list_labels_and_their_font_runs`; a label mixing faces (digit + CJK comma) is drawn run by run (#534). Hebrew, Arabic, Hindi, Thai, Vietnamese and Russian formats show decimal | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | -| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | +| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | | Body top below a tall header, footer pushes body up | ✅ | #138 | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138 | — | @@ -80,6 +80,7 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | +| 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | | 2026-10-12 | trivial | Spaces, tabs and ideographic spaces don't count toward a line's height, as in Word (a line of only them takes the paragraph mark's) | | 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) | | 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 |