mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 05:17:25 -06:00
369 lines
16 KiB
Rust
369 lines
16 KiB
Rust
//! 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 or splits them between lines (`split_row`).
|
|
|
|
use wordcraft_doc::props::{Align, Border, Borders, CharProps, HeightRule, Rgb, VAlign, VMerge};
|
|
use wordcraft_doc::styles::TableStyleParts;
|
|
use wordcraft_doc::{StoryRef, Table};
|
|
use wordcraft_geom::Rect;
|
|
|
|
use crate::{Ctx, Placed, layout_box};
|
|
|
|
pub struct RowLayout {
|
|
pub height: f32,
|
|
/// Items relative to the table's top-left (x) and the row's top (y).
|
|
pub items: Vec<Placed>,
|
|
}
|
|
|
|
pub struct TableLayout {
|
|
/// Table x offset within the column.
|
|
pub x: f32,
|
|
#[allow(dead_code)]
|
|
pub width: f32,
|
|
pub rows: Vec<RowLayout>,
|
|
}
|
|
|
|
const DEFAULT_MARGINS: [f32; 4] = [0.0, 5.4, 0.0, 5.4];
|
|
|
|
fn style_parts(ctx: &Ctx, t: &Table) -> Option<TableStyleParts> {
|
|
let id = t.props.style.as_deref()?;
|
|
ctx.doc.styles.chain(id).iter().rev().find_map(|s| s.table.clone())
|
|
}
|
|
|
|
pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout {
|
|
let parts = style_parts(ctx, t);
|
|
let ncols = t.cols().max(1);
|
|
// Column widths.
|
|
let mut grid: Vec<f32> = if t.grid.len() == ncols {
|
|
t.grid.iter().map(|w| if w.is_finite() { w.max(4.0) } else { 72.0 }).collect()
|
|
} else {
|
|
vec![avail / ncols as f32; ncols]
|
|
};
|
|
let mut total: f32 = grid.iter().sum();
|
|
let target = match (t.props.width_pct, t.props.width) {
|
|
(Some(p), _) if p > 0.0 => Some(avail * (p / 100.0).min(1.0)),
|
|
(_, Some(w)) if w > 0.0 => Some(w),
|
|
_ => None,
|
|
};
|
|
if let Some(tw) = target {
|
|
let k = tw / total.max(1.0);
|
|
grid.iter_mut().for_each(|w| *w *= k);
|
|
total = tw;
|
|
}
|
|
if total > avail * 1.5 && !t.props.fixed {
|
|
let k = avail / total.max(1.0);
|
|
grid.iter_mut().for_each(|w| *w *= k);
|
|
total = avail;
|
|
}
|
|
let mut colx = Vec::with_capacity(ncols + 1);
|
|
let mut acc = 0.0;
|
|
for w in &grid {
|
|
colx.push(acc);
|
|
acc += w;
|
|
}
|
|
colx.push(acc);
|
|
// Word places the table so cell text lines up with the margin: shift left by the left cell margin.
|
|
let margins_def = t.props.cell_margins.unwrap_or(DEFAULT_MARGINS);
|
|
let indent = t.props.indent.unwrap_or(0.0);
|
|
let x = match t.props.align {
|
|
Some(Align::Center) => (avail - total) / 2.0,
|
|
Some(Align::Right) => avail - total,
|
|
_ => indent - margins_def[1],
|
|
};
|
|
let tborders = t.props.borders.or_else(|| parts.as_ref().and_then(|p| p.borders));
|
|
let nrows = t.rows.len();
|
|
let header_rows = t.props.look.header_row;
|
|
// First pass: lay out every cell's content.
|
|
struct CellBox {
|
|
items: Vec<Placed>,
|
|
h: f32,
|
|
x: f32,
|
|
w: f32,
|
|
valign: VAlign,
|
|
fill: Option<Rgb>,
|
|
borders: Borders,
|
|
vmerge: VMerge,
|
|
margins: [f32; 4],
|
|
g: usize,
|
|
span: usize,
|
|
}
|
|
let mut rows: Vec<Vec<CellBox>> = Vec::with_capacity(nrows);
|
|
let mut heights: Vec<f32> = Vec::with_capacity(nrows);
|
|
for (ri, row) in t.rows.iter().enumerate() {
|
|
let mut g = 0usize;
|
|
let mut cells = Vec::with_capacity(row.cells.len());
|
|
let mut rh = 0.0f32;
|
|
let is_header = header_rows && ri == 0;
|
|
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
|
|
let band = t.props.look.banded_rows && !is_header && (ri - usize::from(header_rows)) % 2 == 0;
|
|
for (ci, cell) in row.cells.iter().enumerate() {
|
|
let span = cell.span();
|
|
let x0 = colx.get(g).copied().unwrap_or(acc);
|
|
let x1 = colx.get((g + span).min(ncols)).copied().unwrap_or(acc);
|
|
let margins = cell.props.margins.unwrap_or(margins_def);
|
|
let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0);
|
|
let mut chr: Option<CharProps> = None;
|
|
let mut fill = cell.props.shading;
|
|
if let Some(p) = &parts {
|
|
if is_header {
|
|
chr = Some(p.header_chr.clone());
|
|
fill = fill.or(p.header_fill);
|
|
} else if is_total {
|
|
chr = Some(p.total_chr.clone());
|
|
} else if t.props.look.first_column && g == 0 && !p.first_col_chr.is_empty() {
|
|
chr = Some(p.first_col_chr.clone());
|
|
}
|
|
if band && fill.is_none() {
|
|
fill = p.band_fill;
|
|
}
|
|
}
|
|
let mut cpath = path.to_vec();
|
|
cpath.push(ri as u32);
|
|
cpath.push(ci as u32);
|
|
let (mut items, h) = if cell.props.vmerge == VMerge::Continue {
|
|
(Vec::new(), 0.0)
|
|
} else {
|
|
layout_box(ctx, story, &cell.blocks, &cpath, cw, chr.as_ref(), depth)
|
|
};
|
|
for it in &mut items {
|
|
it.translate(x0 + margins[1], margins[0]);
|
|
}
|
|
let h = h + margins[0] + margins[2];
|
|
if cell.props.vmerge != VMerge::Restart {
|
|
rh = rh.max(h);
|
|
}
|
|
// Effective borders: cell > table (outer vs inside).
|
|
let tb = tborders.unwrap_or_default();
|
|
let edge =
|
|
|own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| own.or(if outer { outer_b } else { inner_b });
|
|
let cb = cell.props.borders.unwrap_or_default();
|
|
let mut borders = Borders {
|
|
top: edge(cb.top, ri == 0, tb.top, tb.between),
|
|
bottom: edge(cb.bottom, ri + 1 == nrows, tb.bottom, tb.between),
|
|
left: edge(cb.left, g == 0, tb.left, tb.inside_v),
|
|
right: edge(cb.right, g + span >= ncols, tb.right, tb.inside_v),
|
|
between: None,
|
|
inside_v: None,
|
|
};
|
|
if is_total && let Some(b) = parts.as_ref().and_then(|p| p.total_border_top) {
|
|
borders.top = Some(b);
|
|
}
|
|
cells.push(CellBox {
|
|
items,
|
|
h,
|
|
x: x0,
|
|
w: x1 - x0,
|
|
valign: cell.props.valign,
|
|
fill,
|
|
borders,
|
|
vmerge: cell.props.vmerge,
|
|
margins,
|
|
g,
|
|
span,
|
|
});
|
|
g += span;
|
|
}
|
|
let rh = match (row.props.height, row.props.height_rule) {
|
|
(Some(h), HeightRule::Exact) if h > 0.0 => h,
|
|
(Some(h), _) if h > 0.0 => rh.max(h),
|
|
_ => rh,
|
|
};
|
|
heights.push(rh.max(4.0));
|
|
rows.push(cells);
|
|
}
|
|
// Vertically merged cells: grow the last row of the merge if the content is taller.
|
|
for ri in 0..rows.len() {
|
|
let restarts: Vec<(usize, f32, usize)> =
|
|
rows.get(ri).map(|r| r.iter().filter(|c| c.vmerge == VMerge::Restart).map(|c| (c.g, c.h, c.span)).collect()).unwrap_or_default();
|
|
for (g, h, _) in restarts {
|
|
let mut end = ri;
|
|
while rows.get(end + 1).is_some_and(|r| r.iter().any(|c| c.g == g && c.vmerge == VMerge::Continue)) {
|
|
end += 1;
|
|
}
|
|
let have: f32 = heights.get(ri..=end).map(|s| s.iter().sum()).unwrap_or(0.0);
|
|
if h > have
|
|
&& let Some(last) = heights.get_mut(end)
|
|
{
|
|
*last += h - have;
|
|
}
|
|
}
|
|
}
|
|
// Grid columns holding a vertical-merge continuation, per row.
|
|
let cont: Vec<Vec<usize>> = t
|
|
.rows
|
|
.iter()
|
|
.map(|r| {
|
|
let mut gx = 0;
|
|
r.cells
|
|
.iter()
|
|
.filter_map(|x| {
|
|
let g = gx;
|
|
gx += x.span();
|
|
(x.props.vmerge == VMerge::Continue).then_some(g)
|
|
})
|
|
.collect()
|
|
})
|
|
.collect();
|
|
// Second pass: place cells with their row height, fills and borders.
|
|
let mut out = Vec::with_capacity(rows.len());
|
|
for (ri, cells) in rows.into_iter().enumerate() {
|
|
let rh = heights.get(ri).copied().unwrap_or(0.0);
|
|
let mut items = Vec::new();
|
|
for (ci, c) in cells.into_iter().enumerate() {
|
|
// A restart cell spans several rows: its height is their sum.
|
|
let mut span_h = rh;
|
|
if c.vmerge == VMerge::Restart {
|
|
let mut k = ri + 1;
|
|
while cont.get(k).is_some_and(|v| v.contains(&c.g)) {
|
|
span_h += heights.get(k).copied().unwrap_or(0.0);
|
|
k += 1;
|
|
}
|
|
}
|
|
let rect = Rect::new(c.x, 0.0, c.w, span_h);
|
|
if c.vmerge != VMerge::Continue
|
|
&& let Some(f) = c.fill
|
|
{
|
|
items.push(Placed::Fill { rect, color: f });
|
|
}
|
|
let dy = match c.valign {
|
|
VAlign::Top => 0.0,
|
|
VAlign::Center => ((span_h - c.h) / 2.0).max(0.0),
|
|
VAlign::Bottom => (span_h - c.h).max(0.0),
|
|
};
|
|
let _ = c.margins;
|
|
for mut it in c.items {
|
|
it.translate(0.0, dy);
|
|
items.push(it);
|
|
}
|
|
let b = c.borders;
|
|
let (x0, x1, y0, y1) = (c.x, c.x + c.w, 0.0, rh);
|
|
let top_vis = c.vmerge != VMerge::Continue;
|
|
if top_vis && let Some(e) = b.top.filter(Border::is_visible) {
|
|
items.push(Placed::Rule { x0, y0, x1, y1: y0, border: e });
|
|
}
|
|
let last_of_merge = c.vmerge == VMerge::None || !cont.get(ri + 1).is_some_and(|v| v.contains(&c.g));
|
|
if last_of_merge && let Some(e) = b.bottom.filter(Border::is_visible) {
|
|
items.push(Placed::Rule { x0, y0: y1, x1, y1, border: e });
|
|
}
|
|
if let Some(e) = b.left.filter(Border::is_visible) {
|
|
items.push(Placed::Rule { x0, y0, x1: x0, y1, border: e });
|
|
}
|
|
if let Some(e) = b.right.filter(Border::is_visible) {
|
|
items.push(Placed::Rule { x0: x1, y0, x1, y1, border: e });
|
|
}
|
|
items.push(Placed::Cell { rect: Rect::new(c.x, 0.0, c.w, rh), table: wordcraft_doc::Path(path.to_vec()), row: ri, cell: ci, story });
|
|
}
|
|
out.push(RowLayout { height: rh, items });
|
|
}
|
|
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 }))
|
|
}
|