diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3ff648436..632b7dde9 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3176,7 +3176,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: // 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 }; + let need = pl.height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0, &mut ctx.counters.clone()) } 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); @@ -3601,15 +3601,17 @@ fn space_after_kept(ctx: &Ctx, block: usize, rp: &wordcraft_doc::resolve::Resolv /// of it under keep lines together — 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). Word carries a kept heading over /// when the paragraph after it cannot start on the page (#966). -fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32 { +fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize, counters: &mut Counters) -> 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. + // Keep list indents and label metrics without consuming the document counters. let rp = ctx.doc.styles.resolve_para(&p.props); + let label = + p.props.numbering.or(rp.numbering).filter(|n| n.num != 0).and_then(|n| counters.next_label(&ctx.doc.numbering, n.num, n.level)); let env = para::ParaEnv { doc: ctx.doc, width, - label: None, + label, fields: &ctx.fields, show_hidden: ctx.opts.show_hidden, field_codes: ctx.opts.field_codes, @@ -3623,10 +3625,9 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32 grid: ctx.grid, fit_objects: false, }; - let _ = rp; let pl = para::layout_para(p, &env); 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) + pl.rp.space_before + pl.height + pl.rp.space_after + next_first_line(ctx, block + 1, width, depth + 1, counters) } else { // The room this one needs to start on the page with the kept paragraph before // it (#966). diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index d6865b0d9..00d215f86 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -7263,3 +7263,50 @@ fn row_nil_overrides_style() { d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); assert_eq!(rules_on_page0(&lay(&d)), vec![2.0]); } + +#[test] +fn keep_next_counts_list_indent() { + use wordcraft_doc::props::{LineSpacing, NumRef}; + let mut doc = Document::new(); + doc.last_section.page_w = 240.0; + doc.last_section.page_h = 200.0; + doc.last_section.margin_top = 20.0; + doc.last_section.margin_bottom = 20.0; + doc.last_section.margin_left = 20.0; + doc.last_section.margin_right = 20.0; + let id = doc.numbering.add_list(wordcraft_doc::ListKind::Numbered); + let level = &mut doc.numbering.abstracts[0].levels[0]; + level.indent = 100.0; + level.hanging = 0.0; + let props = ParaProps { + space_before: Some(0.0), + space_after: Some(0.0), + line_spacing: Some(LineSpacing::Exactly(12.0)), + widow_control: Some(true), + ..Default::default() + }; + let para = |text: &str, props: ParaProps| { + let mut p = + Paragraph::with_text(text, wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() }); + p.props = props; + wordcraft_doc::para_block(p) + }; + doc.body = vec![ + para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(136.0)), ..props.clone() }), + para("Heading", ParaProps { keep_next: Some(true), keep_lines: Some(true), ..props.clone() }), + para("Alpha beta gamma delta", ParaProps { numbering: Some(NumRef { num: id, level: 0 }), ..props }), + ]; + let layout = lay(&doc); + assert_eq!(layout.caret(&Pos::body(2, 0)).unwrap().page, 1, "the indented list cannot start below the heading"); + assert_eq!(layout.caret(&Pos::body(1, 0)).unwrap().page, 1, "the kept heading must follow it"); + let labels: Vec<_> = layout + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|item| match item { + Placed::Lines { para, .. } => para.label.as_ref().map(|label| label.text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(labels, ["1."], "lookahead must not consume a list counter"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index eb6ffad0a..40c026edf 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-14 · **Change:** trivial (consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** trivial (keep-with-next lookahead retains list indents and label metrics without advancing counters; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously 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, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | Keep-with-next lookahead uses copied list counters so list indents and label metrics match placement. An original regression keeps a heading with an indented list that needs two lines. | | 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows | | 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |