M8: tables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 12:44:45 -04:00
parent 6086ceb497
commit 77a23151ba
34 changed files with 3534 additions and 68 deletions

View File

@ -14,11 +14,12 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- ~100 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input.
- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads.
**Next (in order):** tables · TOC/index/footnotes · PDF/X-4 output intent, bookmarks, tagged PDF · Links panel + relink · EPUB · column-break / default-tab fixes.
**Next (in order):** table styles / cell styles, text rotation in cells · TOC/index/footnotes · PDF/X-4 output intent, bookmarks, tagged PDF · Links panel + relink · EPUB · column-break / default-tab fixes.
## Milestones
@ -32,7 +33,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
| M5 | Graphics & links | started |
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) |
| M7 | Long documents (sections, TOC, index, footnotes, books) | |
| M8 | Tables | |
| M8 | Tables | ✅ core (styles, rotation, diagonal lines pending) |
| M9 | Performance (MT composition, tiles) | |
| M10 | Find/Change, spelling, Preflight | |
| M11 | Layout power features (liquid/alternate layouts, data merge) | |

View File

@ -13,6 +13,7 @@ pub mod breaker;
mod cache;
pub mod hyphen;
pub mod shape;
pub mod table;
use std::collections::HashMap;
use std::ops::Range;
@ -28,6 +29,7 @@ use designcraft_geom::{Point, Rect};
use crate::breaker::{Break, Spacing};
pub use crate::cache::Cache;
use crate::shape::{Glyph, StyleTable, SubstCtx};
pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell};
/// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]).
#[derive(Clone, Debug, PartialEq)]
@ -103,6 +105,8 @@ pub struct FrameText {
pub columns: Vec<Rect>,
/// Height the text needs (for auto-sizing), from the text area top.
pub content_height: f64,
/// Table fragments placed in this frame (each also has a glyph-less line in `lines`).
pub tables: Vec<TableFrag>,
}
#[derive(Clone, Debug, Default)]
@ -265,6 +269,40 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
info.truncate(pi);
let pf = &story.paras[pi];
let (pp, base_chars) = doc.styles.resolve_para(pf);
if let Some(t) = story.para_table(pi) {
if cur.last_baseline.is_some() {
match pp.start_paragraph {
StartParagraph::NextColumn => cur.next_column(&cols),
StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => {
cur.next_frame()
}
StartParagraph::Anywhere => {}
}
}
let at_top = cur.last_baseline.is_none();
let start_at = (cur.fi, cur.col);
let anchor = prange.start + story.text[prange.clone()].find(story::TABLE_ANCHOR).unwrap_or(0);
if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) {
out.overset_at = Some(prange.start);
break 'paras;
}
info.push(ParaInfo {
start: start_at,
at_top,
end: (cur.fi, cur.col),
lines: 1,
lines_in_end_col: 1,
keep_with_next: 0,
keep_together: false,
keep_all: false,
keep_first: 1,
keep_last: 1,
});
list_counter = 0;
cur.pending += pp.space_after;
pi += 1;
continue;
}
// Bullets & numbering: generated prefix (shaped as its own glyphs, mapped to the paragraph start).
let sub = SubstCtx {
page_name: opts.page_name.clone().or_else(|| frames.get(cur.fi.min(frames.len().saturating_sub(1))).and_then(|f| f.page_name.clone())),
@ -548,7 +586,12 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
let p = ft_prev_end(&out.overset_at, story.text.len());
ft.range = p..p;
}
let before: Vec<f64> = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect();
vertical_justify(ft, f);
for (t, b) in ft.tables.iter_mut().zip(before) {
let now = ft.lines.get(t.line).map_or(b, |l| l.baseline);
t.shift(now - b);
}
}
// Frame ranges for empty frames after the text: start at the end of the text shown.
let mut last_end = 0;
@ -611,19 +654,21 @@ struct Snapshot {
list_counter: u32,
lines: usize,
decos: usize,
tables: usize,
}
impl Snapshot {
fn take(out: &ComposedStory, cur: &Cursor, list_counter: u32) -> Snapshot {
let (lines, decos) = out.frames.get(cur.fi).map_or((0, 0), |f| (f.lines.len(), f.decos.len()));
Snapshot { cur: cur.clone(), list_counter, lines, decos }
let (lines, decos, tables) = out.frames.get(cur.fi).map_or((0, 0, 0), |f| (f.lines.len(), f.decos.len(), f.tables.len()));
Snapshot { cur: cur.clone(), list_counter, lines, decos, tables }
}
fn restore(&self, out: &mut ComposedStory, cur: &mut Cursor, list_counter: &mut u32) {
let fi = self.cur.fi;
for (i, f) in out.frames.iter_mut().enumerate().skip(fi) {
let (l, d) = if i == fi { (self.lines, self.decos) } else { (0, 0) };
let (l, d, t) = if i == fi { (self.lines, self.decos, self.tables) } else { (0, 0, 0) };
f.lines.truncate(l);
f.decos.truncate(d);
f.tables.truncate(t);
}
*cur = self.cur.clone();
*list_counter = self.list_counter;
@ -1108,6 +1153,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph {
|| breaker::is_forced(g.ch)
|| g.ch == story::INDENT_HERE
|| g.ch == story::RIGHT_INDENT_TAB
|| g.ch == story::TABLE_ANCHOR
|| g.ch == shape::SOFT_HYPHEN);
PlacedGlyph {
face: g.face.clone(),

View File

@ -189,6 +189,7 @@ fn shape_run(
| story::PAGE_BREAK
| story::INDENT_HERE
| story::RIGHT_INDENT_TAB
| story::TABLE_ANCHOR
);
if special {
flush(seg_start, i, &seg_face, out);
@ -205,7 +206,7 @@ fn shape_run(
_ => {
// Zero-width control glyph carrying metrics (tabs get their width at line layout).
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
if c == '\t' || c == story::RIGHT_INDENT_TAB {
if c == '\t' || c == story::RIGHT_INDENT_TAB || c == story::TABLE_ANCHOR {
g.adv = 0.0;
}
out.push(g);

464
crates/compose/src/table.rs Normal file
View File

@ -0,0 +1,464 @@
//! 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,
}
/// 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(),
exclusions: vec![],
page_name: opts.page_name.clone(),
grid: None,
left_page,
};
let cs = crate::compose(doc, story, std::slice::from_ref(&spec), opts);
let content_h = cs.frames.first().map(|f| if f.lines.is_empty() { 0.0 } else { f.content_height }).unwrap_or(0.0);
CellLayout { text: Arc::new(cs), content_h }
}
/// Lay out `table` (anchored at byte `anchor` of paragraph `pi`) from the cursor. Returns false
/// when it doesn't fit the remaining frames (overset).
#[allow(clippy::too_many_arguments)]
pub(crate) fn place_table(
doc: &Document,
table: &Table,
anchor: usize,
pi: usize,
pp: &ParaProps,
frames: &[FrameSpec],
cols: &[Vec<Rect>],
cur: &mut Cursor,
out: &mut ComposedStory,
opts: &ComposeOptions,
) -> bool {
if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 {
return false;
}
let col_rect = |cur: &Cursor| cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
let mut col = col_rect(cur);
// Column widths: scaled to fit the measure.
let avail = (col.width() - pp.left_indent - pp.right_indent).max(1.0);
let natural = table.width();
let k = if natural > avail && natural > 0.0 { avail / natural } else { 1.0 };
let widths: Vec<f64> = table.columns.iter().map(|c| c.width * k).collect();
let total_w: f64 = widths.iter().sum();
let x_off = pp.left_indent
+ match pp.align {
Align::Center | Align::CenterJustified => (avail - total_w) / 2.0,
Align::Right | Align::RightJustified => avail - total_w,
_ => 0.0,
}
.max(0.0);
let mut colx = vec![0.0; widths.len() + 1];
for (i, w) in widths.iter().enumerate() {
colx[i + 1] = colx[i] + w;
}
let (nr, nc) = (table.nrows(), table.ncols());
let owners = table.owners();
let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c);
let left_page = frames[cur.fi].left_page;
// Compose every owner cell.
let mut layouts: Vec<Option<CellLayout>> = Vec::with_capacity(nr * nc);
for r in 0..nr {
for c in 0..nc {
if !is_owner(r, c) {
layouts.push(None);
continue;
}
let cell = &table.cells[r * nc + c];
let cs = (cell.col_span.max(1) as usize).min(nc - c);
let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3];
layouts.push(Some(compose_cell(doc, &cell.text, w, opts, left_page)));
}
}
// Row heights.
let need = |r: usize, c: usize| -> f64 {
let cell = &table.cells[r * nc + c];
layouts[r * nc + c].as_ref().map_or(0.0, |l| l.content_h) + cell.insets[0] + cell.insets[2]
};
let mut heights: Vec<f64> = table
.rows
.iter()
.enumerate()
.map(|(r, row)| match row.mode {
RowHeightMode::Exactly => row.height.max(0.0),
RowHeightMode::AtLeast => {
let content = (0..nc).filter(|&c| is_owner(r, c) && table.cells[r * nc + c].row_span <= 1).map(|c| need(r, c)).fold(0.0, f64::max);
content.max(row.height)
}
})
.collect();
// Row-spanning cells grow the last row they span (unless it is fixed).
for r in 0..nr {
for c in 0..nc {
let cell = &table.cells[r * nc + c];
if !is_owner(r, c) || cell.row_span <= 1 {
continue;
}
let end = (r + cell.row_span as usize).min(nr);
let have: f64 = heights[r..end].iter().sum();
let n = need(r, c);
if n > have
&& let Some(i) = (r..end).rev().find(|&i| table.rows[i].mode == RowHeightMode::AtLeast)
{
heights[i] += n - have;
}
}
}
// Row groups that must stay together: end (exclusive) of the group starting at each row.
let mut group_end: Vec<usize> = (0..nr).map(|r| r + 1).collect();
{
let mut r = 0;
while r < nr {
let mut e = r + 1;
let mut i = r;
while i < e {
for c in 0..nc {
if is_owner(i, c) {
e = e.max((i + table.cells[i * nc + c].row_span.max(1) as usize).min(nr));
}
}
i += 1;
}
for g in group_end.iter_mut().take(e).skip(r) {
*g = e;
}
r = e;
}
}
let header = table.header_rows();
let footer = table.footer_rows();
let body_end = nr - footer;
let sum = |a: usize, b: usize| heights[a..b].iter().sum::<f64>();
let footer_h = sum(body_end, nr);
let o = &table.options;
let mut y = match cur.last_baseline {
Some(b) => b + cur.last_descent + cur.pending + o.space_before,
None => col.y0,
};
let mut frag_rows: Vec<(usize, f64, bool)> = Vec::new(); // (row, y, repeated)
let mut first_frag = true;
let mut body_placed = 0usize;
let mut top = y;
let place_rows = |rows: std::ops::Range<usize>, y: &mut f64, repeated: bool, frag_rows: &mut Vec<(usize, f64, bool)>| {
for r in rows {
frag_rows.push((r, *y, repeated));
*y += heights[r];
}
};
place_rows(0..header, &mut y, false, &mut frag_rows);
let mut r = header;
loop {
if r >= body_end {
break;
}
let ge = group_end[r].min(body_end).max(r + 1);
let gh = sum(r, ge);
let at_col_top = cur.last_baseline.is_none() && (top - col.y0).abs() < 1e-6;
if y + gh + footer_h <= col.y1 + 0.01 || (body_placed == 0 && at_col_top) {
if y + gh + footer_h > col.y1 + 0.01 && body_placed == 0 && at_col_top && y + gh > col.y1 + 0.01 {
// A row group taller than an empty column can never fit.
return false;
}
place_rows(r..ge, &mut y, false, &mut frag_rows);
body_placed += 1;
r = ge;
continue;
}
// Continue in the next column / frame.
if body_placed > 0 {
if o.repeat_footer {
place_rows(body_end..nr, &mut y, true, &mut frag_rows);
}
emit(table, anchor, pi, &frag_rows, &heights, &colx, col.x0 + x_off, top, y, first_frag, false, &layouts, &owners, cur, col, out);
first_frag = false;
}
let was_first = first_frag;
cur.next_column(cols);
if cur.fi >= frames.len() {
return false;
}
col = col_rect(cur);
y = col.y0;
top = y;
frag_rows.clear();
body_placed = 0;
// Headers: the first fragment's header moves along; later ones repeat it.
if was_first {
place_rows(0..header, &mut y, false, &mut frag_rows);
} else if o.repeat_header {
place_rows(0..header, &mut y, true, &mut frag_rows);
}
}
place_rows(body_end..nr, &mut y, false, &mut frag_rows);
emit(table, anchor, pi, &frag_rows, &heights, &colx, col.x0 + x_off, top, y, first_frag, true, &layouts, &owners, cur, col, out);
cur.last_baseline = Some(y);
cur.last_descent = 0.0;
cur.pending = o.space_after;
true
}
/// Build one fragment from its rows and push it (with its glyph-less line) to the current frame.
#[allow(clippy::too_many_arguments)]
fn emit(
table: &Table,
anchor: usize,
pi: usize,
rows: &[(usize, f64, bool)],
heights: &[f64],
colx: &[f64],
x0: f64,
top: f64,
bottom: f64,
first: bool,
last: bool,
layouts: &[Option<CellLayout>],
owners: &[(usize, usize)],
cur: &Cursor,
col: Rect,
out: &mut ComposedStory,
) {
let nc = table.ncols();
let ry = |r: usize| rows.iter().find(|x| x.0 == r).map(|x| x.1);
let header = table.header_rows();
let body = table.body_rows();
let nbody = body.len();
let mut cells = Vec::new();
let mut strokes = Vec::new();
let o = &table.options;
let right = x0 + colx[nc];
for &(r, y, repeated) in rows {
for c in 0..nc {
if owners[r * nc + c] != (r, c) {
continue;
}
let cell = &table.cells[r * nc + c];
let cs = (cell.col_span.max(1) as usize).min(nc - c);
let rs = (cell.row_span.max(1) as usize).min(table.nrows() - r);
let last_row = r + rs - 1;
let y1 = ry(last_row).map_or(y + heights[r], |ly| ly + heights[last_row]);
let rect = 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 lay = layouts[r * nc + c].as_ref().expect("owner composed");
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,
});
// Edges: every cell draws its top and left; the fragment's bottom/right cells their bottom/right.
// The table border replaces outer edges.
let at_top = (y - top).abs() < 1e-6;
let at_bottom = (y1 - bottom).abs() < 1e-6;
let edge = |i: usize, outer: bool| if outer { 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, c == 0));
if at_bottom {
push(Point::new(rect.x0, rect.y1), Point::new(rect.x1, rect.y1), edge(2, true));
}
if c + cs == nc {
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,
});
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)),
}
}

View File

