Honor table row keep-next during pagination (#2494)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Fin WarmanandBrandon Thomas authored and GitHub committed 2026-10-18 04:57:07 -04:00
1 parent 86ad3ddb63
commit c92d120098
3 files changed
+246 -3

No files matched your search

+68 -2
View File
@@ -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);
+176
View File
@@ -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<usize> {
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");
}
+2 -1
View File
@@ -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. |