mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 12:16:53 -06:00
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1579 lines
70 KiB
Rust
1579 lines
70 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, 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);
|
||
}
|
||
}
|