@ -569,3 +569,145 @@ fn tabs_without_stops_use_default_half_inch() {
let b = l.glyphs.iter().find(|g| g.byte == 2).expect("B");
assert!((b.x - 36.0).abs() < 0.5, "B at {}", b.x);
}
// ---------- tables ----------
fn table_doc(frame: Rect, t: designcraft_doc::Table) -> (Document, StoryId, ItemId) {
let (mut d, sid, fid) = doc_with("Intro", frame, ParaAttrs::default());
let st = d.story_mut(sid).unwrap();
let end = st.len();
st.insert_table(end, t);
let end = st.len();
st.insert(end, "After");
d.check().unwrap();
(d, sid, fid)
}
fn filled(rows: usize, cols: usize, header: usize, width: f64) -> designcraft_doc::Table {
let mut t = designcraft_doc::Table::new(77, rows, cols, header, 0, width);
for r in 0..t.nrows() {
for c in 0..t.ncols() {
t.cell_mut(r, c).unwrap().text.insert(0, &format!("R{r}C{c}"));
}
}
t
}
#[test]
fn table_rows_fit_their_content() {
let mut t = filled(3, 3, 1, 300.0);
t.cell_mut(2, 1).unwrap().text.insert(0, &format!("{LOREM} "));
t.rows[3].height = 50.0;
t.rows[3].mode = designcraft_doc::RowHeightMode::Exactly;
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 400.0, 1000.0), t);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset());
let ft = &cs.frames[0];
assert_eq!(ft.tables.len(), 1);
let tf = &ft.tables[0];
assert_eq!(tf.cells.len(), 12);
let h = |r: usize| tf.cell(r, 0).unwrap().rect.height();
// One line of 12 pt text + 4 + 4 insets.
assert!(h(0) > 12.0 && h(0) < 25.0, "{}", h(0));
// The long cell makes its row taller; all cells in a row share the height.
assert!(h(2) > 5.0 * h(0), "{} vs {}", h(2), h(0));
assert!((tf.cell(2, 2).unwrap().rect.height() - h(2)).abs() < 1e-9);
assert!((h(3) - 50.0).abs() < 1e-9);
// Cell text sits inside the cell insets.
let c = tf.cell(1, 1).unwrap();
let l = &c.text.frames[0].lines[0];
assert!(c.origin.x + l.x0 >= c.rect.x0 + 3.99);
assert!(c.origin.y + l.baseline < c.rect.y1);
// Text after the table is below it.
let len = d.story(sid).unwrap().len();
let after = ft.lines.iter().find(|l| l.range.start == len - 5).unwrap();
assert!(after.baseline > tf.rect.y1);
// Strokes: outer border + inner edges.
assert!(tf.strokes.len() >= 4 * 3 + 4);
}
#[test]
fn table_columns_scale_to_the_text_column() {
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 200.0, 1000.0), filled(2, 4, 0, 600.0));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let tf = &cs.frames[0].tables[0];
assert!((tf.rect.width() - 200.0).abs() < 1e-6, "{}", tf.rect.width());
assert!((tf.cell(0, 3).unwrap().rect.x1 - 200.0).abs() < 1e-6);
// A narrow table keeps its widths.
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 400.0, 1000.0), filled(2, 2, 0, 100.0));
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!((cs.frames[0].tables[0].rect.width() - 100.0).abs() < 1e-6);
}
#[test]
fn table_rows_break_across_frames_with_header_repeat() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 150.0), lid, "", ParaFormat::default()).unwrap();
let (f2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 200.0, 300.0, 350.0), lid, "", ParaFormat::default()).unwrap();
d.thread(f1, f2).unwrap();
d.story_mut(sid).unwrap().insert_table(0, filled(9, 2, 1, 300.0));
d.check().unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(!cs.is_overset(), "overset at {:?}", cs.overset_at);
let (a, b) = (&cs.frames[0].tables, &cs.frames[1].tables);
assert_eq!((a.len(), b.len()), (1, 1));
// Every body row appears exactly once; the header is repeated in the second frame.
let body: Vec<usize> = a[0].cells.iter().chain(b[0].cells.iter()).filter(|c| c.row > 0 && c.col == 0).map(|c| c.row).collect();
assert_eq!(body, (1..10).collect::<Vec<_>>());
assert!(!a[0].cell(0, 0).unwrap().repeated);
assert!(b[0].cell(0, 0).unwrap().repeated);
assert!((b[0].rect.y0 - 200.0).abs() < 1e-6, "{}", b[0].rect.y0);
assert!(a[0].rect.y1 <= 150.0 + 1e-6);
assert!(a[0].first && !a[0].last && b[0].last);
// Without header repeat the second fragment starts with a body row.
let st = d.story_mut(sid).unwrap();
st.table_mut(77).unwrap().options.repeat_header = false;
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(cs.frames[1].tables[0].cell(0, 0).is_none());
// Too little room: overset.
let st = d.story_mut(sid).unwrap();
let t = st.table_mut(77).unwrap();
t.insert_rows(5, 30);
let cs = compose_story(&d, sid, &ComposeOptions::default());
assert!(cs.is_overset());
}
#[test]
fn table_cells_hit_test_and_place_carets() {
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 300.0, 1000.0), filled(2, 3, 0, 300.0));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let c = cs.frames[0].tables[0].cell(1, 2).unwrap().clone();
let p = Point::new(c.rect.x1 - 2.0, c.rect.center().y);
let (t, r, col, b) = hit_cell(&cs, 0, p).unwrap();
assert_eq!((t, r, col), (77, 1, 2));
assert_eq!(b, "R1C2".len());
let (fi, x, bl, _, _) = cell_caret(&cs, 77, 1, 2, 0).unwrap();
assert_eq!(fi, 0);
assert!(c.rect.contains(Point::new(x + 0.1, bl - 1.0)));
// Outside every cell: no cell.
assert!(hit_cell(&cs, 0, Point::new(1.0, 1.0)).is_none());
// The anchor has a caret position (after the table).
let a = d.story(sid).unwrap().table_anchor(77).unwrap();
assert!(caret(&cs, a).is_some());
}
#[test]
fn table_fills_and_merged_cells() {
let mut t = filled(4, 3, 1, 300.0);
t.options.alt_rows = Some(designcraft_doc::AltFills { first: 1, next: 1, ..Default::default() });
t.cell_mut(0, 0).unwrap().fill = "[Black]".into();
t.merge(designcraft_doc::CellRange::new(1, 1, 2, 2)).unwrap();
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 300.0, 1000.0), t);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let tf = &cs.frames[0].tables[0];
assert!(tf.cell(0, 0).unwrap().fill.is_some());
assert!(tf.cell(0, 1).unwrap().fill.is_none(), "header rows don't alternate");
assert!(tf.cell(1, 0).unwrap().fill.is_some());
assert!(tf.cell(2, 0).unwrap().fill.is_none());
let m = tf.cell(1, 1).unwrap();
let both = tf.cell(1, 0).unwrap().rect.height() + tf.cell(2, 0).unwrap().rect.height();
assert!((m.rect.height() - both).abs() < 1e-6);
assert!(tf.cell(2, 2).is_none());
assert_eq!(tf.cells.len(), 15 - 3);
}

View File

