From d6f20f93fd22deb441b2216ecfd65acb15ea8b64 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 18 Oct 2026 15:57:47 +0200 Subject: [PATCH] Tables: at-least rows taller than a page start on a fresh page; header rows follow (#1742) (#2004) * WIP for #1742 * Co-Authored-By: Claude Opus 5.5 * #1742: parity doc separates the Word-observed case from synthetic-test behaviours --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/layout/src/lib.rs | 24 ++++--- crates/layout/src/tests.rs | 130 +++++++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +- 4 files changed, 150 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9321d5e9b..6dc6e80c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,6 +139,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | | 2026-10-14 | RTF export writes every section (gap 19): `\sect` ends each section and `\sectd` carries its break type, page size and orientation, margins, columns, page numbering and title page, so multi-section documents survive an RTF round trip | | 2026-10-14 | `ASK` and `SET` fields no longer show their stored result on the page or in PDFs (#1765): like Word, they only assign text to a bookmark and draw nothing; the stored result is kept and saved | +| 2026-10-15 | Table rows with an at-least height start the next page whole when less than their minimum is left on a page that already holds something, so one-row-per-page booklets keep their pages; rows taller than a page split from the top of the next page and repeated header rows go with the row (#1742) | | 2026-10-14 | ODT export writes sections (gap 19): every new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is written as a columned `text:section`, so a landscape, two-column or restarted part survives a save as .odt | | 2026-10-14 | A bulleted or numbered list item that starts with a lower-case word no longer gets the grammar squiggle for a missing sentence capital (#1741): list items are often fragments, so the sentence-capital rule (every proofed language) skips the first word of a list paragraph (own or style numbering); later sentences in the item are still checked | | 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) | @@ -470,6 +471,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-17 | trivial | Progress log: Read Aloud speaks each sentence in its own language, #1843 | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged | | 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) | +| 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) | | 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) | | 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-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination | diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index ca971335b..92c6adc44 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3769,6 +3769,12 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl pb.y += row.height; pb.content_bottom = pb.content_bottom.max(pb.y); }; + // Measured in Word: an at-least row's minimum height is reserved, not split. Where less room + // than that is left on a page that already holds something, the row starts the next page + // whole; a row taller than a page then splits from the top of that page. + let short_of_min = |ri: usize, room: f32| { + t.rows.get(ri).is_some_and(|r| r.props.height_rule == HeightRule::AtLeast && r.props.height.is_some_and(|h| h > room + 0.01)) + }; // Like Word, repeated header rows don't stay alone at the bottom of a page: when the first row // after them can't start under them there, the table starts on the next page. let first = header_rows.len(); @@ -3778,10 +3784,12 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl { let room = pb.bottom - pb.y - tl.rows.iter().take(first).map(|r| r.height).sum::(); let splittable = !t.rows.get(first).is_some_and(|r| r.props.cant_split); - if row.height > room + 0.01 && !(splittable && table::split_row(row, room).is_some()) { + if row.height > room + 0.01 && (short_of_min(first, room) || !(splittable && table::split_row(row, room).is_some())) { pb.advance(block, body_top); } } + // For the move-on rule, the table's own header rows at the top of a page leave it empty. + let table_at_top = pb.at_top(); for (ri, row) in tl.rows.iter().enumerate() { // Word never splits a row of an exact height: it moves on whole. let splittable = !header_rows.contains(&ri) @@ -3792,6 +3800,8 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl let mut rest: Option = None; // On a new page holding only the repeated header rows. let mut fresh = false; + // Nothing but this table's header rows above it on a page it started at the top of. + let under_top_headers = ri == first && table_at_top; // 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); @@ -3814,15 +3824,11 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl 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 - // height is left; it starts the next page whole. + // An at-least row with less room than its minimum moves on whole, unless nothing + // is above it on this page (moving on would gain nothing). 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 && !defer_kept { table::split_row(cur, room) } else { None }; + let moves_on = rest.is_none() && !pb.at_top() && !fresh && !under_top_headers && short_of_min(ri, room); + let split = if splittable && !moves_on && !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 052bbb3fa..abd279c59 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4685,6 +4685,136 @@ fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() { assert_eq!(first_row_line_page(Some(63.6)), Some(1), "a row with a minimum height that doesn't fit moves on"); } +/// #1742: a paragraph that leaves `room` points of the letter page's 648 pt body, then the table +/// `build` shapes (one 468 pt column, `rows` rows of one short line each); with `room` 648 the +/// table comes first, at the top of the page. Returns the layout and, per page, the table rows +/// that have a cell there. +fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayout, Vec>) { + let mut d = Document::from_text("Text above the table"); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(648.0 - room - 14.0); + let mut t = Table::new(rows, 1, 468.0); + build(&mut t); + let at = if room >= 648.0 { 0 } else { 1 }; + d.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let pages = l + .pages + .iter() + .map(|p| { + let mut rows: Vec = + p.items.iter().filter_map(|i| if let Placed::Cell { row, cell: 0, .. } = i { Some(*row) } else { None }).collect(); + rows.dedup(); + rows + }) + .collect(); + (l, pages) +} + +fn lines_in_cell(n: usize, what: &str) -> Vec> { + (1..=n).map(|k| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("{what} {k}"), Default::default()))).collect() +} + +#[test] +fn an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set() { + use wordcraft_doc::props::HeightRule; + // Word for Mac 16.113, A4 with about 2.6 cm (74 pt) left: row 2, at least 8 cm (226.8 pt), + // starts page 2 whole and row 1 stays on page 1, whether the row may break across pages or not. + for cant_split in [false, true] { + let (_, pages) = at_least_rows(74.0, 2, |t| { + t.rows[1].props.height = Some(226.8); + t.rows[1].props.height_rule = HeightRule::AtLeast; + t.rows[1].props.cant_split = cant_split; + }); + assert_eq!(pages, vec![vec![0], vec![1]], "cant_split {cant_split}"); + } +} + +#[test] +fn an_at_least_row_with_centred_lines_moves_whole_and_centres_them_in_its_height() { + use wordcraft_doc::props::{HeightRule, VAlign}; + // Word: 10 short lines, centred vertically, in an "at least 8 cm" row with 2.6 cm left. The + // lines would split there, but the whole row goes to page 2 and they sit in the middle of it. + let (l, pages) = at_least_rows(74.0, 2, |t| { + t.rows[1].props.height = Some(226.8); + t.rows[1].props.height_rule = HeightRule::AtLeast; + t.rows[1].cells[0].props.valign = Some(VAlign::Center); + t.rows[1].cells[0].blocks = lines_in_cell(10, "Line"); + }); + assert_eq!(pages, vec![vec![0], vec![1]]); + let page = &l.pages[1]; + let Some(cell) = page.items.iter().find_map(|i| if let Placed::Cell { rect, row: 1, .. } = i { Some(*rect) } else { None }) else { + panic!("no cell on page 2") + }; + assert!(cell.h >= 226.8 - 0.01 && cell.h < 226.8 + 1.5, "the row keeps its minimum height: {}", cell.h); + let ys: Vec = page + .items + .iter() + .filter_map(|i| if let Placed::Lines { y, path, .. } = i { (path.0.first() == Some(&1)).then_some(*y) } else { None }) + .collect(); + assert_eq!(ys.len(), 10, "all ten lines on page 2: {ys:?}"); + let (top, bottom) = (ys.iter().cloned().fold(f32::MAX, f32::min), ys.iter().cloned().fold(f32::MIN, f32::max)); + let (above, below) = (top - cell.y, cell.y + cell.h - bottom); + assert!(above > 30.0 && below > 30.0, "the lines are centred in the row: {above} above, {below} below the line tops"); +} + +#[test] +fn an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page() { + use wordcraft_doc::props::HeightRule; + // A row at least 700 pt tall (the body is 648 pt) holding 80 lines: it can't fit on any page, + // so it splits, but it starts on a fresh page rather than in the 200 pt left under the text. + let (_, pages) = at_least_rows(200.0, 1, |t| { + t.rows[0].props.height = Some(700.0); + t.rows[0].props.height_rule = HeightRule::AtLeast; + t.rows[0].cells[0].blocks = lines_in_cell(80, "Line"); + }); + assert_eq!(pages.first(), Some(&vec![]), "nothing of the row under the text: {pages:?}"); + assert!(pages.len() >= 3 && pages[1..].iter().all(|p| p == &vec![0]), "it splits from page 2 on: {pages:?}"); +} + +#[test] +fn an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on() { + use wordcraft_doc::props::HeightRule; + // At the top of a page (or under the header rows repeated there) moving on gains nothing: a + // row taller than the room splits, and pagination ends. + let (_, pages) = at_least_rows(648.0, 1, |t| { + t.rows[0].props.height = Some(700.0); + t.rows[0].props.height_rule = HeightRule::AtLeast; + t.rows[0].cells[0].blocks = lines_in_cell(80, "Line"); + }); + assert!(pages.len() >= 2 && pages.iter().all(|p| p == &vec![0]), "{pages:?}"); + // A 100 pt header row, then a row at least 600 pt with 80 lines: under the header only 548 pt + // are left on each page, so the row splits there instead of running past the bottom margin. + let (l, pages) = at_least_rows(648.0, 2, |t| { + t.rows[0].props.header = true; + t.rows[0].props.height = Some(100.0); + t.rows[0].props.height_rule = HeightRule::AtLeast; + t.rows[1].props.height = Some(600.0); + t.rows[1].props.height_rule = HeightRule::AtLeast; + t.rows[1].cells[0].blocks = lines_in_cell(80, "Line"); + }); + assert!(pages.len() >= 3 && pages.iter().all(|p| p == &vec![0, 1]), "{pages:?}"); + for p in &l.pages { + let bottom = p.items.iter().filter_map(|i| if let Placed::Cell { rect, .. } = i { Some(rect.y + rect.h) } else { None }).fold(0.0, f32::max); + assert!(bottom <= 720.0 + 0.5, "a row part runs past the bottom margin to {bottom}"); + } +} + +#[test] +fn repeated_header_moves_with_a_first_row_that_moves_on_whole() { + use wordcraft_doc::props::HeightRule; + // A 20 pt header, then a row at least 200 pt holding eight lines, with 100 pt left: the row + // moves on whole, so the header goes with it rather than staying alone at the foot of page 1. + let (_, pages) = at_least_rows(100.0, 2, |t| { + t.rows[0].props.header = true; + t.rows[0].props.height = Some(20.0); + t.rows[0].props.height_rule = HeightRule::AtLeast; + t.rows[1].props.height = Some(200.0); + t.rows[1].props.height_rule = HeightRule::AtLeast; + t.rows[1].cells[0].blocks = lines_in_cell(8, "Line"); + }); + assert_eq!(pages, vec![vec![], vec![0, 1]]); +} + #[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 d86aee5b5..a86aaff64 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: 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (an at-least row with no room for its minimum starts the next page whole, #1742; previously 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, @@ -49,7 +49,7 @@ font can't match Word's metrics. | 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) | — | | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | 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); 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; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum (`layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | +| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; other overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | @@ -189,6 +189,7 @@ Behind-text pictures in table cells participate in centre and bottom vertical al | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | An at-least table row with less room than its minimum on a page that holds something starts the next page whole, as checked in Word (#1742); the taller-than-a-page case (it then splits from the top of that page) and repeated header rows (they go with it) follow the issue and synthetic tests, not a Word check | | 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. |