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