@ -226,6 +226,7 @@ impl Document {
last.len += 1;
}
st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned());
st.tables.extend(other.tables.iter().map(|(k, v)| (*k, v.clone())));
st.rev += 1;
}
let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1);
@ -344,12 +345,13 @@ impl Document {
/// Replace a story's text and formatting wholesale (agents / import).
pub fn set_story_text(&mut self, sid: StoryId, text: &str) -> Result<()> {
let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?;
let para = st.paras.first().cloned().unwrap_or_default();
let para = st.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default();
let fmt = st.chars.first().map(|r| r.format.clone()).unwrap_or_default();
let n = text.matches('\n').count() + 1;
st.text = text.to_string();
st.paras = vec![para; n];
st.chars = vec![CharRun { len: text.len(), format: fmt }];
st.tables.clear();
st.rev += 1;
Ok(())
}

View File

@ -17,6 +17,7 @@ pub mod page;
pub mod selection;
pub mod story;
pub mod styles;
pub mod table;
use std::collections::BTreeMap;
use std::sync::Arc;
@ -31,6 +32,7 @@ pub use page::*;
pub use selection::*;
pub use story::*;
pub use styles::*;
pub use table::*;
use designcraft_color::Swatch;
use designcraft_geom::Unit;

View File

@ -3,6 +3,25 @@
use serde::{Deserialize, Serialize};
use crate::ids::{ItemId, StoryId};
use crate::table::CellRange;
/// A table cell: the table (in the selection's story) and the owning cell's grid position.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CellAddr {
pub table: u64,
pub row: usize,
pub col: usize,
}
/// Selected table cells (Table > Select, dragging across cells).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableSel {
pub story: StoryId,
pub table: u64,
pub range: CellRange,
}
/// A caret or text range in a story. `anchor` stays put while `focus` moves (Shift-arrows).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -14,11 +33,14 @@ pub struct TextSel {
/// The frame the caret is shown in (for carets at frame boundaries).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame: Option<ItemId>,
/// Text in a table cell: `anchor`/`focus` index the cell's story instead of `story`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cell: Option<CellAddr>,
}
impl TextSel {
pub fn caret(story: StoryId, pos: usize) -> Self {
TextSel { story, anchor: pos, focus: pos, frame: None }
TextSel { story, anchor: pos, focus: pos, frame: None, cell: None }
}
pub fn range(&self) -> std::ops::Range<usize> {
self.anchor.min(self.focus)..self.anchor.max(self.focus)
@ -41,6 +63,9 @@ pub struct Selection {
pub anchors: Vec<(ItemId, usize, usize)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<TextSel>,
/// Selected table cells.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cells: Option<TableSel>,
/// Key object for Align.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<ItemId>,
@ -48,7 +73,7 @@ pub struct Selection {
impl Selection {
pub fn is_empty(&self) -> bool {
self.items.is_empty() && self.text.is_none()
self.items.is_empty() && self.text.is_none() && self.cells.is_none()
}
pub fn len(&self) -> usize {
self.items.len()

View File

@ -9,12 +9,17 @@
//!
//! Special characters: `\t` tab, `\u{2028}` forced line break, [`PAGE_NUMBER`], [`SECTION_MARKER`],
//! [`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], `\u{AD}` discretionary hyphen,
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`].
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`],
//! [`TABLE_ANCHOR`] (a table, see [`crate::table`]).
use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::attrs::{CharAttrs, ParaAttrs};
use crate::ids::{ItemId, StoryId};
use crate::table::Table;
pub const PAGE_NUMBER: char = '\u{E000}';
pub const SECTION_MARKER: char = '\u{E001}';
@ -25,6 +30,8 @@ pub const INDENT_HERE: char = '\u{E005}';
pub const RIGHT_INDENT_TAB: char = '\u{E006}';
pub const NEXT_PAGE_NUMBER: char = '\u{E007}';
pub const PREV_PAGE_NUMBER: char = '\u{E008}';
/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table.
pub const TABLE_ANCHOR: char = '\u{E009}';
pub const FORCED_LINE_BREAK: char = '\u{2028}';
pub const BASIC_PARAGRAPH: &str = "[Basic Paragraph]";
@ -39,11 +46,14 @@ pub struct ParaFormat {
/// Paragraph-level character overrides are stored on the char runs; this holds nothing.
#[serde(default, skip_serializing_if = "CharAttrs::is_empty")]
pub chars: CharAttrs,
/// The table anchored in this paragraph (key into [`Story::tables`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table: Option<u64>,
}
impl Default for ParaFormat {
fn default() -> Self {
ParaFormat { style: BASIC_PARAGRAPH.into(), para: ParaAttrs::default(), chars: CharAttrs::default() }
ParaFormat { style: BASIC_PARAGRAPH.into(), para: ParaAttrs::default(), chars: CharAttrs::default(), table: None }
}
}
@ -81,6 +91,9 @@ pub struct Story {
/// Bumped on every edit (composition cache key).
#[serde(default)]
pub rev: u64,
/// Tables anchored in the story, by id (see [`crate::table`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tables: BTreeMap<u64, Arc<Table>>,
}
impl Story {
@ -92,6 +105,7 @@ impl Story {
chars: vec![CharRun { len: 0, format: CharFormat::default() }],
frames: vec![],
rev: 0,
tables: BTreeMap::new(),
}
}
@ -160,6 +174,17 @@ impl Story {
/// Insert with an explicit character format.
pub fn insert_with(&mut self, pos: usize, text: &str, fmt: CharFormat) {
self.insert_with_raw(pos, text, fmt);
self.fix_tables();
}
/// Insert without the table fix-up (callers fix up themselves).
pub(crate) fn insert_raw(&mut self, pos: usize, text: &str) {
let fmt = self.char_format_at(pos).clone();
self.insert_with_raw(pos, text, fmt);
}
fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) {
if text.is_empty() {
return;
}
@ -189,10 +214,20 @@ impl Story {
}
let pi = self.para_at(a);
let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count();
// Merged paragraphs keep a table whose anchor survives the deletion.
if removed > 0 && !self.tables.is_empty() {
let ranges = self.para_ranges();
let first = self.paras[pi].table.filter(|_| self.text[ranges[pi].start..a].contains(TABLE_ANCHOR));
let last = self.paras[pi + removed]
.table
.filter(|_| self.text[b.min(ranges[pi + removed].end)..ranges[pi + removed].end].contains(TABLE_ANCHOR));
self.paras[pi].table = first.or(last);
}
self.paras.drain(pi + 1..pi + 1 + removed);
self.text.replace_range(a..b, "");
self.remove_run_bytes(a, b - a);
self.normalize();
self.fix_tables();
self.rev += 1;
}
@ -242,7 +277,9 @@ impl Story {
let a = self.para_at(range.start);
let b = self.para_at(range.end.max(range.start));
for p in &mut self.paras[a..=b] {
let table = p.table;
f(p);
p.table = table;
}
self.rev += 1;
}
@ -277,7 +314,7 @@ impl Story {
return Err(format!("run boundary {pos} splits a character"));
}
}
Ok(())
self.check_tables()
}
// ---------- run helpers ----------

944
crates/doc/src/table.rs Normal file
View File

@ -0,0 +1,944 @@
//! Tables.
//!
//! A table lives in a story: a [`TABLE_ANCHOR`](crate::story::TABLE_ANCHOR) character occupies its
//! own paragraph and that paragraph's [`ParaFormat::table`](crate::story::ParaFormat) names the
//! table in [`Story::tables`]. Deleting the anchor character deletes the table.
//!
//! The grid is `rows × columns` cells stored row-major. A merged cell is the top-left cell of its
//! region with `row_span`/`col_span` > 1; the cells it covers stay in the grid (empty, ignored).
//! Each cell holds a [`Story`] (no frames) composed into the cell rectangle.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::ids::StoryId;
use crate::item::{StrokeType, VerticalJustification};
use crate::story::{CharFormat, ParaFormat, Story, TABLE_ANCHOR};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RowHeightMode {
/// Grows with the content (the height is a minimum).
#[default]
AtLeast,
Exactly,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RowKind {
Header,
#[default]
Body,
Footer,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Row {
pub height: f64,
pub mode: RowHeightMode,
pub kind: RowKind,
}
impl Default for Row {
fn default() -> Self {
Row { height: 3.0, mode: RowHeightMode::AtLeast, kind: RowKind::Body }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Column {
pub width: f64,
}
/// One cell edge or the table border.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CellStroke {
pub weight: f64,
pub color: String,
pub tint: f32,
pub kind: StrokeType,
}
impl Default for CellStroke {
fn default() -> Self {
CellStroke { weight: 1.0, color: designcraft_color::swatch::BLACK.into(), tint: 1.0, kind: StrokeType::Solid }
}
}
impl CellStroke {
pub fn none() -> Self {
CellStroke { weight: 0.0, ..Default::default() }
}
pub fn is_visible(&self) -> bool {
self.weight > 0.0 && self.color != designcraft_color::swatch::NONE
}
}
fn one() -> u32 {
1
}
fn full() -> f32 {
1.0
}
fn no_fill() -> String {
designcraft_color::swatch::NONE.into()
}
fn default_insets() -> [f64; 4] {
[4.0; 4]
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Cell {
#[serde(default = "one")]
pub row_span: u32,
#[serde(default = "one")]
pub col_span: u32,
/// Cell content (a frameless story).
pub text: Story,
#[serde(default = "no_fill")]
pub fill: String,
#[serde(default = "full")]
pub fill_tint: f32,
/// Top, left, bottom, right.
#[serde(default = "default_insets")]
pub insets: [f64; 4],
#[serde(default)]
pub vj: VerticalJustification,
/// Text rotation in degrees (only 0 is composed for now).
#[serde(default)]
pub rotation: f64,
/// Edge strokes: top, left, bottom, right.
#[serde(default)]
pub strokes: [CellStroke; 4],
}
impl Default for Cell {
fn default() -> Self {
Cell {
row_span: 1,
col_span: 1,
text: Story::new(StoryId(0)),
fill: no_fill(),
fill_tint: 1.0,
insets: default_insets(),
vj: VerticalJustification::Top,
rotation: 0.0,
strokes: Default::default(),
}
}
}
impl Cell {
pub fn with_text(text: &str, para: ParaFormat) -> Cell {
Cell { text: Story::with_text(StoryId(0), text, para), ..Default::default() }
}
/// A cell with this cell's formatting and no text (new rows / columns, unmerged cells).
pub fn blank_like(&self) -> Cell {
let mut c = Cell { row_span: 1, col_span: 1, ..self.clone() };
let para = self.text.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default();
let fmt = self.text.chars.first().map(|r| r.format.clone()).unwrap_or_default();
c.text = Story::new(StoryId(0));
c.text.paras[0] = para;
c.text.chars[0].format = fmt;
c
}
pub fn has_fill(&self) -> bool {
self.fill != designcraft_color::swatch::NONE
}
}
/// Table → Table Options → Fills (alternating pattern).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AltFills {
pub first: u32,
pub first_color: String,
pub first_tint: f32,
pub next: u32,
pub next_color: String,
pub next_tint: f32,
pub skip_first: u32,
pub skip_last: u32,
}
impl Default for AltFills {
fn default() -> Self {
AltFills {
first: 1,
first_color: designcraft_color::swatch::BLACK.into(),
first_tint: 0.2,
next: 1,
next_color: designcraft_color::swatch::NONE.into(),
next_tint: 1.0,
skip_first: 0,
skip_last: 0,
}
}
}
impl AltFills {
/// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it.
pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> {
let (sf, sl) = (self.skip_first as usize, self.skip_last as usize);
if i < sf || i + sl >= n {
return None;
}
let period = (self.first + self.next).max(1) as usize;
let k = (i - sf) % period;
let (c, t) = if k < self.first as usize { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) };
(c != designcraft_color::swatch::NONE).then_some((c.as_str(), t))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TableOptions {
/// Outer border (overrides the outer cell edges).
pub border: CellStroke,
pub space_before: f64,
pub space_after: f64,
/// Header rows repeat at the top of every text column / frame the table continues into.
pub repeat_header: bool,
pub repeat_footer: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_rows: Option<AltFills>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_cols: Option<AltFills>,
}
impl Default for TableOptions {
fn default() -> Self {
TableOptions {
border: CellStroke::default(),
space_before: 4.0,
space_after: -4.0,
repeat_header: true,
repeat_footer: true,
alt_rows: None,
alt_cols: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Table {
pub id: u64,
pub rows: Vec<Row>,
pub columns: Vec<Column>,
/// Row-major, `rows.len() * columns.len()`.
pub cells: Vec<Cell>,
#[serde(default)]
pub options: TableOptions,
}
/// A rectangular range of cells (inclusive).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CellRange {
pub r0: usize,
pub c0: usize,
pub r1: usize,
pub c1: usize,
}
impl CellRange {
pub fn cell(r: usize, c: usize) -> Self {
CellRange { r0: r, c0: c, r1: r, c1: c }
}
pub fn new(ra: usize, ca: usize, rb: usize, cb: usize) -> Self {
CellRange { r0: ra.min(rb), c0: ca.min(cb), r1: ra.max(rb), c1: ca.max(cb) }
}
pub fn contains(&self, r: usize, c: usize) -> bool {
r >= self.r0 && r <= self.r1 && c >= self.c0 && c <= self.c1
}
}
impl Table {
/// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`.
pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table {
let cols = cols.max(1);
let mut rows = Vec::new();
rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() }));
rows.extend((0..body_rows.max(1)).map(|_| Row::default()));
rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() }));
let w = (width / cols as f64).max(3.0);
let n = rows.len() * cols;
Table { id, rows, columns: vec![Column { width: w }; cols], cells: vec![Cell::default(); n], options: TableOptions::default() }
}
/// A table from rows of cell strings (Convert Text to Table). Short rows are padded.
pub fn from_strings(id: u64, data: &[Vec<String>], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table {
let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1);
let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width);
for (r, row) in data.iter().enumerate() {
for (c, s) in row.iter().enumerate() {
let cell = t.cell_mut(r, c).expect("in range");
cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() });
cell.text.chars[0].format = fmt.clone();
cell.text.insert(0, s);
}
}
t
}
pub fn nrows(&self) -> usize {
self.rows.len()
}
pub fn ncols(&self) -> usize {
self.columns.len()
}
pub fn width(&self) -> f64 {
self.columns.iter().map(|c| c.width).sum()
}
fn idx(&self, r: usize, c: usize) -> Option<usize> {
(r < self.nrows() && c < self.ncols()).then(|| r * self.ncols() + c)
}
pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> {
self.cells.get(self.idx(r, c)?)
}
pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> {
let i = self.idx(r, c)?;
self.cells.get_mut(i)
}
pub fn header_rows(&self) -> usize {
self.rows.iter().take_while(|r| r.kind == RowKind::Header).count()
}
pub fn footer_rows(&self) -> usize {
self.rows.iter().rev().take_while(|r| r.kind == RowKind::Footer).count()
}
pub fn body_rows(&self) -> std::ops::Range<usize> {
self.header_rows()..self.nrows() - self.footer_rows()
}
/// For every grid position, the (row, col) of the cell that owns it.
pub fn owners(&self) -> Vec<(usize, usize)> {
let (nr, nc) = (self.nrows(), self.ncols());
let mut v: Vec<(usize, usize)> = (0..nr * nc).map(|i| (i / nc.max(1), i % nc.max(1))).collect();
for r in 0..nr {
for c in 0..nc {
let cell = &self.cells[r * nc + c];
if v[r * nc + c] != (r, c) {
continue;
}
for rr in r..(r + cell.row_span.max(1) as usize).min(nr) {
for cc in c..(c + cell.col_span.max(1) as usize).min(nc) {
v[rr * nc + cc] = (r, c);
}
}
}
}
v
}
/// The owning (top-left) cell address of grid position (r, c).
pub fn owner(&self, r: usize, c: usize) -> (usize, usize) {
self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c))
}
/// Merged regions `(r, c, row_span, col_span)` (span > 1 in some direction).
pub fn regions(&self) -> Vec<(usize, usize, usize, usize)> {
let owners = self.owners();
let mut v = Vec::new();
for r in 0..self.nrows() {
for c in 0..self.ncols() {
let cell = &self.cells[r * self.ncols() + c];
if owners[r * self.ncols() + c] == (r, c) && (cell.row_span > 1 || cell.col_span > 1) {
v.push((r, c, cell.row_span as usize, cell.col_span as usize));
}
}
}
v
}
fn set_regions(&mut self, regions: &[(usize, usize, usize, usize)]) {
for c in &mut self.cells {
c.row_span = 1;
c.col_span = 1;
}
for &(r, c, rs, cs) in regions {
if rs == 0 || cs == 0 {
continue;
}
if let Some(cell) = self.cell_mut(r, c) {
cell.row_span = rs as u32;
cell.col_span = cs as u32;
}
}
}
/// Insert `n` rows before row `at` (formatted like the neighbouring row).
pub fn insert_rows(&mut self, at: usize, n: usize) {
let at = at.min(self.nrows());
if n == 0 {
return;
}
let nc = self.ncols();
let src = if at < self.nrows() { at } else { at.saturating_sub(1) };
let mut row = self.rows.get(src).cloned().unwrap_or_default();
// A row inserted between body rows (or after the last header / before the first footer) is a body row.
if at > 0 && at < self.nrows() && self.rows[at - 1].kind != self.rows[at].kind {
row.kind = RowKind::Body;
}
if at == self.nrows() && row.kind == RowKind::Header {
row.kind = RowKind::Body;
}
if at == 0 && row.kind == RowKind::Footer {
row.kind = RowKind::Body;
}
let template: Vec<Cell> = (0..nc).map(|c| self.cell(src, c).map(Cell::blank_like).unwrap_or_default()).collect();
let regions: Vec<_> = self
.regions()
.into_iter()
.map(|(r, c, rs, cs)| {
if r >= at {
(r + n, c, rs, cs)
} else if r + rs > at {
(r, c, rs + n, cs)
} else {
(r, c, rs, cs)
}
})
.collect();
for k in 0..n {
self.rows.insert(at + k, row.clone());
let i = (at + k) * nc;
for (c, cell) in template.iter().enumerate() {
self.cells.insert(i + c, cell.clone());
}
}
self.set_regions(&regions);
}
/// Delete rows `at..at+n` (at least one row always remains).
pub fn delete_rows(&mut self, at: usize, n: usize) {
let nr = self.nrows();
let at = at.min(nr);
let n = n.min(nr - at).min(nr.saturating_sub(1));
if n == 0 {
return;
}
let nc = self.ncols();
let end = at + n;
let mut regions = Vec::new();
for (r, c, rs, cs) in self.regions() {
let r1 = r + rs;
if r >= at && r1 <= end {
continue; // removed entirely
}
let new_rs = rs - (r1.min(end).saturating_sub(r.max(at)));
if r >= at && r < end {
// The owner row goes away: move its content to the first surviving row.
let content = self.cells[r * nc + c].clone();
self.cells[end * nc + c] = content;
regions.push((at, c, new_rs, cs));
} else if r < at {
regions.push((r, c, new_rs, cs));
} else {
regions.push((r - n, c, rs, cs));
}
}
self.rows.drain(at..end);
self.cells.drain(at * nc..end * nc);
self.set_regions(&regions);
}
/// Insert `n` columns of `width` before column `at`.
pub fn insert_cols(&mut self, at: usize, n: usize, width: f64) {
let at = at.min(self.ncols());
if n == 0 {
return;
}
let nc = self.ncols();
let src = if at < nc { at } else { at.saturating_sub(1) };
let regions: Vec<_> = self
.regions()
.into_iter()
.map(|(r, c, rs, cs)| {
if c >= at {
(r, c + n, rs, cs)
} else if c + cs > at {
(r, c, rs, cs + n)
} else {
(r, c, rs, cs)
}
})
.collect();
let mut cells = Vec::with_capacity(self.nrows() * (nc + n));
for r in 0..self.nrows() {
for c in 0..nc {
if c == at {
let t = self.cells[r * nc + src].blank_like();
cells.extend(std::iter::repeat_n(t, n));
}
cells.push(self.cells[r * nc + c].clone());
}
if at == nc {
let t = self.cells[r * nc + src].blank_like();
cells.extend(std::iter::repeat_n(t, n));
}
}
self.cells = cells;
for k in 0..n {
self.columns.insert(at + k, Column { width });
}
self.set_regions(&regions);
}
/// Delete columns `at..at+n` (at least one column always remains).
pub fn delete_cols(&mut self, at: usize, n: usize) {
let nc = self.ncols();
let at = at.min(nc);
let n = n.min(nc - at).min(nc.saturating_sub(1));
if n == 0 {
return;
}
let end = at + n;
let mut regions = Vec::new();
for (r, c, rs, cs) in self.regions() {
let c1 = c + cs;
if c >= at && c1 <= end {
continue;
}
let new_cs = cs - (c1.min(end).saturating_sub(c.max(at)));
if c >= at && c < end {
let content = self.cells[r * nc + c].clone();
self.cells[r * nc + end] = content;
regions.push((r, at, rs, new_cs));
} else if c < at {
regions.push((r, c, rs, new_cs));
} else {
regions.push((r, c - n, rs, cs));
}
}
let mut cells = Vec::with_capacity(self.nrows() * (nc - n));
for r in 0..self.nrows() {
for c in 0..nc {
if c < at || c >= end {
cells.push(self.cells[r * nc + c].clone());
}
}
}
self.cells = cells;
self.columns.drain(at..end);
self.set_regions(&regions);
}
/// The smallest range containing `r` that doesn't cut through a merged region.
pub fn expand_range(&self, mut r: CellRange) -> CellRange {
loop {
let mut grown = r;
for (rr, cc, rs, cs) in self.regions() {
let (re, ce) = (rr + rs - 1, cc + cs - 1);
let overlaps = rr <= r.r1 && re >= r.r0 && cc <= r.c1 && ce >= r.c0;
if overlaps {
grown = CellRange { r0: grown.r0.min(rr), c0: grown.c0.min(cc), r1: grown.r1.max(re), c1: grown.c1.max(ce) };
}
}
if grown == r {
return r;
}
r = grown;
}
}
/// Merge a range into its top-left cell; the other cells' text is appended as paragraphs.
pub fn merge(&mut self, range: CellRange) -> Result<(), String> {
if range.r1 >= self.nrows() || range.c1 >= self.ncols() {
return Err("cell range out of bounds".into());
}
let range = self.expand_range(range);
if range.r0 == range.r1 && range.c0 == range.c1 {
return Ok(());
}
let owners = self.owners();
let nc = self.ncols();
let mut regions: Vec<_> = self.regions().into_iter().filter(|&(r, c, _, _)| !range.contains(r, c)).collect();
let mut extra: Vec<Story> = Vec::new();
for r in range.r0..=range.r1 {
for c in range.c0..=range.c1 {
if (r, c) != (range.r0, range.c0) && owners[r * nc + c] == (r, c) {
let t = std::mem::take(&mut self.cells[r * nc + c]);
self.cells[r * nc + c] = t.blank_like();
if !t.text.is_empty() {
extra.push(t.text);
}
}
}
}
let owner = &mut self.cells[range.r0 * nc + range.c0];
for s in extra {
append_story(&mut owner.text, &s);
}
regions.push((range.r0, range.c0, range.r1 - range.r0 + 1, range.c1 - range.c0 + 1));
self.set_regions(&regions);
Ok(())
}
/// Unmerge the merged cell covering (r, c).
pub fn unmerge(&mut self, r: usize, c: usize) {
let (or, oc) = self.owner(r, c);
let regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect();
self.set_regions(&regions);
}
/// Mark the first `header` rows as header rows and the last `footer` rows as footer rows.
pub fn set_header_footer(&mut self, header: usize, footer: usize) {
let n = self.nrows();
let header = header.min(n.saturating_sub(1));
let footer = footer.min(n - header - 1);
for (i, r) in self.rows.iter_mut().enumerate() {
r.kind = if i < header {
RowKind::Header
} else if i >= n - footer {
RowKind::Footer
} else {
RowKind::Body
};
}
}
/// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents).
pub fn plain_text(&self) -> String {
let mut out = String::new();
for r in 0..self.nrows() {
if r > 0 {
out.push('\n');
}
for c in 0..self.ncols() {
if c > 0 {
out.push('\t');
}
if let Some(cell) = self.cell(r, c) {
out.push_str(&cell.text.text.replace('\n', " "));
}
}
}
out
}
pub fn check(&self) -> Result<(), String> {
if self.rows.is_empty() || self.columns.is_empty() {
return Err(format!("table {}: no rows or columns", self.id));
}
if self.cells.len() != self.nrows() * self.ncols() {
return Err(format!("table {}: {} cells for {}×{}", self.id, self.cells.len(), self.nrows(), self.ncols()));
}
// Header rows first, footer rows last.
let (h, f) = (self.header_rows(), self.footer_rows());
if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) {
return Err(format!("table {}: header/footer rows out of order", self.id));
}
let mut seen = vec![false; self.cells.len()];
for r in 0..self.nrows() {
for c in 0..self.ncols() {
let cell = &self.cells[r * self.ncols() + c];
if cell.row_span == 0 || cell.col_span == 0 {
return Err(format!("table {}: zero span at {r},{c}", self.id));
}
if cell.row_span == 1 && cell.col_span == 1 {
continue;
}
if r + cell.row_span as usize > self.nrows() || c + cell.col_span as usize > self.ncols() {
return Err(format!("table {}: span out of bounds at {r},{c}", self.id));
}
for rr in r..r + cell.row_span as usize {
for cc in c..c + cell.col_span as usize {
let i = rr * self.ncols() + cc;
if seen[i] {
return Err(format!("table {}: overlapping merged cells at {rr},{cc}", self.id));
}
seen[i] = true;
}
}
}
}
for cell in &self.cells {
cell.text.check()?;
}
Ok(())
}
}
/// Append `other` to `s` as new paragraphs.
fn append_story(s: &mut Story, other: &Story) {
if s.is_empty() {
*s = Story { id: s.id, ..other.clone() };
s.rev += 1;
return;
}
let base = s.len();
s.insert(base, "\n");
// Copy other's runs with their formats.
for (r, f) in other.runs() {
let at = s.len();
s.insert_with(at, &other.text[r], f.clone());
}
let first_new = s.paras.len() - other.paras.len();
for (k, p) in other.paras.iter().enumerate() {
if let Some(dst) = s.paras.get_mut(first_new + k) {
*dst = p.clone();
}
}
}
// ---------- tables in stories ----------
impl crate::Document {
/// The story text edits go to: a cell's story when `cell` is set, else the story itself.
pub fn text_story(&self, story: StoryId, cell: Option<crate::CellAddr>) -> Option<&Story> {
let st = self.story(story)?;
match cell {
Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text),
None => Some(st),
}
}
/// Mutable [`Document::text_story`] (a cell edit bumps the containing story's revision).
pub fn text_story_mut(&mut self, story: StoryId, cell: Option<crate::CellAddr>) -> Option<&mut Story> {
let st = self.story_mut(story)?;
match cell {
Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text),
None => Some(st),
}
}
}
impl Story {
/// The table whose anchor paragraph is `pi`.
pub fn para_table(&self, pi: usize) -> Option<&Arc<Table>> {
self.tables.get(&self.paras.get(pi)?.table?)
}
/// Byte offset of a table's anchor character.
pub fn table_anchor(&self, id: u64) -> Option<usize> {
let pi = self.paras.iter().position(|p| p.table == Some(id))?;
let r = self.para_ranges().get(pi)?.clone();
self.text[r.clone()].find(TABLE_ANCHOR).map(|k| r.start + k)
}
/// The table anchored at or right before byte `pos` (a caret next to the anchor).
pub fn table_at(&self, pos: usize) -> Option<u64> {
let pi = self.para_at(pos);
self.paras.get(pi)?.table
}
/// Mutable table (copy-on-write); bumps the story revision.
pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> {
let t = self.tables.get_mut(&id)?;
self.rev += 1;
Some(Arc::make_mut(t))
}
/// Insert `table` at `pos`, giving its anchor its own paragraph. Returns the anchor's byte offset.
pub fn insert_table(&mut self, pos: usize, table: Table) -> usize {
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
let pi = self.para_at(pos);
let pstart = self.para_ranges()[pi].start;
let mut s = String::new();
if pos > pstart {
s.push('\n');
}
let anchor = pos + s.len();
s.push(TABLE_ANCHOR);
if !self.text[pos..].starts_with('\n') {
s.push('\n');
}
let id = table.id;
self.tables.insert(id, Arc::new(table));
// Insert the text first (fix-up would drop an unreferenced table), then point the paragraph at it.
self.insert_raw(pos, &s);
let api = self.para_at(anchor);
self.paras[api].table = Some(id);
self.fix_tables();
self.rev += 1;
anchor
}
/// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it
/// still contains an anchor character, every table is referenced once, and tables nobody
/// references are dropped.
pub(crate) fn fix_tables(&mut self) {
if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) {
return;
}
let ranges = self.para_ranges();
let mut used = std::collections::HashSet::new();
for (p, r) in self.paras.iter_mut().zip(ranges) {
if let Some(id) = p.table
&& (!self.text[r].contains(TABLE_ANCHOR) || !self.tables.contains_key(&id) || !used.insert(id))
{
p.table = None;
}
}
self.tables.retain(|id, _| used.contains(id));
}
/// Check table invariants (part of [`Story::check`]).
pub(crate) fn check_tables(&self) -> Result<(), String> {
let ranges = self.para_ranges();
let mut used = std::collections::HashSet::new();
for (pi, p) in self.paras.iter().enumerate() {
let Some(id) = p.table else { continue };
let t = self.tables.get(&id).ok_or_else(|| format!("story {}: paragraph {pi} points at missing table {id}", self.id.0))?;
if t.id != id {
return Err(format!("story {}: table key {id} != id {}", self.id.0, t.id));
}
if self.text[ranges[pi].clone()].matches(TABLE_ANCHOR).count() != 1 {
return Err(format!("story {}: table {id} paragraph must hold exactly one anchor", self.id.0));
}
if !used.insert(id) {
return Err(format!("story {}: table {id} anchored twice", self.id.0));
}
t.check()?;
}
if used.len() != self.tables.len() {
return Err(format!("story {}: {} tables but {} anchored", self.id.0, self.tables.len(), used.len()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn story(t: &str) -> Story {
Story::with_text(StoryId(1), t, ParaFormat::default())
}
#[test]
fn insert_table_gets_own_paragraph() {
let mut s = story("before after");
let a = s.insert_table(6, Table::new(7, 3, 2, 1, 0, 200.0));
s.check().unwrap();
assert_eq!(s.text, format!("before\n{TABLE_ANCHOR}\n after"));
assert_eq!(s.table_anchor(7), Some(a));
assert_eq!(s.para_table(1).map(|t| t.id), Some(7));
assert_eq!(s.tables[&7].nrows(), 4);
// At the start of a paragraph / end of the story.
let mut s = story("x");
s.insert_table(1, Table::new(1, 1, 1, 0, 0, 10.0));
s.check().unwrap();
assert_eq!(s.text, format!("x\n{TABLE_ANCHOR}\n"));
let mut s = story("");
s.insert_table(0, Table::new(1, 1, 1, 0, 0, 10.0));
s.check().unwrap();
assert_eq!(s.text, format!("{TABLE_ANCHOR}\n"));
}
#[test]
fn deleting_the_anchor_removes_the_table() {
let mut s = story("ab");
let a = s.insert_table(2, Table::new(9, 2, 2, 0, 0, 100.0));
s.delete(a..a + TABLE_ANCHOR.len_utf8());
s.check().unwrap();
assert!(s.tables.is_empty());
// Deleting across the paragraph keeps the story consistent too.
let mut s = story("ab\ncd");
s.insert_table(2, Table::new(9, 2, 2, 0, 0, 100.0));
s.delete(1..s.len() - 1);
s.check().unwrap();
assert!(s.tables.is_empty());
}
#[test]
fn edits_around_the_anchor_keep_the_table() {
let mut s = story("ab\ncd");
let a = s.insert_table(2, Table::new(3, 1, 1, 0, 0, 10.0));
// New paragraphs before and after the anchor.
s.insert(a, "\n");
s.check().unwrap();
let a = s.table_anchor(3).unwrap();
s.insert(a + 3, "\nzz");
s.check().unwrap();
assert!(s.tables.contains_key(&3));
// Merging the anchor paragraph with the previous one keeps the table.
let a = s.table_anchor(3).unwrap();
s.delete(a - 1..a);
s.check().unwrap();
assert!(s.tables.contains_key(&3), "{:?}", s.text);
// Restyling the paragraph keeps the table.
let a = s.table_anchor(3).unwrap();
s.format_paras(a..a, |p| *p = ParaFormat { style: "X".into(), ..Default::default() });
s.check().unwrap();
}
#[test]
fn rows_columns_and_merges() {
let mut t = Table::new(1, 3, 3, 1, 0, 300.0);
t.cell_mut(1, 1).unwrap().text.insert(0, "a");
t.cell_mut(1, 2).unwrap().text.insert(0, "b");
t.merge(CellRange::new(1, 1, 2, 2)).unwrap();
t.check().unwrap();
assert_eq!(t.cell(1, 1).unwrap().text.text, "a\nb");
assert_eq!(t.owner(2, 2), (1, 1));
// Inserting a row inside the merged region extends it.
t.insert_rows(2, 1);
t.check().unwrap();
assert_eq!(t.cell(1, 1).unwrap().row_span, 3);
assert_eq!(t.nrows(), 5);
// Header stays first.
t.insert_rows(0, 1);
t.check().unwrap();
assert_eq!(t.rows[0].kind, RowKind::Header);
assert_eq!(t.header_rows(), 2);
// Deleting the owner row moves the content down.
let (r, c) = (2, 1);
assert_eq!(t.owner(r, c), (2, 1));
t.delete_rows(2, 1);
t.check().unwrap();
assert_eq!(t.cell(2, 1).unwrap().text.text, "a\nb");
assert_eq!(t.cell(2, 1).unwrap().row_span, 2);
// Columns.
t.insert_cols(1, 2, 50.0);
t.check().unwrap();
assert_eq!(t.ncols(), 5);
assert_eq!(t.cell(2, 3).unwrap().col_span, 2);
t.delete_cols(3, 1);
t.check().unwrap();
assert_eq!(t.cell(2, 3).unwrap().text.text, "a\nb");
assert_eq!(t.cell(2, 3).unwrap().col_span, 1);
t.unmerge(3, 3);
t.check().unwrap();
assert!(t.regions().is_empty());
// Never below one row / column.
t.delete_rows(0, 99);
t.delete_cols(0, 99);
t.check().unwrap();
assert_eq!((t.nrows(), t.ncols()), (1, 1));
}
#[test]
fn alternating_fills() {
let a = AltFills { first: 1, next: 1, ..Default::default() };
assert!(a.fill_for(0, 4).is_some());
assert!(a.fill_for(1, 4).is_none());
assert!(a.fill_for(2, 4).is_some());
let b = AltFills { first: 2, next: 1, skip_first: 1, ..Default::default() };
assert!(b.fill_for(0, 9).is_none());
assert!(b.fill_for(1, 9).is_some());
assert!(b.fill_for(2, 9).is_some());
assert!(b.fill_for(3, 9).is_none());
}
#[test]
fn serde_roundtrip() {
let mut s = story("x");
s.insert_table(1, Table::new(4, 2, 2, 1, 0, 100.0));
let j = serde_json::to_string(&s).unwrap();
let back: Story = serde_json::from_str(&j).unwrap();
assert_eq!(back, s);
back.check().unwrap();
// Old files without tables still load.
let old = r#"{"id":1,"text":"a","paras":[{"style":"[Basic Paragraph]"}],"chars":[{"len":1,"style":"[None]"}],"frames":[]}"#;
let st: Story = serde_json::from_str(old).unwrap();
assert!(st.tables.is_empty());
}
}

View File

@ -55,6 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
st.selection = Selection { items: ids, content, ..Default::default() };
}
st.selection.text = None;
st.selection.cells = None;
st.revision += 1;
ok()
}),
@ -62,6 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.toggle", "missing id"))?;
let st = s.doc_mut()?;
st.selection.text = None;
st.selection.cells = None;
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
st.selection.items.remove(i);
} else {
@ -73,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
let st = s.doc_mut()?;
if let Some(t) = st.selection.text {
let len = st.doc.story(t.story).map(|x| x.len()).unwrap_or(0);
let len = st.doc.text_story(t.story, t.cell).map(|x| x.len()).unwrap_or(0);
st.selection.text = Some(designcraft_doc::TextSel { anchor: 0, focus: len, ..t });
} else {
let mut ids = Vec::new();

View File

@ -196,7 +196,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
let Some((sid, a, b)) = hit else { return Ok(Value::Null) };
let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied());
let stm = s.doc_mut()?;
stm.selection = designcraft_doc::Selection::text(TextSel { story: sid, anchor: a, focus: b, frame });
stm.selection = designcraft_doc::Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None });
stm.revision += 1;
let _ = story::PAGE_NUMBER;
Ok(json!({"story": sid.0, "start": a, "end": b}))

