528 lines
20 KiB
Rust
528 lines
20 KiB
Rust
//! Table layout: a table anchored in a story is laid out at its anchor paragraph.
|
|
//!
|
|
//! Column widths are scaled down to fit the text column; each cell's story is composed into its
|
|
//! cell (insets applied) with the regular composer; a row is as tall as its tallest single-row
|
|
//! cell (or its fixed height); rows that don't fit continue in the next column / frame, repeating
|
|
//! header (and footer) rows. Rows tied together by row spans never split.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use designcraft_doc::{Align, CellStroke, Document, ItemId, ParaProps, RowHeightMode, Story, Table, TextFrameOptions, VerticalJustification};
|
|
use designcraft_geom::{Point, Rect};
|
|
|
|
use crate::{ComposeOptions, ComposedStory, Cursor, FrameSpec, Line};
|
|
|
|
/// A cell placed in a frame (frame inner space).
|
|
#[derive(Clone, Debug)]
|
|
pub struct PlacedCell {
|
|
pub row: usize,
|
|
pub col: usize,
|
|
pub rect: Rect,
|
|
/// Fill swatch and tint (cell fill or alternating fill).
|
|
pub fill: Option<(String, f32)>,
|
|
/// Where the cell's composed text (its frame 0, in content space) sits in the frame.
|
|
pub origin: Point,
|
|
pub text: Arc<ComposedStory>,
|
|
/// The cell's story text (exporters map glyphs back to text).
|
|
pub source: Arc<str>,
|
|
/// Content height available in the cell (for clipping).
|
|
pub clip: Rect,
|
|
pub overset: bool,
|
|
/// A header/footer row repeated in a continuation fragment.
|
|
pub repeated: bool,
|
|
/// Graphic cell content (drawn at `clip`'s top left, clipped to it).
|
|
pub graphic: Option<designcraft_doc::Graphic>,
|
|
}
|
|
|
|
/// One cell edge (or the table border) to stroke.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct StrokeSeg {
|
|
pub a: Point,
|
|
pub b: Point,
|
|
pub stroke: CellStroke,
|
|
}
|
|
|
|
/// The part of a table shown in one text column.
|
|
#[derive(Clone, Debug)]
|
|
pub struct TableFrag {
|
|
pub table: u64,
|
|
/// Story byte of the anchor character.
|
|
pub anchor: usize,
|
|
pub column: u32,
|
|
pub rect: Rect,
|
|
pub cells: Vec<PlacedCell>,
|
|
pub strokes: Vec<StrokeSeg>,
|
|
/// Index of the fragment's (glyph-less) line in the frame's lines.
|
|
pub line: usize,
|
|
pub first: bool,
|
|
pub last: bool,
|
|
}
|
|
|
|
impl TableFrag {
|
|
pub(crate) fn shift(&mut self, dy: f64) {
|
|
if dy == 0.0 {
|
|
return;
|
|
}
|
|
let v = designcraft_geom::Vec2::new(0.0, dy);
|
|
self.rect = self.rect + v;
|
|
for c in &mut self.cells {
|
|
c.rect = c.rect + v;
|
|
c.clip = c.clip + v;
|
|
c.origin += v;
|
|
}
|
|
for s in &mut self.strokes {
|
|
s.a += v;
|
|
s.b += v;
|
|
}
|
|
}
|
|
|
|
/// The placed cell owning grid position (row, col), if it is in this fragment.
|
|
pub fn cell(&self, row: usize, col: usize) -> Option<&PlacedCell> {
|
|
self.cells.iter().find(|c| c.row == row && c.col == col)
|
|
}
|
|
}
|
|
|
|
struct CellLayout {
|
|
text: Arc<ComposedStory>,
|
|
content_h: f64,
|
|
}
|
|
|
|
/// Compose a cell story at width `w` (content space, top-left at the origin).
|
|
fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout {
|
|
let spec = FrameSpec {
|
|
id: ItemId(0),
|
|
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
|
|
opts: TextFrameOptions::default(),
|
|
vertical: false,
|
|
exclusions: vec![],
|
|
page_name: opts.page_name.clone(),
|
|
page: opts.page,
|
|
grid: None,
|
|
left_page,
|
|
page_rect: None,
|
|
};
|
|
let cs = crate::compose(doc, story, std::slice::from_ref(&spec), opts);
|
|
let content_h = cs.frames.first().map(|f| if f.lines.is_empty() { 0.0 } else { f.content_height }).unwrap_or(0.0);
|
|
CellLayout { text: Arc::new(cs), content_h }
|
|
}
|
|
|
|
/// Lay out `table` (anchored at byte `anchor` of paragraph `pi`) from the cursor. Returns false
|
|
/// when it doesn't fit the remaining frames (overset).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn place_table(
|
|
doc: &Document,
|
|
table: &Table,
|
|
anchor: usize,
|
|
pi: usize,
|
|
pp: &ParaProps,
|
|
frames: &[FrameSpec],
|
|
cols: &[Vec<Rect>],
|
|
cur: &mut Cursor,
|
|
out: &mut ComposedStory,
|
|
opts: &ComposeOptions,
|
|
) -> bool {
|
|
if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 {
|
|
return false;
|
|
}
|
|
let col_rect = |cur: &Cursor| cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
|
|
let mut col = col_rect(cur);
|
|
// Column widths: scaled to fit the measure.
|
|
let avail = (col.width() - pp.left_indent - pp.right_indent).max(1.0);
|
|
let natural = table.width();
|
|
let k = if natural > avail && natural > 0.0 { avail / natural } else { 1.0 };
|
|
let widths: Vec<f64> = table.columns.iter().map(|c| c.width * k).collect();
|
|
let total_w: f64 = widths.iter().sum();
|
|
let x_off = pp.left_indent
|
|
+ match pp.align {
|
|
Align::Center | Align::CenterJustified => (avail - total_w) / 2.0,
|
|
Align::Right | Align::RightJustified => avail - total_w,
|
|
_ => 0.0,
|
|
}
|
|
.max(0.0);
|
|
let mut colx = vec![0.0; widths.len() + 1];
|
|
for (i, w) in widths.iter().enumerate() {
|
|
colx[i + 1] = colx[i] + w;
|
|
}
|
|
let (nr, nc) = (table.nrows(), table.ncols());
|
|
let owners = table.owners();
|
|
let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c);
|
|
let left_page = frames[cur.fi].left_page;
|
|
// Compose every owner cell.
|
|
let mut layouts: Vec<Option<CellLayout>> = Vec::with_capacity(nr * nc);
|
|
for r in 0..nr {
|
|
for c in 0..nc {
|
|
if !is_owner(r, c) {
|
|
layouts.push(None);
|
|
continue;
|
|
}
|
|
let cell = &table.cells[r * nc + c];
|
|
let cs = (cell.col_span.max(1) as usize).min(nc - c);
|
|
let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3];
|
|
layouts.push(Some(compose_cell(doc, &cell.text, w, opts, left_page)));
|
|
}
|
|
}
|
|
// Row heights.
|
|
let need = |r: usize, c: usize| -> f64 {
|
|
let cell = &table.cells[r * nc + c];
|
|
layouts[r * nc + c].as_ref().map_or(0.0, |l| l.content_h) + cell.insets[0] + cell.insets[2]
|
|
};
|
|
let mut heights: Vec<f64> = table
|
|
.rows
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(r, row)| match row.mode {
|
|
RowHeightMode::Exactly => row.height.max(0.0),
|
|
RowHeightMode::AtLeast => {
|
|
let content = (0..nc).filter(|&c| is_owner(r, c) && table.cells[r * nc + c].row_span <= 1).map(|c| need(r, c)).fold(0.0, f64::max);
|
|
content.max(row.height)
|
|
}
|
|
})
|
|
.collect();
|
|
// Row-spanning cells grow the last row they span (unless it is fixed).
|
|
for r in 0..nr {
|
|
for c in 0..nc {
|
|
let cell = &table.cells[r * nc + c];
|
|
if !is_owner(r, c) || cell.row_span <= 1 {
|
|
continue;
|
|
}
|
|
let end = (r + cell.row_span as usize).min(nr);
|
|
let have: f64 = heights[r..end].iter().sum();
|
|
let n = need(r, c);
|
|
if n > have
|
|
&& let Some(i) = (r..end).rev().find(|&i| table.rows[i].mode == RowHeightMode::AtLeast)
|
|
{
|
|
heights[i] += n - have;
|
|
}
|
|
}
|
|
}
|
|
// Row groups that must stay together: end (exclusive) of the group starting at each row.
|
|
let mut group_end: Vec<usize> = (0..nr).map(|r| r + 1).collect();
|
|
{
|
|
let mut r = 0;
|
|
while r < nr {
|
|
let mut e = r + 1;
|
|
let mut i = r;
|
|
while i < e {
|
|
for c in 0..nc {
|
|
if is_owner(i, c) {
|
|
e = e.max((i + table.cells[i * nc + c].row_span.max(1) as usize).min(nr));
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
for g in group_end.iter_mut().take(e).skip(r) {
|
|
*g = e;
|
|
}
|
|
r = e;
|
|
}
|
|
}
|
|
let header = table.header_rows();
|
|
let footer = table.footer_rows();
|
|
let body_end = nr - footer;
|
|
let sum = |a: usize, b: usize| heights[a..b].iter().sum::<f64>();
|
|
let footer_h = sum(body_end, nr);
|
|
let o = &table.options;
|
|
let mut y = match cur.last_baseline {
|
|
Some(b) => b + cur.last_descent + cur.pending + o.space_before,
|
|
None => col.y0,
|
|
};
|
|
let mut frag_rows: Vec<(usize, f64, bool)> = Vec::new(); // (row, y, repeated)
|
|
let mut first_frag = true;
|
|
let mut body_placed = 0usize;
|
|
let mut top = y;
|
|
let place_rows = |rows: std::ops::Range<usize>, y: &mut f64, repeated: bool, frag_rows: &mut Vec<(usize, f64, bool)>| {
|
|
for r in rows {
|
|
frag_rows.push((r, *y, repeated));
|
|
*y += heights[r];
|
|
}
|
|
};
|
|
place_rows(0..header, &mut y, false, &mut frag_rows);
|
|
let mut r = header;
|
|
loop {
|
|
if r >= body_end {
|
|
break;
|
|
}
|
|
let ge = group_end[r].min(body_end).max(r + 1);
|
|
let gh = sum(r, ge);
|
|
let at_col_top = cur.last_baseline.is_none() && (top - col.y0).abs() < 1e-6;
|
|
if y + gh + footer_h <= col.y1 + 0.01 || (body_placed == 0 && at_col_top) {
|
|
if y + gh + footer_h > col.y1 + 0.01 && body_placed == 0 && at_col_top && y + gh > col.y1 + 0.01 {
|
|
// A row group taller than an empty column can never fit.
|
|
return false;
|
|
}
|
|
place_rows(r..ge, &mut y, false, &mut frag_rows);
|
|
body_placed += 1;
|
|
r = ge;
|
|
continue;
|
|
}
|
|
// Continue in the next column / frame.
|
|
if body_placed > 0 {
|
|
if o.repeat_footer {
|
|
place_rows(body_end..nr, &mut y, true, &mut frag_rows);
|
|
}
|
|
emit(table, anchor, pi, &frag_rows, &heights, &colx, col.x0 + x_off, top, y, first_frag, false, &layouts, &owners, cur, col, out);
|
|
first_frag = false;
|
|
}
|
|
let was_first = first_frag;
|
|
cur.next_column(cols);
|
|
if cur.fi >= frames.len() {
|
|
return false;
|
|
}
|
|
col = col_rect(cur);
|
|
y = col.y0;
|
|
top = y;
|
|
frag_rows.clear();
|
|
body_placed = 0;
|
|
// Headers: the first fragment's header moves along; later ones repeat it.
|
|
if was_first {
|
|
place_rows(0..header, &mut y, false, &mut frag_rows);
|
|
} else if o.repeat_header {
|
|
place_rows(0..header, &mut y, true, &mut frag_rows);
|
|
}
|
|
}
|
|
place_rows(body_end..nr, &mut y, false, &mut frag_rows);
|
|
emit(table, anchor, pi, &frag_rows, &heights, &colx, col.x0 + x_off, top, y, first_frag, true, &layouts, &owners, cur, col, out);
|
|
cur.last_baseline = Some(y);
|
|
cur.last_descent = 0.0;
|
|
cur.pending = o.space_after;
|
|
true
|
|
}
|
|
|
|
/// Build one fragment from its rows and push it (with its glyph-less line) to the current frame.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn emit(
|
|
table: &Table,
|
|
anchor: usize,
|
|
pi: usize,
|
|
rows: &[(usize, f64, bool)],
|
|
heights: &[f64],
|
|
colx: &[f64],
|
|
x0: f64,
|
|
top: f64,
|
|
bottom: f64,
|
|
first: bool,
|
|
last: bool,
|
|
layouts: &[Option<CellLayout>],
|
|
owners: &[(usize, usize)],
|
|
cur: &Cursor,
|
|
col: Rect,
|
|
out: &mut ComposedStory,
|
|
) {
|
|
let nc = table.ncols();
|
|
let ry = |r: usize| rows.iter().find(|x| x.0 == r).map(|x| x.1);
|
|
let header = table.header_rows();
|
|
let body = table.body_rows();
|
|
let nbody = body.len();
|
|
let mut cells = Vec::new();
|
|
let mut strokes = Vec::new();
|
|
let o = &table.options;
|
|
let right = x0 + colx[nc];
|
|
for &(r, y, repeated) in rows {
|
|
for c in 0..nc {
|
|
if owners[r * nc + c] != (r, c) {
|
|
continue;
|
|
}
|
|
let cell = &table.cells[r * nc + c];
|
|
let cs = (cell.col_span.max(1) as usize).min(nc - c);
|
|
let rs = (cell.row_span.max(1) as usize).min(table.nrows() - r);
|
|
let last_row = r + rs - 1;
|
|
let y1 = ry(last_row).map_or(y + heights[r], |ly| ly + heights[last_row]);
|
|
let rect = if table.options.direction == designcraft_doc::TextDirection::RightToLeft {
|
|
Rect::new(right - colx[c + cs], y, right - colx[c], y1)
|
|
} else {
|
|
Rect::new(x0 + colx[c], y, x0 + colx[c + cs], y1)
|
|
};
|
|
let fill = if cell.has_fill() {
|
|
Some((cell.fill.clone(), cell.fill_tint))
|
|
} else if body.contains(&r) {
|
|
let i = r - header;
|
|
o.alt_rows
|
|
.as_ref()
|
|
.and_then(|a| a.fill_for(i, nbody))
|
|
.or_else(|| o.alt_cols.as_ref().and_then(|a| a.fill_for(c, nc)))
|
|
.map(|(s, t)| (s.to_string(), t))
|
|
} else {
|
|
None
|
|
};
|
|
let Some(lay) = layouts.get(r * nc + c).and_then(Option::as_ref) else { continue };
|
|
let clip = Rect::new(rect.x0 + cell.insets[1], rect.y0 + cell.insets[0], rect.x1 - cell.insets[3], rect.y1 - cell.insets[2]);
|
|
let space = clip.height() - lay.content_h;
|
|
let dy = match cell.vj {
|
|
VerticalJustification::Center if space > 0.0 => space / 2.0,
|
|
VerticalJustification::Bottom if space > 0.0 => space,
|
|
_ => 0.0,
|
|
};
|
|
let overset = space < -0.01;
|
|
let mut text = lay.text.clone();
|
|
if overset && let Some(f) = text.frames.first() {
|
|
// Drop the lines that don't fit the cell.
|
|
let keep: Vec<Line> = f.lines.iter().filter(|l| l.baseline + l.descent <= clip.height() + 0.01).cloned().collect();
|
|
let mut t = (*text).clone();
|
|
t.frames[0].lines = keep;
|
|
text = Arc::new(t);
|
|
}
|
|
cells.push(PlacedCell {
|
|
row: r,
|
|
col: c,
|
|
rect,
|
|
fill,
|
|
origin: Point::new(clip.x0, clip.y0 + dy),
|
|
text,
|
|
source: Arc::from(cell.text.text.as_str()),
|
|
clip,
|
|
overset,
|
|
repeated,
|
|
graphic: cell.graphic.clone(),
|
|
});
|
|
// Edges: every cell draws its top and left; the fragment's bottom/right cells their bottom/right.
|
|
// The table border replaces outer edges unless the cell explicitly overrides it.
|
|
let at_top = (y - top).abs() < 1e-6;
|
|
let at_bottom = (y1 - bottom).abs() < 1e-6;
|
|
let edge = |i: usize, outer: bool| {
|
|
if outer && !cell.border_overrides[i] { o.border.clone() } else { cell.strokes[i].clone() }
|
|
};
|
|
let mut push = |a: Point, b: Point, s: CellStroke| {
|
|
if s.is_visible() {
|
|
strokes.push(StrokeSeg { a, b, stroke: s });
|
|
}
|
|
};
|
|
push(Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), edge(0, at_top));
|
|
push(Point::new(rect.x0, rect.y0), Point::new(rect.x0, rect.y1), edge(1, (rect.x0 - x0).abs() < 1e-6));
|
|
if at_bottom {
|
|
push(Point::new(rect.x0, rect.y1), Point::new(rect.x1, rect.y1), edge(2, true));
|
|
}
|
|
if (rect.x1 - right).abs() < 1e-6 {
|
|
push(Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), edge(3, true));
|
|
}
|
|
}
|
|
}
|
|
let ft = &mut out.frames[cur.fi];
|
|
let line = ft.lines.len();
|
|
let h = bottom - top;
|
|
ft.lines.push(Line {
|
|
column: cur.col as u32,
|
|
baseline: bottom,
|
|
x0: col.x0,
|
|
x1: col.x1,
|
|
ascent: h,
|
|
descent: 0.0,
|
|
leading: h,
|
|
range: anchor..anchor + designcraft_doc::TABLE_ANCHOR.len_utf8(),
|
|
para: pi,
|
|
glyphs: vec![],
|
|
hyphenated: false,
|
|
first_in_para: first,
|
|
last_in_para: last,
|
|
end_x: right,
|
|
spacing: 1.0,
|
|
hj: 0,
|
|
keep_violation: false,
|
|
});
|
|
ft.tables.push(TableFrag {
|
|
table: table.id,
|
|
anchor,
|
|
column: cur.col as u32,
|
|
rect: Rect::new(x0, top, right, bottom),
|
|
cells,
|
|
strokes,
|
|
line,
|
|
first,
|
|
last,
|
|
});
|
|
}
|
|
|
|
// ---------- hit testing and carets in cells ----------
|
|
|
|
/// The cell under `p` (frame inner space) in frame `fi`: (table, row, col, story byte in the cell).
|
|
pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize, usize, usize)> {
|
|
let ft = cs.frames.get(fi)?;
|
|
for t in &ft.tables {
|
|
if !t.rect.contains(p) {
|
|
continue;
|
|
}
|
|
for c in &t.cells {
|
|
if c.rect.contains(p) {
|
|
let local = Point::new(p.x - c.origin.x, p.y - c.origin.y);
|
|
let b = crate::hit(&c.text, 0, local).unwrap_or(0);
|
|
return Some((t.table, c.row, c.col, b));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Frame index and placed cell for a cell address (the first, non-repeated occurrence).
|
|
pub fn find_cell(cs: &ComposedStory, table: u64, row: usize, col: usize) -> Option<(usize, &TableFrag, &PlacedCell)> {
|
|
let mut found = None;
|
|
for (fi, ft) in cs.frames.iter().enumerate() {
|
|
for t in ft.tables.iter().filter(|t| t.table == table) {
|
|
if let Some(c) = t.cell(row, col) {
|
|
if !c.repeated {
|
|
return Some((fi, t, c));
|
|
}
|
|
found = found.or(Some((fi, t, c)));
|
|
}
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
/// Caret geometry for byte `pos` of a cell's story, in frame inner space:
|
|
/// (frame index, x, baseline, ascent, descent).
|
|
pub fn cell_caret(cs: &ComposedStory, table: u64, row: usize, col: usize, pos: usize) -> Option<(usize, f64, f64, f64, f64)> {
|
|
let (fi, _, c) = find_cell(cs, table, row, col)?;
|
|
match crate::caret(&c.text, pos) {
|
|
Some((_, x, bl, a, d)) => Some((fi, x + c.origin.x, bl + c.origin.y, a, d)),
|
|
None => Some((fi, c.origin.x, c.origin.y + 10.0, 9.0, 3.0)),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod border_tests {
|
|
use super::*;
|
|
use designcraft_doc::{CellRange, ParaFormat, SpreadRef, build::NewDocument};
|
|
|
|
fn compose_table(table: Table) -> ComposedStory {
|
|
let mut doc = Document::new(&NewDocument::default());
|
|
let layer = doc.default_layer();
|
|
let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, 200.0), layer, "", ParaFormat::default()).unwrap();
|
|
doc.story_mut(sid).unwrap().insert_table(0, table);
|
|
crate::compose_story(&doc, sid, &ComposeOptions::default())
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_cell_edges_override_table_border_without_changing_native_defaults() {
|
|
let mut table = Table::new(77, 1, 1, 0, 0, 100.0);
|
|
table.options.border.weight = 2.0;
|
|
table.cell_mut(0, 0).unwrap().strokes = std::array::from_fn(|_| CellStroke::none());
|
|
let native = compose_table(table.clone());
|
|
let strokes = &native.frames[0].tables[0].strokes;
|
|
assert_eq!(strokes.len(), 4, "default cells still use all four table borders");
|
|
assert!(strokes.iter().all(|s| s.stroke.weight == 2.0));
|
|
|
|
let cell = table.cell_mut(0, 0).unwrap();
|
|
cell.border_overrides = [true, true, true, false];
|
|
cell.strokes[2] = CellStroke { weight: 3.0, ..Default::default() };
|
|
let composed = compose_table(table);
|
|
let fragment = &composed.frames[0].tables[0];
|
|
assert_eq!(fragment.strokes.len(), 2, "top and left None overrides suppress borders");
|
|
let bottom = fragment.strokes.iter().find(|s| s.a.y == fragment.rect.y1 && s.b.y == fragment.rect.y1).unwrap();
|
|
assert_eq!(bottom.stroke.weight, 3.0);
|
|
let right = fragment.strokes.iter().find(|s| s.a.x == fragment.rect.x1 && s.b.x == fragment.rect.x1).unwrap();
|
|
assert_eq!(right.stroke.weight, 2.0, "unoverridden right edge keeps the table border");
|
|
}
|
|
|
|
#[test]
|
|
fn merged_cell_keeps_explicit_border_suppression() {
|
|
let mut table = Table::new(77, 2, 2, 0, 0, 100.0);
|
|
table.merge(CellRange::new(0, 0, 1, 1)).unwrap();
|
|
let cell = table.cell_mut(0, 0).unwrap();
|
|
cell.border_overrides = [true; 4];
|
|
cell.strokes = std::array::from_fn(|_| CellStroke::none());
|
|
let composed = compose_table(table);
|
|
let fragment = &composed.frames[0].tables[0];
|
|
assert_eq!(fragment.cells.len(), 1);
|
|
assert!(fragment.strokes.is_empty());
|
|
}
|
|
}
|