Layout: table rows split across pages between lines (cantSplit honoured, header rows repeat)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-06 01:09:16 -04:00
1 parent 6379a25ce6
commit e8c50e9dde
3 files changed
+185 -21

No files matched your search

+31 -20
View File
@@ -1030,26 +1030,7 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
let width = pb.col_w();
let tl = table::layout_table(ctx, StoryRef::Body, t, &[block as u32], width, 1);
let header_rows: Vec<usize> = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect();
for (ri, row) in tl.rows.iter().enumerate() {
if pb.y + row.height > pb.bottom + 0.01 && !pb.at_top() {
pb.advance(block, body_top);
// Repeat header rows.
if !header_rows.contains(&ri) {
for hr in &header_rows {
if let Some(h) = tl.rows.get(*hr) {
let (x, y) = (pb.col_x() + tl.x, pb.y);
if let Some(pg) = pb.page() {
for it in &h.items {
let mut it = it.clone();
it.translate(x, y);
pg.items.push(it);
}
}
pb.y += h.height;
}
}
}
}
let place = |pb: &mut PageBuilder, row: &table::RowLayout| {
let (x, y) = (pb.col_x() + tl.x, pb.y);
if let Some(pg) = pb.page() {
for it in &row.items {
@@ -1059,6 +1040,36 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
}
}
pb.y += row.height;
};
for (ri, row) in tl.rows.iter().enumerate() {
let splittable = !header_rows.contains(&ri) && !t.rows.get(ri).is_some_and(|r| r.props.cant_split);
let mut rest: Option<table::RowLayout> = None;
// 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 {
break;
}
let split = if splittable { table::split_row(cur, pb.bottom - pb.y) } else { None };
match split {
Some((a, b)) => {
place(pb, &a);
rest = Some(b);
}
None if pb.at_top() => break,
None => {}
}
pb.advance(block, body_top);
// Repeat header rows.
if !header_rows.contains(&ri) {
for hr in &header_rows {
if let Some(h) = tl.rows.get(*hr) {
place(pb, h);
}
}
}
}
place(pb, rest.as_ref().unwrap_or(row));
}
}
+110 -1
View File
@@ -1,6 +1,6 @@
//! Table layout: column widths, cell boxes, row heights, merged cells, borders and shading,
//! table-style conditional formatting. Rows are laid out as free-standing boxes; pagination
//! moves whole rows.
//! moves whole rows or splits them between lines (`split_row`).
use wordcraft_doc::props::{Align, Border, Borders, CharProps, HeightRule, Rgb, VAlign, VMerge};
use wordcraft_doc::styles::TableStyleParts;
@@ -257,3 +257,112 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
}
TableLayout { x, width: total, rows: out }
}
/// Split a row at `cut` (points below the row's top) so the part above fits on this page.
/// Paragraph lines break between lines; fills, vertical rules and cell areas are cut; the
/// rest moves up to the top of the continuation. `None` when no line fits above the cut.
pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
if cut <= 0.0 || cut >= row.height {
return None;
}
// Where each paragraph splits, and how far up the continuation moves.
let mut first_moved = f32::MAX;
let mut kept_any = false;
for it in &row.items {
match it {
Placed::Lines { para, l0, l1, y, .. } => {
let base = para.lines.get(*l0).map(|l| l.top).unwrap_or(0.0);
for k in *l0..*l1 {
let Some(l) = para.lines.get(k) else { continue };
let (top, bottom) = (y + l.top - base, y + l.top - base + l.height);
if bottom <= cut + 0.01 {
kept_any = true;
} else {
first_moved = first_moved.min(top);
break;
}
}
}
Placed::Image { rect, .. } | Placed::Shape { rect, .. } => {
if rect.bottom() <= cut + 0.01 {
kept_any = true;
} else {
first_moved = first_moved.min(rect.y);
}
}
_ => {}
}
}
if !kept_any || first_moved == f32::MAX {
return None;
}
// Keep a little of the cell's top margin on the continuation.
let shift = (first_moved - 2.0).max(0.0);
let (mut a, mut b) = (Vec::new(), Vec::new());
for it in &row.items {
match it {
Placed::Lines { story, path, para, l0, l1, x, y } => {
let base = para.lines.get(*l0).map(|l| l.top).unwrap_or(0.0);
let mut split = *l1;
for k in *l0..*l1 {
let Some(l) = para.lines.get(k) else { continue };
if y + l.top - base + l.height > cut + 0.01 {
split = k;
break;
}
}
if split > *l0 {
a.push(Placed::Lines { story: *story, path: path.clone(), para: para.clone(), l0: *l0, l1: split, x: *x, y: *y });
}
if split < *l1 {
let top = para.lines.get(split).map(|l| y + l.top - base).unwrap_or(*y);
b.push(Placed::Lines { story: *story, path: path.clone(), para: para.clone(), l0: split, l1: *l1, x: *x, y: top - shift });
}
}
Placed::Image { rect, .. } | Placed::Shape { rect, .. } => {
if rect.bottom() <= cut + 0.01 {
a.push(it.clone());
} else {
let mut it = it.clone();
it.translate(0.0, -shift);
b.push(it);
}
}
Placed::Fill { rect, color } => {
a.push(Placed::Fill { rect: Rect::new(rect.x, rect.y, rect.w, (cut - rect.y).max(0.0)), color: *color });
let h = (rect.bottom() - shift - 0.0).max(0.0);
b.push(Placed::Fill { rect: Rect::new(rect.x, 0.0, rect.w, h), color: *color });
}
Placed::Cell { rect, table, row: r, cell, story } => {
a.push(Placed::Cell { rect: Rect::new(rect.x, rect.y, rect.w, cut), table: table.clone(), row: *r, cell: *cell, story: *story });
let h = (rect.bottom() - shift).max(0.0);
b.push(Placed::Cell { rect: Rect::new(rect.x, 0.0, rect.w, h), table: table.clone(), row: *r, cell: *cell, story: *story });
}
Placed::Rule { x0, y0, x1, y1, border } => {
if (y0 - y1).abs() < 0.01 {
// Horizontal: the top edge stays, the bottom edge moves; both fragments close.
if *y0 <= 0.01 {
a.push(it.clone());
b.push(Placed::Rule { x0: *x0, y0: 0.0, x1: *x1, y1: 0.0, border: *border });
} else if *y0 >= row.height - 0.01 {
a.push(Placed::Rule { x0: *x0, y0: cut, x1: *x1, y1: cut, border: *border });
b.push(Placed::Rule { x0: *x0, y0: y0 - shift, x1: *x1, y1: y1 - shift, border: *border });
} else if *y0 <= cut {
a.push(it.clone());
} else {
b.push(Placed::Rule { x0: *x0, y0: y0 - shift, x1: *x1, y1: y1 - shift, border: *border });
}
} else {
let (t, bt) = (y0.min(*y1), y0.max(*y1));
if t < cut {
a.push(Placed::Rule { x0: *x0, y0: t, x1: *x1, y1: bt.min(cut), border: *border });
}
if bt > cut {
b.push(Placed::Rule { x0: *x0, y0: (t - shift).max(0.0), x1: *x1, y1: bt - shift, border: *border });
}
}
}
}
}
Some((RowLayout { height: cut, items: a }, RowLayout { height: (row.height - shift).max(4.0), items: b }))
}
+44
View File
@@ -331,3 +331,47 @@ fn line_numbers_borders_text_boxes() {
assert!(p.items.iter().filter(|i| matches!(i, Placed::Rule { .. })).count() >= 4);
assert!(l.caret(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }).is_some());
}
#[test]
fn tall_rows_split_across_pages() {
let mut d = Document::from_text("before\nafter");
let mut t = Table::new(2, 2, 468.0);
let long = "Row text that keeps going and going so the cell grows taller than a page. ".repeat(120);
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&long, Default::default()))];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t.clone())).unwrap();
let l = lay(&d);
assert!(l.pages.len() >= 2, "pages {}", l.pages.len());
// The cell's lines start on page 1 (row split, not moved) and continue on page 2.
let cell_lines = |p: &Page| p.items.iter().filter(|i| matches!(i, Placed::Lines { path, .. } if path.0 == vec![1, 0, 0, 0])).count();
assert!(cell_lines(&l.pages[0]) > 0);
assert!(cell_lines(&l.pages[1]) > 0);
// Nothing runs past the bottom margin.
for p in &l.pages[..l.pages.len() - 1] {
for it in &p.items {
if let Placed::Lines { y, para, l0, l1, .. } = it {
let b = item_bottom(*y, para, *l0, *l1).unwrap_or(0.0);
assert!(b <= 792.0 - 72.0 + 0.5, "line bottom {b}");
}
}
}
// Every line of the long paragraph is placed exactly once.
let total: usize = l
.pages
.iter()
.flat_map(|p| p.items.iter())
.filter_map(|i| if let Placed::Lines { path, l0, l1, .. } = i { (path.0 == vec![1, 0, 0, 0]).then_some(l1 - l0) } else { None })
.sum();
let n = l
.pages
.iter()
.flat_map(|p| p.items.iter())
.find_map(|i| if let Placed::Lines { path, para, .. } = i { (path.0 == vec![1, 0, 0, 0]).then_some(para.lines.len()) } else { None })
.unwrap_or(0);
assert_eq!(total, n);
// "Can't split" rows move whole instead.
let mut d2 = Document::from_text("before\nafter");
t.rows[0].props.cant_split = true;
d2.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l2 = lay(&d2);
assert!(l2.pages.len() >= 2);
}