View File

@ -10,6 +10,7 @@ mod layout;
mod object;
pub mod preflight;
mod style;
pub mod table;
pub mod text;
use designcraft_doc::{ItemId, SpreadRef};
@ -110,6 +111,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(object::specs());
v.extend(text::specs());
v.extend(style::specs());
v.extend(table::specs());
v.extend(layout::specs());
v.extend(inspect::specs());
v.extend(find::specs());

View File

@ -300,7 +300,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
it.shape = sh;
}
*sel = if caret {
Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id) })
Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id), cell: None })
} else {
Selection::items(vec![id])
};

View File

@ -80,8 +80,9 @@ fn apply_para(s: &mut Session, p: &Value) -> Result<Value> {
}
let targets = super::text::format_targets_pub(s);
s.edit(|d, _| {
for (sid, r) in &targets {
if let Some(st) = d.story_mut(*sid) {
for t in &targets {
let r = &t.range;
if let Some(st) = d.text_story_mut(t.story, t.cell) {
st.format_paras(r.clone(), |f| {
f.style = name.clone();
if clear {
@ -106,9 +107,9 @@ fn apply_char(s: &mut Session, p: &Value) -> Result<Value> {
}
let targets = super::text::format_targets_pub(s);
s.edit(|d, _| {
for (sid, r) in &targets {
if let Some(st) = d.story_mut(*sid) {
st.format_chars(r.clone(), |f| f.style = name.clone());
for t in &targets {
if let Some(st) = d.text_story_mut(t.story, t.cell) {
st.format_chars(t.range.clone(), |f| f.style = name.clone());
}
}
ok()

View File

@ -0,0 +1,741 @@
//! Table menu: create, convert, insert/delete rows and columns, merge, cell/row/column options,
//! table options and cell selection.
//!
//! Commands target the table at the text selection (a caret in a cell or next to a table anchor),
//! the selected cells, or an explicit `{story, table}`; `rows: [a, b]` / `cols: [a, b]` (or `row` /
//! `col`) narrow the cell range (default: the selected cells, else the caret's cell, else the
//! whole table).
use designcraft_compose as compose;
use designcraft_doc::{
AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel,
VerticalJustification,
};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, f64_or, has_doc, has_text, ok, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"table.insert",
"Create Table",
[],
Some("Cmd+Alt+Shift+T"),
"{rows?: body rows (4), cols? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",
has_text,
insert
),
cmd!(
"table.convertFromText",
"Convert Text to Table",
["Table"],
None,
"{columnSeparator?: tab|comma} — selected paragraphs become rows",
has_text,
convert_from_text
),
cmd!(
"table.convertToText",
"Convert Table to Text",
["Table"],
None,
"{} — cells separated by tabs, rows by paragraphs",
in_table,
convert_to_text
),
cmd!("table.insertRow", "Insert Row", [], None, "{where?: above|below, count? (1)}", in_table, |s, p| insert_row(s, p, None)),
cmd!("table.insertRowAbove", "Insert Row Above", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_row(s, p, Some(true))),
cmd!("table.insertRowBelow", "Insert Row Below", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_row(s, p, Some(false))),
cmd!("table.insertColumn", "Insert Column", [], None, "{where?: left|right, count? (1), width?}", in_table, |s, p| insert_col(s, p, None)),
cmd!("table.insertColumnLeft", "Insert Column Left", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_col(s, p, Some(true))),
cmd!("table.insertColumnRight", "Insert Column Right", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_col(s, p, Some(false))),
cmd!("table.deleteRow", "Delete Row", ["Table", "Delete"], None, "{} — the rows of the target cells", in_table, delete_rows),
cmd!("table.deleteColumn", "Delete Column", ["Table", "Delete"], None, "{} — the columns of the target cells", in_table, delete_cols),
cmd!("table.delete", "Delete Table", ["Table", "Delete"], None, "{}", in_table, delete_table),
cmd!("table.merge", "Merge Cells", ["Table"], None, "{} — merge the target cell range", in_table, merge),
cmd!("table.unmerge", "Unmerge Cells", ["Table"], None, "{}", in_table, unmerge),
cmd!(
"table.setCell",
"Cell Options",
[],
None,
"{fill?: swatch, tint?, insets?: n | [t,l,b,r], vj?: top|center|bottom|justify, text?, stroke?: {weight?, color?, tint?, type?: solid|dashed|dotted, edges?: all|outer|inner|top|left|bottom|right}}",
in_table,
set_cell
),
cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height),
cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width),
cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols),
cmd!(
"table.options",
"Table Options",
[],
None,
"{border?: {weight?, color?, tint?, type?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}",
in_table,
options
),
cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b], cols?: [a,b], what?: cell|row|column|table}", in_table_or_ids, select),
cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{}", in_table, |s, p| {
let mut p = p.clone();
p["what"] = json!("table");
select(s, &p)
}),
cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{}", in_table, |s, p| {
let mut p = p.clone();
p["what"] = json!("row");
select(s, &p)
}),
cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{}", in_table, |s, p| {
let mut p = p.clone();
p["what"] = json!("column");
select(s, &p)
}),
cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)),
cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)),
cmd!(query "table.get", "Get Table", [], None, "{story?, table?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get),
]
}
// ---------- enablement and targeting ----------
fn in_table(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let st = s.active().expect("doc");
if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) {
return Ok(());
}
if let Some(t) = st.selection.text
&& st.doc.story(t.story).is_some_and(|x| x.table_at(t.focus).is_some())
{
return Ok(());
}
Err("place the insertion point in a table".into())
}
fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> {
has_doc(s)
}
fn in_cell(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) }
}
/// The table a command acts on and its target cell range.
struct Tgt {
story: StoryId,
table: u64,
range: CellRange,
}
fn find_table(doc: &Document, id: u64) -> Option<StoryId> {
doc.stories.iter().find(|(_, s)| s.tables.contains_key(&id)).map(|(k, _)| *k)
}
fn pair(p: &Value, many: &str, one: &str) -> Option<(usize, usize)> {
if let Some(a) = p.get(many).and_then(Value::as_array) {
let a0 = a.first()?.as_u64()? as usize;
let a1 = a.get(1).and_then(Value::as_u64).map_or(a0, |v| v as usize);
return Some((a0.min(a1), a0.max(a1)));
}
p.get(one).and_then(Value::as_u64).map(|v| (v as usize, v as usize))
}
fn target(s: &Session, p: &Value, cmd: &str) -> Result<Tgt> {
let st = s.doc()?;
let doc = &st.doc;
let explicit_table = p.get("table").and_then(Value::as_u64);
let explicit_story = p.get("story").and_then(Value::as_u64).map(StoryId);
let (story, table, range) = if let Some(tid) = explicit_table {
let sid = explicit_story.or_else(|| find_table(doc, tid)).ok_or_else(|| bad(cmd, format!("no table {tid}")))?;
(sid, tid, None)
} else if let Some(c) = st.selection.cells {
(c.story, c.table, Some(c.range))
} else if let Some(t) = st.selection.text {
match t.cell {
Some(c) => (t.story, c.table, Some(CellRange::cell(c.row, c.col))),
None => {
let tid = doc.story(t.story).and_then(|x| x.table_at(t.focus)).ok_or_else(|| bad(cmd, "the insertion point is not in a table"))?;
(t.story, tid, None)
}
}
} else {
return Err(bad(cmd, "place the insertion point in a table"));
};
let t = doc.story(story).and_then(|x| x.tables.get(&table)).ok_or_else(|| bad(cmd, format!("no table {table}")))?;
let (nr, nc) = (t.nrows(), t.ncols());
let mut range = range.unwrap_or(CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 });
if let Some((a, b)) = pair(p, "rows", "row") {
range.r0 = a;
range.r1 = b;
}
if let Some((a, b)) = pair(p, "cols", "col") {
range.c0 = a;
range.c1 = b;
}
if range.r1 >= nr || range.c1 >= nc {
return Err(bad(cmd, format!("cell range out of bounds ({nr}×{nc} table)")));
}
Ok(Tgt { story, table, range })
}
/// Edit the target table; afterwards selections pointing into it are clamped to its new shape.
fn edit_table<T>(s: &mut Session, g: &Tgt, cmd: &str, f: impl FnOnce(&mut Table) -> Result<T>) -> Result<T> {
let cmd = cmd.to_string();
s.edit(|d, sel| {
let st = d.story_mut(g.story).ok_or_else(|| bad(&cmd, "no story"))?;
let t = st.table_mut(g.table).ok_or_else(|| bad(&cmd, "no table"))?;
let r = f(t)?;
clamp_selection(d, sel);
Ok(r)
})
}
/// Keep text/cell selections valid after the table changed shape (or disappeared).
fn clamp_selection(d: &Document, sel: &mut Selection) {
if let Some(ts) = sel.cells {
match d.story(ts.story).and_then(|x| x.tables.get(&ts.table)) {
Some(t) => {
let (mr, mc) = (t.nrows() - 1, t.ncols() - 1);
let r = ts.range;
sel.cells = Some(TableSel { range: CellRange::new(r.r0.min(mr), r.c0.min(mc), r.r1.min(mr), r.c1.min(mc)), ..ts });
}
None => sel.cells = None,
}
}
if let Some(t) = sel.text
&& let Some(c) = t.cell
{
let story = d.story(t.story);
match story.and_then(|x| x.tables.get(&c.table)) {
Some(tb) => {
let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1));
let len = tb.cell(r, col).map_or(0, |x| x.text.len());
sel.text =
Some(TextSel { anchor: t.anchor.min(len), focus: t.focus.min(len), cell: Some(CellAddr { table: c.table, row: r, col }), ..t });
}
None => {
let len = story.map_or(0, |x| x.len());
sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.anchor.min(len), cell: None, ..t });
}
}
}
}
fn count(p: &Value) -> usize {
p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize
}
fn color_param(p: &Value, key: &str) -> Option<String> {
str_param(p, key).map(str::to_string)
}
// ---------- create / convert ----------
/// Width of the text column at the caret (the new table spans it).
fn column_width(s: &Session, t: &TextSel) -> f64 {
let Some(st) = s.active() else { return 300.0 };
let specs = compose::frame_specs(&st.doc, t.story);
let spec = t.frame.and_then(|f| specs.iter().find(|x| x.id == f)).or(specs.first());
spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0)
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
if t.cell.is_some() {
return Err(bad("table.insert", "tables can't be nested in cells"));
}
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize;
let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize;
let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
let mut table = Table::new(id, rows, cols, header, footer, width);
let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?;
// Cells start with the paragraph format at the caret.
let pf = st.paras[st.para_at(t.range().start)].clone();
let cf = st.char_format_at(t.range().start).clone();
for c in &mut table.cells {
c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() };
c.text.chars[0].format = cf.clone();
}
let r = t.range();
st.delete(r.clone());
st.insert_table(r.start, table);
*sel = Selection::text(TextSel { story: t.story, anchor: 0, focus: 0, frame: t.frame, cell: Some(CellAddr { table: id, row: 0, col: 0 }) });
Ok(json!({"story": t.story.0, "table": id}))
})
}
fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?;
if t.cell.is_some() {
return Err(bad("table.convertFromText", "select text outside tables"));
}
let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' };
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
s.edit(|d, sel| {
let id = d.alloc();
let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?;
let ranges = st.para_ranges();
let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end));
if (pa..=pb).any(|i| st.paras[i].table.is_some()) {
return Err(bad("table.convertFromText", "the selection contains a table"));
}
let (a, b) = (ranges[pa].start, ranges[pb].end);
let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
let pf = st.paras[pa].clone();
let cf = st.format_after(a).clone();
let table = Table::from_strings(id, &data, width, &pf, &cf);
st.delete(a..b);
let anchor = st.insert_table(a, table);
*sel = Selection::text(TextSel::caret(t.story, anchor));
Ok(json!({"table": id, "rows": data.len()}))
})
}
fn convert_to_text(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.convertToText")?;
s.edit(|d, sel| {
let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?;
let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default();
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?;
st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text);
*sel = Selection::text(TextSel::caret(g.story, a + text.len()));
ok()
})
}
fn delete_table(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.delete")?;
s.edit(|d, sel| {
let st = d.story_mut(g.story).ok_or_else(|| bad("table.delete", "no story"))?;
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.delete", "no anchor"))?;
// Remove the anchor paragraph (with one separator) so no empty paragraph is left behind.
let end = a + designcraft_doc::TABLE_ANCHOR.len_utf8();
let r = if &st.text[end..] == "\n" && a > 0 && st.text[..a].ends_with('\n') {
// Inserted at the end of the story: remove the empty paragraph after it too.
a - 1..end + 1
} else if st.text[end..].starts_with('\n') {
a..end + 1
} else if a > 0 && st.text[..a].ends_with('\n') {
a - 1..end
} else {
a..end
};
st.delete(r.clone());
*sel = Selection::text(TextSel::caret(g.story, r.start.min(st.len())));
ok()
})
}
// ---------- rows and columns ----------
fn insert_row(s: &mut Session, p: &Value, above: Option<bool>) -> Result<Value> {
let g = target(s, p, "table.insertRow")?;
let above = above.unwrap_or(str_param(p, "where") == Some("above"));
let n = count(p);
let at = if above { g.range.r0 } else { g.range.r1 + 1 };
let r = edit_table(s, &g, "table.insertRow", |t| {
t.insert_rows(at, n);
Ok(json!({"rows": t.nrows()}))
})?;
shift_selection(s, g.table, at, n, true);
Ok(r)
}
/// After inserting rows/columns at `at`, selections at or after it follow their cells.
fn shift_selection(s: &mut Session, table: u64, at: usize, n: usize, rows: bool) {
let Ok(st) = s.doc_mut() else { return };
if let Some(t) = st.selection.text.as_mut()
&& let Some(c) = t.cell.as_mut()
&& c.table == table
{
let v = if rows { &mut c.row } else { &mut c.col };
if *v >= at {
*v += n;
}
}
if let Some(ts) = st.selection.cells.as_mut()
&& ts.table == table
{
let (a, b) = if rows { (&mut ts.range.r0, &mut ts.range.r1) } else { (&mut ts.range.c0, &mut ts.range.c1) };
if *a >= at {
*a += n;
}
if *b >= at {
*b += n;
}
}
}
fn insert_col(s: &mut Session, p: &Value, left: Option<bool>) -> Result<Value> {
let g = target(s, p, "table.insertColumn")?;
let left = left.unwrap_or(str_param(p, "where") == Some("left"));
let n = count(p);
let at = if left { g.range.c0 } else { g.range.c1 + 1 };
let width = p.get("width").and_then(Value::as_f64);
let r = edit_table(s, &g, "table.insertColumn", |t| {
let w = width.unwrap_or_else(|| t.columns[g.range.c0.min(t.ncols() - 1)].width);
t.insert_cols(at, n, w);
Ok(json!({"columns": t.ncols()}))
})?;
shift_selection(s, g.table, at, n, false);
Ok(r)
}
fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.deleteRow")?;
let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.r0 == 0 && g.range.r1 + 1 >= t.nrows());
if all {
return delete_table(s, p);
}
edit_table(s, &g, "table.deleteRow", |t| {
t.delete_rows(g.range.r0, g.range.r1 - g.range.r0 + 1);
Ok(json!({"rows": t.nrows()}))
})
}
fn delete_cols(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.deleteColumn")?;
let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.c0 == 0 && g.range.c1 + 1 >= t.ncols());
if all {
return delete_table(s, p);
}
edit_table(s, &g, "table.deleteColumn", |t| {
t.delete_cols(g.range.c0, g.range.c1 - g.range.c0 + 1);
Ok(json!({"columns": t.ncols()}))
})
}
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.merge")?;
let r = edit_table(s, &g, "table.merge", |t| {
t.merge(g.range).map_err(|e| bad("table.merge", e))?;
Ok(t.expand_range(g.range))
})?;
// The caret goes to the merged cell.
let st = s.doc_mut()?;
st.selection = Selection::text(TextSel {
story: g.story,
anchor: 0,
focus: 0,
frame: st.selection.text.and_then(|t| t.frame),
cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }),
});
ok()
}
fn unmerge(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.unmerge")?;
edit_table(s, &g, "table.unmerge", |t| {
for r in g.range.r0..=g.range.r1 {
for c in g.range.c0..=g.range.c1 {
t.unmerge(r, c);
}
}
ok()
})
}
fn parse_stroke(v: &Value, mut base: CellStroke) -> CellStroke {
if let Some(w) = v.get("weight").and_then(Value::as_f64) {
base.weight = w.max(0.0);
}
if let Some(c) = color_param(v, "color") {
base.color = c;
}
if let Some(t) = v.get("tint").and_then(Value::as_f64) {
base.tint = t.clamp(0.0, 1.0) as f32;
}
match str_param(v, "type") {
Some("dashed") => base.kind = StrokeType::Dashed { pattern: vec![base.weight.max(1.0) * 3.0, base.weight.max(1.0) * 2.0] },
Some("dotted") => base.kind = StrokeType::Dotted,
Some("solid") => base.kind = StrokeType::Solid,
_ => {}
}
base
}
fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.setCell")?;
let fill = color_param(p, "fill");
let tint = p.get("tint").and_then(Value::as_f64).map(|t| t.clamp(0.0, 1.0) as f32);
let insets: Option<[f64; 4]> = match p.get("insets") {
Some(Value::Number(n)) => n.as_f64().map(|v| [v.max(0.0); 4]),
Some(Value::Array(a)) if a.len() == 4 => {
let v: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect();
Some([v[0], v[1], v[2], v[3]])
}
_ => None,
};
let vj = match str_param(p, "vj").or_else(|| str_param(p, "verticalJustification")) {
Some("top") => Some(VerticalJustification::Top),
Some("center") => Some(VerticalJustification::Center),
Some("bottom") => Some(VerticalJustification::Bottom),
Some("justify") => Some(VerticalJustification::Justify),
Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))),
None => None,
};
let text = str_param(p, "text").map(str::to_string);
let stroke = p.get("stroke").cloned();
edit_table(s, &g, "table.setCell", |t| {
let owners = t.owners();
let nc = t.ncols();
let rg = g.range;
for r in rg.r0..=rg.r1 {
for c in rg.c0..=rg.c1 {
if owners[r * nc + c] != (r, c) {
continue;
}
let cell = t.cell_mut(r, c).expect("in range");
if let Some(f) = &fill {
cell.fill = f.clone();
}
if let Some(ti) = tint {
cell.fill_tint = ti;
}
if let Some(i) = insets {
cell.insets = i;
}
if let Some(v) = vj {
cell.vj = v;
}
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
}
if let Some(sv) = &stroke {
let edges = str_param(sv, "edges").unwrap_or("all");
for (i, on) in [
("top", r == rg.r0),
("left", c == rg.c0),
("bottom", r + cell.row_span as usize - 1 == rg.r1),
("right", c + cell.col_span as usize - 1 == rg.c1),
]
.iter()
.enumerate()
.map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer)))
{
if on {
cell.strokes[i] = parse_stroke(sv, cell.strokes[i].clone());
}
}
}
}
}
// Neighbouring cells share edges: mirror the edges along the range boundary.
if let Some(sv) = &stroke {
let edges = str_param(sv, "edges").unwrap_or("all");
if matches!(edges, "all" | "outer" | "bottom") && rg.r1 + 1 < t.nrows() {
for c in rg.c0..=rg.c1 {
let s0 = t.cell(rg.r1, c).map(|x| x.strokes[2].clone());
if let (Some(s0), Some(n)) = (s0, t.cell_mut(rg.r1 + 1, c)) {
n.strokes[0] = s0;
}
}
}
if matches!(edges, "all" | "outer" | "right") && rg.c1 + 1 < t.ncols() {
for r in rg.r0..=rg.r1 {
let s0 = t.cell(r, rg.c1).map(|x| x.strokes[3].clone());
if let (Some(s0), Some(n)) = (s0, t.cell_mut(r, rg.c1 + 1)) {
n.strokes[1] = s0;
}
}
}
}
ok()
})
}
fn set_row_height(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.setRowHeight")?;
let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0));
let mode = match str_param(p, "mode") {
Some("exactly") => Some(RowHeightMode::Exactly),
Some("atLeast") => Some(RowHeightMode::AtLeast),
Some(o) => return Err(bad("table.setRowHeight", format!("unknown mode `{o}`"))),
None => None,
};
if h.is_none() && mode.is_none() {
return Err(bad("table.setRowHeight", "missing height"));
}
edit_table(s, &g, "table.setRowHeight", |t| {
for row in &mut t.rows[g.range.r0..=g.range.r1] {
if let Some(h) = h {
row.height = h;
}
if let Some(m) = mode {
row.mode = m;
}
}
ok()
})
}
fn set_col_width(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.setColumnWidth")?;
let w = p.get("width").and_then(Value::as_f64).ok_or_else(|| bad("table.setColumnWidth", "missing width"))?.clamp(3.0, 10000.0);
edit_table(s, &g, "table.setColumnWidth", |t| {
for c in &mut t.columns[g.range.c0..=g.range.c1] {
c.width = w;
}
ok()
})
}
fn distribute_cols(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.distributeColumns")?;
edit_table(s, &g, "table.distributeColumns", |t| {
let cols = &mut t.columns[g.range.c0..=g.range.c1];
let avg = cols.iter().map(|c| c.width).sum::<f64>() / cols.len() as f64;
cols.iter_mut().for_each(|c| c.width = avg);
ok()
})
}
fn parse_alt(v: &Value) -> Option<AltFills> {
if v.is_null() {
return None;
}
let d = AltFills::default();
Some(AltFills {
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32),
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32),
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32),
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32),
})
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.options")?;
let p = p.clone();
edit_table(s, &g, "table.options", |t| {
let o = &mut t.options;
if let Some(b) = p.get("border") {
o.border = parse_stroke(b, o.border.clone());
}
o.space_before = f64_or(&p, "spaceBefore", o.space_before);
o.space_after = f64_or(&p, "spaceAfter", o.space_after);
if let Some(b) = p.get("repeatHeader").and_then(Value::as_bool) {
o.repeat_header = b;
}
if let Some(b) = p.get("repeatFooter").and_then(Value::as_bool) {
o.repeat_footer = b;
}
if let Some(v) = p.get("altRows") {
o.alt_rows = parse_alt(v);
}
if let Some(v) = p.get("altCols") {
o.alt_cols = parse_alt(v);
}
let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v as usize);
let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v as usize);
if h.is_some() || f.is_some() {
let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows()));
// Add rows when there aren't enough for the requested header/footer plus one body row.
let need = h + f + 1;
if t.nrows() < need {
let at = t.nrows();
t.insert_rows(at, need - at);
}
t.set_header_footer(h, f);
}
Ok(json!({"options": t.options}))
})
}
// ---------- selection ----------
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.select")?;
let (nr, nc) = {
let st = s.doc()?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.select", "no table"))?;
(t.nrows(), t.ncols())
};
let mut r = g.range;
match str_param(p, "what") {
Some("table") => r = CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 },
Some("row") => {
r.c0 = 0;
r.c1 = nc - 1;
}
Some("column") => {
r.r0 = 0;
r.r1 = nr - 1;
}
_ => {}
}
let st = s.doc_mut()?;
let frame = st.selection.text.and_then(|t| t.frame);
st.selection = Selection {
cells: Some(TableSel { story: g.story, table: g.table, range: r }),
text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }),
..Default::default()
};
st.revision += 1;
Ok(json!({"story": g.story.0, "table": g.table, "range": r}))
}
/// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last
/// cell adds a row.
pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.nextCell", "no caret"))?;
let c = t.cell.ok_or_else(|| bad("table.nextCell", "not in a cell"))?;
let (owners, nr, nc) = {
let st = s.doc()?;
let tb = st.doc.story(t.story).and_then(|x| x.tables.get(&c.table)).ok_or_else(|| bad("table.nextCell", "no table"))?;
(tb.owners(), tb.nrows(), tb.ncols())
};
let cur = c.row * nc + c.col;
let next =
if forward { (cur + 1..nr * nc).find(|&i| owners[i] == (i / nc, i % nc)) } else { (0..cur).rev().find(|&i| owners[i] == (i / nc, i % nc)) };
let (row, col) = match next {
Some(i) => (i / nc, i % nc),
None if forward => {
let g = Tgt { story: t.story, table: c.table, range: CellRange::cell(nr - 1, 0) };
edit_table(s, &g, "table.nextCell", |tb| {
tb.insert_rows(nr, 1);
ok()
})?;
(nr, 0)
}
None => return ok(),
};
let st = s.doc_mut()?;
let len = st.doc.text_story(t.story, Some(CellAddr { table: c.table, row, col })).map_or(0, |x| x.len());
st.selection = Selection::text(TextSel { anchor: 0, focus: len, cell: Some(CellAddr { table: c.table, row, col }), ..t });
st.revision += 1;
Ok(json!({"row": row, "col": col}))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let g = target(s, p, "table.get")?;
let st = s.doc()?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?;
let owners = t.owners();
let cells: Vec<Value> = (0..t.nrows())
.flat_map(|r| (0..t.ncols()).map(move |c| (r, c)))
.filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c))
.map(|(r, c)| {
let cell = t.cell(r, c).expect("in range");
json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
"insets": cell.insets, "vj": cell.vj})
})
.collect();
Ok(json!({
"story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)),
"rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(),
"cells": cells, "options": t.options, "range": g.range,
}))
}

