From 1b607731962c2f2854345f50e8aac1d899232575 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:19:27 +0100 Subject: [PATCH] Tables: a row with less room than its minimum height moves on whole (#1618) * Tables: a row with less room than its minimum height moves on whole Measured in Word with about 49 pt left on a page: a table row of eight lines splits there, but the same row with an at-least height of 63.6 pt goes to the next page whole, and so does one holding a nested table. Without the height both split, as before. WordCraft split them all, so a report's title row (a logo beside a small nested table) started at the foot of its cover page. Co-Authored-By: Claude Opus 5.5 * Tables: preserve splitting for oversized minimum-height rows --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 10 +++++++++- crates/layout/src/tests.rs | 30 ++++++++++++++++++++++++++++-- docs/layout-parity.md | 3 ++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 2f1bfa39e..c5cfbadf8 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3630,7 +3630,15 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl if pb.y + cur.height <= pb.bottom + 0.01 { break; } - let split = if splittable { table::split_row(cur, pb.bottom - pb.y) } else { None }; + // Measured in Word: a row with an at-least height doesn't split where less than that + // height is left; it starts the next page whole. + let room = pb.bottom - pb.y; + let short_of_min = rest.is_none() + && t.rows.get(ri).is_some_and(|r| { + 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 }; match split { Some((a, b)) => { place(pb, &a); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7054e96e8..db6097daa 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2160,7 +2160,8 @@ fn split_row_cells_each_continue_at_the_top() { } /// Word never splits a row of an exact height: one the page bottom cuts through, its text -/// included, moves to the next page whole (an "at least" row of that height splits). +/// included, moves to the next page whole. An at-least row also moves when the remaining +/// room is less than its minimum height. #[test] fn exact_rows_move_on_whole() { let text = "Dotted leader lines to fill in by hand later on. ".repeat(14); @@ -2193,7 +2194,8 @@ fn exact_rows_move_on_whole() { assert_eq!(exact.len(), 2); assert_eq!(exact[0], 0, "the exact row moves on whole: {exact:?}"); let at_least = lay_row(wordcraft_doc::props::HeightRule::AtLeast); - assert!(at_least[0] > 0 && at_least.get(1).is_some_and(|n| *n > 0), "an at-least row splits: {at_least:?}"); + assert_eq!(at_least[0], 0, "less than the minimum height remains: {at_least:?}"); + assert!(at_least.get(1).is_some_and(|n| *n > 0), "the at-least row moves: {at_least:?}"); } /// A nested table's cell the page bottom cuts through goes on at the top of the next page with @@ -4145,6 +4147,30 @@ fn autofit_handles_spanning_cells_long_words_and_empty_tables() { assert!(x > 72.0 + 200.0, "the second 50% column starts at {x}"); } +#[test] +fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() { + use wordcraft_doc::props::HeightRule; + // Measured in Word: with 49 pt left on the page, a row of eight lines splits, but the same row + // with an at-least height of 63.6 pt goes to the next page whole. + let first_row_line_page = |min: Option| { + let lines: Vec = (1..=200).map(|i| format!("Filler {i}")).collect(); + let one_page = lay(&Document::from_text(&lines.join("\n"))).pages[0].items.iter().filter(|i| matches!(i, Placed::Lines { .. })).count(); + let mut t = Table::new(1, 1, 400.0); + t.rows[0].props.height = min; + t.rows[0].props.height_rule = HeightRule::AtLeast; + t.rows[0].cells[0].blocks = + (1..=8).map(|k| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row line {k}"), Default::default()))).collect(); + // Fill the page, leaving two filler paragraphs' room (less than 63.6 pt) for the row. + let mut d = Document::from_text(&lines[..one_page - 2].join("\n")); + let end = d.body.len(); + d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + l.pages.iter().position(|p| p.items.iter().any(|i| matches!(i, Placed::Lines { path, .. } if path.0.first() == Some(&(end as u32))))) + }; + assert_eq!(first_row_line_page(None), Some(0), "a row without a minimum height splits"); + assert_eq!(first_row_line_page(Some(63.6)), Some(1), "a row with a minimum height that doesn't fit moves on"); +} + #[test] fn text_overflowing_an_exact_row_is_clipped_to_it() { use wordcraft_doc::props::HeightRule; diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 28590010a..f68dcf125 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 (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-14 · **Change:** 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-14 | trivial | A table row with an at-least height doesn't split where less than that height is left on the page; it starts the next page whole, as measured in Word | | 2026-10-14 | trivial | Text that doesn't fit an exact-height table row is cut off at the cell's edge, as in Word, instead of drawn over the rows below | | 2026-10-14 | trivial | A numbered paragraph whose text opens with a page break draws its number on the next page with the text, as Word does | | 2026-10-14 | trivial | A bookmark (or other zero-width marker) after a paragraph's closing page break stays with the break instead of taking a line at the top of the next page |