M8: tables
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6086ceb497
commit
77a23151ba
|
|
@ -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) | |
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod breaker;
|
|||
mod cache;
|
||||
pub mod hyphen;
|
||||
pub mod shape;
|
||||
pub mod table;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
|
|
@ -28,6 +29,7 @@ use designcraft_geom::{Point, Rect};
|
|||
use crate::breaker::{Break, Spacing};
|
||||
pub use crate::cache::Cache;
|
||||
use crate::shape::{Glyph, StyleTable, SubstCtx};
|
||||
pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell};
|
||||
|
||||
/// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -103,6 +105,8 @@ pub struct FrameText {
|
|||
pub columns: Vec<Rect>,
|
||||
/// Height the text needs (for auto-sizing), from the text area top.
|
||||
pub content_height: f64,
|
||||
/// Table fragments placed in this frame (each also has a glyph-less line in `lines`).
|
||||
pub tables: Vec<TableFrag>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -265,6 +269,40 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
|||
info.truncate(pi);
|
||||
let pf = &story.paras[pi];
|
||||
let (pp, base_chars) = doc.styles.resolve_para(pf);
|
||||
if let Some(t) = story.para_table(pi) {
|
||||
if cur.last_baseline.is_some() {
|
||||
match pp.start_paragraph {
|
||||
StartParagraph::NextColumn => cur.next_column(&cols),
|
||||
StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => {
|
||||
cur.next_frame()
|
||||
}
|
||||
StartParagraph::Anywhere => {}
|
||||
}
|
||||
}
|
||||
let at_top = cur.last_baseline.is_none();
|
||||
let start_at = (cur.fi, cur.col);
|
||||
let anchor = prange.start + story.text[prange.clone()].find(story::TABLE_ANCHOR).unwrap_or(0);
|
||||
if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) {
|
||||
out.overset_at = Some(prange.start);
|
||||
break 'paras;
|
||||
}
|
||||
info.push(ParaInfo {
|
||||
start: start_at,
|
||||
at_top,
|
||||
end: (cur.fi, cur.col),
|
||||
lines: 1,
|
||||
lines_in_end_col: 1,
|
||||
keep_with_next: 0,
|
||||
keep_together: false,
|
||||
keep_all: false,
|
||||
keep_first: 1,
|
||||
keep_last: 1,
|
||||
});
|
||||
list_counter = 0;
|
||||
cur.pending += pp.space_after;
|
||||
pi += 1;
|
||||
continue;
|
||||
}
|
||||
// Bullets & numbering: generated prefix (shaped as its own glyphs, mapped to the paragraph start).
|
||||
let sub = SubstCtx {
|
||||
page_name: opts.page_name.clone().or_else(|| frames.get(cur.fi.min(frames.len().saturating_sub(1))).and_then(|f| f.page_name.clone())),
|
||||
|
|
@ -548,7 +586,12 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
|||
let p = ft_prev_end(&out.overset_at, story.text.len());
|
||||
ft.range = p..p;
|
||||
}
|
||||
let before: Vec<f64> = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect();
|
||||
vertical_justify(ft, f);
|
||||
for (t, b) in ft.tables.iter_mut().zip(before) {
|
||||
let now = ft.lines.get(t.line).map_or(b, |l| l.baseline);
|
||||
t.shift(now - b);
|
||||
}
|
||||
}
|
||||
// Frame ranges for empty frames after the text: start at the end of the text shown.
|
||||
let mut last_end = 0;
|
||||
|
|
@ -611,19 +654,21 @@ struct Snapshot {
|
|||
list_counter: u32,
|
||||
lines: usize,
|
||||
decos: usize,
|
||||
tables: usize,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
fn take(out: &ComposedStory, cur: &Cursor, list_counter: u32) -> Snapshot {
|
||||
let (lines, decos) = out.frames.get(cur.fi).map_or((0, 0), |f| (f.lines.len(), f.decos.len()));
|
||||
Snapshot { cur: cur.clone(), list_counter, lines, decos }
|
||||
let (lines, decos, tables) = out.frames.get(cur.fi).map_or((0, 0, 0), |f| (f.lines.len(), f.decos.len(), f.tables.len()));
|
||||
Snapshot { cur: cur.clone(), list_counter, lines, decos, tables }
|
||||
}
|
||||
fn restore(&self, out: &mut ComposedStory, cur: &mut Cursor, list_counter: &mut u32) {
|
||||
let fi = self.cur.fi;
|
||||
for (i, f) in out.frames.iter_mut().enumerate().skip(fi) {
|
||||
let (l, d) = if i == fi { (self.lines, self.decos) } else { (0, 0) };
|
||||
let (l, d, t) = if i == fi { (self.lines, self.decos, self.tables) } else { (0, 0, 0) };
|
||||
f.lines.truncate(l);
|
||||
f.decos.truncate(d);
|
||||
f.tables.truncate(t);
|
||||
}
|
||||
*cur = self.cur.clone();
|
||||
*list_counter = self.list_counter;
|
||||
|
|
@ -1108,6 +1153,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph {
|
|||
|| breaker::is_forced(g.ch)
|
||||
|| g.ch == story::INDENT_HERE
|
||||
|| g.ch == story::RIGHT_INDENT_TAB
|
||||
|| g.ch == story::TABLE_ANCHOR
|
||||
|| g.ch == shape::SOFT_HYPHEN);
|
||||
PlacedGlyph {
|
||||
face: g.face.clone(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,464 @@
|
|||
//! Table layout: a table anchored in a story is laid out at its anchor paragraph.
|
||||
//!
|
||||
//! Column widths are scaled down to fit the text column; each cell's story is composed into its
|
||||
//! cell (insets applied) with the regular composer; a row is as tall as its tallest single-row
|
||||
//! cell (or its fixed height); rows that don't fit continue in the next column / frame, repeating
|
||||
//! header (and footer) rows. Rows tied together by row spans never split.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{Align, CellStroke, Document, ItemId, ParaProps, RowHeightMode, Story, Table, TextFrameOptions, VerticalJustification};
|
||||
use designcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::{ComposeOptions, ComposedStory, Cursor, FrameSpec, Line};
|
||||
|
||||
/// A cell placed in a frame (frame inner space).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlacedCell {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub rect: Rect,
|
||||
/// Fill swatch and tint (cell fill or alternating fill).
|
||||
pub fill: Option<(String, f32)>,
|
||||
/// Where the cell's composed text (its frame 0, in content space) sits in the frame.
|
||||
pub origin: Point,
|
||||
pub text: Arc<ComposedStory>,
|
||||
/// The cell's story text (exporters map glyphs back to text).
|
||||
pub source: Arc<str>,
|
||||
/// Content height available in the cell (for clipping).
|
||||
pub clip: Rect,
|
||||
pub overset: bool,
|
||||
/// A header/footer row repeated in a continuation fragment.
|
||||
pub repeated: bool,
|
||||
}
|
||||
|
||||
/// One cell edge (or the table border) to stroke.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StrokeSeg {
|
||||
pub a: Point,
|
||||
pub b: Point,
|
||||
pub stroke: CellStroke,
|
||||
}
|
||||
|
||||
/// The part of a table shown in one text column.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TableFrag {
|
||||
pub table: u64,
|
||||
/// Story byte of the anchor character.
|
||||
pub anchor: usize,
|
||||
pub column: u32,
|
||||
pub rect: Rect,
|
||||
pub cells: Vec<PlacedCell>,
|
||||
pub strokes: Vec<StrokeSeg>,
|
||||
/// Index of the fragment's (glyph-less) line in the frame's lines.
|
||||
pub line: usize,
|
||||
pub first: bool,
|
||||
pub last: bool,
|
||||
}
|
||||
|
||||
impl TableFrag {
|
||||
pub(crate) fn shift(&mut self, dy: f64) {
|
||||
if dy == 0.0 {
|
||||
return;
|
||||
}
|
||||
let v = designcraft_geom::Vec2::new(0.0, dy);
|
||||
self.rect = self.rect + v;
|
||||
for c in &mut self.cells {
|
||||
c.rect = c.rect + v;
|
||||
c.clip = c.clip + v;
|
||||
c.origin += v;
|
||||
}
|
||||
for s in &mut self.strokes {
|
||||
s.a += v;
|
||||
s.b += v;
|
||||
}
|
||||
}
|
||||
|
||||
/// The placed cell owning grid position (row, col), if it is in this fragment.
|
||||
pub fn cell(&self, row: usize, col: usize) -> Option<&PlacedCell> {
|
||||
self.cells.iter().find(|c| c.row == row && c.col == col)
|
||||
}
|
||||
}
|
||||
|
||||
struct CellLayout {
|
||||
text: Arc<ComposedStory>,
|
||||
content_h: f64,
|
||||
}
|
||||
|
||||
/// Compose a cell story at width `w` (content space, top-left at the origin).
|
||||
fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout {
|
||||
let spec = FrameSpec {
|
||||
id: ItemId(0),
|
||||
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
|
||||
opts: TextFrameOptions::default(),
|
||||
exclusions: vec![],
|
||||
page_name: opts.page_name.clone(),
|
||||
grid: None,
|
||||
left_page,
|
||||
};
|
||||
let cs = crate::compose(doc, story, std::slice::from_ref(&spec), opts);
|
||||
let content_h = cs.frames.first().map(|f| if f.lines.is_empty() { 0.0 } else { f.content_height }).unwrap_or(0.0);
|
||||
CellLayout { text: Arc::new(cs), content_h }
|
||||
}
|
||||
|
||||
/// Lay out `table` (anchored at byte `anchor` of paragraph `pi`) from the cursor. Returns false
|
||||
/// when it doesn't fit the remaining frames (overset).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn place_table(
|
||||
doc: &Document,
|
||||
table: &Table,
|
||||
anchor: usize,
|
||||
pi: usize,
|
||||
pp: &ParaProps,
|
||||
frames: &[FrameSpec],
|
||||
cols: &[Vec<Rect>],
|
||||
cur: &mut Cursor,
|
||||
out: &mut ComposedStory,
|
||||
opts: &ComposeOptions,
|
||||
) -> bool {
|
||||
if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 {
|
||||
return false;
|
||||
}
|
||||
let col_rect = |cur: &Cursor| cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
|
||||
let mut col = col_rect(cur);
|
||||
// Column widths: scaled to fit the measure.
|
||||
let avail = (col.width() - pp.left_indent - pp.right_indent).max(1.0);
|
||||
let natural = table.width();
|
||||
let k = if natural > avail && natural > 0.0 { avail / natural } else { 1.0 };
|
||||
let widths: Vec<f64> = table.columns.iter().map(|c| c.width * k).collect();
|
||||
let total_w: f64 = widths.iter().sum();
|
||||
let x_off = pp.left_indent
|
||||
+ match pp.align {
|
||||
Align::Center | Align::CenterJustified => (avail - total_w) / 2.0,
|
||||
Align::Right | Align::RightJustified => avail - total_w,
|
||||
_ => 0.0,
|
||||
}
|
||||
.max(0.0);
|
||||
let mut colx = vec![0.0; widths.len() + 1];
|
||||
for (i, w) in widths.iter().enumerate() {
|
||||
colx[i + 1] = colx[i] + w;
|
||||
}
|
||||
let (nr, nc) = (table.nrows(), table.ncols());
|
||||
let owners = table.owners();
|
||||
let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c);
|
||||
let left_page = frames[cur.fi].left_page;
|
||||
// Compose every owner cell.
|
||||
let mut layouts: Vec<Option<CellLayout>> = Vec::with_capacity(nr * nc);
|
||||
for r in 0..nr {
|
||||
for c in 0..nc {
|
||||
if !is_owner(r, c) {
|
||||
layouts.push(None);
|
||||
continue;
|
||||
}
|
||||
let cell = &table.cells[r * nc + c];
|
||||
let cs = (cell.col_span.max(1) as usize).min(nc - c);
|
||||
let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3];
|
||||
layouts.push(Some(compose_cell(doc, &cell.text, w, opts, left_page)));
|
||||
}
|
||||
}
|
||||
// Row heights.
|
||||
let need = |r: usize, c: usize| -> f64 {
|
||||
let cell = &table.cells[r * nc + c];
|
||||
layouts[r * nc + c].as_ref().map_or(0.0, |l| l.content_h) + cell.insets[0] + cell.insets[2]
|
||||
};
|
||||
let mut heights: Vec<f64> = table
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| match row.mode {
|
||||
RowHeightMode::Exactly => row.height.max(0.0),
|
||||
RowHeightMode::AtLeast => {
|
||||
let content = (0..nc).filter(|&c| is_owner(r, c) && table.cells[r * nc + c].row_span <= 1).map(|c| need(r, c)).fold(0.0, f64::max);
|
||||
content.max(row.height)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Row-spanning cells grow the last row they span (unless it is fixed).
|
||||
for r in 0..nr {
|
||||
for c in 0..nc {
|
||||
let cell = &table.cells[r * nc + c];
|
||||
if !is_owner(r, c) || cell.row_span <= 1 {
|
||||
continue;
|
||||
}
|
||||
let end = (r + cell.row_span as usize).min(nr);
|
||||
let have: f64 = heights[r..end].iter().sum();
|
||||
let n = need(r, c);
|
||||
if n > have
|
||||
&& let Some(i) = (r..end).rev().find(|&i| table.rows[i].mode == RowHeightMode::AtLeast)
|
||||
{
|
||||
heights[i] += n - have;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Row groups that must stay together: end (exclusive) of the group starting at each row.
|
||||
let mut group_end: Vec<usize> = (0..nr).map(|r| r + 1).collect();
|
||||
{
|
||||
let mut r = 0;
|
||||
while r < nr {
|
||||
let mut e = r + 1;
|
||||
let mut i = r;
|
||||
while i < e {
|
||||
for c in 0..nc {
|
||||
if is_owner(i, c) {
|
||||
e = e.max((i + table.cells[i * nc + c].row_span.max(1) as usize).min(nr));
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
for g in group_end.iter_mut().take(e).skip(r) {
|
||||
*g = e;
|
||||
}
|
||||
r = e;
|
||||
}
|
||||
}
|
||||
let header = table.header_rows();
|
||||
let footer = table.footer_rows();
|
||||
let body_end = nr - footer;
|
||||
let sum = |a: usize, b: usize| heights[a..b].iter().sum::<f64>();
|
||||
let footer_h = sum(body_end, nr);
|
||||
let o = &table.options;
|
||||
let mut y = match cur.last_baseline {
|
||||
Some(b) => b + cur.last_descent + cur.pending + o.space_before,
|
||||
None => col.y0,
|
||||
};
|
||||
let mut frag_rows: Vec<(usize, f64, bool)> = Vec::new(); // (row, y, repeated)
|
||||
let mut first_frag = true;
|
||||
let mut body_placed = 0usize;
|
||||
let mut top = y;
|
||||
let place_rows = |rows: std::ops::Range<usize>, y: &mut f64, repeated: bool, frag_rows: &mut Vec<(usize, f64, bool)>| {
|
||||
for r in rows {
|
||||
frag_rows.push((r, *y, repeated));
|
||||
*y += heights[r];
|
||||
}
|
||||
};
|
||||
place_rows(0..header, &mut y, false, &mut frag_rows);
|
||||
let mut r = header;
|
||||
loop {
|
||||
if r >= body_end {
|
||||
break;
|
||||
}
|
||||
let ge = group_end[r].min(body_end).max(r + 1);
|
||||
let gh = sum(r, ge);
|
||||
let at_col_top = cur.last_baseline.is_none() && (top - col.y0).abs() < 1e-6;
|
||||
if y + gh + footer_h <= col.y1 + 0.01 || (body_placed == 0 && at_col_top) {
|
||||
if y + gh + footer_h > col.y1 + 0.01 && body_placed == 0 && at_col_top && y + gh > col.y1 + 0.01 {
|
||||
// A row group taller than an empty column can never fit.
|
||||
return false;
|
||||
}
|
||||
place_rows(r..ge, &mut y, false, &mut frag_rows);
|
||||
body_placed += 1;
|
||||
r = ge;
|
||||
continue;
|
||||
}
|
||||
// Continue in the next column / frame.
|
||||
if body_placed > 0 {
|
||||
if o.repeat_footer {
|
||||
place_rows(body_end..nr, &mut y, true, &mut frag_rows);
|
||||
}
|
||||
emit(table, anchor, pi, &frag_rows, &heights, &colx, col.x0 + x_off, top, y, first_frag, false, &layouts, &owners, cur, col, out);
|
||||
first_frag = false;
|
||||
}
|
||||
let was_first = first_frag;
|
||||
cur.next_column(cols);
|
||||
if cur.fi >= frames.len() {
|
||||
return false;
|
||||
}
|
||||
col = col_rect(cur);
|
||||
y = col.y0;
|
||||
top = y;
|
||||
frag_rows.clear();
|
||||
body_placed = 0;
|
||||
// Headers: the first fragment's header moves along; later ones repeat it.
|
||||
if was_first {
|
||||
place_rows(0..header, &mut y, false, &mut frag_rows);
|
||||
} else if o.repeat_header {
|
||||
place_rows(0..header, &mut y, true, &mut frag_rows);
|
||||
}
|
||||
}
|
||||
place_rows(body_end..nr, &mut y, false, &mut frag_rows);
|
||||
emit(table, anchor, pi, &frag_rows, &heights, &colx, col.x0 + x_off, top, y, first_frag, true, &layouts, &owners, cur, col, out);
|
||||
cur.last_baseline = Some(y);
|
||||
cur.last_descent = 0.0;
|
||||
cur.pending = o.space_after;
|
||||
true
|
||||
}
|
||||
|
||||
/// Build one fragment from its rows and push it (with its glyph-less line) to the current frame.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn emit(
|
||||
table: &Table,
|
||||
anchor: usize,
|
||||
pi: usize,
|
||||
rows: &[(usize, f64, bool)],
|
||||
heights: &[f64],
|
||||
colx: &[f64],
|
||||
x0: f64,
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
first: bool,
|
||||
last: bool,
|
||||
layouts: &[Option<CellLayout>],
|
||||
owners: &[(usize, usize)],
|
||||
cur: &Cursor,
|
||||
col: Rect,
|
||||
out: &mut ComposedStory,
|
||||
) {
|
||||
let nc = table.ncols();
|
||||
let ry = |r: usize| rows.iter().find(|x| x.0 == r).map(|x| x.1);
|
||||
let header = table.header_rows();
|
||||
let body = table.body_rows();
|
||||
let nbody = body.len();
|
||||
let mut cells = Vec::new();
|
||||
let mut strokes = Vec::new();
|
||||
let o = &table.options;
|
||||
let right = x0 + colx[nc];
|
||||
for &(r, y, repeated) in rows {
|
||||
for c in 0..nc {
|
||||
if owners[r * nc + c] != (r, c) {
|
||||
continue;
|
||||
}
|
||||
let cell = &table.cells[r * nc + c];
|
||||
let cs = (cell.col_span.max(1) as usize).min(nc - c);
|
||||
let rs = (cell.row_span.max(1) as usize).min(table.nrows() - r);
|
||||
let last_row = r + rs - 1;
|
||||
let y1 = ry(last_row).map_or(y + heights[r], |ly| ly + heights[last_row]);
|
||||
let rect = Rect::new(x0 + colx[c], y, x0 + colx[c + cs], y1);
|
||||
let fill = if cell.has_fill() {
|
||||
Some((cell.fill.clone(), cell.fill_tint))
|
||||
} else if body.contains(&r) {
|
||||
let i = r - header;
|
||||
o.alt_rows
|
||||
.as_ref()
|
||||
.and_then(|a| a.fill_for(i, nbody))
|
||||
.or_else(|| o.alt_cols.as_ref().and_then(|a| a.fill_for(c, nc)))
|
||||
.map(|(s, t)| (s.to_string(), t))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let lay = layouts[r * nc + c].as_ref().expect("owner composed");
|
||||
let clip = Rect::new(rect.x0 + cell.insets[1], rect.y0 + cell.insets[0], rect.x1 - cell.insets[3], rect.y1 - cell.insets[2]);
|
||||
let space = clip.height() - lay.content_h;
|
||||
let dy = match cell.vj {
|
||||
VerticalJustification::Center if space > 0.0 => space / 2.0,
|
||||
VerticalJustification::Bottom if space > 0.0 => space,
|
||||
_ => 0.0,
|
||||
};
|
||||
let overset = space < -0.01;
|
||||
let mut text = lay.text.clone();
|
||||
if overset && let Some(f) = text.frames.first() {
|
||||
// Drop the lines that don't fit the cell.
|
||||
let keep: Vec<Line> = f.lines.iter().filter(|l| l.baseline + l.descent <= clip.height() + 0.01).cloned().collect();
|
||||
let mut t = (*text).clone();
|
||||
t.frames[0].lines = keep;
|
||||
text = Arc::new(t);
|
||||
}
|
||||
cells.push(PlacedCell {
|
||||
row: r,
|
||||
col: c,
|
||||
rect,
|
||||
fill,
|
||||
origin: Point::new(clip.x0, clip.y0 + dy),
|
||||
text,
|
||||
source: Arc::from(cell.text.text.as_str()),
|
||||
clip,
|
||||
overset,
|
||||
repeated,
|
||||
});
|
||||
// Edges: every cell draws its top and left; the fragment's bottom/right cells their bottom/right.
|
||||
// The table border replaces outer edges.
|
||||
let at_top = (y - top).abs() < 1e-6;
|
||||
let at_bottom = (y1 - bottom).abs() < 1e-6;
|
||||
let edge = |i: usize, outer: bool| if outer { o.border.clone() } else { cell.strokes[i].clone() };
|
||||
let mut push = |a: Point, b: Point, s: CellStroke| {
|
||||
if s.is_visible() {
|
||||
strokes.push(StrokeSeg { a, b, stroke: s });
|
||||
}
|
||||
};
|
||||
push(Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), edge(0, at_top));
|
||||
push(Point::new(rect.x0, rect.y0), Point::new(rect.x0, rect.y1), edge(1, c == 0));
|
||||
if at_bottom {
|
||||
push(Point::new(rect.x0, rect.y1), Point::new(rect.x1, rect.y1), edge(2, true));
|
||||
}
|
||||
if c + cs == nc {
|
||||
push(Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), edge(3, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ft = &mut out.frames[cur.fi];
|
||||
let line = ft.lines.len();
|
||||
let h = bottom - top;
|
||||
ft.lines.push(Line {
|
||||
column: cur.col as u32,
|
||||
baseline: bottom,
|
||||
x0: col.x0,
|
||||
x1: col.x1,
|
||||
ascent: h,
|
||||
descent: 0.0,
|
||||
leading: h,
|
||||
range: anchor..anchor + designcraft_doc::TABLE_ANCHOR.len_utf8(),
|
||||
para: pi,
|
||||
glyphs: vec![],
|
||||
hyphenated: false,
|
||||
first_in_para: first,
|
||||
last_in_para: last,
|
||||
end_x: right,
|
||||
spacing: 1.0,
|
||||
});
|
||||
ft.tables.push(TableFrag {
|
||||
table: table.id,
|
||||
anchor,
|
||||
column: cur.col as u32,
|
||||
rect: Rect::new(x0, top, right, bottom),
|
||||
cells,
|
||||
strokes,
|
||||
line,
|
||||
first,
|
||||
last,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- hit testing and carets in cells ----------
|
||||
|
||||
/// The cell under `p` (frame inner space) in frame `fi`: (table, row, col, story byte in the cell).
|
||||
pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize, usize, usize)> {
|
||||
let ft = cs.frames.get(fi)?;
|
||||
for t in &ft.tables {
|
||||
if !t.rect.contains(p) {
|
||||
continue;
|
||||
}
|
||||
for c in &t.cells {
|
||||
if c.rect.contains(p) {
|
||||
let local = Point::new(p.x - c.origin.x, p.y - c.origin.y);
|
||||
let b = crate::hit(&c.text, 0, local).unwrap_or(0);
|
||||
return Some((t.table, c.row, c.col, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Frame index and placed cell for a cell address (the first, non-repeated occurrence).
|
||||
pub fn find_cell(cs: &ComposedStory, table: u64, row: usize, col: usize) -> Option<(usize, &TableFrag, &PlacedCell)> {
|
||||
let mut found = None;
|
||||
for (fi, ft) in cs.frames.iter().enumerate() {
|
||||
for t in ft.tables.iter().filter(|t| t.table == table) {
|
||||
if let Some(c) = t.cell(row, col) {
|
||||
if !c.repeated {
|
||||
return Some((fi, t, c));
|
||||
}
|
||||
found = found.or(Some((fi, t, c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Caret geometry for byte `pos` of a cell's story, in frame inner space:
|
||||
/// (frame index, x, baseline, ascent, descent).
|
||||
pub fn cell_caret(cs: &ComposedStory, table: u64, row: usize, col: usize, pos: usize) -> Option<(usize, f64, f64, f64, f64)> {
|
||||
let (fi, _, c) = find_cell(cs, table, row, col)?;
|
||||
match crate::caret(&c.text, pos) {
|
||||
Some((_, x, bl, a, d)) => Some((fi, x + c.origin.x, bl + c.origin.y, a, d)),
|
||||
None => Some((fi, c.origin.x, c.origin.y + 10.0, 9.0, 3.0)),
|
||||
}
|
||||
}
|
||||
|
|
@ -569,3 +569,145 @@ fn tabs_without_stops_use_default_half_inch() {
|
|||
let b = l.glyphs.iter().find(|g| g.byte == 2).expect("B");
|
||||
assert!((b.x - 36.0).abs() < 0.5, "B at {}", b.x);
|
||||
}
|
||||
|
||||
// ---------- tables ----------
|
||||
|
||||
fn table_doc(frame: Rect, t: designcraft_doc::Table) -> (Document, StoryId, ItemId) {
|
||||
let (mut d, sid, fid) = doc_with("Intro", frame, ParaAttrs::default());
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let end = st.len();
|
||||
st.insert_table(end, t);
|
||||
let end = st.len();
|
||||
st.insert(end, "After");
|
||||
d.check().unwrap();
|
||||
(d, sid, fid)
|
||||
}
|
||||
|
||||
fn filled(rows: usize, cols: usize, header: usize, width: f64) -> designcraft_doc::Table {
|
||||
let mut t = designcraft_doc::Table::new(77, rows, cols, header, 0, width);
|
||||
for r in 0..t.nrows() {
|
||||
for c in 0..t.ncols() {
|
||||
t.cell_mut(r, c).unwrap().text.insert(0, &format!("R{r}C{c}"));
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rows_fit_their_content() {
|
||||
let mut t = filled(3, 3, 1, 300.0);
|
||||
t.cell_mut(2, 1).unwrap().text.insert(0, &format!("{LOREM} "));
|
||||
t.rows[3].height = 50.0;
|
||||
t.rows[3].mode = designcraft_doc::RowHeightMode::Exactly;
|
||||
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 400.0, 1000.0), t);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(!cs.is_overset());
|
||||
let ft = &cs.frames[0];
|
||||
assert_eq!(ft.tables.len(), 1);
|
||||
let tf = &ft.tables[0];
|
||||
assert_eq!(tf.cells.len(), 12);
|
||||
let h = |r: usize| tf.cell(r, 0).unwrap().rect.height();
|
||||
// One line of 12 pt text + 4 + 4 insets.
|
||||
assert!(h(0) > 12.0 && h(0) < 25.0, "{}", h(0));
|
||||
// The long cell makes its row taller; all cells in a row share the height.
|
||||
assert!(h(2) > 5.0 * h(0), "{} vs {}", h(2), h(0));
|
||||
assert!((tf.cell(2, 2).unwrap().rect.height() - h(2)).abs() < 1e-9);
|
||||
assert!((h(3) - 50.0).abs() < 1e-9);
|
||||
// Cell text sits inside the cell insets.
|
||||
let c = tf.cell(1, 1).unwrap();
|
||||
let l = &c.text.frames[0].lines[0];
|
||||
assert!(c.origin.x + l.x0 >= c.rect.x0 + 3.99);
|
||||
assert!(c.origin.y + l.baseline < c.rect.y1);
|
||||
// Text after the table is below it.
|
||||
let len = d.story(sid).unwrap().len();
|
||||
let after = ft.lines.iter().find(|l| l.range.start == len - 5).unwrap();
|
||||
assert!(after.baseline > tf.rect.y1);
|
||||
// Strokes: outer border + inner edges.
|
||||
assert!(tf.strokes.len() >= 4 * 3 + 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_columns_scale_to_the_text_column() {
|
||||
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 200.0, 1000.0), filled(2, 4, 0, 600.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let tf = &cs.frames[0].tables[0];
|
||||
assert!((tf.rect.width() - 200.0).abs() < 1e-6, "{}", tf.rect.width());
|
||||
assert!((tf.cell(0, 3).unwrap().rect.x1 - 200.0).abs() < 1e-6);
|
||||
// A narrow table keeps its widths.
|
||||
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 400.0, 1000.0), filled(2, 2, 0, 100.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!((cs.frames[0].tables[0].rect.width() - 100.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rows_break_across_frames_with_header_repeat() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 150.0), lid, "", ParaFormat::default()).unwrap();
|
||||
let (f2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 200.0, 300.0, 350.0), lid, "", ParaFormat::default()).unwrap();
|
||||
d.thread(f1, f2).unwrap();
|
||||
d.story_mut(sid).unwrap().insert_table(0, filled(9, 2, 1, 300.0));
|
||||
d.check().unwrap();
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(!cs.is_overset(), "overset at {:?}", cs.overset_at);
|
||||
let (a, b) = (&cs.frames[0].tables, &cs.frames[1].tables);
|
||||
assert_eq!((a.len(), b.len()), (1, 1));
|
||||
// Every body row appears exactly once; the header is repeated in the second frame.
|
||||
let body: Vec<usize> = a[0].cells.iter().chain(b[0].cells.iter()).filter(|c| c.row > 0 && c.col == 0).map(|c| c.row).collect();
|
||||
assert_eq!(body, (1..10).collect::<Vec<_>>());
|
||||
assert!(!a[0].cell(0, 0).unwrap().repeated);
|
||||
assert!(b[0].cell(0, 0).unwrap().repeated);
|
||||
assert!((b[0].rect.y0 - 200.0).abs() < 1e-6, "{}", b[0].rect.y0);
|
||||
assert!(a[0].rect.y1 <= 150.0 + 1e-6);
|
||||
assert!(a[0].first && !a[0].last && b[0].last);
|
||||
// Without header repeat the second fragment starts with a body row.
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.table_mut(77).unwrap().options.repeat_header = false;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(cs.frames[1].tables[0].cell(0, 0).is_none());
|
||||
// Too little room: overset.
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let t = st.table_mut(77).unwrap();
|
||||
t.insert_rows(5, 30);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(cs.is_overset());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cells_hit_test_and_place_carets() {
|
||||
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 300.0, 1000.0), filled(2, 3, 0, 300.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let c = cs.frames[0].tables[0].cell(1, 2).unwrap().clone();
|
||||
let p = Point::new(c.rect.x1 - 2.0, c.rect.center().y);
|
||||
let (t, r, col, b) = hit_cell(&cs, 0, p).unwrap();
|
||||
assert_eq!((t, r, col), (77, 1, 2));
|
||||
assert_eq!(b, "R1C2".len());
|
||||
let (fi, x, bl, _, _) = cell_caret(&cs, 77, 1, 2, 0).unwrap();
|
||||
assert_eq!(fi, 0);
|
||||
assert!(c.rect.contains(Point::new(x + 0.1, bl - 1.0)));
|
||||
// Outside every cell: no cell.
|
||||
assert!(hit_cell(&cs, 0, Point::new(1.0, 1.0)).is_none());
|
||||
// The anchor has a caret position (after the table).
|
||||
let a = d.story(sid).unwrap().table_anchor(77).unwrap();
|
||||
assert!(caret(&cs, a).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_fills_and_merged_cells() {
|
||||
let mut t = filled(4, 3, 1, 300.0);
|
||||
t.options.alt_rows = Some(designcraft_doc::AltFills { first: 1, next: 1, ..Default::default() });
|
||||
t.cell_mut(0, 0).unwrap().fill = "[Black]".into();
|
||||
t.merge(designcraft_doc::CellRange::new(1, 1, 2, 2)).unwrap();
|
||||
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 300.0, 1000.0), t);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let tf = &cs.frames[0].tables[0];
|
||||
assert!(tf.cell(0, 0).unwrap().fill.is_some());
|
||||
assert!(tf.cell(0, 1).unwrap().fill.is_none(), "header rows don't alternate");
|
||||
assert!(tf.cell(1, 0).unwrap().fill.is_some());
|
||||
assert!(tf.cell(2, 0).unwrap().fill.is_none());
|
||||
let m = tf.cell(1, 1).unwrap();
|
||||
let both = tf.cell(1, 0).unwrap().rect.height() + tf.cell(2, 0).unwrap().rect.height();
|
||||
assert!((m.rect.height() - both).abs() < 1e-6);
|
||||
assert!(tf.cell(2, 2).is_none());
|
||||
assert_eq!(tf.cells.len(), 15 - 3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,25 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::{ItemId, StoryId};
|
||||
use crate::table::CellRange;
|
||||
|
||||
/// A table cell: the table (in the selection's story) and the owning cell's grid position.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CellAddr {
|
||||
pub table: u64,
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
/// Selected table cells (Table > Select, dragging across cells).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableSel {
|
||||
pub story: StoryId,
|
||||
pub table: u64,
|
||||
pub range: CellRange,
|
||||
}
|
||||
|
||||
/// A caret or text range in a story. `anchor` stays put while `focus` moves (Shift-arrows).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -14,11 +33,14 @@ pub struct TextSel {
|
|||
/// The frame the caret is shown in (for carets at frame boundaries).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub frame: Option<ItemId>,
|
||||
/// Text in a table cell: `anchor`/`focus` index the cell's story instead of `story`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cell: Option<CellAddr>,
|
||||
}
|
||||
|
||||
impl TextSel {
|
||||
pub fn caret(story: StoryId, pos: usize) -> Self {
|
||||
TextSel { story, anchor: pos, focus: pos, frame: None }
|
||||
TextSel { story, anchor: pos, focus: pos, frame: None, cell: None }
|
||||
}
|
||||
pub fn range(&self) -> std::ops::Range<usize> {
|
||||
self.anchor.min(self.focus)..self.anchor.max(self.focus)
|
||||
|
|
@ -41,6 +63,9 @@ pub struct Selection {
|
|||
pub anchors: Vec<(ItemId, usize, usize)>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<TextSel>,
|
||||
/// Selected table cells.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cells: Option<TableSel>,
|
||||
/// Key object for Align.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<ItemId>,
|
||||
|
|
@ -48,7 +73,7 @@ pub struct Selection {
|
|||
|
||||
impl Selection {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty() && self.text.is_none()
|
||||
self.items.is_empty() && self.text.is_none() && self.cells.is_none()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
|
|
|
|||
|
|
@ -9,12 +9,17 @@
|
|||
//!
|
||||
//! Special characters: `\t` tab, `\u{2028}` forced line break, [`PAGE_NUMBER`], [`SECTION_MARKER`],
|
||||
//! [`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], `\u{AD}` discretionary hyphen,
|
||||
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`].
|
||||
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`],
|
||||
//! [`TABLE_ANCHOR`] (a table, see [`crate::table`]).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::attrs::{CharAttrs, ParaAttrs};
|
||||
use crate::ids::{ItemId, StoryId};
|
||||
use crate::table::Table;
|
||||
|
||||
pub const PAGE_NUMBER: char = '\u{E000}';
|
||||
pub const SECTION_MARKER: char = '\u{E001}';
|
||||
|
|
@ -25,6 +30,8 @@ pub const INDENT_HERE: char = '\u{E005}';
|
|||
pub const RIGHT_INDENT_TAB: char = '\u{E006}';
|
||||
pub const NEXT_PAGE_NUMBER: char = '\u{E007}';
|
||||
pub const PREV_PAGE_NUMBER: char = '\u{E008}';
|
||||
/// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table.
|
||||
pub const TABLE_ANCHOR: char = '\u{E009}';
|
||||
pub const FORCED_LINE_BREAK: char = '\u{2028}';
|
||||
|
||||
pub const BASIC_PARAGRAPH: &str = "[Basic Paragraph]";
|
||||
|
|
@ -39,11 +46,14 @@ pub struct ParaFormat {
|
|||
/// Paragraph-level character overrides are stored on the char runs; this holds nothing.
|
||||
#[serde(default, skip_serializing_if = "CharAttrs::is_empty")]
|
||||
pub chars: CharAttrs,
|
||||
/// The table anchored in this paragraph (key into [`Story::tables`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ParaFormat {
|
||||
fn default() -> Self {
|
||||
ParaFormat { style: BASIC_PARAGRAPH.into(), para: ParaAttrs::default(), chars: CharAttrs::default() }
|
||||
ParaFormat { style: BASIC_PARAGRAPH.into(), para: ParaAttrs::default(), chars: CharAttrs::default(), table: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +91,9 @@ pub struct Story {
|
|||
/// Bumped on every edit (composition cache key).
|
||||
#[serde(default)]
|
||||
pub rev: u64,
|
||||
/// Tables anchored in the story, by id (see [`crate::table`]).
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub tables: BTreeMap<u64, Arc<Table>>,
|
||||
}
|
||||
|
||||
impl Story {
|
||||
|
|
@ -92,6 +105,7 @@ impl Story {
|
|||
chars: vec![CharRun { len: 0, format: CharFormat::default() }],
|
||||
frames: vec![],
|
||||
rev: 0,
|
||||
tables: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +174,17 @@ impl Story {
|
|||
|
||||
/// Insert with an explicit character format.
|
||||
pub fn insert_with(&mut self, pos: usize, text: &str, fmt: CharFormat) {
|
||||
self.insert_with_raw(pos, text, fmt);
|
||||
self.fix_tables();
|
||||
}
|
||||
|
||||
/// Insert without the table fix-up (callers fix up themselves).
|
||||
pub(crate) fn insert_raw(&mut self, pos: usize, text: &str) {
|
||||
let fmt = self.char_format_at(pos).clone();
|
||||
self.insert_with_raw(pos, text, fmt);
|
||||
}
|
||||
|
||||
fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -189,10 +214,20 @@ impl Story {
|
|||
}
|
||||
let pi = self.para_at(a);
|
||||
let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count();
|
||||
// Merged paragraphs keep a table whose anchor survives the deletion.
|
||||
if removed > 0 && !self.tables.is_empty() {
|
||||
let ranges = self.para_ranges();
|
||||
let first = self.paras[pi].table.filter(|_| self.text[ranges[pi].start..a].contains(TABLE_ANCHOR));
|
||||
let last = self.paras[pi + removed]
|
||||
.table
|
||||
.filter(|_| self.text[b.min(ranges[pi + removed].end)..ranges[pi + removed].end].contains(TABLE_ANCHOR));
|
||||
self.paras[pi].table = first.or(last);
|
||||
}
|
||||
self.paras.drain(pi + 1..pi + 1 + removed);
|
||||
self.text.replace_range(a..b, "");
|
||||
self.remove_run_bytes(a, b - a);
|
||||
self.normalize();
|
||||
self.fix_tables();
|
||||
self.rev += 1;
|
||||
}
|
||||
|
||||
|
|
@ -242,7 +277,9 @@ impl Story {
|
|||
let a = self.para_at(range.start);
|
||||
let b = self.para_at(range.end.max(range.start));
|
||||
for p in &mut self.paras[a..=b] {
|
||||
let table = p.table;
|
||||
f(p);
|
||||
p.table = table;
|
||||
}
|
||||
self.rev += 1;
|
||||
}
|
||||
|
|
@ -277,7 +314,7 @@ impl Story {
|
|||
return Err(format!("run boundary {pos} splits a character"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
self.check_tables()
|
||||
}
|
||||
|
||||
// ---------- run helpers ----------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,944 @@
|
|||
//! Tables.
|
||||
//!
|
||||
//! A table lives in a story: a [`TABLE_ANCHOR`](crate::story::TABLE_ANCHOR) character occupies its
|
||||
//! own paragraph and that paragraph's [`ParaFormat::table`](crate::story::ParaFormat) names the
|
||||
//! table in [`Story::tables`]. Deleting the anchor character deletes the table.
|
||||
//!
|
||||
//! The grid is `rows × columns` cells stored row-major. A merged cell is the top-left cell of its
|
||||
//! region with `row_span`/`col_span` > 1; the cells it covers stay in the grid (empty, ignored).
|
||||
//! Each cell holds a [`Story`] (no frames) composed into the cell rectangle.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::StoryId;
|
||||
use crate::item::{StrokeType, VerticalJustification};
|
||||
use crate::story::{CharFormat, ParaFormat, Story, TABLE_ANCHOR};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RowHeightMode {
|
||||
/// Grows with the content (the height is a minimum).
|
||||
#[default]
|
||||
AtLeast,
|
||||
Exactly,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RowKind {
|
||||
Header,
|
||||
#[default]
|
||||
Body,
|
||||
Footer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Row {
|
||||
pub height: f64,
|
||||
pub mode: RowHeightMode,
|
||||
pub kind: RowKind,
|
||||
}
|
||||
|
||||
impl Default for Row {
|
||||
fn default() -> Self {
|
||||
Row { height: 3.0, mode: RowHeightMode::AtLeast, kind: RowKind::Body }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Column {
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// One cell edge or the table border.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct CellStroke {
|
||||
pub weight: f64,
|
||||
pub color: String,
|
||||
pub tint: f32,
|
||||
pub kind: StrokeType,
|
||||
}
|
||||
|
||||
impl Default for CellStroke {
|
||||
fn default() -> Self {
|
||||
CellStroke { weight: 1.0, color: designcraft_color::swatch::BLACK.into(), tint: 1.0, kind: StrokeType::Solid }
|
||||
}
|
||||
}
|
||||
|
||||
impl CellStroke {
|
||||
pub fn none() -> Self {
|
||||
CellStroke { weight: 0.0, ..Default::default() }
|
||||
}
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.weight > 0.0 && self.color != designcraft_color::swatch::NONE
|
||||
}
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
fn full() -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn no_fill() -> String {
|
||||
designcraft_color::swatch::NONE.into()
|
||||
}
|
||||
fn default_insets() -> [f64; 4] {
|
||||
[4.0; 4]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cell {
|
||||
#[serde(default = "one")]
|
||||
pub row_span: u32,
|
||||
#[serde(default = "one")]
|
||||
pub col_span: u32,
|
||||
/// Cell content (a frameless story).
|
||||
pub text: Story,
|
||||
#[serde(default = "no_fill")]
|
||||
pub fill: String,
|
||||
#[serde(default = "full")]
|
||||
pub fill_tint: f32,
|
||||
/// Top, left, bottom, right.
|
||||
#[serde(default = "default_insets")]
|
||||
pub insets: [f64; 4],
|
||||
#[serde(default)]
|
||||
pub vj: VerticalJustification,
|
||||
/// Text rotation in degrees (only 0 is composed for now).
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
/// Edge strokes: top, left, bottom, right.
|
||||
#[serde(default)]
|
||||
pub strokes: [CellStroke; 4],
|
||||
}
|
||||
|
||||
impl Default for Cell {
|
||||
fn default() -> Self {
|
||||
Cell {
|
||||
row_span: 1,
|
||||
col_span: 1,
|
||||
text: Story::new(StoryId(0)),
|
||||
fill: no_fill(),
|
||||
fill_tint: 1.0,
|
||||
insets: default_insets(),
|
||||
vj: VerticalJustification::Top,
|
||||
rotation: 0.0,
|
||||
strokes: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn with_text(text: &str, para: ParaFormat) -> Cell {
|
||||
Cell { text: Story::with_text(StoryId(0), text, para), ..Default::default() }
|
||||
}
|
||||
/// A cell with this cell's formatting and no text (new rows / columns, unmerged cells).
|
||||
pub fn blank_like(&self) -> Cell {
|
||||
let mut c = Cell { row_span: 1, col_span: 1, ..self.clone() };
|
||||
let para = self.text.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default();
|
||||
let fmt = self.text.chars.first().map(|r| r.format.clone()).unwrap_or_default();
|
||||
c.text = Story::new(StoryId(0));
|
||||
c.text.paras[0] = para;
|
||||
c.text.chars[0].format = fmt;
|
||||
c
|
||||
}
|
||||
pub fn has_fill(&self) -> bool {
|
||||
self.fill != designcraft_color::swatch::NONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Table → Table Options → Fills (alternating pattern).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct AltFills {
|
||||
pub first: u32,
|
||||
pub first_color: String,
|
||||
pub first_tint: f32,
|
||||
pub next: u32,
|
||||
pub next_color: String,
|
||||
pub next_tint: f32,
|
||||
pub skip_first: u32,
|
||||
pub skip_last: u32,
|
||||
}
|
||||
|
||||
impl Default for AltFills {
|
||||
fn default() -> Self {
|
||||
AltFills {
|
||||
first: 1,
|
||||
first_color: designcraft_color::swatch::BLACK.into(),
|
||||
first_tint: 0.2,
|
||||
next: 1,
|
||||
next_color: designcraft_color::swatch::NONE.into(),
|
||||
next_tint: 1.0,
|
||||
skip_first: 0,
|
||||
skip_last: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AltFills {
|
||||
/// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it.
|
||||
pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> {
|
||||
let (sf, sl) = (self.skip_first as usize, self.skip_last as usize);
|
||||
if i < sf || i + sl >= n {
|
||||
return None;
|
||||
}
|
||||
let period = (self.first + self.next).max(1) as usize;
|
||||
let k = (i - sf) % period;
|
||||
let (c, t) = if k < self.first as usize { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) };
|
||||
(c != designcraft_color::swatch::NONE).then_some((c.as_str(), t))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TableOptions {
|
||||
/// Outer border (overrides the outer cell edges).
|
||||
pub border: CellStroke,
|
||||
pub space_before: f64,
|
||||
pub space_after: f64,
|
||||
/// Header rows repeat at the top of every text column / frame the table continues into.
|
||||
pub repeat_header: bool,
|
||||
pub repeat_footer: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alt_rows: Option<AltFills>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alt_cols: Option<AltFills>,
|
||||
}
|
||||
|
||||
impl Default for TableOptions {
|
||||
fn default() -> Self {
|
||||
TableOptions {
|
||||
border: CellStroke::default(),
|
||||
space_before: 4.0,
|
||||
space_after: -4.0,
|
||||
repeat_header: true,
|
||||
repeat_footer: true,
|
||||
alt_rows: None,
|
||||
alt_cols: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Table {
|
||||
pub id: u64,
|
||||
pub rows: Vec<Row>,
|
||||
pub columns: Vec<Column>,
|
||||
/// Row-major, `rows.len() * columns.len()`.
|
||||
pub cells: Vec<Cell>,
|
||||
#[serde(default)]
|
||||
pub options: TableOptions,
|
||||
}
|
||||
|
||||
/// A rectangular range of cells (inclusive).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CellRange {
|
||||
pub r0: usize,
|
||||
pub c0: usize,
|
||||
pub r1: usize,
|
||||
pub c1: usize,
|
||||
}
|
||||
|
||||
impl CellRange {
|
||||
pub fn cell(r: usize, c: usize) -> Self {
|
||||
CellRange { r0: r, c0: c, r1: r, c1: c }
|
||||
}
|
||||
pub fn new(ra: usize, ca: usize, rb: usize, cb: usize) -> Self {
|
||||
CellRange { r0: ra.min(rb), c0: ca.min(cb), r1: ra.max(rb), c1: ca.max(cb) }
|
||||
}
|
||||
pub fn contains(&self, r: usize, c: usize) -> bool {
|
||||
r >= self.r0 && r <= self.r1 && c >= self.c0 && c <= self.c1
|
||||
}
|
||||
}
|
||||
|
||||
impl Table {
|
||||
/// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`.
|
||||
pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table {
|
||||
let cols = cols.max(1);
|
||||
let mut rows = Vec::new();
|
||||
rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() }));
|
||||
rows.extend((0..body_rows.max(1)).map(|_| Row::default()));
|
||||
rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() }));
|
||||
let w = (width / cols as f64).max(3.0);
|
||||
let n = rows.len() * cols;
|
||||
Table { id, rows, columns: vec![Column { width: w }; cols], cells: vec![Cell::default(); n], options: TableOptions::default() }
|
||||
}
|
||||
|
||||
/// A table from rows of cell strings (Convert Text to Table). Short rows are padded.
|
||||
pub fn from_strings(id: u64, data: &[Vec<String>], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table {
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width);
|
||||
for (r, row) in data.iter().enumerate() {
|
||||
for (c, s) in row.iter().enumerate() {
|
||||
let cell = t.cell_mut(r, c).expect("in range");
|
||||
cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() });
|
||||
cell.text.chars[0].format = fmt.clone();
|
||||
cell.text.insert(0, s);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
pub fn nrows(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
pub fn ncols(&self) -> usize {
|
||||
self.columns.len()
|
||||
}
|
||||
pub fn width(&self) -> f64 {
|
||||
self.columns.iter().map(|c| c.width).sum()
|
||||
}
|
||||
fn idx(&self, r: usize, c: usize) -> Option<usize> {
|
||||
(r < self.nrows() && c < self.ncols()).then(|| r * self.ncols() + c)
|
||||
}
|
||||
pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> {
|
||||
self.cells.get(self.idx(r, c)?)
|
||||
}
|
||||
pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> {
|
||||
let i = self.idx(r, c)?;
|
||||
self.cells.get_mut(i)
|
||||
}
|
||||
pub fn header_rows(&self) -> usize {
|
||||
self.rows.iter().take_while(|r| r.kind == RowKind::Header).count()
|
||||
}
|
||||
pub fn footer_rows(&self) -> usize {
|
||||
self.rows.iter().rev().take_while(|r| r.kind == RowKind::Footer).count()
|
||||
}
|
||||
pub fn body_rows(&self) -> std::ops::Range<usize> {
|
||||
self.header_rows()..self.nrows() - self.footer_rows()
|
||||
}
|
||||
|
||||
/// For every grid position, the (row, col) of the cell that owns it.
|
||||
pub fn owners(&self) -> Vec<(usize, usize)> {
|
||||
let (nr, nc) = (self.nrows(), self.ncols());
|
||||
let mut v: Vec<(usize, usize)> = (0..nr * nc).map(|i| (i / nc.max(1), i % nc.max(1))).collect();
|
||||
for r in 0..nr {
|
||||
for c in 0..nc {
|
||||
let cell = &self.cells[r * nc + c];
|
||||
if v[r * nc + c] != (r, c) {
|
||||
continue;
|
||||
}
|
||||
for rr in r..(r + cell.row_span.max(1) as usize).min(nr) {
|
||||
for cc in c..(c + cell.col_span.max(1) as usize).min(nc) {
|
||||
v[rr * nc + cc] = (r, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The owning (top-left) cell address of grid position (r, c).
|
||||
pub fn owner(&self, r: usize, c: usize) -> (usize, usize) {
|
||||
self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c))
|
||||
}
|
||||
|
||||
/// Merged regions `(r, c, row_span, col_span)` (span > 1 in some direction).
|
||||
pub fn regions(&self) -> Vec<(usize, usize, usize, usize)> {
|
||||
let owners = self.owners();
|
||||
let mut v = Vec::new();
|
||||
for r in 0..self.nrows() {
|
||||
for c in 0..self.ncols() {
|
||||
let cell = &self.cells[r * self.ncols() + c];
|
||||
if owners[r * self.ncols() + c] == (r, c) && (cell.row_span > 1 || cell.col_span > 1) {
|
||||
v.push((r, c, cell.row_span as usize, cell.col_span as usize));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn set_regions(&mut self, regions: &[(usize, usize, usize, usize)]) {
|
||||
for c in &mut self.cells {
|
||||
c.row_span = 1;
|
||||
c.col_span = 1;
|
||||
}
|
||||
for &(r, c, rs, cs) in regions {
|
||||
if rs == 0 || cs == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(cell) = self.cell_mut(r, c) {
|
||||
cell.row_span = rs as u32;
|
||||
cell.col_span = cs as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `n` rows before row `at` (formatted like the neighbouring row).
|
||||
pub fn insert_rows(&mut self, at: usize, n: usize) {
|
||||
let at = at.min(self.nrows());
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let nc = self.ncols();
|
||||
let src = if at < self.nrows() { at } else { at.saturating_sub(1) };
|
||||
let mut row = self.rows.get(src).cloned().unwrap_or_default();
|
||||
// A row inserted between body rows (or after the last header / before the first footer) is a body row.
|
||||
if at > 0 && at < self.nrows() && self.rows[at - 1].kind != self.rows[at].kind {
|
||||
row.kind = RowKind::Body;
|
||||
}
|
||||
if at == self.nrows() && row.kind == RowKind::Header {
|
||||
row.kind = RowKind::Body;
|
||||
}
|
||||
if at == 0 && row.kind == RowKind::Footer {
|
||||
row.kind = RowKind::Body;
|
||||
}
|
||||
let template: Vec<Cell> = (0..nc).map(|c| self.cell(src, c).map(Cell::blank_like).unwrap_or_default()).collect();
|
||||
let regions: Vec<_> = self
|
||||
.regions()
|
||||
.into_iter()
|
||||
.map(|(r, c, rs, cs)| {
|
||||
if r >= at {
|
||||
(r + n, c, rs, cs)
|
||||
} else if r + rs > at {
|
||||
(r, c, rs + n, cs)
|
||||
} else {
|
||||
(r, c, rs, cs)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for k in 0..n {
|
||||
self.rows.insert(at + k, row.clone());
|
||||
let i = (at + k) * nc;
|
||||
for (c, cell) in template.iter().enumerate() {
|
||||
self.cells.insert(i + c, cell.clone());
|
||||
}
|
||||
}
|
||||
self.set_regions(®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<Story> = Vec::new();
|
||||
for r in range.r0..=range.r1 {
|
||||
for c in range.c0..=range.c1 {
|
||||
if (r, c) != (range.r0, range.c0) && owners[r * nc + c] == (r, c) {
|
||||
let t = std::mem::take(&mut self.cells[r * nc + c]);
|
||||
self.cells[r * nc + c] = t.blank_like();
|
||||
if !t.text.is_empty() {
|
||||
extra.push(t.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let owner = &mut self.cells[range.r0 * nc + range.c0];
|
||||
for s in extra {
|
||||
append_story(&mut owner.text, &s);
|
||||
}
|
||||
regions.push((range.r0, range.c0, range.r1 - range.r0 + 1, range.c1 - range.c0 + 1));
|
||||
self.set_regions(®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<crate::CellAddr>) -> Option<&Story> {
|
||||
let st = self.story(story)?;
|
||||
match cell {
|
||||
Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text),
|
||||
None => Some(st),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable [`Document::text_story`] (a cell edit bumps the containing story's revision).
|
||||
pub fn text_story_mut(&mut self, story: StoryId, cell: Option<crate::CellAddr>) -> Option<&mut Story> {
|
||||
let st = self.story_mut(story)?;
|
||||
match cell {
|
||||
Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text),
|
||||
None => Some(st),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Story {
|
||||
/// The table whose anchor paragraph is `pi`.
|
||||
pub fn para_table(&self, pi: usize) -> Option<&Arc<Table>> {
|
||||
self.tables.get(&self.paras.get(pi)?.table?)
|
||||
}
|
||||
|
||||
/// Byte offset of a table's anchor character.
|
||||
pub fn table_anchor(&self, id: u64) -> Option<usize> {
|
||||
let pi = self.paras.iter().position(|p| p.table == Some(id))?;
|
||||
let r = self.para_ranges().get(pi)?.clone();
|
||||
self.text[r.clone()].find(TABLE_ANCHOR).map(|k| r.start + k)
|
||||
}
|
||||
|
||||
/// The table anchored at or right before byte `pos` (a caret next to the anchor).
|
||||
pub fn table_at(&self, pos: usize) -> Option<u64> {
|
||||
let pi = self.para_at(pos);
|
||||
self.paras.get(pi)?.table
|
||||
}
|
||||
|
||||
/// Mutable table (copy-on-write); bumps the story revision.
|
||||
pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> {
|
||||
let t = self.tables.get_mut(&id)?;
|
||||
self.rev += 1;
|
||||
Some(Arc::make_mut(t))
|
||||
}
|
||||
|
||||
/// Insert `table` at `pos`, giving its anchor its own paragraph. Returns the anchor's byte offset.
|
||||
pub fn insert_table(&mut self, pos: usize, table: Table) -> usize {
|
||||
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
|
||||
let pi = self.para_at(pos);
|
||||
let pstart = self.para_ranges()[pi].start;
|
||||
let mut s = String::new();
|
||||
if pos > pstart {
|
||||
s.push('\n');
|
||||
}
|
||||
let anchor = pos + s.len();
|
||||
s.push(TABLE_ANCHOR);
|
||||
if !self.text[pos..].starts_with('\n') {
|
||||
s.push('\n');
|
||||
}
|
||||
let id = table.id;
|
||||
self.tables.insert(id, Arc::new(table));
|
||||
// Insert the text first (fix-up would drop an unreferenced table), then point the paragraph at it.
|
||||
self.insert_raw(pos, &s);
|
||||
let api = self.para_at(anchor);
|
||||
self.paras[api].table = Some(id);
|
||||
self.fix_tables();
|
||||
self.rev += 1;
|
||||
anchor
|
||||
}
|
||||
|
||||
/// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it
|
||||
/// still contains an anchor character, every table is referenced once, and tables nobody
|
||||
/// references are dropped.
|
||||
pub(crate) fn fix_tables(&mut self) {
|
||||
if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) {
|
||||
return;
|
||||
}
|
||||
let ranges = self.para_ranges();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for (p, r) in self.paras.iter_mut().zip(ranges) {
|
||||
if let Some(id) = p.table
|
||||
&& (!self.text[r].contains(TABLE_ANCHOR) || !self.tables.contains_key(&id) || !used.insert(id))
|
||||
{
|
||||
p.table = None;
|
||||
}
|
||||
}
|
||||
self.tables.retain(|id, _| used.contains(id));
|
||||
}
|
||||
|
||||
/// Check table invariants (part of [`Story::check`]).
|
||||
pub(crate) fn check_tables(&self) -> Result<(), String> {
|
||||
let ranges = self.para_ranges();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for (pi, p) in self.paras.iter().enumerate() {
|
||||
let Some(id) = p.table else { continue };
|
||||
let t = self.tables.get(&id).ok_or_else(|| format!("story {}: paragraph {pi} points at missing table {id}", self.id.0))?;
|
||||
if t.id != id {
|
||||
return Err(format!("story {}: table key {id} != id {}", self.id.0, t.id));
|
||||
}
|
||||
if self.text[ranges[pi].clone()].matches(TABLE_ANCHOR).count() != 1 {
|
||||
return Err(format!("story {}: table {id} paragraph must hold exactly one anchor", self.id.0));
|
||||
}
|
||||
if !used.insert(id) {
|
||||
return Err(format!("story {}: table {id} anchored twice", self.id.0));
|
||||
}
|
||||
t.check()?;
|
||||
}
|
||||
if used.len() != self.tables.len() {
|
||||
return Err(format!("story {}: {} tables but {} anchored", self.id.0, self.tables.len(), used.len()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn story(t: &str) -> Story {
|
||||
Story::with_text(StoryId(1), t, ParaFormat::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_table_gets_own_paragraph() {
|
||||
let mut s = story("before after");
|
||||
let a = s.insert_table(6, Table::new(7, 3, 2, 1, 0, 200.0));
|
||||
s.check().unwrap();
|
||||
assert_eq!(s.text, format!("before\n{TABLE_ANCHOR}\n after"));
|
||||
assert_eq!(s.table_anchor(7), Some(a));
|
||||
assert_eq!(s.para_table(1).map(|t| t.id), Some(7));
|
||||
assert_eq!(s.tables[&7].nrows(), 4);
|
||||
// At the start of a paragraph / end of the story.
|
||||
let mut s = story("x");
|
||||
s.insert_table(1, Table::new(1, 1, 1, 0, 0, 10.0));
|
||||
s.check().unwrap();
|
||||
assert_eq!(s.text, format!("x\n{TABLE_ANCHOR}\n"));
|
||||
let mut s = story("");
|
||||
s.insert_table(0, Table::new(1, 1, 1, 0, 0, 10.0));
|
||||
s.check().unwrap();
|
||||
assert_eq!(s.text, format!("{TABLE_ANCHOR}\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_anchor_removes_the_table() {
|
||||
let mut s = story("ab");
|
||||
let a = s.insert_table(2, Table::new(9, 2, 2, 0, 0, 100.0));
|
||||
s.delete(a..a + TABLE_ANCHOR.len_utf8());
|
||||
s.check().unwrap();
|
||||
assert!(s.tables.is_empty());
|
||||
// Deleting across the paragraph keeps the story consistent too.
|
||||
let mut s = story("ab\ncd");
|
||||
s.insert_table(2, Table::new(9, 2, 2, 0, 0, 100.0));
|
||||
s.delete(1..s.len() - 1);
|
||||
s.check().unwrap();
|
||||
assert!(s.tables.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_around_the_anchor_keep_the_table() {
|
||||
let mut s = story("ab\ncd");
|
||||
let a = s.insert_table(2, Table::new(3, 1, 1, 0, 0, 10.0));
|
||||
// New paragraphs before and after the anchor.
|
||||
s.insert(a, "\n");
|
||||
s.check().unwrap();
|
||||
let a = s.table_anchor(3).unwrap();
|
||||
s.insert(a + 3, "\nzz");
|
||||
s.check().unwrap();
|
||||
assert!(s.tables.contains_key(&3));
|
||||
// Merging the anchor paragraph with the previous one keeps the table.
|
||||
let a = s.table_anchor(3).unwrap();
|
||||
s.delete(a - 1..a);
|
||||
s.check().unwrap();
|
||||
assert!(s.tables.contains_key(&3), "{:?}", s.text);
|
||||
// Restyling the paragraph keeps the table.
|
||||
let a = s.table_anchor(3).unwrap();
|
||||
s.format_paras(a..a, |p| *p = ParaFormat { style: "X".into(), ..Default::default() });
|
||||
s.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_columns_and_merges() {
|
||||
let mut t = Table::new(1, 3, 3, 1, 0, 300.0);
|
||||
t.cell_mut(1, 1).unwrap().text.insert(0, "a");
|
||||
t.cell_mut(1, 2).unwrap().text.insert(0, "b");
|
||||
t.merge(CellRange::new(1, 1, 2, 2)).unwrap();
|
||||
t.check().unwrap();
|
||||
assert_eq!(t.cell(1, 1).unwrap().text.text, "a\nb");
|
||||
assert_eq!(t.owner(2, 2), (1, 1));
|
||||
// Inserting a row inside the merged region extends it.
|
||||
t.insert_rows(2, 1);
|
||||
t.check().unwrap();
|
||||
assert_eq!(t.cell(1, 1).unwrap().row_span, 3);
|
||||
assert_eq!(t.nrows(), 5);
|
||||
// Header stays first.
|
||||
t.insert_rows(0, 1);
|
||||
t.check().unwrap();
|
||||
assert_eq!(t.rows[0].kind, RowKind::Header);
|
||||
assert_eq!(t.header_rows(), 2);
|
||||
// Deleting the owner row moves the content down.
|
||||
let (r, c) = (2, 1);
|
||||
assert_eq!(t.owner(r, c), (2, 1));
|
||||
t.delete_rows(2, 1);
|
||||
t.check().unwrap();
|
||||
assert_eq!(t.cell(2, 1).unwrap().text.text, "a\nb");
|
||||
assert_eq!(t.cell(2, 1).unwrap().row_span, 2);
|
||||
// Columns.
|
||||
t.insert_cols(1, 2, 50.0);
|
||||
t.check().unwrap();
|
||||
assert_eq!(t.ncols(), 5);
|
||||
assert_eq!(t.cell(2, 3).unwrap().col_span, 2);
|
||||
t.delete_cols(3, 1);
|
||||
t.check().unwrap();
|
||||
assert_eq!(t.cell(2, 3).unwrap().text.text, "a\nb");
|
||||
assert_eq!(t.cell(2, 3).unwrap().col_span, 1);
|
||||
t.unmerge(3, 3);
|
||||
t.check().unwrap();
|
||||
assert!(t.regions().is_empty());
|
||||
// Never below one row / column.
|
||||
t.delete_rows(0, 99);
|
||||
t.delete_cols(0, 99);
|
||||
t.check().unwrap();
|
||||
assert_eq!((t.nrows(), t.ncols()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_fills() {
|
||||
let a = AltFills { first: 1, next: 1, ..Default::default() };
|
||||
assert!(a.fill_for(0, 4).is_some());
|
||||
assert!(a.fill_for(1, 4).is_none());
|
||||
assert!(a.fill_for(2, 4).is_some());
|
||||
let b = AltFills { first: 2, next: 1, skip_first: 1, ..Default::default() };
|
||||
assert!(b.fill_for(0, 9).is_none());
|
||||
assert!(b.fill_for(1, 9).is_some());
|
||||
assert!(b.fill_for(2, 9).is_some());
|
||||
assert!(b.fill_for(3, 9).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let mut s = story("x");
|
||||
s.insert_table(1, Table::new(4, 2, 2, 1, 0, 100.0));
|
||||
let j = serde_json::to_string(&s).unwrap();
|
||||
let back: Story = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, s);
|
||||
back.check().unwrap();
|
||||
// Old files without tables still load.
|
||||
let old = r#"{"id":1,"text":"a","paras":[{"style":"[Basic Paragraph]"}],"chars":[{"len":1,"style":"[None]"}],"frames":[]}"#;
|
||||
let st: Story = serde_json::from_str(old).unwrap();
|
||||
assert!(st.tables.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
st.selection = Selection { items: ids, content, ..Default::default() };
|
||||
}
|
||||
st.selection.text = None;
|
||||
st.selection.cells = None;
|
||||
st.revision += 1;
|
||||
ok()
|
||||
}),
|
||||
|
|
@ -62,6 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.toggle", "missing id"))?;
|
||||
let st = s.doc_mut()?;
|
||||
st.selection.text = None;
|
||||
st.selection.cells = None;
|
||||
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
|
||||
st.selection.items.remove(i);
|
||||
} else {
|
||||
|
|
@ -73,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(t) = st.selection.text {
|
||||
let len = st.doc.story(t.story).map(|x| x.len()).unwrap_or(0);
|
||||
let len = st.doc.text_story(t.story, t.cell).map(|x| x.len()).unwrap_or(0);
|
||||
st.selection.text = Some(designcraft_doc::TextSel { anchor: 0, focus: len, ..t });
|
||||
} else {
|
||||
let mut ids = Vec::new();
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let Some((sid, a, b)) = hit else { return Ok(Value::Null) };
|
||||
let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied());
|
||||
let stm = s.doc_mut()?;
|
||||
stm.selection = designcraft_doc::Selection::text(TextSel { story: sid, anchor: a, focus: b, frame });
|
||||
stm.selection = designcraft_doc::Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None });
|
||||
stm.revision += 1;
|
||||
let _ = story::PAGE_NUMBER;
|
||||
Ok(json!({"story": sid.0, "start": a, "end": b}))
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
it.shape = sh;
|
||||
}
|
||||
*sel = if caret {
|
||||
Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id) })
|
||||
Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id), cell: None })
|
||||
} else {
|
||||
Selection::items(vec![id])
|
||||
};
|
||||
|
|
|
|||
|
|
@ -80,8 +80,9 @@ fn apply_para(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
let targets = super::text::format_targets_pub(s);
|
||||
s.edit(|d, _| {
|
||||
for (sid, r) in &targets {
|
||||
if let Some(st) = d.story_mut(*sid) {
|
||||
for t in &targets {
|
||||
let r = &t.range;
|
||||
if let Some(st) = d.text_story_mut(t.story, t.cell) {
|
||||
st.format_paras(r.clone(), |f| {
|
||||
f.style = name.clone();
|
||||
if clear {
|
||||
|
|
@ -106,9 +107,9 @@ fn apply_char(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
let targets = super::text::format_targets_pub(s);
|
||||
s.edit(|d, _| {
|
||||
for (sid, r) in &targets {
|
||||
if let Some(st) = d.story_mut(*sid) {
|
||||
st.format_chars(r.clone(), |f| f.style = name.clone());
|
||||
for t in &targets {
|
||||
if let Some(st) = d.text_story_mut(t.story, t.cell) {
|
||||
st.format_chars(t.range.clone(), |f| f.style = name.clone());
|
||||
}
|
||||
}
|
||||
ok()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,741 @@
|
|||
//! Table menu: create, convert, insert/delete rows and columns, merge, cell/row/column options,
|
||||
//! table options and cell selection.
|
||||
//!
|
||||
//! Commands target the table at the text selection (a caret in a cell or next to a table anchor),
|
||||
//! the selected cells, or an explicit `{story, table}`; `rows: [a, b]` / `cols: [a, b]` (or `row` /
|
||||
//! `col`) narrow the cell range (default: the selected cells, else the caret's cell, else the
|
||||
//! whole table).
|
||||
|
||||
use designcraft_compose as compose;
|
||||
use designcraft_doc::{
|
||||
AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel,
|
||||
VerticalJustification,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, f64_or, has_doc, has_text, ok, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"table.insert",
|
||||
"Create Table",
|
||||
[],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{rows?: body rows (4), cols? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",
|
||||
has_text,
|
||||
insert
|
||||
),
|
||||
cmd!(
|
||||
"table.convertFromText",
|
||||
"Convert Text to Table",
|
||||
["Table"],
|
||||
None,
|
||||
"{columnSeparator?: tab|comma} — selected paragraphs become rows",
|
||||
has_text,
|
||||
convert_from_text
|
||||
),
|
||||
cmd!(
|
||||
"table.convertToText",
|
||||
"Convert Table to Text",
|
||||
["Table"],
|
||||
None,
|
||||
"{} — cells separated by tabs, rows by paragraphs",
|
||||
in_table,
|
||||
convert_to_text
|
||||
),
|
||||
cmd!("table.insertRow", "Insert Row", [], None, "{where?: above|below, count? (1)}", in_table, |s, p| insert_row(s, p, None)),
|
||||
cmd!("table.insertRowAbove", "Insert Row Above", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_row(s, p, Some(true))),
|
||||
cmd!("table.insertRowBelow", "Insert Row Below", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_row(s, p, Some(false))),
|
||||
cmd!("table.insertColumn", "Insert Column", [], None, "{where?: left|right, count? (1), width?}", in_table, |s, p| insert_col(s, p, None)),
|
||||
cmd!("table.insertColumnLeft", "Insert Column Left", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_col(s, p, Some(true))),
|
||||
cmd!("table.insertColumnRight", "Insert Column Right", ["Table", "Insert"], None, "{count?}", in_table, |s, p| insert_col(s, p, Some(false))),
|
||||
cmd!("table.deleteRow", "Delete Row", ["Table", "Delete"], None, "{} — the rows of the target cells", in_table, delete_rows),
|
||||
cmd!("table.deleteColumn", "Delete Column", ["Table", "Delete"], None, "{} — the columns of the target cells", in_table, delete_cols),
|
||||
cmd!("table.delete", "Delete Table", ["Table", "Delete"], None, "{}", in_table, delete_table),
|
||||
cmd!("table.merge", "Merge Cells", ["Table"], None, "{} — merge the target cell range", in_table, merge),
|
||||
cmd!("table.unmerge", "Unmerge Cells", ["Table"], None, "{}", in_table, unmerge),
|
||||
cmd!(
|
||||
"table.setCell",
|
||||
"Cell Options",
|
||||
[],
|
||||
None,
|
||||
"{fill?: swatch, tint?, insets?: n | [t,l,b,r], vj?: top|center|bottom|justify, text?, stroke?: {weight?, color?, tint?, type?: solid|dashed|dotted, edges?: all|outer|inner|top|left|bottom|right}}",
|
||||
in_table,
|
||||
set_cell
|
||||
),
|
||||
cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height),
|
||||
cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width),
|
||||
cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols),
|
||||
cmd!(
|
||||
"table.options",
|
||||
"Table Options",
|
||||
[],
|
||||
None,
|
||||
"{border?: {weight?, color?, tint?, type?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}",
|
||||
in_table,
|
||||
options
|
||||
),
|
||||
cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b], cols?: [a,b], what?: cell|row|column|table}", in_table_or_ids, select),
|
||||
cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{}", in_table, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["what"] = json!("table");
|
||||
select(s, &p)
|
||||
}),
|
||||
cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{}", in_table, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["what"] = json!("row");
|
||||
select(s, &p)
|
||||
}),
|
||||
cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{}", in_table, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["what"] = json!("column");
|
||||
select(s, &p)
|
||||
}),
|
||||
cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)),
|
||||
cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)),
|
||||
cmd!(query "table.get", "Get Table", [], None, "{story?, table?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- enablement and targeting ----------
|
||||
|
||||
fn in_table(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let st = s.active().expect("doc");
|
||||
if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(t) = st.selection.text
|
||||
&& st.doc.story(t.story).is_some_and(|x| x.table_at(t.focus).is_some())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err("place the insertion point in a table".into())
|
||||
}
|
||||
|
||||
fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)
|
||||
}
|
||||
|
||||
fn in_cell(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) }
|
||||
}
|
||||
|
||||
/// The table a command acts on and its target cell range.
|
||||
struct Tgt {
|
||||
story: StoryId,
|
||||
table: u64,
|
||||
range: CellRange,
|
||||
}
|
||||
|
||||
fn find_table(doc: &Document, id: u64) -> Option<StoryId> {
|
||||
doc.stories.iter().find(|(_, s)| s.tables.contains_key(&id)).map(|(k, _)| *k)
|
||||
}
|
||||
|
||||
fn pair(p: &Value, many: &str, one: &str) -> Option<(usize, usize)> {
|
||||
if let Some(a) = p.get(many).and_then(Value::as_array) {
|
||||
let a0 = a.first()?.as_u64()? as usize;
|
||||
let a1 = a.get(1).and_then(Value::as_u64).map_or(a0, |v| v as usize);
|
||||
return Some((a0.min(a1), a0.max(a1)));
|
||||
}
|
||||
p.get(one).and_then(Value::as_u64).map(|v| (v as usize, v as usize))
|
||||
}
|
||||
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<Tgt> {
|
||||
let st = s.doc()?;
|
||||
let doc = &st.doc;
|
||||
let explicit_table = p.get("table").and_then(Value::as_u64);
|
||||
let explicit_story = p.get("story").and_then(Value::as_u64).map(StoryId);
|
||||
let (story, table, range) = if let Some(tid) = explicit_table {
|
||||
let sid = explicit_story.or_else(|| find_table(doc, tid)).ok_or_else(|| bad(cmd, format!("no table {tid}")))?;
|
||||
(sid, tid, None)
|
||||
} else if let Some(c) = st.selection.cells {
|
||||
(c.story, c.table, Some(c.range))
|
||||
} else if let Some(t) = st.selection.text {
|
||||
match t.cell {
|
||||
Some(c) => (t.story, c.table, Some(CellRange::cell(c.row, c.col))),
|
||||
None => {
|
||||
let tid = doc.story(t.story).and_then(|x| x.table_at(t.focus)).ok_or_else(|| bad(cmd, "the insertion point is not in a table"))?;
|
||||
(t.story, tid, None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(bad(cmd, "place the insertion point in a table"));
|
||||
};
|
||||
let t = doc.story(story).and_then(|x| x.tables.get(&table)).ok_or_else(|| bad(cmd, format!("no table {table}")))?;
|
||||
let (nr, nc) = (t.nrows(), t.ncols());
|
||||
let mut range = range.unwrap_or(CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 });
|
||||
if let Some((a, b)) = pair(p, "rows", "row") {
|
||||
range.r0 = a;
|
||||
range.r1 = b;
|
||||
}
|
||||
if let Some((a, b)) = pair(p, "cols", "col") {
|
||||
range.c0 = a;
|
||||
range.c1 = b;
|
||||
}
|
||||
if range.r1 >= nr || range.c1 >= nc {
|
||||
return Err(bad(cmd, format!("cell range out of bounds ({nr}×{nc} table)")));
|
||||
}
|
||||
Ok(Tgt { story, table, range })
|
||||
}
|
||||
|
||||
/// Edit the target table; afterwards selections pointing into it are clamped to its new shape.
|
||||
fn edit_table<T>(s: &mut Session, g: &Tgt, cmd: &str, f: impl FnOnce(&mut Table) -> Result<T>) -> Result<T> {
|
||||
let cmd = cmd.to_string();
|
||||
s.edit(|d, sel| {
|
||||
let st = d.story_mut(g.story).ok_or_else(|| bad(&cmd, "no story"))?;
|
||||
let t = st.table_mut(g.table).ok_or_else(|| bad(&cmd, "no table"))?;
|
||||
let r = f(t)?;
|
||||
clamp_selection(d, sel);
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep text/cell selections valid after the table changed shape (or disappeared).
|
||||
fn clamp_selection(d: &Document, sel: &mut Selection) {
|
||||
if let Some(ts) = sel.cells {
|
||||
match d.story(ts.story).and_then(|x| x.tables.get(&ts.table)) {
|
||||
Some(t) => {
|
||||
let (mr, mc) = (t.nrows() - 1, t.ncols() - 1);
|
||||
let r = ts.range;
|
||||
sel.cells = Some(TableSel { range: CellRange::new(r.r0.min(mr), r.c0.min(mc), r.r1.min(mr), r.c1.min(mc)), ..ts });
|
||||
}
|
||||
None => sel.cells = None,
|
||||
}
|
||||
}
|
||||
if let Some(t) = sel.text
|
||||
&& let Some(c) = t.cell
|
||||
{
|
||||
let story = d.story(t.story);
|
||||
match story.and_then(|x| x.tables.get(&c.table)) {
|
||||
Some(tb) => {
|
||||
let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1));
|
||||
let len = tb.cell(r, col).map_or(0, |x| x.text.len());
|
||||
sel.text =
|
||||
Some(TextSel { anchor: t.anchor.min(len), focus: t.focus.min(len), cell: Some(CellAddr { table: c.table, row: r, col }), ..t });
|
||||
}
|
||||
None => {
|
||||
let len = story.map_or(0, |x| x.len());
|
||||
sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.anchor.min(len), cell: None, ..t });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn count(p: &Value) -> usize {
|
||||
p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize
|
||||
}
|
||||
|
||||
fn color_param(p: &Value, key: &str) -> Option<String> {
|
||||
str_param(p, key).map(str::to_string)
|
||||
}
|
||||
|
||||
// ---------- create / convert ----------
|
||||
|
||||
/// Width of the text column at the caret (the new table spans it).
|
||||
fn column_width(s: &Session, t: &TextSel) -> f64 {
|
||||
let Some(st) = s.active() else { return 300.0 };
|
||||
let specs = compose::frame_specs(&st.doc, t.story);
|
||||
let spec = t.frame.and_then(|f| specs.iter().find(|x| x.id == f)).or(specs.first());
|
||||
spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0)
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad("table.insert", "tables can't be nested in cells"));
|
||||
}
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize;
|
||||
let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize;
|
||||
let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
|
||||
let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize;
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
|
||||
s.edit(|d, sel| {
|
||||
let id = d.alloc();
|
||||
let mut table = Table::new(id, rows, cols, header, footer, width);
|
||||
let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?;
|
||||
// Cells start with the paragraph format at the caret.
|
||||
let pf = st.paras[st.para_at(t.range().start)].clone();
|
||||
let cf = st.char_format_at(t.range().start).clone();
|
||||
for c in &mut table.cells {
|
||||
c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() };
|
||||
c.text.chars[0].format = cf.clone();
|
||||
}
|
||||
let r = t.range();
|
||||
st.delete(r.clone());
|
||||
st.insert_table(r.start, table);
|
||||
*sel = Selection::text(TextSel { story: t.story, anchor: 0, focus: 0, frame: t.frame, cell: Some(CellAddr { table: id, row: 0, col: 0 }) });
|
||||
Ok(json!({"story": t.story.0, "table": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad("table.convertFromText", "select text outside tables"));
|
||||
}
|
||||
let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' };
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
|
||||
s.edit(|d, sel| {
|
||||
let id = d.alloc();
|
||||
let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?;
|
||||
let ranges = st.para_ranges();
|
||||
let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end));
|
||||
if (pa..=pb).any(|i| st.paras[i].table.is_some()) {
|
||||
return Err(bad("table.convertFromText", "the selection contains a table"));
|
||||
}
|
||||
let (a, b) = (ranges[pa].start, ranges[pb].end);
|
||||
let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
|
||||
let pf = st.paras[pa].clone();
|
||||
let cf = st.format_after(a).clone();
|
||||
let table = Table::from_strings(id, &data, width, &pf, &cf);
|
||||
st.delete(a..b);
|
||||
let anchor = st.insert_table(a, table);
|
||||
*sel = Selection::text(TextSel::caret(t.story, anchor));
|
||||
Ok(json!({"table": id, "rows": data.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_to_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.convertToText")?;
|
||||
s.edit(|d, sel| {
|
||||
let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?;
|
||||
let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default();
|
||||
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?;
|
||||
st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text);
|
||||
*sel = Selection::text(TextSel::caret(g.story, a + text.len()));
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.delete")?;
|
||||
s.edit(|d, sel| {
|
||||
let st = d.story_mut(g.story).ok_or_else(|| bad("table.delete", "no story"))?;
|
||||
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.delete", "no anchor"))?;
|
||||
// Remove the anchor paragraph (with one separator) so no empty paragraph is left behind.
|
||||
let end = a + designcraft_doc::TABLE_ANCHOR.len_utf8();
|
||||
let r = if &st.text[end..] == "\n" && a > 0 && st.text[..a].ends_with('\n') {
|
||||
// Inserted at the end of the story: remove the empty paragraph after it too.
|
||||
a - 1..end + 1
|
||||
} else if st.text[end..].starts_with('\n') {
|
||||
a..end + 1
|
||||
} else if a > 0 && st.text[..a].ends_with('\n') {
|
||||
a - 1..end
|
||||
} else {
|
||||
a..end
|
||||
};
|
||||
st.delete(r.clone());
|
||||
*sel = Selection::text(TextSel::caret(g.story, r.start.min(st.len())));
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- rows and columns ----------
|
||||
|
||||
fn insert_row(s: &mut Session, p: &Value, above: Option<bool>) -> Result<Value> {
|
||||
let g = target(s, p, "table.insertRow")?;
|
||||
let above = above.unwrap_or(str_param(p, "where") == Some("above"));
|
||||
let n = count(p);
|
||||
let at = if above { g.range.r0 } else { g.range.r1 + 1 };
|
||||
let r = edit_table(s, &g, "table.insertRow", |t| {
|
||||
t.insert_rows(at, n);
|
||||
Ok(json!({"rows": t.nrows()}))
|
||||
})?;
|
||||
shift_selection(s, g.table, at, n, true);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// After inserting rows/columns at `at`, selections at or after it follow their cells.
|
||||
fn shift_selection(s: &mut Session, table: u64, at: usize, n: usize, rows: bool) {
|
||||
let Ok(st) = s.doc_mut() else { return };
|
||||
if let Some(t) = st.selection.text.as_mut()
|
||||
&& let Some(c) = t.cell.as_mut()
|
||||
&& c.table == table
|
||||
{
|
||||
let v = if rows { &mut c.row } else { &mut c.col };
|
||||
if *v >= at {
|
||||
*v += n;
|
||||
}
|
||||
}
|
||||
if let Some(ts) = st.selection.cells.as_mut()
|
||||
&& ts.table == table
|
||||
{
|
||||
let (a, b) = if rows { (&mut ts.range.r0, &mut ts.range.r1) } else { (&mut ts.range.c0, &mut ts.range.c1) };
|
||||
if *a >= at {
|
||||
*a += n;
|
||||
}
|
||||
if *b >= at {
|
||||
*b += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_col(s: &mut Session, p: &Value, left: Option<bool>) -> Result<Value> {
|
||||
let g = target(s, p, "table.insertColumn")?;
|
||||
let left = left.unwrap_or(str_param(p, "where") == Some("left"));
|
||||
let n = count(p);
|
||||
let at = if left { g.range.c0 } else { g.range.c1 + 1 };
|
||||
let width = p.get("width").and_then(Value::as_f64);
|
||||
let r = edit_table(s, &g, "table.insertColumn", |t| {
|
||||
let w = width.unwrap_or_else(|| t.columns[g.range.c0.min(t.ncols() - 1)].width);
|
||||
t.insert_cols(at, n, w);
|
||||
Ok(json!({"columns": t.ncols()}))
|
||||
})?;
|
||||
shift_selection(s, g.table, at, n, false);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.deleteRow")?;
|
||||
let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.r0 == 0 && g.range.r1 + 1 >= t.nrows());
|
||||
if all {
|
||||
return delete_table(s, p);
|
||||
}
|
||||
edit_table(s, &g, "table.deleteRow", |t| {
|
||||
t.delete_rows(g.range.r0, g.range.r1 - g.range.r0 + 1);
|
||||
Ok(json!({"rows": t.nrows()}))
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_cols(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.deleteColumn")?;
|
||||
let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.c0 == 0 && g.range.c1 + 1 >= t.ncols());
|
||||
if all {
|
||||
return delete_table(s, p);
|
||||
}
|
||||
edit_table(s, &g, "table.deleteColumn", |t| {
|
||||
t.delete_cols(g.range.c0, g.range.c1 - g.range.c0 + 1);
|
||||
Ok(json!({"columns": t.ncols()}))
|
||||
})
|
||||
}
|
||||
|
||||
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.merge")?;
|
||||
let r = edit_table(s, &g, "table.merge", |t| {
|
||||
t.merge(g.range).map_err(|e| bad("table.merge", e))?;
|
||||
Ok(t.expand_range(g.range))
|
||||
})?;
|
||||
// The caret goes to the merged cell.
|
||||
let st = s.doc_mut()?;
|
||||
st.selection = Selection::text(TextSel {
|
||||
story: g.story,
|
||||
anchor: 0,
|
||||
focus: 0,
|
||||
frame: st.selection.text.and_then(|t| t.frame),
|
||||
cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }),
|
||||
});
|
||||
ok()
|
||||
}
|
||||
|
||||
fn unmerge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.unmerge")?;
|
||||
edit_table(s, &g, "table.unmerge", |t| {
|
||||
for r in g.range.r0..=g.range.r1 {
|
||||
for c in g.range.c0..=g.range.c1 {
|
||||
t.unmerge(r, c);
|
||||
}
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_stroke(v: &Value, mut base: CellStroke) -> CellStroke {
|
||||
if let Some(w) = v.get("weight").and_then(Value::as_f64) {
|
||||
base.weight = w.max(0.0);
|
||||
}
|
||||
if let Some(c) = color_param(v, "color") {
|
||||
base.color = c;
|
||||
}
|
||||
if let Some(t) = v.get("tint").and_then(Value::as_f64) {
|
||||
base.tint = t.clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
match str_param(v, "type") {
|
||||
Some("dashed") => base.kind = StrokeType::Dashed { pattern: vec![base.weight.max(1.0) * 3.0, base.weight.max(1.0) * 2.0] },
|
||||
Some("dotted") => base.kind = StrokeType::Dotted,
|
||||
Some("solid") => base.kind = StrokeType::Solid,
|
||||
_ => {}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.setCell")?;
|
||||
let fill = color_param(p, "fill");
|
||||
let tint = p.get("tint").and_then(Value::as_f64).map(|t| t.clamp(0.0, 1.0) as f32);
|
||||
let insets: Option<[f64; 4]> = match p.get("insets") {
|
||||
Some(Value::Number(n)) => n.as_f64().map(|v| [v.max(0.0); 4]),
|
||||
Some(Value::Array(a)) if a.len() == 4 => {
|
||||
let v: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect();
|
||||
Some([v[0], v[1], v[2], v[3]])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let vj = match str_param(p, "vj").or_else(|| str_param(p, "verticalJustification")) {
|
||||
Some("top") => Some(VerticalJustification::Top),
|
||||
Some("center") => Some(VerticalJustification::Center),
|
||||
Some("bottom") => Some(VerticalJustification::Bottom),
|
||||
Some("justify") => Some(VerticalJustification::Justify),
|
||||
Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))),
|
||||
None => None,
|
||||
};
|
||||
let text = str_param(p, "text").map(str::to_string);
|
||||
let stroke = p.get("stroke").cloned();
|
||||
edit_table(s, &g, "table.setCell", |t| {
|
||||
let owners = t.owners();
|
||||
let nc = t.ncols();
|
||||
let rg = g.range;
|
||||
for r in rg.r0..=rg.r1 {
|
||||
for c in rg.c0..=rg.c1 {
|
||||
if owners[r * nc + c] != (r, c) {
|
||||
continue;
|
||||
}
|
||||
let cell = t.cell_mut(r, c).expect("in range");
|
||||
if let Some(f) = &fill {
|
||||
cell.fill = f.clone();
|
||||
}
|
||||
if let Some(ti) = tint {
|
||||
cell.fill_tint = ti;
|
||||
}
|
||||
if let Some(i) = insets {
|
||||
cell.insets = i;
|
||||
}
|
||||
if let Some(v) = vj {
|
||||
cell.vj = v;
|
||||
}
|
||||
if let Some(tx) = &text {
|
||||
let len = cell.text.len();
|
||||
cell.text.replace(0..len, tx);
|
||||
}
|
||||
if let Some(sv) = &stroke {
|
||||
let edges = str_param(sv, "edges").unwrap_or("all");
|
||||
for (i, on) in [
|
||||
("top", r == rg.r0),
|
||||
("left", c == rg.c0),
|
||||
("bottom", r + cell.row_span as usize - 1 == rg.r1),
|
||||
("right", c + cell.col_span as usize - 1 == rg.c1),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer)))
|
||||
{
|
||||
if on {
|
||||
cell.strokes[i] = parse_stroke(sv, cell.strokes[i].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Neighbouring cells share edges: mirror the edges along the range boundary.
|
||||
if let Some(sv) = &stroke {
|
||||
let edges = str_param(sv, "edges").unwrap_or("all");
|
||||
if matches!(edges, "all" | "outer" | "bottom") && rg.r1 + 1 < t.nrows() {
|
||||
for c in rg.c0..=rg.c1 {
|
||||
let s0 = t.cell(rg.r1, c).map(|x| x.strokes[2].clone());
|
||||
if let (Some(s0), Some(n)) = (s0, t.cell_mut(rg.r1 + 1, c)) {
|
||||
n.strokes[0] = s0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(edges, "all" | "outer" | "right") && rg.c1 + 1 < t.ncols() {
|
||||
for r in rg.r0..=rg.r1 {
|
||||
let s0 = t.cell(r, rg.c1).map(|x| x.strokes[3].clone());
|
||||
if let (Some(s0), Some(n)) = (s0, t.cell_mut(r, rg.c1 + 1)) {
|
||||
n.strokes[1] = s0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn set_row_height(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.setRowHeight")?;
|
||||
let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0));
|
||||
let mode = match str_param(p, "mode") {
|
||||
Some("exactly") => Some(RowHeightMode::Exactly),
|
||||
Some("atLeast") => Some(RowHeightMode::AtLeast),
|
||||
Some(o) => return Err(bad("table.setRowHeight", format!("unknown mode `{o}`"))),
|
||||
None => None,
|
||||
};
|
||||
if h.is_none() && mode.is_none() {
|
||||
return Err(bad("table.setRowHeight", "missing height"));
|
||||
}
|
||||
edit_table(s, &g, "table.setRowHeight", |t| {
|
||||
for row in &mut t.rows[g.range.r0..=g.range.r1] {
|
||||
if let Some(h) = h {
|
||||
row.height = h;
|
||||
}
|
||||
if let Some(m) = mode {
|
||||
row.mode = m;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn set_col_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.setColumnWidth")?;
|
||||
let w = p.get("width").and_then(Value::as_f64).ok_or_else(|| bad("table.setColumnWidth", "missing width"))?.clamp(3.0, 10000.0);
|
||||
edit_table(s, &g, "table.setColumnWidth", |t| {
|
||||
for c in &mut t.columns[g.range.c0..=g.range.c1] {
|
||||
c.width = w;
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn distribute_cols(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.distributeColumns")?;
|
||||
edit_table(s, &g, "table.distributeColumns", |t| {
|
||||
let cols = &mut t.columns[g.range.c0..=g.range.c1];
|
||||
let avg = cols.iter().map(|c| c.width).sum::<f64>() / cols.len() as f64;
|
||||
cols.iter_mut().for_each(|c| c.width = avg);
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_alt(v: &Value) -> Option<AltFills> {
|
||||
if v.is_null() {
|
||||
return None;
|
||||
}
|
||||
let d = AltFills::default();
|
||||
Some(AltFills {
|
||||
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32),
|
||||
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
|
||||
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
|
||||
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32),
|
||||
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
|
||||
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
|
||||
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32),
|
||||
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32),
|
||||
})
|
||||
}
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.options")?;
|
||||
let p = p.clone();
|
||||
edit_table(s, &g, "table.options", |t| {
|
||||
let o = &mut t.options;
|
||||
if let Some(b) = p.get("border") {
|
||||
o.border = parse_stroke(b, o.border.clone());
|
||||
}
|
||||
o.space_before = f64_or(&p, "spaceBefore", o.space_before);
|
||||
o.space_after = f64_or(&p, "spaceAfter", o.space_after);
|
||||
if let Some(b) = p.get("repeatHeader").and_then(Value::as_bool) {
|
||||
o.repeat_header = b;
|
||||
}
|
||||
if let Some(b) = p.get("repeatFooter").and_then(Value::as_bool) {
|
||||
o.repeat_footer = b;
|
||||
}
|
||||
if let Some(v) = p.get("altRows") {
|
||||
o.alt_rows = parse_alt(v);
|
||||
}
|
||||
if let Some(v) = p.get("altCols") {
|
||||
o.alt_cols = parse_alt(v);
|
||||
}
|
||||
let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v as usize);
|
||||
if h.is_some() || f.is_some() {
|
||||
let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows()));
|
||||
// Add rows when there aren't enough for the requested header/footer plus one body row.
|
||||
let need = h + f + 1;
|
||||
if t.nrows() < need {
|
||||
let at = t.nrows();
|
||||
t.insert_rows(at, need - at);
|
||||
}
|
||||
t.set_header_footer(h, f);
|
||||
}
|
||||
Ok(json!({"options": t.options}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- selection ----------
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.select")?;
|
||||
let (nr, nc) = {
|
||||
let st = s.doc()?;
|
||||
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.select", "no table"))?;
|
||||
(t.nrows(), t.ncols())
|
||||
};
|
||||
let mut r = g.range;
|
||||
match str_param(p, "what") {
|
||||
Some("table") => r = CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 },
|
||||
Some("row") => {
|
||||
r.c0 = 0;
|
||||
r.c1 = nc - 1;
|
||||
}
|
||||
Some("column") => {
|
||||
r.r0 = 0;
|
||||
r.r1 = nr - 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
let frame = st.selection.text.and_then(|t| t.frame);
|
||||
st.selection = Selection {
|
||||
cells: Some(TableSel { story: g.story, table: g.table, range: r }),
|
||||
text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }),
|
||||
..Default::default()
|
||||
};
|
||||
st.revision += 1;
|
||||
Ok(json!({"story": g.story.0, "table": g.table, "range": r}))
|
||||
}
|
||||
|
||||
/// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last
|
||||
/// cell adds a row.
|
||||
pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> {
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.nextCell", "no caret"))?;
|
||||
let c = t.cell.ok_or_else(|| bad("table.nextCell", "not in a cell"))?;
|
||||
let (owners, nr, nc) = {
|
||||
let st = s.doc()?;
|
||||
let tb = st.doc.story(t.story).and_then(|x| x.tables.get(&c.table)).ok_or_else(|| bad("table.nextCell", "no table"))?;
|
||||
(tb.owners(), tb.nrows(), tb.ncols())
|
||||
};
|
||||
let cur = c.row * nc + c.col;
|
||||
let next =
|
||||
if forward { (cur + 1..nr * nc).find(|&i| owners[i] == (i / nc, i % nc)) } else { (0..cur).rev().find(|&i| owners[i] == (i / nc, i % nc)) };
|
||||
let (row, col) = match next {
|
||||
Some(i) => (i / nc, i % nc),
|
||||
None if forward => {
|
||||
let g = Tgt { story: t.story, table: c.table, range: CellRange::cell(nr - 1, 0) };
|
||||
edit_table(s, &g, "table.nextCell", |tb| {
|
||||
tb.insert_rows(nr, 1);
|
||||
ok()
|
||||
})?;
|
||||
(nr, 0)
|
||||
}
|
||||
None => return ok(),
|
||||
};
|
||||
let st = s.doc_mut()?;
|
||||
let len = st.doc.text_story(t.story, Some(CellAddr { table: c.table, row, col })).map_or(0, |x| x.len());
|
||||
st.selection = Selection::text(TextSel { anchor: 0, focus: len, cell: Some(CellAddr { table: c.table, row, col }), ..t });
|
||||
st.revision += 1;
|
||||
Ok(json!({"row": row, "col": col}))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.get")?;
|
||||
let st = s.doc()?;
|
||||
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?;
|
||||
let owners = t.owners();
|
||||
let cells: Vec<Value> = (0..t.nrows())
|
||||
.flat_map(|r| (0..t.ncols()).map(move |c| (r, c)))
|
||||
.filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c))
|
||||
.map(|(r, c)| {
|
||||
let cell = t.cell(r, c).expect("in range");
|
||||
json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
|
||||
"insets": cell.insets, "vj": cell.vj})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)),
|
||||
"rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(),
|
||||
"cells": cells, "options": t.options, "range": g.range,
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//! Text editing (Type tool) and character/paragraph formatting.
|
||||
|
||||
use designcraft_compose as compose;
|
||||
use designcraft_doc::{CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel};
|
||||
use designcraft_doc::{CellAddr, CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel};
|
||||
use designcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
place(s, p, false)?;
|
||||
let st = s.doc_mut()?;
|
||||
let Some(t) = st.selection.text else { return ok() };
|
||||
let text = &st.doc.story(t.story).map(|x| x.text.clone()).unwrap_or_default();
|
||||
let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default();
|
||||
let (a, b) = word_bounds(text, t.focus);
|
||||
st.selection.text = Some(TextSel { anchor: a, focus: b, ..t });
|
||||
ok()
|
||||
|
|
@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
let len = st.doc.story(sid).ok_or_else(|| bad("text.select", "no such story"))?.len();
|
||||
let a = (p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize).min(len);
|
||||
let f = (p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a)).min(len);
|
||||
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None });
|
||||
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None });
|
||||
st.revision += 1;
|
||||
ok()
|
||||
}),
|
||||
|
|
@ -40,6 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
let f = t.frame.or_else(|| st.doc.story(t.story).and_then(|x| x.frames.first().copied()));
|
||||
st.selection.items = f.into_iter().collect();
|
||||
}
|
||||
st.selection.cells = None;
|
||||
st.revision += 1;
|
||||
ok()
|
||||
}),
|
||||
|
|
@ -146,8 +147,8 @@ fn story_of(s: &Session, p: &Value) -> Option<StoryId> {
|
|||
st.selection.text.map(|t| t.story)
|
||||
}
|
||||
|
||||
/// Story byte at spread point `pt` in text frame `frame`.
|
||||
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize)> {
|
||||
/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell).
|
||||
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option<CellAddr>)> {
|
||||
let st = s.active()?;
|
||||
let loc = st.doc.find(frame)?;
|
||||
let it = st.doc.item_at(&loc)?;
|
||||
|
|
@ -156,8 +157,11 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize)> {
|
|||
let inner = xf.inverse() * pt;
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
|
||||
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
|
||||
return Some((sid, b, Some(CellAddr { table, row, col })));
|
||||
}
|
||||
let b = compose::hit(&cs, fi, inner).unwrap_or(0);
|
||||
Some((sid, b))
|
||||
Some((sid, b, None))
|
||||
}
|
||||
|
||||
fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
|
||||
|
|
@ -168,27 +172,60 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
|
|||
if needs_convert {
|
||||
s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?;
|
||||
}
|
||||
let (sid, b) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?;
|
||||
let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?;
|
||||
let st = s.doc_mut()?;
|
||||
// Dragging from one cell into another selects cells.
|
||||
if extend
|
||||
&& let (Some(t), Some(c)) = (st.selection.text, cell)
|
||||
&& let Some(a) = t.cell
|
||||
&& t.story == sid
|
||||
&& a.table == c.table
|
||||
&& (a.row, a.col) != (c.row, c.col)
|
||||
{
|
||||
let range = designcraft_doc::CellRange::new(a.row, a.col, c.row, c.col);
|
||||
st.selection.cells = Some(designcraft_doc::TableSel { story: sid, table: c.table, range });
|
||||
st.revision += 1;
|
||||
return Ok(json!({"story": sid.0, "cells": range}));
|
||||
}
|
||||
let t = match (extend, st.selection.text) {
|
||||
(true, Some(t)) if t.story == sid => TextSel { focus: b, frame: Some(frame), ..t },
|
||||
_ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame) },
|
||||
(true, Some(t)) if t.story == sid && t.cell == cell => TextSel { focus: b, frame: Some(frame), ..t },
|
||||
_ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame), cell },
|
||||
};
|
||||
st.selection = Selection::text(t);
|
||||
st.revision += 1;
|
||||
Ok(json!({"story": sid.0, "pos": b}))
|
||||
Ok(json!({"story": sid.0, "pos": b, "cell": cell}))
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`).
|
||||
if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) {
|
||||
return super::table::step_cell(s, true);
|
||||
}
|
||||
let text = if s.prefs.typographers_quotes { smart_quotes(s, &text) } else { text };
|
||||
s.edit(|d, sel| {
|
||||
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
|
||||
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let r = t.range();
|
||||
let r = r.start.min(st.len())..r.end.min(st.len());
|
||||
// Typing next to a table anchor starts a paragraph of its own.
|
||||
let mut text = text.clone();
|
||||
let mut trail = 0;
|
||||
if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() {
|
||||
let pi = st.para_at(r.start);
|
||||
let pr = st.para_ranges()[pi].clone();
|
||||
let anchor_at = pr.start + st.text[pr.clone()].find(designcraft_doc::TABLE_ANCHOR).unwrap_or(0);
|
||||
if r.start <= anchor_at {
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
trail = 1;
|
||||
}
|
||||
} else if !text.starts_with('\n') {
|
||||
text.insert(0, '\n');
|
||||
}
|
||||
}
|
||||
st.replace(r.clone(), &text);
|
||||
let pos = r.start + text.len();
|
||||
let pos = r.start + text.len() - trail;
|
||||
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
|
||||
Ok(json!({"pos": pos}))
|
||||
})
|
||||
|
|
@ -202,7 +239,7 @@ fn smart_quotes(s: &Session, text: &str) -> String {
|
|||
.active()
|
||||
.and_then(|st| {
|
||||
let t = st.selection.text?;
|
||||
let story = st.doc.story(t.story)?;
|
||||
let story = st.doc.text_story(t.story, t.cell)?;
|
||||
story.text[..t.range().start.min(story.len())].chars().last()
|
||||
})
|
||||
.unwrap_or(' ');
|
||||
|
|
@ -237,7 +274,7 @@ pub(crate) fn delete_selection(s: &mut Session) -> Result<Value> {
|
|||
s.edit(|d, sel| {
|
||||
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
|
||||
let r = t.range();
|
||||
if let Some(st) = d.story_mut(t.story) {
|
||||
if let Some(st) = d.text_story_mut(t.story, t.cell) {
|
||||
st.delete(r.clone());
|
||||
}
|
||||
sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t });
|
||||
|
|
@ -250,7 +287,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let word = bool_or(p, "word", false);
|
||||
s.edit(|d, sel| {
|
||||
let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?;
|
||||
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let r = t.range();
|
||||
let r = if !r.is_empty() {
|
||||
r
|
||||
|
|
@ -312,9 +349,14 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let word = bool_or(p, "word", false);
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?;
|
||||
let story = st.doc.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let text = story.text.clone();
|
||||
let cs = s.cache.get(&st.doc, t.story, None);
|
||||
// In a cell, lines come from the cell's own composition.
|
||||
let cs = match t.cell {
|
||||
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
|
||||
None => cs,
|
||||
};
|
||||
let pos = t.focus.min(text.len());
|
||||
let collapse_to = |left: bool| if left { t.range().start } else { t.range().end };
|
||||
let new = match dir.as_str() {
|
||||
|
|
@ -360,19 +402,42 @@ fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option<usize>
|
|||
compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0))
|
||||
}
|
||||
|
||||
/// Ranges and stories a formatting command applies to: the text selection, or whole stories of
|
||||
/// the selected text frames.
|
||||
fn format_targets(s: &Session) -> Vec<(StoryId, std::ops::Range<usize>)> {
|
||||
/// Where a formatting command applies: a story (or a table cell's story) and a byte range.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Target {
|
||||
pub story: StoryId,
|
||||
pub cell: Option<CellAddr>,
|
||||
pub range: std::ops::Range<usize>,
|
||||
}
|
||||
|
||||
/// Ranges and stories a formatting command applies to: selected table cells (whole cells), the
|
||||
/// text selection, or whole stories of the selected text frames.
|
||||
fn format_targets(s: &Session) -> Vec<Target> {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
if let Some(t) = st.selection.text {
|
||||
return vec![(t.story, t.range())];
|
||||
if let Some(ts) = st.selection.cells
|
||||
&& let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table))
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
let owners = t.owners();
|
||||
for r in ts.range.r0..=ts.range.r1.min(t.nrows().saturating_sub(1)) {
|
||||
for c in ts.range.c0..=ts.range.c1.min(t.ncols().saturating_sub(1)) {
|
||||
if owners[r * t.ncols() + c] == (r, c) {
|
||||
let len = t.cell(r, c).map_or(0, |x| x.text.len());
|
||||
v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len });
|
||||
}
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if let Some(t) = st.selection.text {
|
||||
return vec![Target { story: t.story, cell: t.cell, range: t.range() }];
|
||||
}
|
||||
let mut v: Vec<Target> = Vec::new();
|
||||
for id in &st.selection.items {
|
||||
if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) {
|
||||
let len = st.doc.story(tf.story).map(|x| x.len()).unwrap_or(0);
|
||||
if !v.iter().any(|(s, _)| *s == tf.story) {
|
||||
v.push((tf.story, 0..len));
|
||||
if !v.iter().any(|t| t.story == tf.story) {
|
||||
v.push(Target { story: tf.story, cell: None, range: 0..len });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -388,8 +453,9 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
|||
}
|
||||
let targets = format_targets(s);
|
||||
s.edit(|d, _| {
|
||||
for (sid, r) in &targets {
|
||||
let Some(st) = d.story_mut(*sid) else { continue };
|
||||
for t in &targets {
|
||||
let r = &t.range;
|
||||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||||
if r.is_empty() {
|
||||
// Caret: change the typing format (the empty run at the caret).
|
||||
let pos = r.start;
|
||||
|
|
@ -416,9 +482,9 @@ pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
|
|||
}
|
||||
let targets = format_targets(s);
|
||||
s.edit(|d, _| {
|
||||
for (sid, r) in &targets {
|
||||
if let Some(st) = d.story_mut(*sid) {
|
||||
st.format_paras(r.clone(), |p| p.para.merge(&a));
|
||||
for t in &targets {
|
||||
if let Some(st) = d.text_story_mut(t.story, t.cell) {
|
||||
st.format_paras(t.range.clone(), |p| p.para.merge(&a));
|
||||
}
|
||||
}
|
||||
ok()
|
||||
|
|
@ -434,8 +500,8 @@ fn step_size(s: &mut Session, delta: f64) -> Result<Value> {
|
|||
fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let targets = format_targets(s);
|
||||
let Some((sid, r)) = targets.first().cloned() else { return Ok(Value::Null) };
|
||||
let story = st.doc.story(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||||
let Some(Target { story: sid, cell, range: r }) = targets.first().cloned() else { return Ok(Value::Null) };
|
||||
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||||
let pi = story.para_at(r.start);
|
||||
let pf = &story.paras[pi];
|
||||
let (pp, base) = st.doc.styles.resolve_para(pf);
|
||||
|
|
@ -489,6 +555,6 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) fn format_targets_pub(s: &Session) -> Vec<(StoryId, std::ops::Range<usize>)> {
|
||||
pub(crate) fn format_targets_pub(s: &Session) -> Vec<Target> {
|
||||
format_targets(s)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,3 +255,5 @@ pub(crate) use push_undo as record_undo;
|
|||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_idml;
|
||||
#[cfg(test)]
|
||||
mod tests_table;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1239,6 +1239,15 @@ impl<'a> Ex<'a> {
|
|||
.attr("StoryOrientation", "Horizontal")
|
||||
.attr("StoryDirection", "LeftToRightDirection"),
|
||||
);
|
||||
for psr in self.story_paras(s) {
|
||||
el.push(psr);
|
||||
}
|
||||
el
|
||||
}
|
||||
|
||||
/// `ParagraphStyleRange`s of a story (or a table cell's story).
|
||||
fn story_paras(&mut self, s: &Story) -> Vec<El> {
|
||||
let mut paras = Vec::new();
|
||||
let ranges = s.para_ranges();
|
||||
let n = ranges.len();
|
||||
for (pi, r) in ranges.into_iter().enumerate() {
|
||||
|
|
@ -1295,6 +1304,12 @@ impl<'a> Ex<'a> {
|
|||
if let Some(code) = ace {
|
||||
flush_text(&mut cur, &mut pending);
|
||||
pending.push(Node::Pi("ACE".into(), code.into()));
|
||||
} else if ch == st::TABLE_ANCHOR {
|
||||
flush_text(&mut cur, &mut pending);
|
||||
flush_content(&mut pending, &mut out);
|
||||
if let Some(t) = s.para_table(pi) {
|
||||
out.push(Node::El(self.table_el(t)));
|
||||
}
|
||||
} else if let Some(bt) = brk {
|
||||
flush_text(&mut cur, &mut pending);
|
||||
flush_content(&mut pending, &mut out);
|
||||
|
|
@ -1326,7 +1341,98 @@ impl<'a> Ex<'a> {
|
|||
psr.push(c);
|
||||
}
|
||||
}
|
||||
el.push(psr);
|
||||
paras.push(psr);
|
||||
}
|
||||
paras
|
||||
}
|
||||
|
||||
fn cell_stroke_attrs(&self, el: &mut El, prefix: &str, s: &designcraft_doc::CellStroke) {
|
||||
el.set(&format!("{prefix}StrokeWeight"), num(s.weight));
|
||||
el.set(&format!("{prefix}StrokeColor"), self.sw(&s.color));
|
||||
el.set(&format!("{prefix}StrokeTint"), pct(s.tint as f64));
|
||||
el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(&s.kind));
|
||||
}
|
||||
|
||||
/// `<Table>` with rows, columns and cells (cell names are `column:row`).
|
||||
fn table_el(&mut self, t: &designcraft_doc::Table) -> El {
|
||||
let id = uid(t.id);
|
||||
let (h, f) = (t.header_rows(), t.footer_rows());
|
||||
let mut el = El::new("Table")
|
||||
.attr("Self", &id)
|
||||
.attr("HeaderRowCount", h)
|
||||
.attr("FooterRowCount", f)
|
||||
.attr("BodyRowCount", t.nrows() - h - f)
|
||||
.attr("ColumnCount", t.ncols())
|
||||
.attr("AppliedTableStyle", "TableStyle/$ID/[Basic Table]")
|
||||
.attr("TableDirection", "LeftToRightDirection")
|
||||
.attr("SpaceBefore", num(t.options.space_before))
|
||||
.attr("SpaceAfter", num(t.options.space_after))
|
||||
.attr("HeaderBehavior", if t.options.repeat_header { "RepeatOnEachTextColumn" } else { "RepeatOnce" })
|
||||
.attr("FooterBehavior", if t.options.repeat_footer { "RepeatOnEachTextColumn" } else { "RepeatOnce" });
|
||||
for side in ["Top", "Left", "Bottom", "Right"] {
|
||||
self.cell_stroke_attrs(&mut el, &format!("{side}Border"), &t.options.border);
|
||||
}
|
||||
for (kind, alt) in [("Row", &t.options.alt_rows), ("Column", &t.options.alt_cols)] {
|
||||
if let Some(a) = alt {
|
||||
el.set(&format!("Start{kind}FillColor"), self.sw(&a.first_color));
|
||||
el.set(&format!("Start{kind}FillCount"), a.first);
|
||||
el.set(&format!("Start{kind}FillTint"), pct(a.first_tint as f64));
|
||||
el.set(&format!("End{kind}FillColor"), self.sw(&a.next_color));
|
||||
el.set(&format!("End{kind}FillCount"), a.next);
|
||||
el.set(&format!("End{kind}FillTint"), pct(a.next_tint as f64));
|
||||
el.set(&format!("SkipFirstAlternatingFill{kind}s"), a.skip_first);
|
||||
el.set(&format!("SkipLastAlternatingFill{kind}s"), a.skip_last);
|
||||
}
|
||||
}
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
let exact = row.mode == designcraft_doc::RowHeightMode::Exactly;
|
||||
el.push(
|
||||
El::new("Row")
|
||||
.attr("Self", format!("{id}Row{r}"))
|
||||
.attr("Name", r)
|
||||
.attr("SingleRowHeight", num(row.height.max(3.0)))
|
||||
.attr("MinimumHeight", num(row.height.max(0.0)))
|
||||
.attr("AutoGrow", bool_s(!exact)),
|
||||
);
|
||||
}
|
||||
for (c, col) in t.columns.iter().enumerate() {
|
||||
el.push(El::new("Column").attr("Self", format!("{id}Column{c}")).attr("Name", c).attr("SingleColumnWidth", num(col.width)));
|
||||
}
|
||||
let owners = t.owners();
|
||||
for r in 0..t.nrows() {
|
||||
for c in 0..t.ncols() {
|
||||
if owners[r * t.ncols() + c] != (r, c) {
|
||||
continue;
|
||||
}
|
||||
let cell = t.cell(r, c).expect("in range");
|
||||
let vj = match cell.vj {
|
||||
designcraft_doc::VerticalJustification::Top => "TopAlign",
|
||||
designcraft_doc::VerticalJustification::Center => "CenterAlign",
|
||||
designcraft_doc::VerticalJustification::Bottom => "BottomAlign",
|
||||
designcraft_doc::VerticalJustification::Justify => "JustifyAlign",
|
||||
};
|
||||
let mut ce = El::new("Cell")
|
||||
.attr("Self", format!("{id}i{}", r * t.ncols() + c))
|
||||
.attr("Name", format!("{c}:{r}"))
|
||||
.attr("RowSpan", cell.row_span)
|
||||
.attr("ColumnSpan", cell.col_span)
|
||||
.attr("AppliedCellStyle", "CellStyle/$ID/[None]")
|
||||
.attr("FillColor", self.sw(&cell.fill))
|
||||
.attr("FillTint", pct(cell.fill_tint as f64))
|
||||
.attr("TopInset", num(cell.insets[0]))
|
||||
.attr("LeftInset", num(cell.insets[1]))
|
||||
.attr("BottomInset", num(cell.insets[2]))
|
||||
.attr("RightInset", num(cell.insets[3]))
|
||||
.attr("VerticalJustification", vj)
|
||||
.attr("RotationAngle", num(cell.rotation));
|
||||
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
|
||||
self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]);
|
||||
}
|
||||
for psr in self.story_paras(&cell.text) {
|
||||
ce.push(psr);
|
||||
}
|
||||
el.push(ce);
|
||||
}
|
||||
}
|
||||
el
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ use std::sync::Arc;
|
|||
use designcraft_color::cms::lab;
|
||||
use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue, swatch};
|
||||
use designcraft_doc::{
|
||||
Asset, AssetId, BaselineGrid, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document, DropShadow,
|
||||
Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle, Page, PageId,
|
||||
PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, Rule, Section, Shape, SpanColumns, Spread, SpreadId, Story, StoryId, Stroke, Styles,
|
||||
TabStop, TextFrame, TextFrameOptions, TextWrap, story as st,
|
||||
AltFills, Asset, AssetId, BaselineGrid, Cell, CellRange, CellStroke, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind,
|
||||
Content, DocSettings, Document, DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId,
|
||||
ListType, Margins, ObjectStyle, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape,
|
||||
SpanColumns, Spread, SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, VerticalJustification,
|
||||
story as st,
|
||||
};
|
||||
use designcraft_geom::corners::{Corner, CornerOptions};
|
||||
use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
|
||||
|
|
@ -818,15 +819,143 @@ impl<'r> Importer<'r> {
|
|||
// ---------- stories ----------
|
||||
|
||||
fn story(&mut self, id: StoryId, e: &El) -> Story {
|
||||
let mut b =
|
||||
StoryBuilder { text: String::new(), paras: vec![ParaFormat::default()], runs: Vec::new(), fresh: true, last: CharFormat::default() };
|
||||
let mut b = StoryBuilder {
|
||||
text: String::new(),
|
||||
paras: vec![ParaFormat::default()],
|
||||
runs: Vec::new(),
|
||||
fresh: true,
|
||||
last: CharFormat::default(),
|
||||
tables: Vec::new(),
|
||||
after_table: None,
|
||||
};
|
||||
self.walk_story(e, &mut b, &ParaFormat::default(), &CharAttrs::default(), &CharFormat::default(), None);
|
||||
let StoryBuilder { text, paras, runs, last, .. } = b;
|
||||
let StoryBuilder { text, mut paras, runs, last, tables: tbls, .. } = b;
|
||||
let mut chars: Vec<CharRun> = runs.into_iter().filter(|r| r.len > 0).collect();
|
||||
if chars.is_empty() {
|
||||
chars.push(CharRun { len: 0, format: last });
|
||||
}
|
||||
Story { id, text, paras, chars, frames: vec![], rev: 0 }
|
||||
let mut tables = BTreeMap::new();
|
||||
for (pi, t) in tbls {
|
||||
if let Some(p) = paras.get_mut(pi) {
|
||||
p.table = Some(t.id);
|
||||
tables.insert(t.id, Arc::new(t));
|
||||
}
|
||||
}
|
||||
let mut st = Story { id, text, paras, chars, frames: vec![], rev: 0, tables };
|
||||
// Drop anything inconsistent (e.g. a table sharing a paragraph with another).
|
||||
if st.check().is_err() {
|
||||
st.tables.retain(|_, _| false);
|
||||
for p in &mut st.paras {
|
||||
p.table = None;
|
||||
}
|
||||
}
|
||||
st
|
||||
}
|
||||
|
||||
/// `<Table>`: rows, columns and cells (cell `Name` is `column:row`).
|
||||
fn table(&mut self, e: &El) -> Table {
|
||||
let id = self.alloc();
|
||||
let rows: Vec<&El> = e.find_all("Row").collect();
|
||||
let cols: Vec<&El> = e.find_all("Column").collect();
|
||||
let nr = rows.len().max(1);
|
||||
let nc = cols.len().max(1);
|
||||
let mut t = Table::new(id, nr, nc, 0, 0, 0.0);
|
||||
for (r, re) in rows.iter().enumerate() {
|
||||
let auto = re.boolean("AutoGrow").unwrap_or(true);
|
||||
t.rows[r].mode = if auto { RowHeightMode::AtLeast } else { RowHeightMode::Exactly };
|
||||
t.rows[r].height = if auto { re.num("MinimumHeight").unwrap_or(3.0) } else { re.num("SingleRowHeight").unwrap_or(12.0) };
|
||||
}
|
||||
for (c, ce) in cols.iter().enumerate() {
|
||||
t.columns[c].width = ce.num("SingleColumnWidth").unwrap_or(72.0).max(3.0);
|
||||
}
|
||||
let h = e.num("HeaderRowCount").unwrap_or(0.0) as usize;
|
||||
let f = e.num("FooterRowCount").unwrap_or(0.0) as usize;
|
||||
if h + f < nr {
|
||||
t.set_header_footer(h, f);
|
||||
}
|
||||
let stroke = |me: &mut Self, el: &El, prefix: &str, base: &CellStroke| -> CellStroke {
|
||||
let mut s = base.clone();
|
||||
if let Some(w) = el.num(&format!("{prefix}StrokeWeight")) {
|
||||
s.weight = w.max(0.0);
|
||||
}
|
||||
if let Some(c) = el.get(&format!("{prefix}StrokeColor")) {
|
||||
s.color = me.swatch_ref(c);
|
||||
}
|
||||
if let Some(v) = el.num(&format!("{prefix}StrokeTint")) {
|
||||
s.tint = tint(Some(v)).unwrap_or(1.0);
|
||||
}
|
||||
if let Some(v) = el.get(&format!("{prefix}StrokeType")) {
|
||||
s.kind = names::stroke_type_in(v);
|
||||
}
|
||||
s
|
||||
};
|
||||
t.options.border = stroke(self, e, "TopBorder", &t.options.border);
|
||||
t.options.space_before = e.num("SpaceBefore").unwrap_or(t.options.space_before);
|
||||
t.options.space_after = e.num("SpaceAfter").unwrap_or(t.options.space_after);
|
||||
t.options.repeat_header = e.get("HeaderBehavior") != Some("RepeatOnce");
|
||||
t.options.repeat_footer = e.get("FooterBehavior") != Some("RepeatOnce");
|
||||
for kind in ["Row", "Column"] {
|
||||
let Some(first) = e.num(&format!("Start{kind}FillCount")).filter(|n| *n > 0.0) else { continue };
|
||||
let alt = AltFills {
|
||||
first: first as u32,
|
||||
first_color: e.get(&format!("Start{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
|
||||
first_tint: tint(e.num(&format!("Start{kind}FillTint"))).unwrap_or(1.0),
|
||||
next: e.num(&format!("End{kind}FillCount")).unwrap_or(1.0) as u32,
|
||||
next_color: e.get(&format!("End{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()),
|
||||
next_tint: tint(e.num(&format!("End{kind}FillTint"))).unwrap_or(1.0),
|
||||
skip_first: e.num(&format!("SkipFirstAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
|
||||
skip_last: e.num(&format!("SkipLastAlternatingFill{kind}s")).unwrap_or(0.0) as u32,
|
||||
};
|
||||
if kind == "Row" {
|
||||
t.options.alt_rows = Some(alt);
|
||||
} else {
|
||||
t.options.alt_cols = Some(alt);
|
||||
}
|
||||
}
|
||||
let mut regions = Vec::new();
|
||||
for ce in e.find_all("Cell") {
|
||||
let Some((c, r)) =
|
||||
ce.get("Name").and_then(|n| n.split_once(':')).and_then(|(a, b)| Some((a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if r >= nr || c >= nc {
|
||||
continue;
|
||||
}
|
||||
let rs = ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize;
|
||||
let cs = ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize;
|
||||
let text = self.story(StoryId(0), ce);
|
||||
let mut cell = Cell { text, ..Default::default() };
|
||||
if let Some(fc) = ce.get("FillColor") {
|
||||
cell.fill = self.swatch_ref(fc);
|
||||
}
|
||||
cell.fill_tint = tint(ce.num("FillTint")).unwrap_or(1.0);
|
||||
for (i, k) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().enumerate() {
|
||||
if let Some(v) = ce.num(k) {
|
||||
cell.insets[i] = v.max(0.0);
|
||||
}
|
||||
}
|
||||
cell.vj = match ce.get("VerticalJustification") {
|
||||
Some("CenterAlign") => VerticalJustification::Center,
|
||||
Some("BottomAlign") => VerticalJustification::Bottom,
|
||||
Some("JustifyAlign") => VerticalJustification::Justify,
|
||||
_ => VerticalJustification::Top,
|
||||
};
|
||||
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
|
||||
let base = cell.strokes[i].clone();
|
||||
cell.strokes[i] = stroke(self, ce, side, &base);
|
||||
}
|
||||
if let Some(slot) = t.cell_mut(r, c) {
|
||||
*slot = cell;
|
||||
}
|
||||
if rs > 1 || cs > 1 {
|
||||
regions.push(CellRange { r0: r, c0: c, r1: (r + rs - 1).min(nr - 1), c1: (c + cs - 1).min(nc - 1) });
|
||||
}
|
||||
}
|
||||
for rg in regions {
|
||||
let _ = t.merge(rg);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
fn walk_story(&mut self, e: &El, b: &mut StoryBuilder, pf: &ParaFormat, pchars: &CharAttrs, cf: &CharFormat, brk: Option<&str>) {
|
||||
|
|
@ -837,7 +966,7 @@ impl<'r> Importer<'r> {
|
|||
let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| st::BASIC_PARAGRAPH.into());
|
||||
let para = self.para_attrs(c);
|
||||
let chars = self.char_attrs(c);
|
||||
let npf = ParaFormat { style, para, chars: CharAttrs::default() };
|
||||
let npf = ParaFormat { style, para, chars: CharAttrs::default(), table: None };
|
||||
if b.fresh
|
||||
&& let Some(last) = b.paras.last_mut()
|
||||
{
|
||||
|
|
@ -889,9 +1018,12 @@ impl<'r> Importer<'r> {
|
|||
b.fresh = true;
|
||||
}
|
||||
},
|
||||
"Table" => {
|
||||
let t = self.table(c);
|
||||
b.push_table(t, pf, cf);
|
||||
}
|
||||
// Not supported yet: skip their content entirely.
|
||||
"Properties"
|
||||
| "Table"
|
||||
| "Footnote"
|
||||
| "Note"
|
||||
| "Rectangle"
|
||||
|
|
@ -1373,13 +1505,38 @@ struct StoryBuilder {
|
|||
/// The current paragraph has no content yet (its format can still be set by a range).
|
||||
fresh: bool,
|
||||
last: CharFormat,
|
||||
/// Tables by anchor paragraph index.
|
||||
tables: Vec<(usize, Table)>,
|
||||
/// Content after a table anchor starts a new paragraph (with this format).
|
||||
after_table: Option<ParaFormat>,
|
||||
}
|
||||
|
||||
impl StoryBuilder {
|
||||
/// A table gets an anchor paragraph of its own.
|
||||
fn push_table(&mut self, t: Table, pf: &ParaFormat, cf: &CharFormat) {
|
||||
let para_empty = self.text.rsplit('\n').next().is_none_or(str::is_empty);
|
||||
if !para_empty {
|
||||
self.after_table = None;
|
||||
self.push("\n", cf);
|
||||
self.paras.push(pf.clone());
|
||||
}
|
||||
self.after_table = None;
|
||||
self.push(&st::TABLE_ANCHOR.to_string(), cf);
|
||||
self.tables.push((self.paras.len() - 1, t));
|
||||
self.after_table = Some(pf.clone());
|
||||
self.fresh = false;
|
||||
}
|
||||
|
||||
fn push(&mut self, s: &str, f: &CharFormat) {
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(pf) = self.after_table.take()
|
||||
&& !s.starts_with('\n')
|
||||
{
|
||||
self.push("\n", f);
|
||||
self.paras.push(pf);
|
||||
}
|
||||
self.text.push_str(s);
|
||||
if !s.contains('\n') || s.len() > 1 {
|
||||
self.fresh = false;
|
||||
|
|
|
|||
|
|
@ -329,3 +329,38 @@ fn uri_and_base64_helpers() {
|
|||
let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
|
||||
assert_eq!(base64_decode(&base64_encode(&data)), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_tables() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
d.swatches.push(Swatch::color("Brand", Color::rgb(1.0, 0.0, 0.0)));
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Intro\nOutro", ParaFormat::default()).unwrap();
|
||||
let mut t = designcraft_doc::Table::new(d.alloc(), 3, 3, 1, 0, 300.0);
|
||||
t.cell_mut(0, 0).unwrap().text.insert(0, "Head");
|
||||
t.cell_mut(0, 0).unwrap().fill = "Brand".into();
|
||||
t.cell_mut(1, 2).unwrap().text.insert(0, "Body\nTwo");
|
||||
t.cell_mut(2, 1).unwrap().vj = designcraft_doc::VerticalJustification::Center;
|
||||
t.rows[2].mode = designcraft_doc::RowHeightMode::Exactly;
|
||||
t.rows[2].height = 30.0;
|
||||
t.columns[0].width = 50.0;
|
||||
t.merge(designcraft_doc::CellRange::new(2, 1, 3, 2)).unwrap();
|
||||
t.options.alt_rows = Some(designcraft_doc::AltFills { first: 1, first_color: "Brand".into(), first_tint: 0.2, ..Default::default() });
|
||||
d.story_mut(sid).unwrap().insert_table(5, t.clone());
|
||||
d.check().unwrap();
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
back.check().unwrap();
|
||||
let s = back.stories.values().next().unwrap();
|
||||
assert_eq!(s.text, format!("Intro\n{}\nOutro", st::TABLE_ANCHOR));
|
||||
let bt = s.tables.values().next().unwrap();
|
||||
assert_eq!((bt.nrows(), bt.ncols(), bt.header_rows()), (4, 3, 1));
|
||||
assert_eq!(bt.cell(0, 0).unwrap().text.text, "Head");
|
||||
assert_eq!(bt.cell(0, 0).unwrap().fill, "Brand");
|
||||
assert_eq!(bt.cell(1, 2).unwrap().text.text, "Body\nTwo");
|
||||
assert_eq!(bt.cell(2, 1).unwrap().vj, designcraft_doc::VerticalJustification::Center);
|
||||
assert_eq!((bt.cell(2, 1).unwrap().row_span, bt.cell(2, 1).unwrap().col_span), (2, 2));
|
||||
assert_eq!(bt.rows[2].mode, designcraft_doc::RowHeightMode::Exactly);
|
||||
assert!((bt.rows[2].height - 30.0).abs() < 1e-6);
|
||||
assert!((bt.columns[0].width - 50.0).abs() < 1e-6);
|
||||
assert_eq!(bt.options.alt_rows.as_ref().map(|a| a.first_color.as_str()), Some("Brand"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ impl Exporter<'_> {
|
|||
self.fill_rect(s, d.rect, c);
|
||||
}
|
||||
}
|
||||
if !ft.tables.is_empty() {
|
||||
self.tables(s, ft);
|
||||
}
|
||||
let mut deco: Vec<(u32, Rect)> = Vec::new();
|
||||
for l in &ft.lines {
|
||||
let gs = &l.glyphs;
|
||||
|
|
@ -105,6 +108,51 @@ impl Exporter<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Table fragments: cell fills, cell text (real text), edges and the border.
|
||||
fn tables(&mut self, s: &mut Surface, ft: &FrameText) {
|
||||
for t in &ft.tables {
|
||||
for c in &t.cells {
|
||||
if let Some((sw, tint)) = &c.fill
|
||||
&& let Some(col) = self.swatch_color(sw, *tint)
|
||||
{
|
||||
self.fill_rect(s, c.rect, col);
|
||||
}
|
||||
}
|
||||
for c in &t.cells {
|
||||
if let Some(cft) = c.text.frames.first() {
|
||||
s.push_transform(&tf(Affine::translate(c.origin.to_vec2())));
|
||||
self.frame_text(s, &c.text, cft, &c.source);
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
for seg in &t.strokes {
|
||||
let st = &seg.stroke;
|
||||
let Some(col) = self.swatch_color(&st.color, st.tint) else { continue };
|
||||
let (cap, dash) = match &st.kind {
|
||||
designcraft_doc::StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => {
|
||||
let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();
|
||||
if pat.len() % 2 == 1 {
|
||||
pat.extend(pat.clone());
|
||||
}
|
||||
(krilla::paint::LineCap::Butt, Some(krilla::paint::StrokeDash { array: pat, offset: 0.0 }))
|
||||
}
|
||||
designcraft_doc::StrokeType::Dotted => {
|
||||
(krilla::paint::LineCap::Round, Some(krilla::paint::StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 }))
|
||||
}
|
||||
_ => (krilla::paint::LineCap::Square, None),
|
||||
};
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(seg.a);
|
||||
bp.line_to(seg.b);
|
||||
let Some(p) = to_path(&bp) else { continue };
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke { paint: col.into(), width: st.weight as f32, line_cap: cap, dash, ..Default::default() }));
|
||||
s.draw_path(&p);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_rect(&self, s: &mut Surface, r: Rect, c: krilla::color::Color) {
|
||||
if let Some(p) = to_path(&r.to_path(0.1)) {
|
||||
s.set_stroke(None);
|
||||
|
|
|
|||
|
|
@ -465,6 +465,9 @@ impl Renderer {
|
|||
ctx.fill_rect(&d.rect);
|
||||
}
|
||||
}
|
||||
if !ft.tables.is_empty() {
|
||||
self.draw_tables(ctx, f, ft, xf);
|
||||
}
|
||||
let scale = (f.view * xf).determinant().abs().sqrt();
|
||||
// Batch glyph outlines per run style into one path (in frame inner space).
|
||||
let mut batches: HashMap<u32, BezPath> = HashMap::new();
|
||||
|
|
@ -535,6 +538,48 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Table fragments: cell fills, cell text, then cell edges and the border.
|
||||
fn draw_tables(&mut self, ctx: &mut RenderContext, f: &Frame, ft: &FrameText, xf: Affine) {
|
||||
let doc = f.doc;
|
||||
for t in &ft.tables {
|
||||
for c in &t.cells {
|
||||
if let Some((sw, tint)) = &c.fill
|
||||
&& let Some(col) = doc.resolve_color(sw, *tint)
|
||||
{
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.set_paint(color_of(&col, 1.0));
|
||||
// Overlap neighbours by half a device pixel so adjacent fills show no seams.
|
||||
let h = 0.5 * f.px;
|
||||
ctx.fill_rect(&c.rect.inflate(h, h));
|
||||
}
|
||||
}
|
||||
for c in &t.cells {
|
||||
if let Some(cft) = c.text.frames.first() {
|
||||
self.draw_text(ctx, f, &c.text, cft, xf * Affine::translate(c.origin.to_vec2()));
|
||||
}
|
||||
}
|
||||
ctx.set_transform(f.view * xf);
|
||||
for s in &t.strokes {
|
||||
let Some(col) = doc.resolve_color(&s.stroke.color, s.stroke.tint) else { continue };
|
||||
ctx.set_paint(color_of(&col, 1.0));
|
||||
ctx.set_stroke(cell_stroke(&s.stroke));
|
||||
ctx.stroke_path(&kurbo::Line::new(s.a, s.b).to_path(0.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kurbo stroke for a table edge.
|
||||
fn cell_stroke(s: &designcraft_doc::CellStroke) -> kurbo::Stroke {
|
||||
let st = kurbo::Stroke::new(s.weight).with_caps(kurbo::Cap::Square);
|
||||
match &s.kind {
|
||||
StrokeType::Dashed { pattern } if !pattern.is_empty() => st.with_caps(kurbo::Cap::Butt).with_dashes(0.0, pattern.iter().copied()),
|
||||
StrokeType::Dotted => st.with_dashes(0.0, [0.0, s.weight * 2.0]).with_caps(kurbo::Cap::Round),
|
||||
_ => st,
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_el(a: Affine, el: kurbo::PathEl) -> kurbo::PathEl {
|
||||
use kurbo::PathEl::*;
|
||||
match el {
|
||||
|
|
@ -710,4 +755,39 @@ mod tests {
|
|||
let _ = fid;
|
||||
assert!(r.stats.glyphs >= 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_tables() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 400.0), lid, "", ParaFormat::default()).unwrap();
|
||||
let mut t = designcraft_doc::Table::new(5, 3, 3, 1, 0, 300.0);
|
||||
for c in 0..3 {
|
||||
let cell = t.cell_mut(0, c).unwrap();
|
||||
cell.fill = "C=100 M=0 Y=0 K=0".into();
|
||||
cell.text.insert(0, "Head");
|
||||
t.cell_mut(2, c).unwrap().text.insert(0, "WWWWWWWW");
|
||||
}
|
||||
d.story_mut(sid).unwrap().insert_table(0, t);
|
||||
let cache = Cache::new();
|
||||
let cs = cache.get(&d, sid, None);
|
||||
let tf = &cs.frames[0].tables[0];
|
||||
let head = tf.cell(0, 1).unwrap().rect;
|
||||
let body = tf.cell(2, 0).unwrap().rect;
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 0;
|
||||
let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
|
||||
// Header fill (cyan) near the right edge of the middle header cell.
|
||||
let c = img.pixel((head.x1 - 3.0) as u32, (head.y0 + 3.0) as u32);
|
||||
assert!(c[0] < 80 && c[2] > 150, "{c:?}");
|
||||
// The table border (black) on the left edge.
|
||||
let e = img.pixel(body.x0.round() as u32, body.center().y as u32);
|
||||
assert!(e[0] < 200, "{e:?}");
|
||||
// Cell text drew dark pixels inside the body cell.
|
||||
let dark = ((body.x0 + 5.0) as u32..(body.x1 - 2.0) as u32)
|
||||
.flat_map(|x| ((body.y0 + 2.0) as u32..(body.y1 - 2.0) as u32).map(move |y| (x, y)))
|
||||
.filter(|&(x, y)| img.pixel(x, y)[0] < 100)
|
||||
.count();
|
||||
assert!(dark > 30, "cell text pixels: {dark}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
|
|||
let sel: &Selection = &st.selection;
|
||||
// Text selection / caret.
|
||||
if let Some(ts) = sel.text {
|
||||
draw_text_selection(app, painter, xf, doc, layout, ts);
|
||||
draw_text_selection(app, painter, xf, doc, layout, ts, sel.cells);
|
||||
}
|
||||
let mut union: Option<Rect> = None;
|
||||
let mut color = Color32::from_rgb(79, 153, 255);
|
||||
|
|
@ -660,9 +660,40 @@ fn draw_ports(
|
|||
}
|
||||
}
|
||||
|
||||
fn draw_text_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, ts: designcraft_doc::TextSel) {
|
||||
fn draw_text_selection(
|
||||
app: &DesignApp,
|
||||
painter: &egui::Painter,
|
||||
xf: &Xf,
|
||||
doc: &Document,
|
||||
layout: &CanvasLayout,
|
||||
ts: designcraft_doc::TextSel,
|
||||
cells: Option<designcraft_doc::TableSel>,
|
||||
) {
|
||||
let cs = app.session.cache.get(doc, ts.story, None);
|
||||
let range = ts.range();
|
||||
// Selected table cells: tint every selected cell.
|
||||
if let Some(sel) = cells.filter(|c| c.story == ts.story) {
|
||||
for ft in &cs.frames {
|
||||
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };
|
||||
let m = a * it.xf;
|
||||
for t in ft.tables.iter().filter(|t| t.table == sel.table) {
|
||||
for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) {
|
||||
let q = [
|
||||
xf.to_screen(m * Point::new(c.rect.x0, c.rect.y0)),
|
||||
xf.to_screen(m * Point::new(c.rect.x1, c.rect.y0)),
|
||||
xf.to_screen(m * Point::new(c.rect.x1, c.rect.y1)),
|
||||
xf.to_screen(m * Point::new(c.rect.x0, c.rect.y1)),
|
||||
];
|
||||
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(cell) = ts.cell {
|
||||
draw_cell_selection(app, painter, xf, doc, layout, &cs, ts, cell);
|
||||
return;
|
||||
}
|
||||
for ft in &cs.frames {
|
||||
let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue };
|
||||
let Some(it) = doc.item(ft.frame) else { continue };
|
||||
|
|
@ -706,6 +737,58 @@ fn draw_text_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &
|
|||
}
|
||||
}
|
||||
|
||||
/// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_cell_selection(
|
||||
_app: &DesignApp,
|
||||
painter: &egui::Painter,
|
||||
xf: &Xf,
|
||||
doc: &Document,
|
||||
layout: &CanvasLayout,
|
||||
cs: &compose::ComposedStory,
|
||||
ts: designcraft_doc::TextSel,
|
||||
cell: designcraft_doc::CellAddr,
|
||||
) {
|
||||
let Some((fi, _, pc)) = compose::find_cell(cs, cell.table, cell.row, cell.col) else { return };
|
||||
let Some(ft) = cs.frames.get(fi) else { return };
|
||||
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return };
|
||||
let m = a * it.xf * designcraft_geom::Affine::translate(pc.origin.to_vec2());
|
||||
let range = ts.range();
|
||||
if !range.is_empty()
|
||||
&& let Some(cft) = pc.text.frames.first()
|
||||
{
|
||||
for l in &cft.lines {
|
||||
let s = range.start.max(l.range.start);
|
||||
let e = range.end.min(l.range.end);
|
||||
if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) {
|
||||
continue;
|
||||
}
|
||||
let x0 = caret_x(l, s);
|
||||
let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { caret_x(l, e) };
|
||||
let q = [
|
||||
xf.to_screen(m * Point::new(x0, l.baseline - l.ascent)),
|
||||
xf.to_screen(m * Point::new(x1, l.baseline - l.ascent)),
|
||||
xf.to_screen(m * Point::new(x1, l.baseline + l.descent)),
|
||||
xf.to_screen(m * Point::new(x0, l.baseline + l.descent)),
|
||||
];
|
||||
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
|
||||
}
|
||||
}
|
||||
if ts.is_caret()
|
||||
&& let Some((_, x, bl, asc, desc)) = compose::cell_caret(cs, cell.table, cell.row, cell.col, ts.focus)
|
||||
{
|
||||
let m = a * it.xf;
|
||||
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
|
||||
if blink {
|
||||
painter.line_segment(
|
||||
[xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))],
|
||||
Stroke::new(1.0, Color32::BLACK),
|
||||
);
|
||||
}
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
|
||||
}
|
||||
}
|
||||
|
||||
fn caret_x(l: &compose::Line, pos: usize) -> f64 {
|
||||
for g in &l.glyphs {
|
||||
if g.len > 0 && pos >= g.byte && pos < g.byte + g.len {
|
||||
|
|
@ -1010,7 +1093,7 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc
|
|||
pub fn selected_text(app: &DesignApp) -> Option<String> {
|
||||
let st = app.session.active()?;
|
||||
let t = st.selection.text?;
|
||||
let story = st.doc.story(t.story)?;
|
||||
let story = st.doc.text_story(t.story, t.cell)?;
|
||||
let s = story.slice(t.range());
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ impl Dialog {
|
|||
"marginTop": "3p0", "marginBottom": "3p0", "marginInside": "3p0", "marginOutside": "3p0", "bleed": "0p0", "primaryTextFrame": false})
|
||||
}
|
||||
"goToPage" => json!({"page": 1}),
|
||||
"insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}),
|
||||
"findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}),
|
||||
"textFrameOptions" => json!({"columns": 1, "gutter": "1p0", "inset": "0p0", "verticalJustification": "top"}),
|
||||
"documentSetup" => json!({}),
|
||||
|
|
@ -95,6 +96,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
|||
"newDocument" => "New Document",
|
||||
"frameSize" => "Rectangle",
|
||||
"goToPage" => "Go to Page",
|
||||
"insertTable" => "Create Table",
|
||||
"textFrameOptions" => "Text Frame Options",
|
||||
"documentSetup" => "Document Setup",
|
||||
"findChange" => "Find/Change",
|
||||
|
|
@ -232,6 +234,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
|||
text_field(ui, &mut d, "page", 80.0);
|
||||
});
|
||||
}
|
||||
"insertTable" => {
|
||||
ui.label(egui::RichText::new("Table Dimensions").font(semibold(12.0)));
|
||||
egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
ui.label("Body Rows");
|
||||
text_field(ui, &mut d, "bodyRows", 60.0);
|
||||
ui.label("Columns");
|
||||
text_field(ui, &mut d, "columns", 60.0);
|
||||
ui.end_row();
|
||||
ui.label("Header Rows");
|
||||
text_field(ui, &mut d, "headerRows", 60.0);
|
||||
ui.label("Footer Rows");
|
||||
text_field(ui, &mut d, "footerRows", 60.0);
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
"textFrameOptions" => {
|
||||
egui::Grid::new("tfo").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
ui.label("Number of columns");
|
||||
|
|
@ -327,6 +344,11 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
|||
crate::canvas::go_to_page(app, (d.n("page").unwrap_or(1.0) as usize).saturating_sub(1));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"insertTable" => app.run(
|
||||
"table.insert",
|
||||
json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64,
|
||||
"headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}),
|
||||
),
|
||||
"textFrameOptions" => app.run(
|
||||
"object.textFrameOptions",
|
||||
json!({"columns": d.n("columns").unwrap_or(1.0) as u64, "gutter": d.m("gutter").unwrap_or(12.0), "inset": d.m("inset").unwrap_or(0.0), "verticalJustification": d.s("verticalJustification")}),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"),
|
||||
("app.palette", "Command Palette…", Some("Cmd+K"), "{}"),
|
||||
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
|
||||
("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),
|
||||
("app.tablePanel", "Table Panel", Some("Shift+F9"), "{}"),
|
||||
("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"),
|
||||
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
|
||||
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
|
||||
|
|
@ -37,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
"window.panel",
|
||||
"Show Panel",
|
||||
None,
|
||||
"{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links}",
|
||||
"{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}",
|
||||
),
|
||||
("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"),
|
||||
("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"),
|
||||
|
|
@ -145,7 +147,37 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
|||
"<",
|
||||
],
|
||||
),
|
||||
("Table", &[]),
|
||||
(
|
||||
"Table",
|
||||
&[
|
||||
"ui:app.insertTableDialog",
|
||||
"cmd:table.convertFromText",
|
||||
"cmd:table.convertToText",
|
||||
"-",
|
||||
">Insert",
|
||||
"cmd:table.insertRowAbove",
|
||||
"cmd:table.insertRowBelow",
|
||||
"cmd:table.insertColumnLeft",
|
||||
"cmd:table.insertColumnRight",
|
||||
"<",
|
||||
">Delete",
|
||||
"cmd:table.deleteRow",
|
||||
"cmd:table.deleteColumn",
|
||||
"cmd:table.delete",
|
||||
"<",
|
||||
">Select",
|
||||
"cmd:table.selectRow",
|
||||
"cmd:table.selectColumn",
|
||||
"cmd:table.selectTable",
|
||||
"<",
|
||||
"-",
|
||||
"cmd:table.merge",
|
||||
"cmd:table.unmerge",
|
||||
"cmd:table.distributeColumns",
|
||||
"-",
|
||||
"ui:app.tablePanel",
|
||||
],
|
||||
),
|
||||
(
|
||||
"View",
|
||||
&[
|
||||
|
|
@ -222,6 +254,17 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
|||
app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.insertTableDialog" => {
|
||||
if app.session.active().is_none_or(|d| d.selection.text.is_none()) {
|
||||
return Some(Err("place the insertion point in a text frame to create a table".into()));
|
||||
}
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("insertTable", p.clone()));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.tablePanel" => {
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some("table") { None } else { Some("table".into()) };
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.exportPdf" => export_pdf(app, p),
|
||||
"app.palette" => {
|
||||
app.ui.palette = Some(String::new());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
//! The Table panel: row/column counts, row height, column width, cell text alignment and insets,
|
||||
//! merge/unmerge — for the table at the text insertion point or the selected cells.
|
||||
|
||||
use designcraft_geom::Unit;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::DesignApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{caption, divider, measure, number};
|
||||
|
||||
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Ok(info) = app.session.execute("table.get", &json!({})) else {
|
||||
ui.label(egui::RichText::new("Place the insertion point in a table, or create one.").color(t.text_dim));
|
||||
ui.add_space(6.0);
|
||||
if ui.button("Create Table…").clicked() {
|
||||
let _ = app.run("app.insertTableDialog", json!({}));
|
||||
}
|
||||
return;
|
||||
};
|
||||
let unit = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
|
||||
let rows = info["rows"].as_array().cloned().unwrap_or_default();
|
||||
let cols = info["columns"].as_array().cloned().unwrap_or_default();
|
||||
let range = info["range"].clone();
|
||||
let (r0, c0) = (range["r0"].as_u64().unwrap_or(0) as usize, range["c0"].as_u64().unwrap_or(0) as usize);
|
||||
let tid = info["table"].clone();
|
||||
let cell = info["cells"].as_array().and_then(|cs| cs.iter().find(|c| c["row"] == r0 && c["col"] == c0)).cloned().unwrap_or(Value::Null);
|
||||
let run = |app: &mut DesignApp, id: &str, p: Value| {
|
||||
let _ = app.run(id, p);
|
||||
};
|
||||
egui::Grid::new("table_panel").num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| {
|
||||
caption(ui, "Rows");
|
||||
if let Some(v) = number(ui, "tp_rows", Some(rows.len() as f64), "", 52.0, 0) {
|
||||
let n = v.round().max(1.0) as usize;
|
||||
if n > rows.len() {
|
||||
run(
|
||||
app,
|
||||
"table.insertRow",
|
||||
json!({"table": tid, "rows": [rows.len() - 1, rows.len() - 1], "where": "below", "count": n - rows.len()}),
|
||||
);
|
||||
} else if n < rows.len() {
|
||||
run(app, "table.deleteRow", json!({"table": tid, "rows": [n, rows.len() - 1]}));
|
||||
}
|
||||
}
|
||||
caption(ui, "Columns");
|
||||
if let Some(v) = number(ui, "tp_cols", Some(cols.len() as f64), "", 52.0, 0) {
|
||||
let n = v.round().max(1.0) as usize;
|
||||
if n > cols.len() {
|
||||
run(
|
||||
app,
|
||||
"table.insertColumn",
|
||||
json!({"table": tid, "cols": [cols.len() - 1, cols.len() - 1], "where": "right", "count": n - cols.len()}),
|
||||
);
|
||||
} else if n < cols.len() {
|
||||
run(app, "table.deleteColumn", json!({"table": tid, "cols": [n, cols.len() - 1]}));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
let row = rows.get(r0).cloned().unwrap_or(Value::Null);
|
||||
let exactly = row["mode"] == "exactly";
|
||||
caption(ui, "Row Height");
|
||||
egui::ComboBox::from_id_salt("tp_rowmode").selected_text(if exactly { "Exactly" } else { "At Least" }).width(70.0).show_ui(ui, |ui| {
|
||||
for (label, mode) in [("At Least", "atLeast"), ("Exactly", "exactly")] {
|
||||
if ui.selectable_label((mode == "exactly") == exactly, label).clicked() {
|
||||
run(app, "table.setRowHeight", json!({"mode": mode}));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(h) = measure(ui, "tp_rowh", row["height"].as_f64(), unit, 64.0) {
|
||||
run(app, "table.setRowHeight", json!({"height": h}));
|
||||
}
|
||||
ui.end_row();
|
||||
caption(ui, "Column Width");
|
||||
ui.label("");
|
||||
if let Some(w) = measure(ui, "tp_colw", cols.get(c0).and_then(|c| c["width"].as_f64()), unit, 64.0) {
|
||||
run(app, "table.setColumnWidth", json!({"width": w}));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
divider(ui);
|
||||
caption(ui, "Cell Text");
|
||||
ui.horizontal(|ui| {
|
||||
for (label, vj) in [("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")] {
|
||||
if ui.selectable_label(cell["vj"] == vj, label).clicked() {
|
||||
run(app, "table.setCell", json!({"vj": vj}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let insets: Vec<f64> = cell["insets"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_else(|| vec![4.0; 4]);
|
||||
egui::Grid::new("table_insets").num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| {
|
||||
let labels = ["Top", "Left", "Bottom", "Right"];
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
caption(ui, l);
|
||||
if let Some(v) = measure(ui, &format!("tp_inset{i}"), insets.get(i).copied(), unit, 56.0) {
|
||||
let mut n = insets.clone();
|
||||
n.resize(4, 4.0);
|
||||
n[i] = v.max(0.0);
|
||||
run(app, "table.setCell", json!({"insets": n}));
|
||||
}
|
||||
if i % 2 == 1 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
divider(ui);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Merge Cells").clicked() {
|
||||
run(app, "table.merge", json!({}));
|
||||
}
|
||||
if ui.button("Unmerge").clicked() {
|
||||
run(app, "table.unmerge", json!({}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Insert Row").clicked() {
|
||||
run(app, "table.insertRowBelow", json!({}));
|
||||
}
|
||||
if ui.button("Insert Column").clicked() {
|
||||
run(app, "table.insertColumnRight", json!({}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Delete Row").clicked() {
|
||||
run(app, "table.deleteRow", json!({}));
|
||||
}
|
||||
if ui.button("Delete Column").clicked() {
|
||||
run(app, "table.deleteColumn", json!({}));
|
||||
}
|
||||
});
|
||||
divider(ui);
|
||||
let mut alt = !info["options"]["altRows"].is_null();
|
||||
if ui.checkbox(&mut alt, "Alternating Row Fills").changed() {
|
||||
let v = if alt { json!({"first": 1, "firstColor": "[Black]", "firstTint": 0.1, "next": 1, "nextColor": "[None]"}) } else { Value::Null };
|
||||
run(app, "table.options", json!({"altRows": v}));
|
||||
}
|
||||
let mut rep = info["options"]["repeatHeader"].as_bool().unwrap_or(true);
|
||||
if ui.checkbox(&mut rep, "Repeat Header Rows").changed() {
|
||||
run(app, "table.options", json!({"repeatHeader": rep}));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
caption(ui, "Header Rows");
|
||||
if let Some(v) = number(ui, "tp_hdr", info["headerRows"].as_f64(), "", 40.0, 0) {
|
||||
run(app, "table.options", json!({"headerRows": v.max(0.0) as u64}));
|
||||
}
|
||||
caption(ui, "Footer Rows");
|
||||
if let Some(v) = number(ui, "tp_ftr", info["footerRows"].as_f64(), "", 40.0, 0) {
|
||||
run(app, "table.options", json!({"footerRows": v.max(0.0) as u64}));
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue