Files
wordcraft/crates/layout/src/tests/nested_cant_split.rs
T
855a0fe9bd Layout: a nested table's Can't Split row moves on with its outer row (#5456)
A row is split across pages from its flattened items, so a row of a nested
table marked w:cantSplit was cut between its lines like any other text in
the outer cell. Word leaves such a row whole: the outer row moves to the
next page whole when that row comes first in it (docxide-pdf
nested-first-row-prefix300/330-cant1), and otherwise splits above it
(inferred).

table_flow now moves the cut up to just below the top of the highest nested
Can't Split row it runs through (nested_keep_cut): the outer row's bottom
band below that top, so the text and nested rows ending there stay on the
page. A row runs through the cut when it ends below the room left for text,
as in the split itself; one walk over the rows from the lowest top up moves
the cut until none does. A vertically merged cell doesn't stretch its row's
band, and a cell aligned to the middle or bottom is measured from its top, as
the split runs it. This applies to the first part of a row and to the kept-chain and
header-row checks. On a fresh page, where moving on gains nothing, a nested
row taller than the room still splits at the room rather than running into
the bottom margin.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 05:03:54 +01:00

214 lines
9.6 KiB
Rust

//! A row of a nested table marked "can't split" moves on with its outer row, as in Word
//! (docxide-pdf `nested-first-row-*-cant1`): the outer row splits only above it.
use crate::*;
use wordcraft_doc::{Table, para_block};
fn one_line_paras(what: &str, n: usize) -> Vec<Arc<Block>> {
(0..n)
.map(|i| {
let mut p = Paragraph::with_text(&format!("{what} {i}"), Default::default());
p.props.space_before = Some(0.0);
p.props.space_after = Some(0.0);
para_block(p)
})
.collect()
}
/// A filler paragraph pushed `push` points down, then a one-row table whose cells each hold
/// `prefix` one-line paragraphs and a nested table of one row with `nested` of them. Returns,
/// per cell, the prefix lines and the nested lines on each page.
fn lines_per_page(push: f32, cells: &[(usize, usize)], cant_split: bool) -> Vec<(Vec<usize>, Vec<usize>)> {
let mut d = Document::from_text("Filler");
if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) {
p.props.space_before = Some(push);
}
let mut outer = Table::new(1, cells.len(), 440.0);
for (cell, &(prefix, nested)) in outer.rows[0].cells.iter_mut().zip(cells) {
let mut inner = Table::new(1, 1, 200.0);
inner.rows[0].props.cant_split = cant_split;
inner.rows[0].cells[0].blocks = one_line_paras("Nested", nested);
cell.blocks = one_line_paras("Prefix", prefix);
cell.blocks.push(Arc::new(Block::Table(inner)));
cell.blocks.push(para_block(Paragraph::with_text("", Default::default())));
}
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap();
let mut c = LayoutCache::new();
let l = layout(&d, &mut c, &LayoutOptions::default());
let count = |cell: u32, nested: bool| -> Vec<usize> {
l.pages
.iter()
.map(|pg| {
pg.items
.iter()
.filter_map(|it| match it {
Placed::Lines { path, l0, l1, .. } => {
let p = &path.0;
let hit = if nested {
p.len() == 7 && p[..3] == [1, 0, cell] && p[4..6] == [0, 0]
} else {
p.len() == 4 && p[..3] == [1, 0, cell]
};
hit.then_some(l1 - l0)
}
_ => None,
})
.sum()
})
.collect()
};
(0..cells.len() as u32).map(|c| (count(c, false), count(c, true))).collect()
}
#[test]
fn nested_cant_split_row_moves_on_with_its_outer_row() {
let split = &lines_per_page(520.0, &[(0, 20)], false)[0].1;
assert!(split[0] > 0 && split[1] > 0, "a splittable nested row splits with its outer row: {split:?}");
assert_eq!(lines_per_page(520.0, &[(0, 20)], true)[0].1[..2], [0, 20], "a can't-split nested row moves on whole");
}
#[test]
fn the_outer_row_splits_above_a_nested_cant_split_row() {
// About 22 lines of room: the nested row (lines 10 to 40) runs through it.
let cell = &lines_per_page(340.0, &[(10, 30)], true)[0];
assert!(cell.0[0] > 0, "the text above the nested row stays: {cell:?}");
assert_eq!(cell.1[..2], [0, 30], "the nested row moves on whole: {cell:?}");
}
#[test]
fn the_cut_moves_up_until_no_nested_cant_split_row_runs_through_it() {
// Room for about 22 lines. Only cell 0's nested row (lines 10 to 40) runs through it; the
// cut at its top then runs through cell 1's (lines 5 to 13), which moves on whole too.
let cells = lines_per_page(340.0, &[(10, 30), (5, 8)], true);
assert_eq!(cells[0].1[..2], [0, 30], "{cells:?}");
assert_eq!(cells[1].1[..2], [0, 8], "{cells:?}");
assert!(cells[0].0[0] > 0 && cells[1].0[0] > 0, "the text above both stays: {cells:?}");
}
#[test]
fn nested_cant_split_row_taller_than_a_page_still_splits() {
let pages = &lines_per_page(520.0, &[(0, 60)], true)[0].1;
assert_eq!(pages[0], 0, "it leaves the partly filled page: {pages:?}");
assert_eq!(pages.iter().sum::<usize>(), 60, "every line is placed once: {pages:?}");
assert!(pages.iter().filter(|&&n| n > 0).count() >= 2, "it runs on over pages instead of into the margin: {pages:?}");
}
#[test]
fn the_outer_row_breaks_between_nested_cant_split_rows_above_its_bottom_band() {
let mut nested = Table::new(40, 1, 200.0);
nested.props.cell_margins = Some([0.0; 4]);
for (i, row) in nested.rows.iter_mut().enumerate() {
row.props.cant_split = true;
row.cells[0].blocks = one_line_paras(&format!("Row {i}"), 2);
}
let mut outer = Table::new(1, 1, 400.0);
// A bottom margin: the part left on the page keeps it below the last row there.
outer.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]);
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()];
let l = lay_pushed(520.0, outer);
let pages = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 0, 0]);
assert!(pages[0] > 0, "the rows that fit stay on the page: {pages:?}");
assert_eq!(pages.iter().sum::<usize>(), 80, "every line is placed once: {pages:?}");
for r in 0..40u32 {
let on: Vec<usize> = per_page(&l, |p| p.len() == 7 && p[..5] == [1, 0, 0, 0, r]);
assert_eq!(on.iter().filter(|&&n| n > 0).count(), 1, "row {r} stays on one page: {on:?}");
}
}
fn empty() -> Arc<Block> {
para_block(Paragraph::with_text("", Default::default()))
}
/// A nested table of one row, marked "can't split", with `lines` one-line paragraphs.
fn kept_nested(lines: usize) -> Arc<Block> {
let mut t = Table::new(1, 1, 200.0);
t.rows[0].props.cant_split = true;
t.rows[0].cells[0].blocks = one_line_paras("Nested", lines);
Arc::new(Block::Table(t))
}
/// Lay out a filler paragraph pushed `push` points down, then `outer`.
fn lay_pushed(push: f32, outer: Table) -> DocLayout {
let mut d = Document::from_text("Filler");
if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) {
p.props.space_before = Some(push);
}
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap();
let mut c = LayoutCache::new();
layout(&d, &mut c, &LayoutOptions::default())
}
/// The lines on each page of the body paragraphs whose path `hit` picks.
fn per_page(l: &DocLayout, hit: impl Fn(&[u32]) -> bool) -> Vec<usize> {
l.pages
.iter()
.map(|pg| {
pg.items
.iter()
.filter_map(|it| match it {
Placed::Lines { path, l0, l1, .. } => hit(&path.0).then_some(l1 - l0),
_ => None,
})
.sum()
})
.collect()
}
#[test]
fn a_bottom_aligned_cell_keeps_its_nested_cant_split_row_whole() {
// Cell 1 sits at the bottom of the row as laid out, but the split runs it from its top,
// where its nested row (lines 2 to 32) runs through the room of about 22 lines.
let mut outer = Table::new(1, 2, 440.0);
outer.rows[0].cells[0].blocks = one_line_paras("Tall", 50);
outer.rows[0].cells[1].props.valign = Some(wordcraft_doc::props::VAlign::Bottom);
let mut blocks = one_line_paras("Prefix", 2);
blocks.extend([kept_nested(30), empty()]);
outer.rows[0].cells[1].blocks = blocks;
let l = lay_pushed(340.0, outer);
let nested = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 1, 2]);
assert_eq!(nested[..2], [0, 30], "the nested row moves on whole: {nested:?}");
}
#[test]
fn header_rows_go_on_with_a_first_row_whose_nested_cant_split_row_comes_first() {
let mut outer = Table::new(2, 1, 400.0);
outer.rows[0].props.header = true;
outer.rows[0].cells[0].blocks = one_line_paras("Header", 1);
outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()];
let l = lay_pushed(520.0, outer);
let header = per_page(&l, |p| p == [1, 0, 0, 0]);
assert_eq!(header[0], 0, "the header row doesn't stay alone at the page bottom: {header:?}");
assert_eq!(per_page(&l, |p| p.len() == 7 && p[..4] == [1, 1, 0, 0])[..2], [0, 20]);
}
#[test]
fn a_kept_row_goes_on_with_a_row_whose_nested_cant_split_row_cant_start_below_it() {
let mut outer = Table::new(2, 1, 400.0);
let mut kept = one_line_paras("Kept", 1);
if let Some(Block::Para(p)) = kept.first_mut().map(Arc::make_mut) {
p.props.keep_next = Some(true);
}
outer.rows[0].cells[0].blocks = kept;
outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()];
let l = lay_pushed(520.0, outer);
let kept = per_page(&l, |p| p == [1, 0, 0, 0]);
assert_eq!(kept[..2], [0, 1], "the kept row moves on with the next one: {kept:?}");
}
#[test]
fn a_merged_cell_doesnt_stretch_its_cant_split_row() {
// Row 0 can't split; its first cell is merged down into row 1, which can and runs through
// the room. The cut stays in row 1.
let mut nested = Table::new(2, 2, 300.0);
nested.rows[0].props.cant_split = true;
nested.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart;
nested.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue;
nested.rows[0].cells[0].blocks = one_line_paras("Merged", 1);
nested.rows[0].cells[1].blocks = one_line_paras("Short", 1);
nested.rows[1].cells[1].blocks = one_line_paras("Long", 20);
let mut outer = Table::new(1, 1, 400.0);
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()];
let l = lay_pushed(520.0, outer);
let long = per_page(&l, |p| p.len() == 7 && p[..6] == [1, 0, 0, 0, 1, 1]);
assert!(long[0] > 0 && long[1] > 0, "the splittable row splits: {long:?}");
}