View File

@ -1,7 +1,7 @@
//! Text editing (Type tool) and character/paragraph formatting.
use designcraft_compose as compose;
use designcraft_doc::{CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel};
use designcraft_doc::{CellAddr, CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel};
use designcraft_geom::Point;
use serde_json::{Value, json};
@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
place(s, p, false)?;
let st = s.doc_mut()?;
let Some(t) = st.selection.text else { return ok() };
let text = &st.doc.story(t.story).map(|x| x.text.clone()).unwrap_or_default();
let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default();
let (a, b) = word_bounds(text, t.focus);
st.selection.text = Some(TextSel { anchor: a, focus: b, ..t });
ok()
@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
let len = st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.len();
let a = (p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize).min(len);
let f = (p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a)).min(len);
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None });
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None });
st.revision += 1;
ok()
}),
@ -40,6 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
let f = t.frame.or_else(|| st.doc.story(t.story).and_then(|x| x.frames.first().copied()));
st.selection.items = f.into_iter().collect();
}
st.selection.cells = None;
st.revision += 1;
ok()
}),
@ -146,8 +147,8 @@ fn story_of(s: &Session, p: &Value) -> Option<StoryId> {
st.selection.text.map(|t| t.story)
}
/// Story byte at spread point `pt` in text frame `frame`.
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize)> {
/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell).
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option<CellAddr>)> {
let st = s.active()?;
let loc = st.doc.find(frame)?;
let it = st.doc.item_at(&loc)?;
@ -156,8 +157,11 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize)> {
let inner = xf.inverse() * pt;
let cs = s.cache.get(&st.doc, sid, None);
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
return Some((sid, b, Some(CellAddr { table, row, col })));
}
let b = compose::hit(&cs, fi, inner).unwrap_or(0);
Some((sid, b))
Some((sid, b, None))
}
fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
@ -168,27 +172,60 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
if needs_convert {
s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?;
}
let (sid, b) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?;
let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?;
let st = s.doc_mut()?;
// Dragging from one cell into another selects cells.
if extend
&& let (Some(t), Some(c)) = (st.selection.text, cell)
&& let Some(a) = t.cell
&& t.story == sid
&& a.table == c.table
&& (a.row, a.col) != (c.row, c.col)
{
let range = designcraft_doc::CellRange::new(a.row, a.col, c.row, c.col);
st.selection.cells = Some(designcraft_doc::TableSel { story: sid, table: c.table, range });
st.revision += 1;
return Ok(json!({"story": sid.0, "cells": range}));
}
let t = match (extend, st.selection.text) {
(true, Some(t)) if t.story == sid => TextSel { focus: b, frame: Some(frame), ..t },
_ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame) },
(true, Some(t)) if t.story == sid && t.cell == cell => TextSel { focus: b, frame: Some(frame), ..t },
_ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame), cell },
};
st.selection = Selection::text(t);
st.revision += 1;
Ok(json!({"story": sid.0, "pos": b}))
Ok(json!({"story": sid.0, "pos": b, "cell": cell}))
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`).
if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) {
return super::table::step_cell(s, true);
}
let text = if s.prefs.typographers_quotes { smart_quotes(s, &text) } else { text };
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
let r = r.start.min(st.len())..r.end.min(st.len());
// Typing next to a table anchor starts a paragraph of its own.
let mut text = text.clone();
let mut trail = 0;
if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() {
let pi = st.para_at(r.start);
let pr = st.para_ranges()[pi].clone();
let anchor_at = pr.start + st.text[pr.clone()].find(designcraft_doc::TABLE_ANCHOR).unwrap_or(0);
if r.start <= anchor_at {
if !text.ends_with('\n') {
text.push('\n');
trail = 1;
}
} else if !text.starts_with('\n') {
text.insert(0, '\n');
}
}
st.replace(r.clone(), &text);
let pos = r.start + text.len();
let pos = r.start + text.len() - trail;
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
Ok(json!({"pos": pos}))
})
@ -202,7 +239,7 @@ fn smart_quotes(s: &Session, text: &str) -> String {
.active()
.and_then(|st| {
let t = st.selection.text?;
let story = st.doc.story(t.story)?;
let story = st.doc.text_story(t.story, t.cell)?;
story.text[..t.range().start.min(story.len())].chars().last()
})
.unwrap_or(' ');
@ -237,7 +274,7 @@ pub(crate) fn delete_selection(s: &mut Session) -> Result<Value> {
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
let r = t.range();
if let Some(st) = d.story_mut(t.story) {
if let Some(st) = d.text_story_mut(t.story, t.cell) {
st.delete(r.clone());
}
sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
@ -250,7 +287,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let word = bool_or(p, "word", false);
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
let r = if !r.is_empty() {
r
@ -312,9 +349,14 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
let word = bool_or(p, "word", false);
let st = s.doc()?;
let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?;
let story = st.doc.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let text = story.text.clone();
let cs = s.cache.get(&st.doc, t.story, None);
// In a cell, lines come from the cell's own composition.
let cs = match t.cell {
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
None => cs,
};
let pos = t.focus.min(text.len());
let collapse_to = |left: bool| if left { t.range().start } else { t.range().end };
let new = match dir.as_str() {
@ -360,19 +402,42 @@ fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option<usize>
compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0))
}
/// Ranges and stories a formatting command applies to: the text selection, or whole stories of
/// the selected text frames.
fn format_targets(s: &Session) -> Vec<(StoryId, std::ops::Range<usize>)> {
/// Where a formatting command applies: a story (or a table cell's story) and a byte range.
#[derive(Clone, Debug)]
pub(crate) struct Target {
pub story: StoryId,
pub cell: Option<CellAddr>,
pub range: std::ops::Range<usize>,
}
/// Ranges and stories a formatting command applies to: selected table cells (whole cells), the
/// text selection, or whole stories of the selected text frames.
fn format_targets(s: &Session) -> Vec<Target> {
let Some(st) = s.active() else { return vec![] };
if let Some(t) = st.selection.text {
return vec![(t.story, t.range())];
if let Some(ts) = st.selection.cells
&& let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table))
{
let mut v = Vec::new();
let owners = t.owners();
for r in ts.range.r0..=ts.range.r1.min(t.nrows().saturating_sub(1)) {
for c in ts.range.c0..=ts.range.c1.min(t.ncols().saturating_sub(1)) {
if owners[r * t.ncols() + c] == (r, c) {
let len = t.cell(r, c).map_or(0, |x| x.text.len());
v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len });
}
}
}
return v;
}
let mut v = Vec::new();
if let Some(t) = st.selection.text {
return vec![Target { story: t.story, cell: t.cell, range: t.range() }];
}
let mut v: Vec<Target> = Vec::new();
for id in &st.selection.items {
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) {
let len = st.doc.story(tf.story).map(|x| x.len()).unwrap_or(0);
if !v.iter().any(|(s, _)| *s == tf.story) {
v.push((tf.story, 0..len));
if !v.iter().any(|t| t.story == tf.story) {
v.push(Target { story: tf.story, cell: None, range: 0..len });
}
}
}
@ -388,8 +453,9 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
}
let targets = format_targets(s);
s.edit(|d, _| {
for (sid, r) in &targets {
let Some(st) = d.story_mut(*sid) else { continue };
for t in &targets {
let r = &t.range;
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
if r.is_empty() {
// Caret: change the typing format (the empty run at the caret).
let pos = r.start;
@ -416,9 +482,9 @@ pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
}
let targets = format_targets(s);
s.edit(|d, _| {
for (sid, r) in &targets {
if let Some(st) = d.story_mut(*sid) {
st.format_paras(r.clone(), |p| p.para.merge(&a));
for t in &targets {
if let Some(st) = d.text_story_mut(t.story, t.cell) {
st.format_paras(t.range.clone(), |p| p.para.merge(&a));
}
}
ok()
@ -434,8 +500,8 @@ fn step_size(s: &mut Session, delta: f64) -> Result<Value> {
fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let targets = format_targets(s);
let Some((sid, r)) = targets.first().cloned() else { return Ok(Value::Null) };
let story = st.doc.story(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let Some(Target { story: sid, cell, range: r }) = targets.first().cloned() else { return Ok(Value::Null) };
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let pi = story.para_at(r.start);
let pf = &story.paras[pi];
let (pp, base) = st.doc.styles.resolve_para(pf);
@ -489,6 +555,6 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
})
}
pub(crate) fn format_targets_pub(s: &Session) -> Vec<(StoryId, std::ops::Range<usize>)> {
pub(crate) fn format_targets_pub(s: &Session) -> Vec<Target> {
format_targets(s)
}

View File

@ -255,3 +255,5 @@ pub(crate) use push_undo as record_undo;
mod tests;
#[cfg(test)]
mod tests_idml;
#[cfg(test)]
mod tests_table;

View File

@ -186,6 +186,18 @@ pub fn magazine() -> Document {
ParaAttrs::default(),
CharAttrs { leading: Some(Leading::Points(10.5)), fill_tint: Some(0.7), ..sans("Regular", 7.5) },
));
st.paragraph.push(para_style(
"Table Head",
None,
ParaAttrs { hyphenate: Some(false), ..Default::default() },
CharAttrs { leading: Some(Leading::Points(10.0)), tracking: Some(40.0), fill: Some("[Paper]".into()), ..sans("Semibold", 7.5) },
));
st.paragraph.push(para_style(
"Table Body",
None,
ParaAttrs { hyphenate: Some(false), ..Default::default() },
CharAttrs { leading: Some(Leading::Points(11.0)), ..sans("Regular", 8.5) },
));
st.paragraph.push(para_style("Folio", None, ParaAttrs::default(), CharAttrs { tracking: Some(60.0), ..sans("Semibold", 7.5) }));
st.paragraph.push(para_style(
"Cover Title",
@ -350,6 +362,51 @@ pub fn magazine() -> Document {
if let Some(it) = d.item_mut(f4).and_then(Item::text_frame_mut) {
it.options.columns = 2;
}
// A spec table in the second column: header row, alternating fills.
let sid4 = d.item(f4).and_then(|i| i.text_frame()).map(|t| t.story);
let tid = d.alloc();
if let Some(st) = sid4.and_then(|sid| d.story_mut(sid)) {
let rows = [
["SWATCH", "BUILD", "USED FOR"],
["Ink Plum", "C62 M95 Y30 K25", "Backgrounds, heads"],
["Sunset", "M62 Y78", "Kickers, rules"],
["Cyan", "C100", "Charts, links"],
["Paper Warm", "M3 Y8", "Pull quotes"],
];
let mut t = designcraft_doc::Table::new(tid, 4, 3, 1, 0, 258.0);
t.columns[0].width = 74.0;
t.columns[1].width = 90.0;
t.columns[2].width = 94.0;
for (r, row) in rows.iter().enumerate() {
let style = if r == 0 { "Table Head" } else { "Table Body" };
for (c, txt) in row.iter().enumerate() {
let cell = t.cell_mut(r, c).expect("cell");
*cell = designcraft_doc::Cell::with_text(txt, ParaFormat { style: style.into(), ..Default::default() });
cell.insets = [4.0, 6.0, 4.0, 6.0];
cell.vj = VerticalJustification::Center;
let thin = designcraft_doc::CellStroke { weight: 0.5, color: "Ink Plum".into(), tint: 0.35, ..Default::default() };
cell.strokes = [thin.clone(), designcraft_doc::CellStroke::none(), thin, designcraft_doc::CellStroke::none()];
if r == 0 {
cell.fill = "Ink Plum".into();
}
}
}
t.options.border = designcraft_doc::CellStroke { weight: 1.0, color: "Ink Plum".into(), ..Default::default() };
t.options.space_before = 0.0;
t.options.alt_rows = Some(designcraft_doc::AltFills {
first: 1,
first_color: "Paper Warm".into(),
first_tint: 1.0,
next: 1,
next_color: designcraft_color::swatch::NONE.into(),
next_tint: 1.0,
skip_first: 0,
skip_last: 0,
});
let end = st.len();
let anchor = st.insert_table(end, t);
st.format_paras(anchor..anchor, |p| p.para.start_paragraph = Some(designcraft_doc::StartParagraph::NextColumn));
}
debug_assert!(d.check().is_ok(), "{:?}", d.check());
d
}

View File

@ -0,0 +1,178 @@
use serde_json::json;
use super::*;
fn session_with_frame() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 500], "content": "text"})).unwrap();
let sid = r["story"].as_u64().unwrap();
let fid = r["id"].as_u64().unwrap();
s.execute("text.insert", &json!({"text": "Intro"})).unwrap();
(s, sid, fid)
}
fn check(s: &Session) {
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn insert_type_in_cells_and_tab() {
let (mut s, sid, _) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 2, "cols": 3, "headerRows": 1})).unwrap();
let tid = r["table"].as_u64().unwrap();
check(&s);
// The caret is in the first cell; typing goes there.
s.execute("text.insert", &json!({"text": "Name"})).unwrap();
s.execute("text.insert", &json!({"text": "\t"})).unwrap();
s.execute("text.insert", &json!({"text": "Value"})).unwrap();
check(&s);
let t = s.execute("table.get", &json!({})).unwrap();
assert_eq!(t["table"], tid);
assert_eq!(t["headerRows"], 1);
assert_eq!(t["cells"][0]["text"], "Name");
assert_eq!(t["cells"][1]["text"], "Value");
// The story text holds only the anchor.
let story = s.execute("story.get", &json!({"story": sid})).unwrap();
assert!(story["text"].as_str().unwrap().contains(designcraft_doc::TABLE_ANCHOR));
assert!(!story["text"].as_str().unwrap().contains("Name"));
// Backspace edits the cell.
s.execute("text.delete", &json!({})).unwrap();
assert_eq!(s.execute("table.get", &json!({})).unwrap()["cells"][1]["text"], "Valu");
// Tab in the last cell adds a row.
for _ in 0..8 {
s.execute("table.nextCell", &json!({})).unwrap();
}
assert_eq!(s.execute("table.get", &json!({})).unwrap()["rows"].as_array().unwrap().len(), 4);
check(&s);
// Undo restores the previous shape.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("table.get", &json!({"table": tid})).unwrap()["rows"].as_array().unwrap().len(), 3);
}
#[test]
fn rows_columns_merge_and_options() {
let (mut s, _, _) = session_with_frame();
s.execute("table.insert", &json!({"rows": 3, "cols": 3})).unwrap();
s.execute("table.insertRow", &json!({"where": "below", "count": 2})).unwrap();
s.execute("table.insertColumnLeft", &json!({})).unwrap();
check(&s);
let t = s.execute("table.get", &json!({})).unwrap();
assert_eq!((t["rows"].as_array().unwrap().len(), t["columns"].as_array().unwrap().len()), (5, 4));
// The caret's cell moved right with the inserted column.
assert_eq!(s.doc().unwrap().selection.text.unwrap().cell.unwrap().col, 1);
s.execute("table.select", &json!({"rows": [1, 2], "cols": [1, 2]})).unwrap();
s.execute("table.merge", &json!({})).unwrap();
check(&s);
let t = s.execute("table.get", &json!({})).unwrap();
let m = t["cells"].as_array().unwrap().iter().find(|c| c["row"] == 1 && c["col"] == 1).unwrap().clone();
assert_eq!((m["rowSpan"].as_u64(), m["colSpan"].as_u64()), (Some(2), Some(2)));
s.execute("table.unmerge", &json!({})).unwrap();
check(&s);
s.execute("table.select", &json!({"what": "row", "rows": [0, 0]})).unwrap();
s.execute("table.setCell", &json!({"fill": "[Black]", "tint": 0.3, "insets": 6, "vj": "center", "stroke": {"weight": 2, "edges": "bottom"}}))
.unwrap();
s.execute("table.setRowHeight", &json!({"height": 30, "mode": "exactly"})).unwrap();
s.execute("table.setColumnWidth", &json!({"cols": [0, 0], "width": 40})).unwrap();
s.execute(
"table.options",
&json!({"headerRows": 1, "altRows": {"first": 1, "firstColor": "[Black]", "firstTint": 0.1, "next": 1}, "border": {"weight": 2}}),
)
.unwrap();
check(&s);
let t = s.execute("table.get", &json!({})).unwrap();
assert_eq!(t["headerRows"], 1);
assert_eq!(t["rows"][0]["mode"], "exactly");
assert_eq!(t["rows"][0]["height"], 30.0);
assert_eq!(t["columns"][0]["width"], 40.0);
assert_eq!(t["cells"][0]["fill"], "[Black]");
assert_eq!(t["options"]["border"]["weight"], 2.0);
// Composition reflects the fixed row height.
let st = s.doc().unwrap();
let sid = st.selection.text.unwrap().story;
let cs = s.cache.get(&st.doc, sid, None);
let tf = &cs.frames[0].tables[0];
assert!((tf.cell(0, 1).unwrap().rect.height() - 30.0).abs() < 1e-6);
assert!(tf.cell(1, 0).unwrap().fill.is_some());
// Delete a row and a column, then the whole table.
s.execute("table.deleteRow", &json!({"rows": [4, 4]})).unwrap();
s.execute("table.deleteColumn", &json!({"cols": [0, 0]})).unwrap();
check(&s);
let t = s.execute("table.get", &json!({})).unwrap();
assert_eq!((t["rows"].as_array().unwrap().len(), t["columns"].as_array().unwrap().len()), (4, 3));
s.execute("table.delete", &json!({})).unwrap();
check(&s);
let st = s.doc().unwrap();
assert!(st.doc.stories.values().all(|x| x.tables.is_empty()));
assert_eq!(st.doc.story(sid).unwrap().text, "Intro");
}
#[test]
fn convert_text_to_table_and_back() {
let (mut s, sid, _) = session_with_frame();
s.execute("story.setText", &json!({"story": sid, "text": "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 25})).unwrap();
let r = s.execute("table.convertFromText", &json!({})).unwrap();
assert_eq!(r["rows"], 3);
check(&s);
let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap();
assert_eq!(t["columns"].as_array().unwrap().len(), 2);
assert_eq!(t["cells"][3]["text"], "80%");
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone();
assert_eq!(story, format!("{}\nAfter", designcraft_doc::TABLE_ANCHOR));
s.execute("table.convertToText", &json!({"table": r["table"]})).unwrap();
check(&s);
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter");
}
#[test]
fn caret_placement_in_cells_and_cell_formatting() {
let (mut s, sid, fid) = session_with_frame();
let r = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
let tid = r["table"].as_u64().unwrap();
s.execute("table.setCell", &json!({"rows": [1, 1], "cols": [1, 1], "text": "Hello"})).unwrap();
// Click inside cell (1,1): spread coordinates = frame inner space here.
let (rect, _) = {
let st = s.doc().unwrap();
let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None);
let c = cs.frames[0].tables[0].cell(1, 1).unwrap().clone();
(c.rect, c)
};
let r = s.execute("text.placeCaret", &json!({"frame": fid, "point": [rect.x1 - 1.0, rect.center().y]})).unwrap();
assert_eq!(r["cell"]["row"], 1);
assert_eq!(r["cell"]["col"], 1);
assert_eq!(r["pos"], 5);
s.execute("text.insert", &json!({"text": "!"})).unwrap();
assert_eq!(s.execute("table.get", &json!({"table": tid})).unwrap()["cells"][3]["text"], "Hello!");
// Formatting at a cell caret/selection goes to the cell story.
s.execute("edit.selectAll", &json!({})).unwrap();
s.execute("type.char", &json!({"attrs": {"size": 20}})).unwrap();
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 20.0);
check(&s);
// Selected cells format whole cells.
s.execute("table.select", &json!({"table": tid, "what": "table"})).unwrap();
s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap();
let st = s.doc().unwrap();
let t = &st.doc.story(designcraft_doc::StoryId(sid)).unwrap().tables[&tid];
assert!(t.cells.iter().all(|c| c.text.paras[0].para.align == Some(designcraft_doc::Align::Center)));
// Typing after the table starts a new paragraph instead of hiding text in the anchor paragraph.
let a = st.doc.story(designcraft_doc::StoryId(sid)).unwrap().table_anchor(tid).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": a + 3, "focus": a + 3})).unwrap();
s.execute("text.insert", &json!({"text": "Tail"})).unwrap();
check(&s);
let text = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone();
assert!(text.contains(&format!("{}\nTail", designcraft_doc::TABLE_ANCHOR)), "{text:?}");
}
#[test]
fn file_roundtrip_keeps_tables() {
let (mut s, sid, _) = session_with_frame();
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
s.execute("text.insert", &json!({"text": "A1"})).unwrap();
let d = s.doc().unwrap().doc.clone();
let bytes = crate::cmd::file_bytes(&d);
let back = crate::cmd::file_from(&bytes).unwrap();
back.check().unwrap();
let st = back.story(designcraft_doc::StoryId(sid)).unwrap();
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1");
}

View File

@ -50,6 +50,7 @@ impl Session {
&& let Some(f) = t.frame.or_else(|| st.doc.story(t.story).and_then(|s| s.frames.first().copied()))
{
st.selection.items = vec![f];
st.selection.cells = None;
}
}

View File

@ -1239,6 +1239,15 @@ impl<'a> Ex<'a> {
.attr("StoryOrientation", "Horizontal")
.attr("StoryDirection", "LeftToRightDirection"),
);
for psr in self.story_paras(s) {
el.push(psr);
}
el
}
/// `ParagraphStyleRange`s of a story (or a table cell's story).
fn story_paras(&mut self, s: &Story) -> Vec<El> {
let mut paras = Vec::new();
let ranges = s.para_ranges();
let n = ranges.len();
for (pi, r) in ranges.into_iter().enumerate() {
@ -1295,6 +1304,12 @@ impl<'a> Ex<'a> {
if let Some(code) = ace {
flush_text(&mut cur, &mut pending);
pending.push(Node::Pi("ACE".into(), code.into()));
} else if ch == st::TABLE_ANCHOR {
flush_text(&mut cur, &mut pending);
flush_content(&mut pending, &mut out);
if let Some(t) = s.para_table(pi) {
out.push(Node::El(self.table_el(t)));
}
} else if let Some(bt) = brk {
flush_text(&mut cur, &mut pending);
flush_content(&mut pending, &mut out);
@ -1326,7 +1341,98 @@ impl<'a> Ex<'a> {
psr.push(c);
}
}
el.push(psr);
paras.push(psr);
}
paras
}
fn cell_stroke_attrs(&self, el: &mut El, prefix: &str, s: &designcraft_doc::CellStroke) {
el.set(&format!("{prefix}StrokeWeight"), num(s.weight));
el.set(&format!("{prefix}StrokeColor"), self.sw(&s.color));
el.set(&format!("{prefix}StrokeTint"), pct(s.tint as f64));
el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(&s.kind));
}
/// `<Table>` with rows, columns and cells (cell names are `column:row`).
fn table_el(&mut self, t: &designcraft_doc::Table) -> El {
let id = uid(t.id);
let (h, f) = (t.header_rows(), t.footer_rows());
let mut el = El::new("Table")
.attr("Self", &id)
.attr("HeaderRowCount", h)
.attr("FooterRowCount", f)
.attr("BodyRowCount", t.nrows() - h - f)
.attr("ColumnCount", t.ncols())
.attr("AppliedTableStyle", "TableStyle/$ID/[Basic Table]")
.attr("TableDirection", "LeftToRightDirection")
.attr("SpaceBefore", num(t.options.space_before))
.attr("SpaceAfter", num(t.options.space_after))
.attr("HeaderBehavior", if t.options.repeat_header { "RepeatOnEachTextColumn" } else { "RepeatOnce" })
.attr("FooterBehavior", if t.options.repeat_footer { "RepeatOnEachTextColumn" } else { "RepeatOnce" });
for side in ["Top", "Left", "Bottom", "Right"] {
self.cell_stroke_attrs(&mut el, &format!("{side}Border"), &t.options.border);
}
for (kind, alt) in [("Row", &t.options.alt_rows), ("Column", &t.options.alt_cols)] {
if let Some(a) = alt {
el.set(&format!("Start{kind}FillColor"), self.sw(&a.first_color));
el.set(&format!("Start{kind}FillCount"), a.first);
el.set(&format!("Start{kind}FillTint"), pct(a.first_tint as f64));
el.set(&format!("End{kind}FillColor"), self.sw(&a.next_color));
el.set(&format!("End{kind}FillCount"), a.next);
el.set(&format!("End{kind}FillTint"), pct(a.next_tint as f64));
el.set(&format!("SkipFirstAlternatingFill{kind}s"), a.skip_first);
el.set(&format!("SkipLastAlternatingFill{kind}s"), a.skip_last);
}
}
for (r, row) in t.rows.iter().enumerate() {
let exact = row.mode == designcraft_doc::RowHeightMode::Exactly;
el.push(
El::new("Row")
.attr("Self", format!("{id}Row{r}"))
.attr("Name", r)
.attr("SingleRowHeight", num(row.height.max(3.0)))
.attr("MinimumHeight", num(row.height.max(0.0)))
.attr("AutoGrow", bool_s(!exact)),
);
}
for (c, col) in t.columns.iter().enumerate() {
el.push(El::new("Column").attr("Self", format!("{id}Column{c}")).attr("Name", c).attr("SingleColumnWidth", num(col.width)));
}
let owners = t.owners();
for r in 0..t.nrows() {
for c in 0..t.ncols() {
if owners[r * t.ncols() + c] != (r, c) {
continue;
}
let cell = t.cell(r, c).expect("in range");
let vj = match cell.vj {
designcraft_doc::VerticalJustification::Top => "TopAlign",
designcraft_doc::VerticalJustification::Center => "CenterAlign",
designcraft_doc::VerticalJustification::Bottom => "BottomAlign",
designcraft_doc::VerticalJustification::Justify => "JustifyAlign",
};
let mut ce = El::new("Cell")
.attr("Self", format!("{id}i{}", r * t.ncols() + c))
.attr("Name", format!("{c}:{r}"))
.attr("RowSpan", cell.row_span)
.attr("ColumnSpan", cell.col_span)
.attr("AppliedCellStyle", "CellStyle/$ID/[None]")
.attr("FillColor", self.sw(&cell.fill))
.attr("FillTint", pct(cell.fill_tint as f64))
.attr("TopInset", num(cell.insets[0]))
.attr("LeftInset", num(cell.insets[1]))
.attr("BottomInset", num(cell.insets[2]))
.attr("RightInset", num(cell.insets[3]))
.attr("VerticalJustification", vj)
.attr("RotationAngle", num(cell.rotation));
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]);
}
for psr in self.story_paras(&cell.text) {
ce.push(psr);
}
el.push(ce);
}
}
el
}

View File

@ -7,10 +7,11 @@ use std::sync::Arc;
use designcraft_color::cms::lab;
use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue, swatch};
use designcraft_doc::{
Asset, AssetId, BaselineGrid, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document, DropShadow,
Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle, Page, PageId,
PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, Rule, Section, Shape, SpanColumns, Spread, SpreadId, Story, StoryId, Stroke, Styles,
TabStop, TextFrame, TextFrameOptions, TextWrap, story as st,
AltFills, Asset, AssetId, BaselineGrid, Cell, CellRange, CellStroke, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind,
Content, DocSettings, Document, DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId,
ListType, Margins, ObjectStyle, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape,
SpanColumns, Spread, SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, VerticalJustification,
story as st,
};
use designcraft_geom::corners::{Corner, CornerOptions};
use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
@ -818,15 +819,143 @@ impl<'r> Importer<'r> {
// ---------- stories ----------
fn story(&mut self, id: StoryId, e: &El) -> Story {
let mut b =
StoryBuilder { text: String::new(), paras: vec![ParaFormat::default()], runs: Vec::new(), fresh: true, last: CharFormat::default() };
let mut b = StoryBuilder {
text: String::new(),
paras: vec![ParaFormat::default()],
runs: Vec::new(),
fresh: true,
last: CharFormat::default(),
tables: Vec::new(),
after_table: None,
};
self.walk_story(e, &mut b, &ParaFormat::default(), &CharAttrs::default(), &CharFormat::default(), None);
let StoryBuilder { text, paras, runs, last, .. } = b;
let StoryBuilder { text, mut paras, runs, last, tables: tbls, .. } = b;
let mut chars: Vec<CharRun> = runs.into_iter().filter(|r| r.len > 0).collect();
if chars.is_empty() {
chars.push(CharRun { len: 0, format: last });
}
Story { id, text, paras, chars, frames: vec![], rev: 0 }
let mut tables = BTreeMap::new();
for (pi, t) in tbls {
if let Some(p) = paras.get_mut(pi) {
p.table = Some(t.id);
tables.insert(t.id, Arc::new(t));
}
}
let mut st = Story { id, text, paras, chars, frames: vec![], rev: 0, tables };
// Drop anything inconsistent (e.g. a table sharing a paragraph with another).
if st.check().is_err() {
st.tables.retain(|_, _| false);
for p in &mut st.paras {
p.table = None;
}
}
st
}
/// `<Table>`: rows, columns and cells (cell `Name` is `column:row`).
fn table(&mut self, e: &El) -> Table {
let id = self.alloc();
let rows: Vec<&El> = e.find_all("Row").collect();
let cols: Vec<&El> = e.find_all("Column").collect();
let nr = rows.len().max(1);
let nc = cols.len().max(1);
let mut t = Table::new(id, nr, nc, 0, 0, 0.0);
for (r, re) in rows.iter().enumerate() {
let auto = re.boolean("AutoGrow").unwrap_or(true);
t.rows[r].mode = if auto { RowHeightMode::AtLeast } else { RowHeightMode::Exactly };
t.rows[r].height = if auto { re.num("MinimumHeight").unwrap_or(3.0) } else { re.num("SingleRowHeight").unwrap_or(12.0) };
}
for (c, ce) in cols.iter().enumerate() {
t.columns[c].width = ce.num("SingleColumnWidth").unwrap_or(72.0).max(3.0);
}
let h = e.num("HeaderRowCount").unwrap_or(0.0) as usize;
let f = e.num("FooterRowCount").unwrap_or(0.0) as usize;
if h + f < nr {
t.set_header_footer(h, f);
}
let stroke = |me: &mut Self, el: &El, prefix: &str, base: &CellStroke| -> CellStroke {
let mut s = base.clone();
if let Some(w) = el.num(&format!("{prefix}StrokeWeight")) {
s.weight = w.max(0.0);
}
if let Some(c) = el.get(&format!("{prefix}StrokeColor")) {
s.color = me.swatch_ref(c);
}
if let Some(v) = el.num(&format!("{prefix}StrokeTint")) {
s.tint = tint(Some(v)).unwrap_or(1.0);
}
if let Some(v) = el.get(&format!("{prefix}StrokeType")) {
s.kind = names::stroke_type_in(v);
}
s
};
t.options.border = stroke(self, e, "TopBorder", &t.options.border);
t.options.space_before = e.num("SpaceBefore").unwrap_or(t.options.space_before);
t.options.space_after = e.num("SpaceAfter").unwrap_or(t.options.space_after);
t.options.repeat_header = e.get("HeaderBehavior") != Some("RepeatOnce");
t.options.repeat_footer = e.get("FooterBehavior") != Some("RepeatOnce");
for kind in ["Row", "Column"] {
let Some(first) = e.num(&format!("Start{kind}FillCount")).filter(|n| *n > 0.0) else { continue };
let alt = AltFills {
first: first as u32,
first_color: e.get(&format!("Start{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
first_tint: tint(e.num(&format!("Start{kind}FillTint"))).unwrap_or(1.0),
next: e.num(&format!("End{kind}FillCount")).unwrap_or(1.0) as u32,
next_color: e.get(&format!("End{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
next_tint: tint(e.num(&format!("End{kind}FillTint"))).unwrap_or(1.0),
skip_first: e.num(&format!("SkipFirstAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
skip_last: e.num(&format!("SkipLastAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
};
if kind == "Row" {
t.options.alt_rows = Some(alt);
} else {
t.options.alt_cols = Some(alt);
}
}
let mut regions = Vec::new();
for ce in e.find_all("Cell") {
let Some((c, r)) =
ce.get("Name").and_then(|n| n.split_once(':')).and_then(|(a, b)| Some((a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)))
else {
continue;
};
if r >= nr || c >= nc {
continue;
}
let rs = ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize;
let cs = ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize;
let text = self.story(StoryId(0), ce);
let mut cell = Cell { text, ..Default::default() };
if let Some(fc) = ce.get("FillColor") {
cell.fill = self.swatch_ref(fc);
}
cell.fill_tint = tint(ce.num("FillTint")).unwrap_or(1.0);
for (i, k) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().enumerate() {
if let Some(v) = ce.num(k) {
cell.insets[i] = v.max(0.0);
}
}
cell.vj = match ce.get("VerticalJustification") {
Some("CenterAlign") => VerticalJustification::Center,
Some("BottomAlign") => VerticalJustification::Bottom,
Some("JustifyAlign") => VerticalJustification::Justify,
_ => VerticalJustification::Top,
};
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let base = cell.strokes[i].clone();
cell.strokes[i] = stroke(self, ce, side, &base);
}
if let Some(slot) = t.cell_mut(r, c) {
*slot = cell;
}
if rs > 1 || cs > 1 {
regions.push(CellRange { r0: r, c0: c, r1: (r + rs - 1).min(nr - 1), c1: (c + cs - 1).min(nc - 1) });
}
}
for rg in regions {
let _ = t.merge(rg);
}
t
}
fn walk_story(&mut self, e: &El, b: &mut StoryBuilder, pf: &ParaFormat, pchars: &CharAttrs, cf: &CharFormat, brk: Option<&str>) {
@ -837,7 +966,7 @@ impl<'r> Importer<'r> {
let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| st::BASIC_PARAGRAPH.into());
let para = self.para_attrs(c);
let chars = self.char_attrs(c);
let npf = ParaFormat { style, para, chars: CharAttrs::default() };
let npf = ParaFormat { style, para, chars: CharAttrs::default(), table: None };
if b.fresh
&& let Some(last) = b.paras.last_mut()
{
@ -889,9 +1018,12 @@ impl<'r> Importer<'r> {
b.fresh = true;
}
},
"Table" => {
let t = self.table(c);
b.push_table(t, pf, cf);
}
// Not supported yet: skip their content entirely.
"Properties"
| "Table"
| "Footnote"
| "Note"
| "Rectangle"
@ -1373,13 +1505,38 @@ struct StoryBuilder {
/// The current paragraph has no content yet (its format can still be set by a range).
fresh: bool,
last: CharFormat,
/// Tables by anchor paragraph index.
tables: Vec<(usize, Table)>,
/// Content after a table anchor starts a new paragraph (with this format).
after_table: Option<ParaFormat>,
}
impl StoryBuilder {
/// A table gets an anchor paragraph of its own.
fn push_table(&mut self, t: Table, pf: &ParaFormat, cf: &CharFormat) {
let para_empty = self.text.rsplit('\n').next().is_none_or(str::is_empty);
if !para_empty {
self.after_table = None;
self.push("\n", cf);
self.paras.push(pf.clone());
}
self.after_table = None;
self.push(&st::TABLE_ANCHOR.to_string(), cf);
self.tables.push((self.paras.len() - 1, t));
self.after_table = Some(pf.clone());
self.fresh = false;
}
fn push(&mut self, s: &str, f: &CharFormat) {
if s.is_empty() {
return;
}
if let Some(pf) = self.after_table.take()
&& !s.starts_with('\n')
{
self.push("\n", f);
self.paras.push(pf);
}
self.text.push_str(s);
if !s.contains('\n') || s.len() > 1 {
self.fresh = false;

View File

@ -329,3 +329,38 @@ fn uri_and_base64_helpers() {
let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
assert_eq!(base64_decode(&base64_encode(&data)), data);
}
#[test]
fn round_trips_tables() {
let mut d = Document::new(&NewDocument::default());
d.swatches.push(Swatch::color("Brand", Color::rgb(1.0, 0.0, 0.0)));
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Intro\nOutro", ParaFormat::default()).unwrap();
let mut t = designcraft_doc::Table::new(d.alloc(), 3, 3, 1, 0, 300.0);
t.cell_mut(0, 0).unwrap().text.insert(0, "Head");
t.cell_mut(0, 0).unwrap().fill = "Brand".into();
t.cell_mut(1, 2).unwrap().text.insert(0, "Body\nTwo");
t.cell_mut(2, 1).unwrap().vj = designcraft_doc::VerticalJustification::Center;
t.rows[2].mode = designcraft_doc::RowHeightMode::Exactly;
t.rows[2].height = 30.0;
t.columns[0].width = 50.0;
t.merge(designcraft_doc::CellRange::new(2, 1, 3, 2)).unwrap();
t.options.alt_rows = Some(designcraft_doc::AltFills { first: 1, first_color: "Brand".into(), first_tint: 0.2, ..Default::default() });
d.story_mut(sid).unwrap().insert_table(5, t.clone());
d.check().unwrap();
let back = import_idml(&export_idml(&d)).unwrap();
back.check().unwrap();
let s = back.stories.values().next().unwrap();
assert_eq!(s.text, format!("Intro\n{}\nOutro", st::TABLE_ANCHOR));
let bt = s.tables.values().next().unwrap();
assert_eq!((bt.nrows(), bt.ncols(), bt.header_rows()), (4, 3, 1));
assert_eq!(bt.cell(0, 0).unwrap().text.text, "Head");
assert_eq!(bt.cell(0, 0).unwrap().fill, "Brand");
assert_eq!(bt.cell(1, 2).unwrap().text.text, "Body\nTwo");
assert_eq!(bt.cell(2, 1).unwrap().vj, designcraft_doc::VerticalJustification::Center);
assert_eq!((bt.cell(2, 1).unwrap().row_span, bt.cell(2, 1).unwrap().col_span), (2, 2));
assert_eq!(bt.rows[2].mode, designcraft_doc::RowHeightMode::Exactly);
assert!((bt.rows[2].height - 30.0).abs() < 1e-6);
assert!((bt.columns[0].width - 50.0).abs() < 1e-6);
assert_eq!(bt.options.alt_rows.as_ref().map(|a| a.first_color.as_str()), Some("Brand"));
}

View File

@ -159,3 +159,21 @@ fn standards() {
assert_eq!(Standard::parse("PDF/X-4"), Some(Standard::PdfX4));
assert_eq!(Standard::parse("pdfa-2b"), Some(Standard::PdfA2b));
}
#[test]
fn tables_export_cell_text() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 400.0), lid, "Before", ParaFormat::default()).unwrap();
let mut t = designcraft_doc::Table::new(9, 2, 2, 1, 0, 300.0);
t.cell_mut(0, 0).unwrap().text.insert(0, "Ink");
t.cell_mut(0, 0).unwrap().fill = "C=100 M=0 Y=0 K=0".into();
t.cell_mut(1, 1).unwrap().text.insert(0, "Plum");
t.cell_mut(2, 0).unwrap().text.insert(0, "Sunset");
d.story_mut(sid).unwrap().insert_table(6, t);
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let text = extract_text(&bytes).concat();
for w in ["Before", "Ink", "Plum", "Sunset"] {
assert!(text.contains(w), "{w} missing from {text:?}");
}
}

View File

@ -61,6 +61,9 @@ impl Exporter<'_> {
self.fill_rect(s, d.rect, c);
}
}
if !ft.tables.is_empty() {
self.tables(s, ft);
}
let mut deco: Vec<(u32, Rect)> = Vec::new();
for l in &ft.lines {
let gs = &l.glyphs;
@ -105,6 +108,51 @@ impl Exporter<'_> {
}
}
/// Table fragments: cell fills, cell text (real text), edges and the border.
fn tables(&mut self, s: &mut Surface, ft: &FrameText) {
for t in &ft.tables {
for c in &t.cells {
if let Some((sw, tint)) = &c.fill
&& let Some(col) = self.swatch_color(sw, *tint)
{
self.fill_rect(s, c.rect, col);
}
}
for c in &t.cells {
if let Some(cft) = c.text.frames.first() {
s.push_transform(&tf(Affine::translate(c.origin.to_vec2())));
self.frame_text(s, &c.text, cft, &c.source);
s.pop();
}
}
for seg in &t.strokes {
let st = &seg.stroke;
let Some(col) = self.swatch_color(&st.color, st.tint) else { continue };
let (cap, dash) = match &st.kind {
designcraft_doc::StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => {
let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
(krilla::paint::LineCap::Butt, Some(krilla::paint::StrokeDash { array: pat, offset: 0.0 }))
}
designcraft_doc::StrokeType::Dotted => {
(krilla::paint::LineCap::Round, Some(krilla::paint::StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 }))
}
_ => (krilla::paint::LineCap::Square, None),
};
let mut bp = BezPath::new();
bp.move_to(seg.a);
bp.line_to(seg.b);
let Some(p) = to_path(&bp) else { continue };
s.set_fill(None);
s.set_stroke(Some(Stroke { paint: col.into(), width: st.weight as f32, line_cap: cap, dash, ..Default::default() }));
s.draw_path(&p);
s.set_stroke(None);
}
}
}
fn fill_rect(&self, s: &mut Surface, r: Rect, c: krilla::color::Color) {
if let Some(p) = to_path(&r.to_path(0.1)) {
s.set_stroke(None);

View File

@ -465,6 +465,9 @@ impl Renderer {
ctx.fill_rect(&d.rect);
}
}
if !ft.tables.is_empty() {
self.draw_tables(ctx, f, ft, xf);
}
let scale = (f.view * xf).determinant().abs().sqrt();
// Batch glyph outlines per run style into one path (in frame inner space).
let mut batches: HashMap<u32, BezPath> = HashMap::new();
@ -535,6 +538,48 @@ impl Renderer {
}
}
impl Renderer {
/// Table fragments: cell fills, cell text, then cell edges and the border.
fn draw_tables(&mut self, ctx: &mut RenderContext, f: &Frame, ft: &FrameText, xf: Affine) {
let doc = f.doc;
for t in &ft.tables {
for c in &t.cells {
if let Some((sw, tint)) = &c.fill
&& let Some(col) = doc.resolve_color(sw, *tint)
{
ctx.set_transform(f.view * xf);
ctx.set_paint(color_of(&col, 1.0));
// Overlap neighbours by half a device pixel so adjacent fills show no seams.
let h = 0.5 * f.px;
ctx.fill_rect(&c.rect.inflate(h, h));
}
}
for c in &t.cells {
if let Some(cft) = c.text.frames.first() {
self.draw_text(ctx, f, &c.text, cft, xf * Affine::translate(c.origin.to_vec2()));
}
}
ctx.set_transform(f.view * xf);
for s in &t.strokes {
let Some(col) = doc.resolve_color(&s.stroke.color, s.stroke.tint) else { continue };
ctx.set_paint(color_of(&col, 1.0));
ctx.set_stroke(cell_stroke(&s.stroke));
ctx.stroke_path(&kurbo::Line::new(s.a, s.b).to_path(0.1));
}
}
}
}
/// Kurbo stroke for a table edge.
fn cell_stroke(s: &designcraft_doc::CellStroke) -> kurbo::Stroke {
let st = kurbo::Stroke::new(s.weight).with_caps(kurbo::Cap::Square);
match &s.kind {
StrokeType::Dashed { pattern } if !pattern.is_empty() => st.with_caps(kurbo::Cap::Butt).with_dashes(0.0, pattern.iter().copied()),
StrokeType::Dotted => st.with_dashes(0.0, [0.0, s.weight * 2.0]).with_caps(kurbo::Cap::Round),
_ => st,
}
}
fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl {
use kurbo::PathEl::*;
match el {
@ -710,4 +755,39 @@ mod tests {
let _ = fid;
assert!(r.stats.glyphs >= 15);
}
#[test]
fn renders_tables() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 400.0), lid, "", ParaFormat::default()).unwrap();
let mut t = designcraft_doc::Table::new(5, 3, 3, 1, 0, 300.0);
for c in 0..3 {
let cell = t.cell_mut(0, c).unwrap();
cell.fill = "C=100 M=0 Y=0 K=0".into();
cell.text.insert(0, "Head");
t.cell_mut(2, c).unwrap().text.insert(0, "WWWWWWWW");
}
d.story_mut(sid).unwrap().insert_table(0, t);
let cache = Cache::new();
let cs = cache.get(&d, sid, None);
let tf = &cs.frames[0].tables[0];
let head = tf.cell(0, 1).unwrap().rect;
let body = tf.cell(2, 0).unwrap().rect;
let mut r = Renderer::new();
r.threads = 0;
let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
// Header fill (cyan) near the right edge of the middle header cell.
let c = img.pixel((head.x1 - 3.0) as u32, (head.y0 + 3.0) as u32);
assert!(c[0] < 80 && c[2] > 150, "{c:?}");
// The table border (black) on the left edge.
let e = img.pixel(body.x0.round() as u32, body.center().y as u32);
assert!(e[0] < 200, "{e:?}");
// Cell text drew dark pixels inside the body cell.
let dark = ((body.x0 + 5.0) as u32..(body.x1 - 2.0) as u32)
.flat_map(|x| ((body.y0 + 2.0) as u32..(body.y1 - 2.0) as u32).map(move |y| (x, y)))
.filter(|&(x, y)| img.pixel(x, y)[0] < 100)
.count();
assert!(dark > 30, "cell text pixels: {dark}");
}
}

View File

@ -520,7 +520,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
let sel: &Selection = &st.selection;
// Text selection / caret.
if let Some(ts) = sel.text {
draw_text_selection(app, painter, xf, doc, layout, ts);
draw_text_selection(app, painter, xf, doc, layout, ts, sel.cells);
}
let mut union: Option<Rect> = None;
let mut color = Color32::from_rgb(79, 153, 255);
@ -660,9 +660,40 @@ fn draw_ports(
}
}
fn draw_text_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, ts: designcraft_doc::TextSel) {
fn draw_text_selection(
app: &DesignApp,
painter: &egui::Painter,
xf: &Xf,
doc: &Document,
layout: &CanvasLayout,
ts: designcraft_doc::TextSel,
cells: Option<designcraft_doc::TableSel>,
) {
let cs = app.session.cache.get(doc, ts.story, None);
let range = ts.range();
// Selected table cells: tint every selected cell.
if let Some(sel) = cells.filter(|c| c.story == ts.story) {
for ft in &cs.frames {
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };
let m = a * it.xf;
for t in ft.tables.iter().filter(|t| t.table == sel.table) {
for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) {
let q = [
xf.to_screen(m * Point::new(c.rect.x0, c.rect.y0)),
xf.to_screen(m * Point::new(c.rect.x1, c.rect.y0)),
xf.to_screen(m * Point::new(c.rect.x1, c.rect.y1)),
xf.to_screen(m * Point::new(c.rect.x0, c.rect.y1)),
];
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE));
}
}
}
return;
}
if let Some(cell) = ts.cell {
draw_cell_selection(app, painter, xf, doc, layout, &cs, ts, cell);
return;
}
for ft in &cs.frames {
let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue };
let Some(it) = doc.item(ft.frame) else { continue };
@ -706,6 +737,58 @@ fn draw_text_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &
}
}
/// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin).
#[allow(clippy::too_many_arguments)]
fn draw_cell_selection(
_app: &DesignApp,
painter: &egui::Painter,
xf: &Xf,
doc: &Document,
layout: &CanvasLayout,
cs: &compose::ComposedStory,
ts: designcraft_doc::TextSel,
cell: designcraft_doc::CellAddr,
) {
let Some((fi, _, pc)) = compose::find_cell(cs, cell.table, cell.row, cell.col) else { return };
let Some(ft) = cs.frames.get(fi) else { return };
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return };
let m = a * it.xf * designcraft_geom::Affine::translate(pc.origin.to_vec2());
let range = ts.range();
if !range.is_empty()
&& let Some(cft) = pc.text.frames.first()
{
for l in &cft.lines {
let s = range.start.max(l.range.start);
let e = range.end.min(l.range.end);
if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) {
continue;
}
let x0 = caret_x(l, s);
let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { caret_x(l, e) };
let q = [
xf.to_screen(m * Point::new(x0, l.baseline - l.ascent)),
xf.to_screen(m * Point::new(x1, l.baseline - l.ascent)),
xf.to_screen(m * Point::new(x1, l.baseline + l.descent)),
xf.to_screen(m * Point::new(x0, l.baseline + l.descent)),
];
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
}
}
if ts.is_caret()
&& let Some((_, x, bl, asc, desc)) = compose::cell_caret(cs, cell.table, cell.row, cell.col, ts.focus)
{
let m = a * it.xf;
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
if blink {
painter.line_segment(
[xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))],
Stroke::new(1.0, Color32::BLACK),
);
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
}
}
fn caret_x(l: &compose::Line, pos: usize) -> f64 {
for g in &l.glyphs {
if g.len > 0 && pos >= g.byte && pos < g.byte + g.len {
@ -1010,7 +1093,7 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc
pub fn selected_text(app: &DesignApp) -> Option<String> {
let st = app.session.active()?;
let t = st.selection.text?;
let story = st.doc.story(t.story)?;
let story = st.doc.text_story(t.story, t.cell)?;
let s = story.slice(t.range());
if s.is_empty() {
return None;

View File

@ -26,6 +26,7 @@ impl Dialog {
"marginTop": "3p0", "marginBottom": "3p0", "marginInside": "3p0", "marginOutside": "3p0", "bleed": "0p0", "primaryTextFrame": false})
}
"goToPage" => json!({"page": 1}),
"insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}),
"findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}),
"textFrameOptions" => json!({"columns": 1, "gutter": "1p0", "inset": "0p0", "verticalJustification": "top"}),
"documentSetup" => json!({}),
@ -95,6 +96,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"newDocument" => "New Document",
"frameSize" => "Rectangle",
"goToPage" => "Go to Page",
"insertTable" => "Create Table",
"textFrameOptions" => "Text Frame Options",
"documentSetup" => "Document Setup",
"findChange" => "Find/Change",
@ -232,6 +234,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
text_field(ui, &mut d, "page", 80.0);
});
}
"insertTable" => {
ui.label(egui::RichText::new("Table Dimensions").font(semibold(12.0)));
egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Body Rows");
text_field(ui, &mut d, "bodyRows", 60.0);
ui.label("Columns");
text_field(ui, &mut d, "columns", 60.0);
ui.end_row();
ui.label("Header Rows");
text_field(ui, &mut d, "headerRows", 60.0);
ui.label("Footer Rows");
text_field(ui, &mut d, "footerRows", 60.0);
ui.end_row();
});
}
"textFrameOptions" => {
egui::Grid::new("tfo").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label("Number of columns");
@ -327,6 +344,11 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
crate::canvas::go_to_page(app, (d.n("page").unwrap_or(1.0) as usize).saturating_sub(1));
Ok(Value::Null)
}
"insertTable" => app.run(
"table.insert",
json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64,
"headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}),
),
"textFrameOptions" => app.run(
"object.textFrameOptions",
json!({"columns": d.n("columns").unwrap_or(1.0) as u64, "gutter": d.m("gutter").unwrap_or(12.0), "inset": d.m("inset").unwrap_or(0.0), "verticalJustification": d.s("verticalJustification")}),

View File

@ -21,6 +21,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("character", "Character", "panel-character"),
("paragraph", "Paragraph", "panel-paragraph"),
("textWrap", "Text Wrap", "panel-wrap"),
("table", "Table", "panel-table"),
("align", "Align", "panel-align"),
("links", "Links", "panel-links"),
("info", "Info", "panel-info"),
@ -128,6 +129,7 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {
"effects" => panels::properties::effects_panel(app, ui),
"links" => panels::properties::links_panel(app, ui),
"preflight" => panels::properties::preflight_panel(app, ui),
"table" => panels::table::show(app, ui),
_ => panels::properties::info_panel(app, ui),
});
});

View File

@ -285,6 +285,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
pen.line(&[(3.0, 10.0), (5.0, 10.0)]);
pen.line(&[(15.0, 10.0), (17.0, 10.0)]);
}
"panel-table" => {
pen.frect(3.0, 3.0, 17.0, 7.5);
pen.rect(3.0, 3.0, 17.0, 17.0);
pen.line(&[(3.0, 12.0), (17.0, 12.0)]);
pen.line(&[(10.0, 3.0), (10.0, 17.0)]);
}
"panel-align" => {
pen.line(&[(3.0, 3.0), (3.0, 17.0)]);
pen.frect(5.0, 5.0, 15.0, 9.0);

View File

@ -16,6 +16,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"),
("app.palette", "Command Palette…", Some("Cmd+K"), "{}"),
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),
("app.tablePanel", "Table Panel", Some("Shift+F9"), "{}"),
("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"),
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
@ -37,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
"window.panel",
"Show Panel",
None,
"{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links}",
"{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}",
),
("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"),
("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"),
@ -145,7 +147,37 @@ pub const MENUS: &[(&str, &[&str])] = &[
"<",
],
),
("Table", &[]),
(
"Table",
&[
"ui:app.insertTableDialog",
"cmd:table.convertFromText",
"cmd:table.convertToText",
"-",
">Insert",
"cmd:table.insertRowAbove",
"cmd:table.insertRowBelow",
"cmd:table.insertColumnLeft",
"cmd:table.insertColumnRight",
"<",
">Delete",
"cmd:table.deleteRow",
"cmd:table.deleteColumn",
"cmd:table.delete",
"<",
">Select",
"cmd:table.selectRow",
"cmd:table.selectColumn",
"cmd:table.selectTable",
"<",
"-",
"cmd:table.merge",
"cmd:table.unmerge",
"cmd:table.distributeColumns",
"-",
"ui:app.tablePanel",
],
),
(
"View",
&[
@ -222,6 +254,17 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({})));
Ok(Value::Null)
}
"app.insertTableDialog" => {
if app.session.active().is_none_or(|d| d.selection.text.is_none()) {
return Some(Err("place the insertion point in a text frame to create a table".into()));
}
app.ui.dialog = Some(crate::dialogs::Dialog::new("insertTable", p.clone()));
Ok(Value::Null)
}
"app.tablePanel" => {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some("table") { None } else { Some("table".into()) };
Ok(Value::Null)
}
"app.exportPdf" => export_pdf(app, p),
"app.palette" => {
app.ui.palette = Some(String::new());

View File

@ -5,6 +5,7 @@ pub mod pages;
pub mod properties;
pub mod styles;
pub mod swatches;
pub mod table;
use designcraft_doc::{Content, StrokeAlign, WrapMode};
use designcraft_geom::Rect;

View File

@ -0,0 +1,151 @@
//! The Table panel: row/column counts, row height, column width, cell text alignment and insets,
//! merge/unmerge — for the table at the text insertion point or the selected cells.
use designcraft_geom::Unit;
use serde_json::{Value, json};
use crate::DesignApp;
use crate::theme::Tokens;
use crate::widgets::{caption, divider, measure, number};
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let Ok(info) = app.session.execute("table.get", &json!({})) else {
ui.label(egui::RichText::new("Place the insertion point in a table, or create one.").color(t.text_dim));
ui.add_space(6.0);
if ui.button("Create Table…").clicked() {
let _ = app.run("app.insertTableDialog", json!({}));
}
return;
};
let unit = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
let rows = info["rows"].as_array().cloned().unwrap_or_default();
let cols = info["columns"].as_array().cloned().unwrap_or_default();
let range = info["range"].clone();
let (r0, c0) = (range["r0"].as_u64().unwrap_or(0) as usize, range["c0"].as_u64().unwrap_or(0) as usize);
let tid = info["table"].clone();
let cell = info["cells"].as_array().and_then(|cs| cs.iter().find(|c| c["row"] == r0 && c["col"] == c0)).cloned().unwrap_or(Value::Null);
let run = |app: &mut DesignApp, id: &str, p: Value| {
let _ = app.run(id, p);
};
egui::Grid::new("table_panel").num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| {
caption(ui, "Rows");
if let Some(v) = number(ui, "tp_rows", Some(rows.len() as f64), "", 52.0, 0) {
let n = v.round().max(1.0) as usize;
if n > rows.len() {
run(
app,
"table.insertRow",
json!({"table": tid, "rows": [rows.len() - 1, rows.len() - 1], "where": "below", "count": n - rows.len()}),
);
} else if n < rows.len() {
run(app, "table.deleteRow", json!({"table": tid, "rows": [n, rows.len() - 1]}));
}
}
caption(ui, "Columns");
if let Some(v) = number(ui, "tp_cols", Some(cols.len() as f64), "", 52.0, 0) {
let n = v.round().max(1.0) as usize;
if n > cols.len() {
run(
app,
"table.insertColumn",
json!({"table": tid, "cols": [cols.len() - 1, cols.len() - 1], "where": "right", "count": n - cols.len()}),
);
} else if n < cols.len() {
run(app, "table.deleteColumn", json!({"table": tid, "cols": [n, cols.len() - 1]}));
}
}
ui.end_row();
let row = rows.get(r0).cloned().unwrap_or(Value::Null);
let exactly = row["mode"] == "exactly";
caption(ui, "Row Height");
egui::ComboBox::from_id_salt("tp_rowmode").selected_text(if exactly { "Exactly" } else { "At Least" }).width(70.0).show_ui(ui, |ui| {
for (label, mode) in [("At Least", "atLeast"), ("Exactly", "exactly")] {
if ui.selectable_label((mode == "exactly") == exactly, label).clicked() {
run(app, "table.setRowHeight", json!({"mode": mode}));
}
}
});
if let Some(h) = measure(ui, "tp_rowh", row["height"].as_f64(), unit, 64.0) {
run(app, "table.setRowHeight", json!({"height": h}));
}
ui.end_row();
caption(ui, "Column Width");
ui.label("");
if let Some(w) = measure(ui, "tp_colw", cols.get(c0).and_then(|c| c["width"].as_f64()), unit, 64.0) {
run(app, "table.setColumnWidth", json!({"width": w}));
}
ui.end_row();
});
divider(ui);
caption(ui, "Cell Text");
ui.horizontal(|ui| {
for (label, vj) in [("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")] {
if ui.selectable_label(cell["vj"] == vj, label).clicked() {
run(app, "table.setCell", json!({"vj": vj}));
}
}
});
ui.add_space(4.0);
let insets: Vec<f64> = cell["insets"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_else(|| vec![4.0; 4]);
egui::Grid::new("table_insets").num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| {
let labels = ["Top", "Left", "Bottom", "Right"];
for (i, l) in labels.iter().enumerate() {
caption(ui, l);
if let Some(v) = measure(ui, &format!("tp_inset{i}"), insets.get(i).copied(), unit, 56.0) {
let mut n = insets.clone();
n.resize(4, 4.0);
n[i] = v.max(0.0);
run(app, "table.setCell", json!({"insets": n}));
}
if i % 2 == 1 {
ui.end_row();
}
}
});
divider(ui);
ui.horizontal(|ui| {
if ui.button("Merge Cells").clicked() {
run(app, "table.merge", json!({}));
}
if ui.button("Unmerge").clicked() {
run(app, "table.unmerge", json!({}));
}
});
ui.horizontal(|ui| {
if ui.button("Insert Row").clicked() {
run(app, "table.insertRowBelow", json!({}));
}
if ui.button("Insert Column").clicked() {
run(app, "table.insertColumnRight", json!({}));
}
});
ui.horizontal(|ui| {
if ui.button("Delete Row").clicked() {
run(app, "table.deleteRow", json!({}));
}
if ui.button("Delete Column").clicked() {
run(app, "table.deleteColumn", json!({}));
}
});
divider(ui);
let mut alt = !info["options"]["altRows"].is_null();
if ui.checkbox(&mut alt, "Alternating Row Fills").changed() {
let v = if alt { json!({"first": 1, "firstColor": "[Black]", "firstTint": 0.1, "next": 1, "nextColor": "[None]"}) } else { Value::Null };
run(app, "table.options", json!({"altRows": v}));
}
let mut rep = info["options"]["repeatHeader"].as_bool().unwrap_or(true);
if ui.checkbox(&mut rep, "Repeat Header Rows").changed() {
run(app, "table.options", json!({"repeatHeader": rep}));
}
ui.horizontal(|ui| {
caption(ui, "Header Rows");
if let Some(v) = number(ui, "tp_hdr", info["headerRows"].as_f64(), "", 40.0, 0) {
run(app, "table.options", json!({"headerRows": v.max(0.0) as u64}));
}
caption(ui, "Footer Rows");
if let Some(v) = number(ui, "tp_ftr", info["footerRows"].as_f64(), "", 40.0, 0) {
run(app, "table.options", json!({"footerRows": v.max(0.0) as u64}));
}
});
}