Files
designcraft/crates/compose/src/table.rs
T

1579 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, Cell, CellStroke, Document, ItemId, ParaProps, RowHeightMode, 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>,
/// Inset content box. Text baselines fit here; descenders may enter the bottom inset.
/// Graphic content is clipped to this box.
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 {
/// The ends of `s`, each moved outwards by half the weight where it is a corner of the
/// fragment. Edge types drawn as butt-ended fills (stripes, wavy, hash, the gap colour)
/// then close the table's corners as square-capped solid edges do.
pub fn corner_capped(&self, s: &StrokeSeg) -> (Point, Point) {
let d = s.b - s.a;
let len = d.hypot();
let half = s.stroke.weight / 2.0;
if !(len > 0.0 && half.is_finite() && half > 0.0) {
return (s.a, s.b);
}
let r = self.rect;
let near = |v: f64, edge: f64| (v - edge).abs() < 1e-6;
let corner = |p: Point| (near(p.x, r.x0) || near(p.x, r.x1)) && (near(p.y, r.y0) || near(p.y, r.y1));
let out = d * (half / len);
(if corner(s.a) { s.a - out } else { s.a }, if corner(s.b) { s.b + out } else { s.b })
}
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,
line_heights: Vec<f64>,
}
/// Cell text fits by its baseline, while its typographic/inline-object bounds must still
/// remain inside the physical cell. The bottom inset can contain ordinary descenders;
/// adding both the inset and the full descent unnecessarily grows every row.
struct CellLineExtent {
baseline: f64,
bottom: f64,
}
impl CellLineExtent {
fn fit_height(&self, bottom_inset: f64) -> f64 {
self.baseline.max(self.bottom - bottom_inset)
}
}
/// Compose a cell story at width `w` (content space, top-left at the origin).
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout {
let spec = FrameSpec {
id: ItemId(0),
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() },
vertical: false,
exclusions: vec![],
outline: None,
page_name: opts.page_name.clone(),
section_marker: opts.section_marker.clone(),
page: opts.page,
grid: None,
left_page,
right_page,
page_rect: None,
};
let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
let line_heights: Vec<f64> = cs
.frames
.first()
.map(|f| {
let mut extents: Vec<_> = f.lines.iter().map(|l| CellLineExtent { baseline: l.baseline, bottom: l.baseline + l.descent }).collect();
// Line metrics include baseline shifts and super/subscript. Include the final
// geometry of in-flow objects too, but leave custom anchors out of text flow.
for object in &f.objects {
if cell.text.objects.get(object.index).is_some_and(|o| !matches!(o.position, designcraft_doc::AnchorPosition::Custom { .. }))
&& let Some(extent) = extents.get_mut(object.line)
{
extent.bottom = extent.bottom.max(object.origin.y + object.size.1);
}
}
extents.iter().map(|e| e.fit_height(cell.insets[2])).collect()
})
.unwrap_or_default();
// Check all lines: a large descender or lowered inline object on an earlier line can
// extend below the final baseline when leading is fixed.
let content_h = line_heights.iter().copied().fold(0.0, f64::max);
CellLayout { text: Arc::new(cs), content_h, line_heights }
}
/// 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| cur.clip(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 side = (frames[cur.fi].left_page, frames[cur.fi].right_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, w, opts, side)));
}
}
// 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
}
/// A cell's text cut to the lines that fit `clip_height`, with where the overset text begins.
/// Uses the same baseline/bounds test as auto-growth, and retains a prefix: overset text cannot
/// reappear after a line that did not fit.
fn clip_overset(lay: &CellLayout, clip_height: f64) -> Arc<ComposedStory> {
let Some(f) = lay.text.frames.first() else { return lay.text.clone() };
let keep = lay.line_heights.iter().take_while(|h| **h <= clip_height + 0.01).count();
let overset_at = f.lines.get(keep).map(|l| l.range.start);
let mut t = (*lay.text).clone();
if let Some(frame) = t.frames.first_mut() {
frame.lines.truncate(keep);
frame.objects.retain(|o| o.line < keep);
frame.tables.retain(|table| table.line < keep);
frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end);
}
if let Some(at) = overset_at {
t.overset_at = Some(at);
}
Arc::new(t)
}
/// 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 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 || lay.text.is_overset();
let text = if overset { clip_overset(lay, clip.height()) } else { lay.text.clone() };
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(),
});
}
}
let strokes = fragment_strokes(table, rows, heights, colx, x0, bottom, owners);
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,
drop_cap: None,
});
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,
});
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum EdgeSource {
Cell((usize, usize), usize),
TablePattern,
}
/// Resolve a shared edge before dropping invisible strokes: a higher-priority None is
/// meaningful and must be allowed to suppress the other cell's visible edge.
fn boundary_stroke<'a>(
table: &'a Table,
before: Option<((usize, usize), &'a Cell)>,
after: Option<((usize, usize), &'a Cell)>,
before_side: usize,
after_side: usize,
pattern: Option<&'a CellStroke>,
) -> Option<(&'a CellStroke, i32, EdgeSource)> {
let supplied = |cell: &Cell, side: usize| cell.stroke_defined[side] || cell.border_overrides[side] || cell.stroke_priorities[side] != 0;
let rank =
|cell: &Cell, side: usize| (cell.stroke_priorities[side], supplied(cell, side), supplied(cell, side) && cell.strokes[side].is_visible());
let (owner, cell, side, outer) = match (before, after) {
(Some((ao, a)), Some((bo, b))) => {
// Table patterns supply missing interior edges; cell styles and local edits
// retain precedence, including explicit zero/None. Perimeter rules never enter
// this branch.
if !supplied(a, before_side)
&& !supplied(b, after_side)
&& let Some(stroke) = pattern
{
return Some((stroke, 0, EdgeSource::TablePattern));
}
// Adobe IDML specification, Cell attributes (pp. 237-240): higher numeric
// edge priority means higher drawing priority. Equal-priority conflicts are
// unspecified: prefer supplied formatting, then a visible supplied rule.
// Metadata-free native / legacy cells retain lower/right top/left ownership,
// including explicit None already stored there by older native editing code.
if rank(a, before_side) > rank(b, after_side) { (ao, a, before_side, false) } else { (bo, b, after_side, false) }
}
(Some((owner, a)), None) => (owner, a, before_side, true),
(None, Some((owner, b))) => (owner, b, after_side, true),
(None, None) => return None,
};
if outer && !cell.border_overrides[side] {
Some((table.options.border_for(side), 0, EdgeSource::Cell(owner, side)))
} else {
Some((&cell.strokes[side], cell.stroke_priorities[side], EdgeSource::Cell(owner, side)))
}
}
/// The table's alternating row and column strokes, as drawn on body edges no cell supplies.
/// Uniform active groups do not depend on which shared edge starts the pattern. Nonuniform
/// and skipped patterns stay intact in the model until their phase is verified.
fn table_patterns(table: &Table) -> (Option<&CellStroke>, Option<&CellStroke>) {
fn uniform(p: &Option<designcraft_doc::AltStrokes>) -> Option<&CellStroke> {
p.as_ref().filter(|p| p.skip_first == 0 && p.skip_last == 0).and_then(|p| p.uniform_stroke())
}
(uniform(&table.options.row_strokes), uniform(&table.options.column_strokes))
}
/// One stretch of a cell side, against one neighbouring cell or the outside of the table.
#[derive(Clone, Copy, Debug)]
pub struct SideStroke<'a> {
/// The neighbouring owner cell; `None` on the table's perimeter.
pub neighbor: Option<(usize, usize)>,
/// The grid line it lies on: a row boundary (0 = top) for top and bottom sides, a logical
/// column boundary (0 = start) for left and right sides.
pub boundary: usize,
/// The stroke drawn there.
pub stroke: &'a CellStroke,
}
/// The strokes drawn along side `side` (top, left, bottom, right; physical) of the owner cell
/// at `(r, c)`, one per neighbouring cell, resolved as rendering resolves them: the winning
/// copy of a shared edge, the table's row or column pattern where neither cell supplies the
/// edge, and the table border on unoverridden perimeter edges. `owners` is [`Table::owners`].
pub fn cell_side_strokes<'a>(table: &'a Table, owners: &[(usize, usize)], (r, c): (usize, usize), side: usize) -> Vec<SideStroke<'a>> {
let Some(cell) = table.cell(r, c) else { return Vec::new() };
let (nr, nc) = (table.nrows(), table.ncols());
let rtl = table.options.direction == designcraft_doc::TextDirection::RightToLeft;
let end_row = r.saturating_add(cell.row_span.max(1) as usize).min(nr);
let end_col = c.saturating_add(cell.col_span.max(1) as usize).min(nc);
let owner = |rr: usize, cc: usize| {
if rr >= nr || cc >= nc {
return None;
}
let &(orr, occ) = owners.get(rr.checked_mul(nc)?.checked_add(cc)?)?;
Some(((orr, occ), table.cell(orr, occ)?))
};
let me = Some(((r, c), cell));
let (row_pattern, column_pattern) = table_patterns(table);
let body = table.body_rows();
let mut out = Vec::new();
match side {
0 | 2 => {
let boundary = if side == 0 { r } else { end_row };
let above = boundary.checked_sub(1);
let pattern = if above.is_some_and(|a| body.contains(&a)) && body.contains(&boundary) { row_pattern } else { None };
for cc in c..end_col {
let other = if side == 0 { above.and_then(|a| owner(a, cc)) } else { owner(boundary, cc) };
let (a, b) = if side == 0 { (other, me) } else { (me, other) };
if let Some((stroke, _, _)) = boundary_stroke(table, a, b, 2, 0, pattern) {
out.push(SideStroke { neighbor: other.map(|o| o.0), boundary, stroke });
}
}
}
1 | 3 => {
// Physical left is the logical start in left-to-right tables.
let start = (side == 1) != rtl;
let (boundary, other_col) = if start { (c, c.checked_sub(1)) } else { (end_col, Some(end_col)) };
for rr in r..end_row {
let other = other_col.and_then(|oc| owner(rr, oc));
let (left, right) = if side == 1 { (other, me) } else { (me, other) };
let pattern = if body.contains(&rr) { column_pattern } else { None };
if let Some((stroke, _, _)) = boundary_stroke(table, left, right, 3, 1, pattern) {
out.push(SideStroke { neighbor: other.map(|o| o.0), boundary, stroke });
}
}
}
_ => {}
}
out
}
/// Keep an unchanged winning cell edge continuous across different losing neighbors. In
/// particular, splitting a merged dashed edge would restart its dash phase at every cell.
fn push_boundary_stroke(
strokes: &mut Vec<(StrokeSeg, i32)>,
previous: &mut Option<EdgeSource>,
a: Point,
b: Point,
edge: Option<(&CellStroke, i32, EdgeSource)>,
) {
let Some((stroke, priority, source)) = edge.filter(|(s, _, _)| s.is_visible()) else {
*previous = None;
return;
};
if *previous == Some(source)
&& let Some((last, last_priority)) = strokes.last_mut()
&& last.b == a
&& last.stroke == *stroke
&& *last_priority == priority
{
last.b = b;
} else {
strokes.push((StrokeSeg { a, b, stroke: stroke.clone() }, priority));
}
*previous = Some(source);
}
/// Visit each displayed grid boundary once, including boundaries between repeated headers /
/// footers and nonadjacent source body rows. Interior lines within a merged owner are omitted.
fn fragment_strokes(
table: &Table,
rows: &[(usize, f64, bool)],
heights: &[f64],
colx: &[f64],
x0: f64,
bottom: f64,
owners: &[(usize, usize)],
) -> Vec<StrokeSeg> {
let nc = table.ncols();
let right = x0 + colx.last().copied().unwrap_or(0.0);
let rtl = table.options.direction == designcraft_doc::TextDirection::RightToLeft;
let owner = |r: usize, c: usize| {
let &(r, c) = owners.get(r.checked_mul(nc)?.checked_add(c)?)?;
Some(((r, c), table.cell(r, c)?))
};
let x = |c: usize| colx.get(c).map(|&offset| if rtl { right - offset } else { x0 + offset });
let (row_stroke, column_stroke) = table_patterns(table);
let body = table.body_rows();
let mut strokes = Vec::new();
for boundary in 0..=rows.len() {
let before = boundary.checked_sub(1).and_then(|r| rows.get(r));
let after = rows.get(boundary);
let y = after.map_or(bottom, |row| row.1);
let pattern =
if before.is_some_and(|row| body.contains(&row.0)) && after.is_some_and(|row| body.contains(&row.0)) { row_stroke } else { None };
let mut previous = None;
for physical_col in 0..nc {
let c = if rtl { nc - physical_col - 1 } else { physical_col };
let above = before.and_then(|row| owner(row.0, c));
let below = after.and_then(|row| owner(row.0, c));
let pair = (above.map(|v| v.0), below.map(|v| v.0));
if pair.0 == pair.1 {
previous = None;
continue;
}
let (Some(a), Some(b)) = (x(c), x(c + 1)) else { continue };
let edge = boundary_stroke(table, above, below, 2, 0, pattern);
push_boundary_stroke(&mut strokes, &mut previous, Point::new(a.min(b), y), Point::new(a.max(b), y), edge);
}
}
for boundary in 0..=nc {
let Some(x) = x(boundary) else { continue };
let mut previous = None;
for &(r, y, _) in rows {
let before = boundary.checked_sub(1).and_then(|c| owner(r, c));
let after = (boundary < nc).then(|| owner(r, boundary)).flatten();
let (left, right) = if rtl { (after, before) } else { (before, after) };
let pair = (left.map(|v| v.0), right.map(|v| v.0));
if pair.0 == pair.1 {
previous = None;
continue;
}
let Some(&height) = heights.get(r) else { continue };
let edge = boundary_stroke(table, left, right, 3, 1, if body.contains(&r) { column_stroke } else { None });
push_boundary_stroke(&mut strokes, &mut previous, Point::new(x, y), Point::new(x, y + height), edge);
}
}
// Shared edges have a single winner; retain the documented priority at crossings too.
strokes.sort_by_key(|(_, priority)| *priority);
strokes.into_iter().map(|(stroke, _)| stroke).collect()
}
// ---------- 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)> {
hit_cell_with(cs, fi, p, crate::hit)
}
/// [`hit_cell`] with the hit test for the cell's text: [`crate::hit`] (caret position) or
/// [`crate::hit_char`] (character under the point).
pub fn hit_cell_with(
cs: &ComposedStory,
fi: usize,
p: Point,
hit: fn(&ComposedStory, usize, Point) -> Option<usize>,
) -> 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 = hit(&c.text, 0, local).unwrap_or(0);
return Some((t.table, c.row, c.col, b));
}
}
}
None
}
/// A row or column border under the pointer, found by [`hit_border`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BorderHit {
pub table: u64,
/// `true`: a column border (dragged horizontally); `false`: a row border.
pub column: bool,
/// The row above / logical column before the border. The last index is the table's
/// bottom or end edge.
pub index: usize,
/// The column border is the physical left edge of its column (a right-to-left table), so
/// dragging left widens it.
pub mirrored: bool,
}
/// The row or column border within `tol` of `p` (frame inner space) in frame `fi`, nearest
/// first. Only borders that resize something count: inner borders and the bottom and end
/// edges. `tables` holds the story's tables (for cell spans).
pub fn hit_border(cs: &ComposedStory, fi: usize, tables: &std::collections::BTreeMap<u64, Arc<Table>>, p: Point, tol: f64) -> Option<BorderHit> {
if !(tol.is_finite() && p.x.is_finite() && p.y.is_finite()) {
return None;
}
let ft = cs.frames.get(fi)?;
let mut best: Option<(f64, BorderHit)> = None;
let mut offer = |d: f64, hit: BorderHit| {
if d <= tol && best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, hit));
}
};
for t in &ft.tables {
if !t.rect.inflate(tol, tol).contains(p) {
continue;
}
let Some(table) = tables.get(&t.table) else { continue };
let rtl = table.options.direction == designcraft_doc::TextDirection::RightToLeft;
for c in &t.cells {
let Some(cell) = table.cell(c.row, c.col) else { continue };
let last_col = c.col.saturating_add((cell.col_span.max(1) - 1) as usize);
let last_row = c.row.saturating_add((cell.row_span.max(1) - 1) as usize);
if (c.rect.y0..=c.rect.y1).contains(&p.y) {
let x = if rtl { c.rect.x0 } else { c.rect.x1 };
offer((p.x - x).abs(), BorderHit { table: t.table, column: true, index: last_col, mirrored: rtl });
}
if (c.rect.x0..=c.rect.x1).contains(&p.x) {
offer((p.y - c.rect.y1).abs(), BorderHit { table: t.table, column: false, index: last_row, mirrored: false });
}
}
}
best.map(|(_, h)| h)
}
/// 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 height_tests {
use super::*;
use designcraft_doc::{AnchorPosition, AnchoredObject, CellRange, LayerId, ParaFormat, Position, SpreadRef, build::NewDocument};
fn text_cell(text: &str, bottom: f64) -> Cell {
let mut cell = Cell::with_text(text, ParaFormat::default());
cell.insets = [8.5, 4.0, bottom, 4.0];
cell.text.format_chars(0..cell.text.len(), |f| {
f.over.font_family = Some("Source Sans 3".into());
f.over.font_style = Some("Regular".into());
f.over.size = Some(11.0);
});
cell
}
fn measure(cell: &Cell, width: f64) -> CellLayout {
compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), (false, false))
}
fn compose_table(table: Table, height: f64) -> 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, 200.0, height), layer, "", ParaFormat::default()).unwrap();
doc.story_mut(sid).unwrap().insert_table(0, table);
crate::compose_story(&doc, sid, &ComposeOptions::default())
}
fn single_table(cell: Cell) -> Table {
let mut table = Table::new(77, 1, 1, 0, 0, 100.0);
table.cells[0] = cell;
table
}
fn object(position: AnchorPosition) -> AnchoredObject {
let path = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 30.0));
AnchoredObject::new(designcraft_doc::Item::new(ItemId(0), LayerId(0), designcraft_doc::Shape::Rectangle, path), position)
}
#[test]
fn cell_first_baseline_option_sets_the_first_line_and_the_row_height() {
// Cell Options › Text › First Baseline: Cap Height sets the first line by the cap height,
// not the typographic ascent, so a row sized for one line of caps keeps its height.
let ascent = text_cell("Hd", 4.0);
let mut cap = ascent.clone();
cap.first_baseline = designcraft_doc::FirstBaseline::CapHeight;
let (a, c) = (measure(&ascent, 92.0), measure(&cap, 92.0));
let (la, lc) = (&a.text.frames[0].lines[0], &c.text.frames[0].lines[0]);
assert!(lc.baseline < la.baseline, "cap height {} should sit above ascent {}", lc.baseline, la.baseline);
assert!(c.content_h < a.content_h);
let mut fixed = ascent.clone();
fixed.first_baseline = designcraft_doc::FirstBaseline::Fixed;
fixed.first_baseline_min = 6.0;
assert!((measure(&fixed, 92.0).text.frames[0].lines[0].baseline - 6.0).abs() < 1e-6);
}
#[test]
fn cell_bottom_inset_contains_descenders_without_growing_every_row() {
let cell = text_cell("gyp", 8.5);
let layout = measure(&cell, 92.0);
let line = &layout.text.frames[0].lines[0];
assert!(line.descent > 0.0 && line.descent < cell.insets[2]);
let expected = cell.insets[0] + line.baseline + cell.insets[2];
let composed = compose_table(single_table(cell), 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert!((placed.rect.height() - expected).abs() < 1e-6);
assert!(!placed.overset);
assert_eq!(placed.text.frames[0].lines.len(), 1);
assert!(placed.origin.y + line.baseline + line.descent <= placed.rect.y1);
// The shared frame composer keeps its full typographic height for non-cell users.
assert!((layout.text.frames[0].content_height - line.baseline - line.descent).abs() < 1e-6);
}
#[test]
fn baseline_sized_rows_fit_the_frame_without_losing_the_final_rows() {
let cell = text_cell("gyp", 8.5);
let layout = measure(&cell, 92.0);
let row_height = 17.0 + layout.text.frames[0].lines[0].baseline;
let mut table = Table::new(77, 16, 1, 1, 0, 100.0);
table.cells.fill(cell);
let composed = compose_table(table, 17.0 * row_height + 25.0);
assert!(!composed.is_overset());
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.cells.len(), 17);
assert!(fragment.cell(16, 0).is_some());
assert!((fragment.rect.height() - 17.0 * row_height).abs() < 1e-6);
}
#[test]
fn multiline_cells_keep_leading_and_paragraph_spacing() {
let mut cell = text_cell("gyp\njqp", 8.5);
cell.text.format_paras(0..3, |p| p.para.space_after = Some(6.0));
let layout = measure(&cell, 92.0);
let lines = &layout.text.frames[0].lines;
assert_eq!(lines.len(), 2);
assert!((lines[1].baseline - lines[0].baseline - lines[1].leading - 6.0).abs() < 1e-6);
let expected = 17.0 + lines[1].baseline;
let composed = compose_table(single_table(cell), 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert_eq!(placed.text.frames[0].lines.len(), 2);
assert!((placed.rect.height() - expected).abs() < 1e-6);
let wrapped = measure(&text_cell("gyp gyp gyp gyp gyp gyp gyp gyp", 8.5), 30.0);
assert!(wrapped.text.frames[0].lines.len() > 2);
assert!((wrapped.content_h - wrapped.text.frames[0].lines.last().unwrap().baseline).abs() < 1e-6);
}
#[test]
fn small_insets_still_contain_descenders_and_shifted_text() {
for bottom in [0.0, 1.0] {
for (shift, position) in [(0.0, Position::Normal), (-15.0, Position::Normal), (0.0, Position::Superscript), (0.0, Position::Subscript)] {
let mut cell = text_cell("gyp", bottom);
cell.text.format_chars(0..3, |f| {
f.over.baseline_shift = Some(shift);
f.over.position = Some(position);
});
let layout = measure(&cell, 92.0);
let line = &layout.text.frames[0].lines[0];
let expected = cell.insets[0] + line.baseline + bottom.max(line.descent);
let composed = compose_table(single_table(cell), 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert!((placed.rect.height() - expected).abs() < 1e-6);
assert!(placed.origin.y + line.baseline + line.descent <= placed.rect.y1 + 1e-6);
assert!(!placed.overset);
}
}
}
#[test]
fn exact_height_uses_the_same_fit_rule_and_keeps_only_a_prefix() {
let cell = text_cell("gyp\njqp", 8.5);
let layout = measure(&cell, 92.0);
let first_height = 17.0 + layout.text.frames[0].lines[0].baseline;
let full_height = 17.0 + layout.text.frames[0].lines[1].baseline;
for (height, count, overset) in [(full_height, 2, false), (first_height, 1, true), (first_height - 1.0, 0, true)] {
let mut table = single_table(cell.clone());
table.rows[0].mode = RowHeightMode::Exactly;
table.rows[0].height = height;
let composed = compose_table(table, 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert!((placed.rect.height() - height).abs() < 1e-6);
assert_eq!(placed.overset, overset);
assert_eq!(placed.text.frames[0].lines.len(), count);
if overset {
assert_eq!(placed.text.overset_at, Some(layout.text.frames[0].lines[count].range.start));
}
}
}
#[test]
fn exact_height_cannot_fit_a_descender_outside_the_physical_cell() {
let cell = text_cell("gyp", 0.0);
let layout = measure(&cell, 92.0);
let line = &layout.text.frames[0].lines[0];
let mut table = single_table(cell);
table.rows[0].mode = RowHeightMode::Exactly;
table.rows[0].height = 8.5 + line.baseline + line.descent / 2.0;
let composed = compose_table(table, 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert!(placed.overset);
assert!(placed.text.frames[0].lines.is_empty());
}
#[test]
fn earlier_lowered_line_controls_height_and_cannot_be_skipped_on_overset() {
let mut cell = text_cell("gyp\njqp", 8.5);
cell.text.format_chars(0..cell.text.len(), |f| f.over.leading = Some(designcraft_doc::Leading::Points(6.0)));
cell.text.format_chars(0..3, |f| f.over.baseline_shift = Some(-30.0));
let layout = measure(&cell, 92.0);
let lines = &layout.text.frames[0].lines;
assert_eq!(lines.len(), 2);
let deep_bottom = lines[0].baseline + lines[0].descent;
assert!(deep_bottom > lines[1].baseline + lines[1].descent + 8.5);
let composed = compose_table(single_table(cell.clone()), 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert!((placed.rect.height() - 8.5 - deep_bottom).abs() < 1e-6);
assert!(!placed.overset);
let mut table = single_table(cell);
table.rows[0].mode = RowHeightMode::Exactly;
table.rows[0].height = 17.0 + lines[1].baseline;
let composed = compose_table(table, 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert!(placed.overset);
assert!(placed.text.frames[0].lines.is_empty(), "a later fitting line cannot reappear after the first line oversets");
}
#[test]
fn empty_and_trailing_empty_paragraphs_keep_their_baseline_height() {
for text in ["", "gyp\n", "gyp\n\n"] {
let cell = text_cell(text, 8.5);
let layout = measure(&cell, 92.0);
let lines = &layout.text.frames[0].lines;
assert_eq!(lines.len(), text.matches('\n').count() + 1);
assert!(lines.last().unwrap().glyphs.is_empty());
let expected = 17.0 + lines.last().unwrap().baseline;
let composed = compose_table(single_table(cell), 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert!((placed.rect.height() - expected).abs() < 1e-6);
assert_eq!(placed.text.frames[0].lines.len(), lines.len());
assert!(!placed.overset);
}
}
#[test]
fn vertical_alignment_uses_baseline_fit_without_losing_descenders() {
for bottom in [0.0, 8.5] {
for (vj, expected_shift) in
[(VerticalJustification::Top, 0.0), (VerticalJustification::Center, 10.0), (VerticalJustification::Bottom, 20.0)]
{
let mut cell = text_cell("gyp", bottom);
cell.vj = vj;
let layout = measure(&cell, 92.0);
let required = cell.insets[0] + layout.content_h + bottom;
let mut table = single_table(cell);
table.rows[0].mode = RowHeightMode::Exactly;
table.rows[0].height = required + 20.0;
let composed = compose_table(table, 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
let line = &placed.text.frames[0].lines[0];
assert!((placed.origin.y - placed.clip.y0 - expected_shift).abs() < 1e-6);
assert!(placed.origin.y + line.baseline + line.descent <= placed.rect.y1 + 1e-6);
assert!(!placed.overset);
}
}
}
#[test]
fn row_spans_grow_to_the_same_baseline_and_physical_bounds() {
let cell = text_cell("gyp\njqp\ngyp", 8.5);
let layout = measure(&cell, 92.0);
let expected = 17.0 + layout.text.frames[0].lines.last().unwrap().baseline;
let mut table = Table::new(77, 2, 1, 0, 0, 100.0);
table.merge(CellRange::new(0, 0, 1, 0)).unwrap();
table.rows[0].mode = RowHeightMode::Exactly;
table.rows[0].height = 3.0;
table.cells[0] = Cell { row_span: 2, ..cell };
let composed = compose_table(table, 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert!((placed.rect.height() - expected).abs() < 1e-6);
assert_eq!(placed.text.frames[0].lines.len(), 3);
assert!(!placed.overset);
}
#[test]
fn lowered_inline_objects_are_contained_but_custom_anchors_stay_out_of_flow() {
let mut cell = text_cell("gyp", 8.5);
cell.text.insert_object(0, object(AnchorPosition::Inline { y_offset: -40.0 }));
let composed = compose_table(single_table(cell), 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
let object = &placed.text.frames[0].objects[0];
assert!((placed.origin.y + object.origin.y + object.size.1 - placed.rect.y1).abs() < 1e-6);
assert!(!placed.overset);
let mut cell = text_cell("gyp", 8.5);
let original = measure(&cell, 92.0).content_h;
cell.text.insert_object(
0,
self::object(AnchorPosition::Custom {
x_relative: Default::default(),
y_relative: Default::default(),
x_offset: 0.0,
y_offset: 100.0,
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
}),
);
assert!((measure(&cell, 92.0).content_h - original).abs() < 1e-6);
}
#[test]
fn overset_lines_cannot_leave_visible_objects_or_nested_tables_behind() {
let plain = text_cell("gyp", 8.5);
let first_height = 17.0 + measure(&plain, 92.0).text.frames[0].lines[0].baseline;
for nested in [false, true] {
let mut cell = text_cell("gyp\nHidden", 8.5);
if nested {
cell.text.insert_table(4, single_table(text_cell("nested", 8.5)));
} else {
cell.text.insert_object(4, object(AnchorPosition::Inline { y_offset: 0.0 }));
}
let mut table = single_table(cell);
table.rows[0].mode = RowHeightMode::Exactly;
table.rows[0].height = first_height;
let composed = compose_table(table, 200.0);
let placed = composed.frames[0].tables[0].cell(0, 0).unwrap();
assert!(placed.overset);
assert_eq!(placed.text.frames[0].lines.len(), 1);
assert!(placed.text.frames[0].objects.is_empty());
assert!(placed.text.frames[0].tables.is_empty());
}
}
}
#[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());
}
fn rule_table(body: usize, cols: usize, header: usize, footer: usize) -> Table {
let mut table = Table::new(77, body, cols, header, footer, 100.0);
table.options.border = CellStroke::none();
for row in &mut table.rows {
row.height = 20.0;
row.mode = RowHeightMode::Exactly;
}
for cell in &mut table.cells {
cell.insets = [0.0; 4];
cell.strokes = std::array::from_fn(|_| CellStroke::none());
}
table
}
fn set_rule(table: &mut Table, row: usize, col: usize, side: usize, weight: f64, priority: i32) {
let cell = table.cell_mut(row, col).unwrap();
cell.strokes[side] = CellStroke { weight, ..Default::default() };
cell.stroke_defined[side] = true;
cell.stroke_priorities[side] = priority;
}
fn assert_no_overlapping_rules(fragment: &TableFrag) {
for (i, a) in fragment.strokes.iter().enumerate() {
for b in &fragment.strokes[i + 1..] {
let horizontal_overlap = a.a.y == a.b.y && a.a.y == b.a.y && b.a.y == b.b.y && a.a.x.max(b.a.x) < a.b.x.min(b.b.x);
let vertical_overlap = a.a.x == a.b.x && a.a.x == b.a.x && b.a.x == b.b.x && a.a.y.max(b.a.y) < a.b.y.min(b.b.y);
assert!(!horizontal_overlap && !vertical_overlap, "duplicate shared edge: {a:?}, {b:?}");
}
}
}
#[test]
fn header_bottom_only_style_survives_zero_body_edges() {
// Synthetic equivalent of a header with a one-point bottom rule and zero on all
// remaining header/body edges. Both sides supply style formatting at priority zero.
let mut table = rule_table(2, 3, 1, 0);
for cell in &mut table.cells {
cell.stroke_defined = [true; 4];
cell.border_overrides = [true; 4];
}
for col in 0..3 {
set_rule(&mut table, 0, col, 2, 1.0, 0);
}
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
let y = fragment.cell(0, 0).unwrap().rect.y1;
assert_eq!(fragment.strokes.len(), 3);
assert!(fragment.strokes.iter().all(|s| s.a.y == y && s.b.y == y && s.stroke.weight == 1.0));
assert_eq!(fragment.strokes.iter().map(|s| s.b.x - s.a.x).sum::<f64>(), 100.0);
assert_no_overlapping_rules(fragment);
}
#[test]
fn shared_rules_choose_numeric_priority_once_and_preserve_drawing_order() {
let mut table = rule_table(2, 2, 0, 0);
set_rule(&mut table, 0, 0, 2, 3.0, 7);
set_rule(&mut table, 1, 0, 0, 1.0, 4);
set_rule(&mut table, 0, 0, 3, 4.0, 3);
set_rule(&mut table, 0, 1, 1, 2.0, 9);
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 2);
let horizontal = &fragment.strokes[0];
assert_eq!(horizontal.a.y, horizontal.b.y);
assert_eq!(horizontal.stroke.weight, 3.0);
let vertical = &fragment.strokes[1];
assert_eq!(vertical.a.x, vertical.b.x);
assert_eq!(vertical.stroke.weight, 2.0, "higher priority draws last at a crossing");
assert_no_overlapping_rules(fragment);
}
#[test]
fn signed_negative_priorities_resolve_and_draw_in_numeric_order() {
let mut table = rule_table(2, 2, 0, 0);
set_rule(&mut table, 0, 0, 2, 1.0, -2);
set_rule(&mut table, 1, 0, 0, 3.0, -1);
set_rule(&mut table, 0, 0, 3, 2.0, -3);
set_rule(&mut table, 0, 1, 1, 4.0, -2);
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 2);
assert_eq!(fragment.strokes[0].stroke.weight, 4.0, "priority -2 draws before -1");
assert_eq!(fragment.strokes[0].a.x, fragment.strokes[0].b.x);
assert_eq!(fragment.strokes[1].stroke.weight, 3.0, "priority -1 wins over -2");
assert_eq!(fragment.strokes[1].a.y, fragment.strokes[1].b.y);
assert_no_overlapping_rules(fragment);
}
#[test]
fn default_zero_priority_beats_a_negative_priority_rule() {
let mut table = rule_table(2, 1, 0, 0);
set_rule(&mut table, 0, 0, 2, 3.0, -1);
assert_eq!(table.cell(1, 0).unwrap().stroke_priorities, [0; 4]);
let composed = compose_table(table.clone());
assert!(composed.frames[0].tables[0].strokes.is_empty(), "priority zero None suppresses priority -1");
table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::default();
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 1);
assert_eq!(fragment.strokes[0].stroke.weight, 1.0, "the priority-zero native rule wins over priority -1");
}
#[test]
fn higher_priority_zero_or_none_suppresses_a_visible_shared_rule() {
for invisible in [CellStroke::none(), CellStroke { color: designcraft_color::swatch::NONE.into(), ..Default::default() }] {
let mut table = rule_table(2, 2, 0, 0);
set_rule(&mut table, 0, 0, 2, 3.0, 7);
set_rule(&mut table, 1, 0, 0, 0.0, 8);
table.cell_mut(1, 0).unwrap().strokes[0] = invisible.clone();
set_rule(&mut table, 0, 0, 3, 3.0, 7);
set_rule(&mut table, 0, 1, 1, 0.0, 8);
table.cell_mut(0, 1).unwrap().strokes[1] = invisible;
let composed = compose_table(table);
assert!(composed.frames[0].tables[0].strokes.is_empty());
}
}
#[test]
fn supplied_zero_shared_edge_beats_missing_native_default() {
let mut table = Table::new(77, 2, 1, 0, 0, 100.0);
table.options.border = CellStroke::none();
let upper = table.cell_mut(0, 0).unwrap();
upper.strokes[2] = CellStroke::none();
upper.stroke_defined[2] = true;
let composed = compose_table(table.clone());
assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit zero beats a missing edge's one-point default");
table.cell_mut(0, 0).unwrap().stroke_defined[2] = false;
let composed = compose_table(table);
assert_eq!(composed.frames[0].tables[0].strokes.len(), 1, "a missing zero cannot suppress the opposite visible default");
}
#[test]
fn specified_zero_priority_outer_edges_keep_per_side_table_borders() {
for direction in [designcraft_doc::TextDirection::LeftToRight, designcraft_doc::TextDirection::RightToLeft] {
let mut table = rule_table(1, 1, 0, 0);
table.options.direction = direction;
table.options.borders = std::array::from_fn(|i| Some(CellStroke { weight: (i + 1) as f64, ..Default::default() }));
table.cell_mut(0, 0).unwrap().stroke_defined = [true; 4];
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 4);
for rule in &fragment.strokes {
let expected = if rule.a.y == rule.b.y {
if rule.a.y == fragment.rect.y0 { 1.0 } else { 3.0 }
} else if rule.a.x == fragment.rect.x0 {
2.0
} else {
4.0
};
assert_eq!(rule.stroke.weight, expected, "priority-zero cell values do not override perimeter formatting");
}
assert_no_overlapping_rules(fragment);
}
}
fn uniform_pattern(weight: f64) -> designcraft_doc::AltStrokes {
let stroke = CellStroke { weight, ..Default::default() };
designcraft_doc::AltStrokes { first: 1, next: 1, first_stroke: stroke.clone(), next_stroke: stroke, skip_first: 0, skip_last: 0 }
}
#[test]
fn uniform_zero_table_patterns_suppress_missing_grid_edges_but_not_perimeter() {
let mut table = Table::new(77, 2, 2, 0, 0, 100.0);
table.options.row_strokes = Some(uniform_pattern(0.0));
table.options.column_strokes = Some(uniform_pattern(0.0));
table.options.border.weight = 2.0;
let composed = compose_table(table.clone());
let fragment = &composed.frames[0].tables[0];
assert!(!fragment.strokes.is_empty(), "table perimeter remains visible");
assert!(fragment.strokes.iter().all(|s| {
s.stroke.weight == 2.0
&& ((s.a.x == s.b.x && (s.a.x == fragment.rect.x0 || s.a.x == fragment.rect.x1))
|| (s.a.y == s.b.y && (s.a.y == fragment.rect.y0 || s.a.y == fragment.rect.y1)))
}));
assert_no_overlapping_rules(fragment);
table.options.border = CellStroke::none();
set_rule(&mut table, 0, 0, 2, 3.0, 0);
set_rule(&mut table, 0, 1, 1, 4.0, 0);
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 2, "supplied cell strokes override the table patterns");
assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0));
assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 4.0));
assert_no_overlapping_rules(fragment);
}
#[test]
fn uniform_visible_table_patterns_supply_continuous_rules_below_explicit_none() {
let mut table = Table::new(77, 2, 2, 0, 0, 100.0);
table.options.border = CellStroke::none();
table.options.row_strokes = Some(uniform_pattern(2.0));
table.options.column_strokes = Some(uniform_pattern(3.0));
let composed = compose_table(table.clone());
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 2, "a table pattern stays continuous across cell boundaries");
assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 2.0 && s.b.x - s.a.x == 100.0));
assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0 && s.b.y - s.a.y == fragment.rect.height()));
for cell in &mut table.cells {
cell.strokes = std::array::from_fn(|_| CellStroke::none());
cell.stroke_defined = [true; 4];
}
let composed = compose_table(table);
assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit cell None is stronger than table patterns");
}
#[test]
fn uniform_table_patterns_leave_header_and_footer_edges_unchanged() {
let mut table = Table::new(77, 2, 2, 1, 1, 100.0);
table.options.border = CellStroke::none();
table.options.row_strokes = Some(uniform_pattern(0.0));
table.options.column_strokes = Some(uniform_pattern(0.0));
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
let header = fragment.cell(0, 0).unwrap().rect;
let body = fragment.cell(1, 0).unwrap().rect;
let footer = fragment.cell(3, 0).unwrap().rect;
assert!(fragment.strokes.iter().any(|s| s.a == Point::new(header.x1, header.y0) && s.b == Point::new(header.x1, header.y1)));
assert!(fragment.strokes.iter().any(|s| s.a == Point::new(footer.x1, footer.y0) && s.b == Point::new(footer.x1, footer.y1)));
assert!(!fragment.strokes.iter().any(|s| s.a.y == body.y1 && s.b.y == body.y1));
assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(body.x1, body.y0) && s.b == Point::new(body.x1, body.y1)));
assert_no_overlapping_rules(fragment);
}
#[test]
fn unverified_nonuniform_or_skipped_pattern_phase_keeps_native_edges() {
for skipped in [false, true] {
let mut table = Table::new(77, 2, 2, 0, 0, 100.0);
table.options.border = CellStroke::none();
let mut pattern = uniform_pattern(2.0);
if skipped {
pattern.skip_first = 1;
} else {
pattern.next_stroke.weight = 3.0;
}
table.options.row_strokes = Some(pattern.clone());
table.options.column_strokes = Some(pattern);
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 4);
assert!(fragment.strokes.iter().all(|s| s.stroke.weight == 1.0));
}
}
#[test]
fn metadata_free_native_and_legacy_edges_keep_top_and_left_suppression() {
let mut table = Table::new(77, 2, 2, 0, 0, 100.0);
table.options.border = CellStroke::none();
let composed = compose_table(table.clone());
assert_eq!(composed.frames[0].tables[0].strokes.len(), 4, "unmodified native grid control");
// The metadata arrays also default to zero/false when reading legacy JSON. Older
// native table.setCell code changed these strokes without setting any metadata.
table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::none();
table.cell_mut(0, 1).unwrap().strokes[1].color = designcraft_color::swatch::NONE.into();
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 2);
let hidden_top = fragment.cell(1, 0).unwrap().rect;
let hidden_left = fragment.cell(0, 1).unwrap().rect;
assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_top.x0, hidden_top.y0) && s.b == Point::new(hidden_top.x1, hidden_top.y0)));
assert!(
!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_left.x0, hidden_left.y0) && s.b == Point::new(hidden_left.x0, hidden_left.y1))
);
assert_no_overlapping_rules(fragment);
}
#[test]
fn equal_priority_visible_rules_keep_top_and_left_ownership() {
let mut table = rule_table(2, 2, 0, 0);
set_rule(&mut table, 0, 0, 2, 5.0, 0);
set_rule(&mut table, 1, 0, 0, 2.0, 0);
set_rule(&mut table, 0, 0, 3, 6.0, 0);
set_rule(&mut table, 0, 1, 1, 3.0, 0);
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 2);
assert_eq!(fragment.strokes[0].stroke.weight, 2.0);
assert_eq!(fragment.strokes[1].stroke.weight, 3.0);
assert_no_overlapping_rules(fragment);
}
#[test]
fn row_spanning_edge_resolves_each_neighbor_without_internal_rules() {
let mut table = rule_table(2, 2, 0, 0);
table.merge(CellRange::new(0, 0, 1, 0)).unwrap();
set_rule(&mut table, 0, 0, 3, 1.0, 5);
set_rule(&mut table, 0, 1, 1, 2.0, 6);
set_rule(&mut table, 1, 1, 1, 3.0, 4);
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 2);
let upper = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap();
let lower = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap();
assert_eq!(upper.a, Point::new(50.0, fragment.rect.y0));
assert_eq!(upper.b, lower.a);
assert_eq!(lower.b, Point::new(50.0, fragment.rect.y1));
assert_no_overlapping_rules(fragment);
}
#[test]
fn column_spanning_edge_resolves_each_neighbor_without_internal_rules() {
let mut table = rule_table(2, 2, 0, 0);
table.merge(CellRange::new(0, 0, 0, 1)).unwrap();
set_rule(&mut table, 0, 0, 2, 1.0, 5);
set_rule(&mut table, 1, 0, 0, 2.0, 6);
set_rule(&mut table, 1, 1, 0, 3.0, 4);
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 2);
let left = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap();
let right = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap();
let y = fragment.cell(0, 0).unwrap().rect.y1;
assert_eq!(left.a, Point::new(fragment.rect.x0, y));
assert_eq!(left.b, right.a);
assert_eq!(right.b, Point::new(fragment.rect.x1, y));
assert_no_overlapping_rules(fragment);
}
#[test]
fn merged_winning_dashed_edge_stays_continuous_across_losing_neighbors() {
let mut table = rule_table(2, 2, 0, 0);
table.columns.iter_mut().for_each(|column| column.width = 45.0);
table.merge(CellRange::new(1, 0, 1, 1)).unwrap();
set_rule(&mut table, 1, 0, 0, 1.0, 1);
table.cell_mut(1, 0).unwrap().strokes[0].kind = designcraft_doc::StrokeType::Dashed { pattern: vec![6.0, 4.0] };
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 1, "one winning source edge must retain one dash phase");
let rule = &fragment.strokes[0];
let merged = fragment.cell(1, 0).unwrap().rect;
assert_eq!(rule.a, Point::new(merged.x0, merged.y0));
assert_eq!(rule.b, Point::new(merged.x1, merged.y0));
assert_eq!(rule.b.x - rule.a.x, 90.0);
assert_no_overlapping_rules(fragment);
}
#[test]
fn merged_perimeter_keeps_four_continuous_strokes() {
let mut table = rule_table(2, 2, 0, 0);
table.merge(CellRange::new(0, 0, 1, 1)).unwrap();
table.options.border.weight = 2.0;
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 4);
assert_eq!(fragment.strokes.iter().filter(|s| s.a.y == s.b.y).count(), 2);
assert_eq!(fragment.strokes.iter().filter(|s| s.a.x == s.b.x).count(), 2);
assert_no_overlapping_rules(fragment);
}
#[test]
fn rtl_shared_rules_use_physical_edges_and_outer_borders() {
let mut table = rule_table(1, 2, 0, 0);
table.options.direction = designcraft_doc::TextDirection::RightToLeft;
table.columns[0].width = 30.0;
table.columns[1].width = 70.0;
set_rule(&mut table, 0, 0, 1, 2.0, 9);
set_rule(&mut table, 0, 1, 3, 4.0, 2);
set_rule(&mut table, 0, 0, 3, 5.0, 3);
table.cell_mut(0, 0).unwrap().border_overrides[3] = true;
set_rule(&mut table, 0, 1, 1, 6.0, 4);
table.cell_mut(0, 1).unwrap().border_overrides[1] = true;
let composed = compose_table(table);
let fragment = &composed.frames[0].tables[0];
assert_eq!(fragment.strokes.len(), 3);
for (x, weight) in [(0.0, 6.0), (70.0, 2.0), (100.0, 5.0)] {
let rule = fragment.strokes.iter().find(|s| s.a.x == x && s.b.x == x).unwrap();
assert_eq!(rule.stroke.weight, weight);
}
assert_no_overlapping_rules(fragment);
}
fn compose_two_fragments(table: Table, height: f64) -> ComposedStory {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let (first, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, height), layer, "", ParaFormat::default()).unwrap();
let (second, _) =
doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 200.0, 120.0, 200.0 + height), layer, "", ParaFormat::default()).unwrap();
doc.thread(first, second).unwrap();
doc.story_mut(sid).unwrap().insert_table(0, table);
let composed = crate::compose_story(&doc, sid, &ComposeOptions::default());
assert!(!composed.is_overset(), "overset at {:?}", composed.overset_at);
assert_eq!(composed.frames[0].tables.len(), 1);
assert_eq!(composed.frames[1].tables.len(), 1);
composed
}
#[test]
fn repeated_header_and_footer_rules_resolve_displayed_fragment_neighbors() {
let mut table = rule_table(4, 2, 1, 1);
table.merge(CellRange::new(0, 0, 0, 1)).unwrap();
table.merge(CellRange::new(5, 0, 5, 1)).unwrap();
set_rule(&mut table, 0, 0, 2, 1.0, 2);
set_rule(&mut table, 5, 0, 0, 2.0, 3);
// Keep 19 points for the anchor's trailing empty paragraph, still below the
// 100 points needed to fit a third body row plus the header and footer.
let composed = compose_two_fragments(table, 99.0);
for (i, frame) in composed.frames.iter().enumerate() {
let fragment = &frame.tables[0];
let header = fragment.cell(0, 0).unwrap();
let footer = fragment.cell(5, 0).unwrap();
assert_eq!(header.repeated, i == 1);
assert_eq!(footer.repeated, i == 0);
assert_eq!(fragment.strokes.len(), 2);
let header_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == header.rect.y1 && s.b.y == header.rect.y1).collect();
let footer_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == footer.rect.y0 && s.b.y == footer.rect.y0).collect();
assert_eq!(header_rules.len(), 1);
assert!(header_rules.iter().all(|s| s.stroke.weight == 1.0));
assert_eq!(footer_rules.len(), 1);
assert!(footer_rules.iter().all(|s| s.stroke.weight == 2.0));
assert_no_overlapping_rules(fragment);
}
assert!(composed.frames[0].tables[0].cell(1, 0).is_some());
assert!(composed.frames[1].tables[0].cell(3, 0).is_some());
}
#[test]
fn fragment_boundary_does_not_resolve_against_an_undisplayed_neighbor() {
let mut table = rule_table(4, 1, 0, 0);
set_rule(&mut table, 1, 0, 2, 3.0, 5);
table.cell_mut(1, 0).unwrap().border_overrides[2] = true;
set_rule(&mut table, 2, 0, 0, 4.0, 6);
table.cell_mut(2, 0).unwrap().border_overrides[0] = true;
// Two rows fit in each frame; leave room for the trailing empty paragraph.
let composed = compose_two_fragments(table, 59.0);
let first = &composed.frames[0].tables[0];
let second = &composed.frames[1].tables[0];
assert_eq!(first.strokes.len(), 1);
assert_eq!(first.strokes[0].stroke.weight, 3.0);
assert_eq!(first.strokes[0].a.y, first.rect.y1);
assert_eq!(second.strokes.len(), 1);
assert_eq!(second.strokes[0].stroke.weight, 4.0);
assert_eq!(second.strokes[0].a.y, second.rect.y0);
}
}