From c92d120098039e55d2dc2293a1692e070e80ded5 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Sun, 18 Oct 2026 09:57:07 +0100 Subject: [PATCH] Honor table row keep-next during pagination (#2494) Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 70 ++++++++++++++- crates/layout/src/tests.rs | 176 +++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 3 +- 3 files changed, 246 insertions(+), 3 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 299b0c5e4..ca971335b 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3680,6 +3680,43 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32 } } +fn row_keeps_next(row: &table::RowLayout) -> bool { + let Some(items) = row.cells.first().and_then(|cell| row.items.get(cell.items.clone())) else { return false }; + let Some((depth, story)) = items + .iter() + .filter_map(|item| match item { + Placed::Cell { table, story, .. } => Some((table.0.len() + 3, *story)), + _ => None, + }) + .min_by_key(|(depth, _)| *depth) + else { + return false; + }; + // Word takes the row's keep flag from the first paragraph of its first cell. + items + .iter() + .find_map(|item| match item { + Placed::Lines { path, story: own, para, .. } if path.0.len() == depth && path.0.last() == Some(&0) && *own == story => { + Some(para.rp.keep_next) + } + _ => None, + }) + .unwrap_or(false) +} + +fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool { + if laid.height <= room + 0.01 { + return true; + } + if row.props.header || row.props.cant_split || (row.props.height_rule == HeightRule::Exact && row.props.height.is_some_and(|h| h > 0.0)) { + return false; + } + if row.props.height_rule == HeightRule::AtLeast && row.props.height.is_some_and(|h| h > room + 0.01 && h <= page_height + 0.01) { + return false; + } + table::split_row(laid, room).is_some() +} + fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) { pb.prev = None; let width = pb.col_w(); @@ -3707,6 +3744,19 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl // table (e.g. 3 header rows over 30 000 pages). const MAX_HEADER_REPEATS: usize = 200_000; let mut header_repeats = 0usize; + // Resolve kept chains once; nested paragraphs cannot keep their enclosing row. + let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect(); + for i in (0..tl.rows.len().saturating_sub(1)).rev() { + let Some(row) = tl.rows.get(i) else { continue }; + if i < header_rows.len() || !row_keeps_next(row) { + continue; + } + let Some((end, height)) = kept.get(i + 1).copied() else { continue }; + if let Some(group) = kept.get_mut(i) { + *group = (end, row.height + height); + } + } + let repeated_height: f32 = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.height).sum(); let place = |pb: &mut PageBuilder, row: &table::RowLayout| { let (x, y) = (pb.col_x() + tl.x, pb.y); if let Some(pg) = pb.page() { @@ -3745,7 +3795,23 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl // Each pass places the part of the row that fits, then breaks the page. for _ in 0..1000 { let cur = rest.as_ref().unwrap_or(row); - if pb.y + cur.height <= pb.bottom + 0.01 { + // A fitting kept row follows the next row when that row cannot start below it. + // Move only the prefix's start; an oversized follower cannot separate its kept rows. + let defer_kept = pb.y + cur.height <= pb.bottom + 0.01 + && !pb.web + && !pb.at_top() + && !fresh + && rest.is_none() + && !ri.checked_sub(1).and_then(|prev| kept.get(prev)).is_some_and(|&(end, _)| end >= ri) + && kept.get(ri).is_some_and(|&(end, height)| { + if end == ri { + return false; + } + let (Some(next), Some(laid)) = (t.rows.get(end), tl.rows.get(end)) else { return false }; + let page_height = pb.bottom - body_top - repeated_height; + height <= page_height + 0.01 && !row_can_start(next, laid, pb.bottom - pb.y - height, page_height) + }); + if pb.y + cur.height <= pb.bottom + 0.01 && !defer_kept { break; } // Measured in Word: a row with an at-least height doesn't split where less than that @@ -3756,7 +3822,7 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl r.props.height_rule == wordcraft_doc::props::HeightRule::AtLeast && r.props.height.is_some_and(|h| h > room + 0.01 && h <= pb.bottom - body_top + 0.01) }); - let split = if splittable && !short_of_min { table::split_row(cur, room) } else { None }; + let split = if splittable && !short_of_min && !defer_kept { table::split_row(cur, room) } else { None }; match split { Some((a, b)) => { place(pb, &a); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a33a8aba8..d69e75359 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -7627,3 +7627,179 @@ fn split_cell_moves_image_frame() { assert!((rest.height - (50.0 + top)).abs() < 0.01); } } + +fn row_keep_doc() -> Document { + use wordcraft_doc::props::{HeightRule, LineSpacing}; + let plain = ParaProps { + space_before: Some(0.0), + space_after: Some(0.0), + line_spacing: Some(LineSpacing::Exactly(12.0)), + widow_control: Some(false), + ..Default::default() + }; + let para = |text: &str, props: ParaProps| { + let mut p = Paragraph::with_text(text, Default::default()); + p.props = props; + wordcraft_doc::para_block(p) + }; + let mut d = Document::new(); + d.last_section.page_w = 400.0; + d.last_section.page_h = 360.0; + d.last_section.margin_top = 36.0; + d.last_section.margin_bottom = 36.0; + d.last_section.margin_left = 36.0; + d.last_section.margin_right = 36.0; + d.styles.upsert(wordcraft_doc::Style { + id: "KeptRow".into(), + name: "Kept row".into(), + para: ParaProps { keep_next: Some(true), keep_lines: Some(true), ..Default::default() }, + ..Default::default() + }); + let mut t = Table::new(2, 1, 328.0); + t.props.style = None; + t.props.cell_margins = Some([0.0; 4]); + t.rows[0].props.height = Some(24.0); + t.rows[0].props.height_rule = HeightRule::Exact; + t.rows[0].cells[0].blocks = vec![para("Heading", ParaProps { style: Some("KeptRow".into()), ..plain.clone() })]; + t.rows[1].props.height = Some(180.0); + t.rows[1].props.height_rule = HeightRule::AtLeast; + t.rows[1].cells[0].blocks = vec![para("", plain.clone()), para("Following row", plain.clone())]; + d.body = vec![para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(190.0)), ..plain }), Arc::new(Block::Table(t))]; + d +} + +fn row_keep_pages(d: &Document) -> Vec { + let l = lay(d); + (0..d.table(StoryRef::Body, &Path::top(1)).unwrap().rows.len()) + .map(|row| { + l.pages + .iter() + .position(|page| { + page.items.iter().any(|item| matches!(item, Placed::Lines { path, .. } if path.0.len() == 4 && path.0[..3] == [1, row as u32, 0])) + }) + .unwrap() + }) + .collect() +} + +#[test] +fn kept_row_moves_with_next() { + for rule in [wordcraft_doc::props::HeightRule::AtLeast, wordcraft_doc::props::HeightRule::Exact] { + let mut d = row_keep_doc(); + d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[1].props.height_rule = rule; + d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = Some(false); + assert_eq!(row_keep_pages(&d), [0, 1], "unkept control, {rule:?}"); + d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = None; + assert_eq!(row_keep_pages(&d), [1, 1], "inherited keep, {rule:?}"); + } +} + +#[test] +fn kept_row_allows_next_split() { + let mut d = row_keep_doc(); + let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); + t.rows[1].props.height = None; + let p = d.para_mut(StoryRef::Body, &Path(vec![1, 1, 0, 0])).unwrap(); + let props = p.props.clone(); + *p = Paragraph::with_text(&"Next\n".repeat(30), Default::default()); + p.props = props; + assert_eq!(row_keep_pages(&d), [0, 0]); +} + +#[test] +fn kept_rows_move_as_chain() { + let mut d = row_keep_doc(); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(170.0)); + let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); + let mut middle = t.rows[0].clone(); + middle.props.height = Some(80.0); + middle.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast; + let mut p = Paragraph::with_text("Middle", Default::default()); + p.props = middle.cells[0].blocks[0].as_para().unwrap().props.clone(); + middle.cells[0].blocks = vec![wordcraft_doc::para_block(p)]; + t.rows.insert(1, middle); + assert_eq!(row_keep_pages(&d), [1, 1, 1]); + d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = Some(false); + assert_eq!(row_keep_pages(&d), [0, 1, 1]); +} + +#[test] +fn oversized_kept_rows_finish() { + let mut d = row_keep_doc(); + d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[1].props.height = Some(400.0); + let l = lay(&d); + assert!(l.pages.len() <= 4, "{} pages", l.pages.len()); + assert_eq!(row_keep_pages(&d), [1, 2], "Word starts the oversized kept group on a fresh page"); +} + +#[test] +fn kept_row_can_split_itself() { + let mut d = row_keep_doc(); + let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); + t.rows[0].props.height = None; + t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Auto; + t.rows[1].props.height = Some(20.0); + let p = d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap(); + let mut props = p.props.clone(); + props.keep_lines = Some(false); + *p = Paragraph::with_text(&"Heading\n".repeat(10), Default::default()); + p.props = props; + assert_eq!(row_keep_pages(&d), [0, 1]); +} + +#[test] +fn first_cell_controls_keep() { + for (first, last, pages) in [(true, false, [1, 1]), (false, true, [0, 1]), (true, true, [1, 1]), (false, false, [0, 1])] { + let mut d = row_keep_doc(); + let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); + t.grid = vec![164.0, 164.0]; + for (ri, row) in t.rows.iter_mut().enumerate() { + row.cells.push(row.cells[0].clone()); + for (ci, cell) in row.cells.iter_mut().enumerate() { + let mut p = Paragraph::with_text("Cell", Default::default()); + p.props = cell.blocks[0].as_para().unwrap().props.clone(); + p.props.keep_next = Some(ri == 0 && if ci == 0 { first } else { last }); + cell.blocks = vec![wordcraft_doc::para_block(p)]; + } + } + assert_eq!(row_keep_pages(&d), pages, "first={first}, last={last}"); + } +} + +#[test] +fn first_para_controls_row_keep() { + let mut d = row_keep_doc(); + let mut p = Paragraph::with_text("Last paragraph", Default::default()); + p.props = d.para(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.clone(); + p.props.keep_next = Some(false); + d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[0].cells[0].blocks.push(wordcraft_doc::para_block(p)); + assert_eq!(row_keep_pages(&d), [1, 1], "Word honors the first paragraph even when the last opts out"); + d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = Some(false); + d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 1])).unwrap().props.keep_next = Some(true); + assert_eq!(row_keep_pages(&d), [0, 1], "a later paragraph cannot keep its row"); +} + +#[test] +fn nested_keep_leaves_outer_row() { + let mut d = row_keep_doc(); + let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); + t.rows[0].props.height = Some(60.0); + let mut nested = Table::new(1, 1, 328.0); + nested.rows[0].cells[0].blocks = vec![t.rows[0].cells[0].blocks[0].clone()]; + let mut p = Paragraph::with_text("Outer tail", Default::default()); + p.props.keep_next = Some(false); + t.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), wordcraft_doc::para_block(p)]; + assert_eq!(row_keep_pages(&d), [0, 1]); +} + +#[test] +fn kept_prefix_stays_together() { + let mut d = row_keep_doc(); + let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); + t.rows[1].props.height = Some(400.0); + let mut middle = t.rows[0].clone(); + middle.props.height = Some(80.0); + middle.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast; + t.rows.insert(1, middle); + assert_eq!(row_keep_pages(&d), [1, 1, 2], "an oversized follower cannot separate an already placed kept prefix"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 6cc8b2931..dc5cb9b7d 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-17 · **Change:** trivial (AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; 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, @@ -186,6 +186,7 @@ When content exceeds a preferred column width, AutoFit donors lose equal widths | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Table row keep-next uses the first paragraph of the first cell, including inherited flags. Kept row chains start on a fresh page when the following row cannot start below them. Synthetic Word export covers mixed-cell flags, multiple paragraphs and kept prefixes before oversized followers. | | 2026-10-16 | trivial | Include table-cell headings in regenerated TOCs with full target paths | | 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. | | 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |