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 <noreply@anthropic.com>

* #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 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-14 02:03:30 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov Brandon Thomas
parent 2dcde62220
commit 85a3a53a8e
5 changed files with 184 additions and 5 deletions
+2 -1
View File
@@ -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 |
+5
View File
@@ -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);
+45 -2
View File
@@ -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<f32> = 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
+129
View File
@@ -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<u32>) {
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,
);
}
+3 -2
View File
@@ -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 |