From 77a23151bab32127d2f98e5469f636109ea4ded9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:44:45 -0400 Subject: [PATCH] M8: tables Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 5 +- crates/compose/src/lib.rs | 52 +- crates/compose/src/shape.rs | 3 +- crates/compose/src/table.rs | 464 ++++++++++++++ crates/compose/src/tests.rs | 142 +++++ crates/doc/src/edit.rs | 4 +- crates/doc/src/lib.rs | 2 + crates/doc/src/selection.rs | 29 +- crates/doc/src/story.rs | 43 +- crates/doc/src/table.rs | 944 +++++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/cmd/find.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/object.rs | 2 +- crates/engine/src/cmd/style.rs | 11 +- crates/engine/src/cmd/table.rs | 741 ++++++++++++++++++++++ crates/engine/src/cmd/text.rs | 130 +++- crates/engine/src/lib.rs | 2 + crates/engine/src/sample.rs | 57 ++ crates/engine/src/tests_table.rs | 178 ++++++ crates/engine/src/tooling.rs | 1 + crates/idml/src/export.rs | 108 +++- crates/idml/src/import.rs | 177 +++++- crates/idml/src/tests.rs | 35 ++ crates/pdf/src/tests.rs | 18 + crates/pdf/src/text.rs | 48 ++ crates/render/src/lib.rs | 80 +++ crates/ui-egui/src/canvas.rs | 89 ++- crates/ui-egui/src/dialogs.rs | 22 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/icons.rs | 6 + crates/ui-egui/src/menus.rs | 47 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/table.rs | 151 +++++ 34 files changed, 3534 insertions(+), 68 deletions(-) create mode 100644 crates/compose/src/table.rs create mode 100644 crates/doc/src/table.rs create mode 100644 crates/engine/src/cmd/table.rs create mode 100644 crates/engine/src/tests_table.rs create mode 100644 crates/ui-egui/src/panels/table.rs diff --git a/ROADMAP.md b/ROADMAP.md index dbedbd3..4a03a27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | | diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c492434..a492b59 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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, /// 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, } #[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 = 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(), diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index a935f52..66cd7c2 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -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); diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs new file mode 100644 index 0000000..d99578e --- /dev/null +++ b/crates/compose/src/table.rs @@ -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, + /// The cell's story text (exporters map glyphs back to text). + pub source: Arc, + /// 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, + pub strokes: Vec, + /// 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, + 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], + 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 = 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> = 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 = 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 = (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::(); + 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, 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], + 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 = 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)), + } +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 9378d86..848806f 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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 = 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::>()); + 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); +} diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 95f685e..06655f3 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -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(()) } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 337e5cb..b0f6e22 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 1f31dfe..fbe4b91 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -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, + /// 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, } 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 { 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, + /// Selected table cells. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cells: Option, /// Key object for Align. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option, @@ -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() diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 976bf5a..1323158 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -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, } 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>, } 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 ---------- diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs new file mode 100644 index 0000000..853cf6a --- /dev/null +++ b/crates/doc/src/table.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_cols: Option, +} + +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, + pub columns: Vec, + /// Row-major, `rows.len() * columns.len()`. + pub cells: Vec, + #[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], 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 { + (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 { + 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 = (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(®ions); + } + + /// 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(®ions); + } + + /// 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(®ions); + } + + /// 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(®ions); + } + + /// 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 = 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(®ions); + 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(®ions); + } + + /// 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) -> 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) -> 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> { + self.tables.get(&self.paras.get(pi)?.table?) + } + + /// Byte offset of a table's anchor character. + pub fn table_anchor(&self, id: u64) -> Option { + 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 { + 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()); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b8d456d..baf663c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -55,6 +55,7 @@ pub fn specs() -> Vec { 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 { 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 { 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(); diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 8f3170e..455a6e0 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -196,7 +196,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { 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})) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bd98f41..efd5c84 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index daf1c45..4ca8f88 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -300,7 +300,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { 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]) }; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 21b12d2..189f45e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -80,8 +80,9 @@ fn apply_para(s: &mut Session, p: &Value) -> Result { } 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 { } 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() diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs new file mode 100644 index 0000000..2d51c08 --- /dev/null +++ b/crates/engine/src/cmd/table.rs @@ -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 { + 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 { + 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 { + 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(s: &mut Session, g: &Tgt, cmd: &str, f: impl FnOnce(&mut Table) -> Result) -> Result { + 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 { + 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 { + 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 { + 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> = 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 { + 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 { + 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) -> Result { + 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) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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::() / cols.len() as f64; + cols.iter_mut().for_each(|c| c.width = avg); + ok() + }) +} + +fn parse_alt(v: &Value) -> Option { + 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 { + 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 { + 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 { + 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 { + 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 = (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, + })) +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index aa78ab8..7c7b907 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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 { 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 { 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 { 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 { 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)> { 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 { @@ -168,27 +172,60 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result { 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 { 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 { 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 { 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 { 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 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)> { +/// 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, + pub range: std::ops::Range, +} + +/// 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 { 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 = 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 { } 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 { } 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 { fn selection_attrs(s: &mut Session, _p: &Value) -> Result { 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 { }) } -pub(crate) fn format_targets_pub(s: &Session) -> Vec<(StoryId, std::ops::Range)> { +pub(crate) fn format_targets_pub(s: &Session) -> Vec { format_targets(s) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79de81f..db5561b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -255,3 +255,5 @@ pub(crate) use push_undo as record_undo; mod tests; #[cfg(test)] mod tests_idml; +#[cfg(test)] +mod tests_table; diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index c9637a2..08072d6 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -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 } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs new file mode 100644 index 0000000..8f128ad --- /dev/null +++ b/crates/engine/src/tests_table.rs @@ -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"); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 68efc6d..644593a 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -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; } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index f5bc1ed..26f6460 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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 { + 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)); + } + + /// `
` 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 } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index f976f50..6eac12a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -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 = 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 + } + + /// `
`: 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::().ok()?, b.parse::().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, } 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; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 0640f7f..3339575 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -329,3 +329,38 @@ fn uri_and_base64_helpers() { let data: Vec = (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")); +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 4697e3e..bcffba2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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:?}"); + } +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index d36bd96..194854f 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -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 = 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); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7dacf57..dccd133 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 = 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}"); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 241c654..507397b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 = 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, +) { 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 { 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; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 72eefc4..77fe430 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { 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")}), diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index e494f1f..0d615f4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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), }); }); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 9f9519f..76cb4bb 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a1f6ae9..3b3c33f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { + 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()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b9e3a2c..a4b6121 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs new file mode 100644 index 0000000..1074df9 --- /dev/null +++ b/crates/ui-egui/src/panels/table.rs @@ -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 = 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})); + } + }); +}