No automatic spacing between list items (#924)

* R17: no automatic paragraph spacing between list items

Word drops automatic (HTML-style) space before and after between two list
items, of one list or two, at any level; the first item keeps its space
before and the last its space after, and measured spacing stays. Measured in
Word for Windows: 16 synthetic cases. NIH's bulleted IND report items now
touch as in Word.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ROADMAP: keep the progress log separator under its header

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-12 10:22:53 -04:00
1 parent 681a57fc1a
commit d863811511
4 files changed
+119 -10

No files matched your search

+2 -1
View File
@@ -132,8 +132,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
| Date | What landed |
| 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents |
|---|---|
| 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents |
| 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged |
| 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon |
| 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
@@ -159,6 +159,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | Floating objects positioned relative to their paragraph start above its space before, as in Word (10 cases measured in Word for Windows) |
| 2026-10-12 | Unequal columns: text after a column break is broken for the column it lands in (NIH's SARP form had a paragraph a word or two wide) |
| 2026-10-12 | Space before at the top of a page like Word after page breaks (32 cases measured): a paragraph ending with a page break keeps its mark with it, the next paragraph gets no space before; page break before drops it in Word 2013 mode |
| 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan |
| 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges |
| 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) |
+38 -9
View File
@@ -876,6 +876,7 @@ fn layout_box(
let mut y = 0.0f32;
let mut prev_after = 0.0f32;
let mut prev_after_auto = false;
let mut prev_listed = false;
let mut prev_style: Option<(String, bool)> = None;
let mut last_top = 0.0f32;
for (i, b) in blocks.iter().enumerate() {
@@ -885,6 +886,7 @@ fn layout_box(
match &**b {
Block::Para(p) => {
let label = ctx.next_label(p);
let listed = label.is_some();
let mut pl = ctx.para_labelled(p, width, table, &[], label.clone());
// A text frame (a footer's page number in a frame, say) is placed apart, like a
// floating object, and the text after it wraps around it; it takes no room in
@@ -909,6 +911,17 @@ fn layout_box(
let mut before = if same && ctxl { 0.0 } else { pl.rp.space_before };
if same && ctxl {
y -= prev_after;
prev_after = 0.0;
}
// Automatic spacing: none between two list items (as in an HTML list).
if i > 0 && prev_listed && listed {
if prev_after_auto {
y -= prev_after;
prev_after = 0.0;
}
if pl.rp.before_auto {
before = 0.0;
}
}
// Automatic spacing: none at the top of a cell or box, and it collapses with the
// space after the paragraph above (the larger one counts).
@@ -953,6 +966,7 @@ fn layout_box(
y += pl.height + after;
prev_after = if same && ctxl { 0.0 } else { after };
prev_after_auto = pl.rp.after_auto;
prev_listed = listed;
prev_style = Some((pl.rp.style.clone(), ctxl));
}
Block::Table(t) => {
@@ -1227,9 +1241,9 @@ struct PageBuilder<'a> {
carry: Vec<NoteBox>,
/// Page bottom before footnotes took space.
orig_bottom: f32,
/// The previous paragraph: (style, contextual spacing, space after) for contextual spacing.
/// The paragraph above: its style, contextual spacing, space after and whether that is automatic.
prev: Option<(String, bool, f32, bool)>,
/// The paragraph above: its style, contextual spacing, space after, whether that is automatic
/// and whether it is a list item.
prev: Option<(String, bool, f32, bool, bool)>,
/// The previous paragraph ended with a page break (its mark stayed with the break): the next
/// paragraph, first on the new page, gets no space before (Word).
after_break_mark: bool,
@@ -2658,30 +2672,45 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
if pb.at_top() && (after_break_mark || (broke_before && ctx.doc.settings.compat_mode >= 15)) {
before = 0.0;
} else if pb.at_top()
&& let Some((_, _, after, _)) = &prev_para
&& let Some((_, _, after, _, _)) = &prev_para
{
// After a page break before or a section break the space between the paragraphs is
// still counted as one: the previous paragraph's space after (left on its page) comes
// off the space before here (Word: 24 pt before after 10 pt after leaves 14 pt).
before = (before - after.max(0.0)).max(0.0);
}
let prev = pb.prev.take();
if let Some((style, ctxl, after, _)) = &prev
let mut prev = pb.prev.take();
if let Some((style, ctxl, after, _, _)) = &mut prev
&& *style == pl.rp.style
&& !pb.at_top()
{
if *ctxl {
pb.y -= after;
pb.y -= *after;
*after = 0.0;
}
if pl.rp.contextual_spacing {
before = 0.0;
}
}
// Automatic spacing: none between two list items (as in an HTML list; measured in Word: any
// two items, of one list or two, at any level).
if let Some((_, _, after, after_auto, true)) = &mut prev
&& label.is_some()
&& !pb.at_top()
{
if *after_auto {
pb.y -= *after;
*after = 0.0;
}
if pl.rp.before_auto {
before = 0.0;
}
}
// Automatic spacing: none at the top of the document, and it collapses with the space after
// the paragraph above (the larger one counts).
if pl.rp.before_auto && block == 0 {
before = 0.0;
} else if let Some((_, _, after, after_auto)) = prev
} else if let Some((_, _, after, after_auto, _)) = prev
&& (pl.rp.before_auto || after_auto)
&& !pb.at_top()
{
@@ -2925,7 +2954,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
}
}
pb.y += pl.rp.space_after;
pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after, pl.rp.after_auto));
pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after, pl.rp.after_auto, label.is_some()));
}
/// The outline of the body for an Outline view pass.
+78
View File
@@ -4666,3 +4666,81 @@ fn automatic_paragraph_spacing_drops_at_cell_edges() {
assert!((a1 - a0 - (p1 - p0) - 14.0).abs() < 0.05, "14 pt between the cell's paragraphs");
assert!((a_below - a1 - (p_below - p1)).abs() < 0.05, "none after the cell's last paragraph");
}
/// Automatic spacing inside a list, measured in Word for Windows (`plan/word` R17, 16 cases): none
/// between two list items, of one list or two, at any level, whichever item asks for it; the first
/// item keeps its automatic space before and the last its space after, a paragraph with its list
/// switched off in between keeps both, and measured spacing stays. NIH's bulleted IND reports
/// (each item automatic before and after) were 14 pt apart instead of touching.
#[test]
fn automatic_spacing_drops_between_list_items() {
use wordcraft_doc::props::NumRef;
let auto = ParaProps {
space_before: Some(5.0),
space_after: Some(5.0),
space_before_auto: Some(true),
space_after_auto: Some(true),
..Default::default()
};
let plain = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
let tops = |items: [ParaProps; 3], in_cell: bool| {
let mut d = Document::new();
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let other = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let fix = |p: ParaProps| ParaProps {
numbering: p.numbering.map(|n| NumRef {
num: match n.num {
0 => 0,
1 => num,
_ => other,
},
..n
}),
..p
};
let paras: Vec<_> = std::iter::once(plain.clone())
.chain(items.map(fix))
.chain(std::iter::once(plain.clone()))
.enumerate()
.map(|(i, p)| {
let mut para = wordcraft_doc::Paragraph::with_text(&format!("P{i}"), Default::default());
para.props = p;
wordcraft_doc::para_block(para)
})
.collect();
if in_cell {
let mut t = Table::new(1, 1, 300.0);
t.rows[0].cells[0].blocks = paras;
d.body = vec![wordcraft_doc::Block::Table(t).into()];
} else {
d.body = paras;
}
let at = |i: u32| if in_cell { Pos { story: StoryRef::Body, path: Path(vec![0, 0, 0, i]), off: 0 } } else { Pos::body(i as usize, 0) };
let l = lay(&d);
[0, 1, 2, 3, 4].map(|i| l.caret(&at(i)).unwrap().top)
};
let item = |num: u32, level: u8, p: &ParaProps| ParaProps { numbering: Some(NumRef { num, level }), ..p.clone() };
for in_cell in [false, true] {
let base = tops([plain.clone(), plain.clone(), plain.clone()], in_cell);
let line = base[1] - base[0];
let gaps = |t: [f32; 5]| [1, 2, 3, 4].map(|i| ((t[i] - t[i - 1] - line) * 10.0).round() / 10.0);
let one = item(1, 0, &auto);
let cases = [
("one list", [one.clone(), one.clone(), one.clone()], [14.0, 0.0, 0.0, 14.0]),
("two lists", [one.clone(), item(2, 0, &auto), item(2, 0, &auto)], [14.0, 0.0, 0.0, 14.0]),
("two levels", [one.clone(), item(1, 1, &auto), one.clone()], [14.0, 0.0, 0.0, 14.0]),
("a plain item between", [one.clone(), item(1, 0, &plain), one.clone()], [14.0, 0.0, 0.0, 14.0]),
("list off between", [one.clone(), item(0, 0, &auto), one.clone()], [14.0, 14.0, 14.0, 14.0]),
("no list", [auto.clone(), auto.clone(), auto.clone()], [14.0, 14.0, 14.0, 14.0]),
];
for (what, items, want) in cases {
assert_eq!(gaps(tops(items, in_cell)), want, "{what} (in a cell: {in_cell})");
}
let fourteen = ParaProps { space_before: Some(14.0), ..plain.clone() };
assert_eq!(
gaps(tops([item(1, 0, &fourteen), item(1, 0, &fourteen), item(1, 0, &fourteen)], in_cell)),
[14.0, 14.0, 14.0, 0.0],
"measured spacing stays"
);
}
}
+1
View File
@@ -87,6 +87,7 @@ font can't match Word's metrics.
| 2026-10-12 | trivial | Floats relative to a paragraph (or its line) are measured from the paragraph's start above its space before |
| 2026-10-12 | trivial | Text after a column break in unequal columns is broken for its new column's width |
| 2026-10-12 | trivial | Space before at the top of a page after page breaks, measured in Word for Windows in compatibility modes 12 and 15 (32 cases) |
| 2026-10-12 | trivial | No automatic (HTML-style) spacing between two list items, of one list or two, at any level; the first item keeps its automatic space before and the last its space after |
| 2026-10-12 | trivial | Kerning only where the document asks for it, and automatic (HTML-style) paragraph spacing, both measured in Word for Windows against real .gov documents |
| 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) |