mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
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:
1 parent
6379a25ce6
commit
e8c50e9dde
3 files changed
+185
-21
No files matched your search
+31
-20
@@ -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
@@ -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 }))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user