Tables: table breaking and CSV data links (fixes #534) (#553)

Squash of PR #553 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 12:47:39 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 784a7937ef
commit 9ca0dcf7d4
16 changed files with 897 additions and 21 deletions
+16
View File
@@ -846,6 +846,12 @@ pub struct Table {
/// The cell style of each row; missing or empty: by position (Title, Header, Data).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub row_styles: Vec<String>,
/// Table breaking (TABLEBREAK): the table drawn in several parts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub breaks: Option<crate::TableBreak>,
/// The CSV file the cells come from (DATALINK).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_link: Option<crate::DataLink>,
}
fn default_text_h() -> f64 {
0.18
@@ -1245,6 +1251,12 @@ impl EntityKind {
}
EntityKind::Wipeout(w) => w.boundary.iter_mut().for_each(|p| *p = m.apply(*p)),
EntityKind::Table(t) => {
if let Some(b) = &mut t.breaks {
b.height *= s;
b.spacing *= s;
b.heights.iter_mut().for_each(|h| *h *= s);
b.offsets.iter_mut().for_each(|o| *o = m.apply_vec(*o));
}
t3(&mut t.insert);
t.col_widths.iter_mut().for_each(|w| *w *= s);
t.row_heights.iter_mut().for_each(|h| *h *= s);
@@ -1262,6 +1274,10 @@ impl EntityKind {
/// Grip points (editable defining points), in display order.
pub fn grips(&self) -> Vec<Vec2> {
// A broken table has a grip for each part.
if let EntityKind::Table(t) = self {
return t.grip_points();
}
match self {
EntityKind::Line(l) => vec![l.a.xy(), l.a.xy().mid(l.b.xy()), l.b.xy()],
EntityKind::Point(p) => vec![p.p.xy()],
+1
View File
@@ -66,6 +66,7 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
/// per reference (which grows exponentially with the nesting depth).
fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String, Bounds2>) -> Bounds2 {
match &e.kind {
EntityKind::Table(t) if t.is_broken() => t.bounds(),
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY,
EntityKind::Text(t) => text_box(t, t.value.chars().count()),
+2
View File
@@ -637,7 +637,9 @@ impl Default for TableStyle {
}
mod cellstyle;
mod tablebreak;
pub use cellstyle::*;
pub use tablebreak::*;
/// A block definition.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+2
View File
@@ -233,6 +233,8 @@ impl Default for Table {
title: false,
header: false,
row_styles: Vec::new(),
breaks: None,
data_link: None,
}
}
}
+268
View File
@@ -0,0 +1,268 @@
//! Table breaking: a long table drawn in several parts side by side or one below the other, with
//! the label rows repeated, and data links (the table's cells filled from a CSV file).
use cadcraft_geom::{Bounds2, Vec2};
use serde::{Deserialize, Serialize};
use crate::Table;
/// The most parts a table is broken into.
pub const MAX_TABLE_PARTS: usize = 1000;
/// Where the next part of a broken table goes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BreakDirection {
#[default]
Right,
Left,
Down,
}
/// TABLEBREAK settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableBreak {
pub enabled: bool,
pub direction: BreakDirection,
/// Repeat the title and header rows at the top of every part.
pub repeat_top: bool,
/// Repeat the last row (bottom labels) at the bottom of every part.
pub repeat_bottom: bool,
/// Largest part height (labels included).
pub height: f64,
/// Manual heights of the first parts; later parts use `height`.
pub heights: Vec<f64>,
/// Space between parts.
pub spacing: f64,
/// Moves of parts 2, 3, … from their automatic place (part grips).
pub offsets: Vec<Vec2>,
}
impl Default for TableBreak {
fn default() -> Self {
TableBreak {
enabled: false,
direction: BreakDirection::Right,
repeat_top: true,
repeat_bottom: false,
height: 0.0,
heights: Vec::new(),
spacing: 0.5,
offsets: Vec::new(),
}
}
}
/// A CSV file the table's cells come from (DATALINK).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DataLink {
pub path: String,
/// The first table row the file's rows fill.
pub start_row: usize,
}
/// One part of a (broken) table: the rows it shows, top to bottom, and its insertion point.
#[derive(Clone, Debug, PartialEq)]
pub struct TablePart {
pub rows: Vec<usize>,
pub origin: Vec2,
}
fn size(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v } else { 0.0 }
}
impl Table {
/// Number of label rows at the top (title and header).
pub fn top_label_rows(&self) -> usize {
(usize::from(self.title) + usize::from(self.header)).min(self.row_heights.len())
}
/// Whether the table is drawn in several parts.
pub fn is_broken(&self) -> bool {
self.breaks.as_ref().is_some_and(|b| b.enabled && (size(b.height) > 0.0 || b.heights.iter().any(|h| size(*h) > 0.0)))
}
/// The parts the table is drawn in (one part when it isn't broken).
pub fn parts(&self) -> Vec<TablePart> {
let rows = self.row_heights.len().min(10_000);
let o = self.insert.xy();
let all = || TablePart { rows: (0..rows).collect(), origin: o };
let Some(b) = self.breaks.as_ref().filter(|_| self.is_broken()) else { return vec![all()] };
let h = |r: usize| size(self.row_heights.get(r).copied().unwrap_or(0.0));
let top: Vec<usize> = (0..self.top_label_rows()).collect();
let bottom: Option<usize> = (b.repeat_bottom && rows > top.len() + 1).then(|| rows - 1);
let data_end = bottom.unwrap_or(rows);
let bottom_h = bottom.map(h).unwrap_or(0.0);
let mut parts: Vec<Vec<usize>> = Vec::new();
let mut r = top.len();
while r < data_end && parts.len() < MAX_TABLE_PARTS {
let i = parts.len();
let limit = b.heights.get(i).copied().map(size).filter(|v| *v > 0.0).unwrap_or(size(b.height));
let mut rows_here: Vec<usize> = if i == 0 || b.repeat_top { top.clone() } else { Vec::new() };
let mut used: f64 = rows_here.iter().map(|r| h(*r)).sum::<f64>() + if b.repeat_bottom { bottom_h } else { 0.0 };
// At least one data row per part.
while r < data_end {
let next = r;
let fits = limit <= 0.0 || used + h(next) <= limit + 1e-9;
if !fits && rows_here.len() > if i == 0 || b.repeat_top { top.len() } else { 0 } {
break;
}
used += h(next);
rows_here.push(next);
r += 1;
}
parts.push(rows_here);
}
if parts.is_empty() {
return vec![all()];
}
// The rest when the part limit is reached; the bottom labels.
let n = parts.len();
if let Some(last) = parts.last_mut() {
last.extend(r..data_end);
}
for p in &mut parts {
if let Some(br) = bottom {
p.push(br);
}
}
// Place them.
let width: f64 = self.col_widths.iter().map(|w| size(*w)).sum();
let spacing = size(b.spacing);
let mut out = Vec::with_capacity(n);
let mut at = o;
for (i, rows) in parts.into_iter().enumerate() {
if i > 0 {
let prev_h: f64 = out.last().map(|p: &TablePart| p.rows.iter().map(|r| h(*r)).sum()).unwrap_or(0.0);
at = match b.direction {
BreakDirection::Right => at + Vec2::new(width + spacing, 0.0),
BreakDirection::Left => at - Vec2::new(width + spacing, 0.0),
BreakDirection::Down => at - Vec2::new(0.0, prev_h + spacing),
};
}
let off = if i > 0 { b.offsets.get(i - 1).copied().filter(|v| v.is_finite()).unwrap_or(Vec2::ZERO) } else { Vec2::ZERO };
// Automatic places follow the automatic chain; manual moves apply to the part only.
out.push(TablePart { rows, origin: at + off });
}
out
}
/// The area the table covers, all parts included.
pub fn bounds(&self) -> Bounds2 {
let w: f64 = self.col_widths.iter().map(|w| size(*w)).sum();
let mut b = Bounds2::EMPTY;
for p in self.parts() {
let h: f64 = p.rows.iter().map(|r| size(self.row_heights.get(*r).copied().unwrap_or(0.0))).sum();
b.add(p.origin);
b.add(p.origin + Vec2::new(w, -h));
}
b
}
/// The cell (row, col) under `p`, in any part.
pub fn cell_at_point(&self, p: Vec2) -> Option<(usize, usize)> {
for part in self.parts() {
let (x, y) = (p.x - part.origin.x, part.origin.y - p.y);
if !(x.is_finite() && y.is_finite()) || x < 0.0 || y < 0.0 {
continue;
}
let mut acc = 0.0;
let mut row = None;
for r in &part.rows {
acc += size(self.row_heights.get(*r).copied().unwrap_or(0.0));
if y <= acc {
row = Some(*r);
break;
}
}
let Some(row) = row else { continue };
let mut acc = 0.0;
for (c, w) in self.col_widths.iter().enumerate() {
acc += size(*w);
if x <= acc {
return Some((row, c));
}
}
}
None
}
/// Grip points: the insertion point, then the origin of every further part.
pub fn grip_points(&self) -> Vec<Vec2> {
let mut g = vec![self.insert.xy()];
if self.is_broken() {
g.extend(self.parts().into_iter().skip(1).map(|p| p.origin));
}
g
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TableCell;
fn table(rows: usize) -> Table {
Table {
col_widths: vec![2.0, 3.0],
row_heights: vec![1.0; rows],
cells: vec![vec![TableCell::default(); 2]; rows],
title: true,
header: true,
..Table::default()
}
}
#[test]
fn unbroken_is_one_part() {
let t = table(5);
assert_eq!(t.parts(), vec![TablePart { rows: vec![0, 1, 2, 3, 4], origin: Vec2::ZERO }]);
}
#[test]
fn breaks_repeat_labels_and_go_right() {
let mut t = table(8);
t.breaks = Some(TableBreak { enabled: true, height: 4.0, spacing: 1.0, ..TableBreak::default() });
let p = t.parts();
let rows: Vec<Vec<usize>> = p.iter().map(|x| x.rows.clone()).collect();
assert_eq!(rows, vec![vec![0, 1, 2, 3], vec![0, 1, 4, 5], vec![0, 1, 6, 7]]);
assert_eq!(p[1].origin, Vec2::new(6.0, 0.0));
assert_eq!(t.grip_points().len(), 3);
assert_eq!(t.cell_at_point(Vec2::new(7.0, -2.5)), Some((4, 0)));
let b = t.bounds();
assert_eq!((b.min, b.max), (Vec2::new(0.0, -4.0), Vec2::new(17.0, 0.0)));
}
#[test]
fn down_without_repeats_and_manual_heights() {
let mut t = table(8);
t.breaks = Some(TableBreak {
enabled: true,
direction: BreakDirection::Down,
repeat_top: false,
repeat_bottom: true,
height: 3.0,
heights: vec![5.0],
offsets: vec![Vec2::new(1.0, 0.0)],
..TableBreak::default()
});
let p = t.parts();
let rows: Vec<Vec<usize>> = p.iter().map(|x| x.rows.clone()).collect();
assert_eq!(rows, vec![vec![0, 1, 2, 3, 7], vec![4, 5, 7], vec![6, 7]]);
assert_eq!(p[1].origin, Vec2::new(1.0, -5.5));
}
#[test]
fn hostile_break_settings() {
let mut t = table(3000);
t.breaks = Some(TableBreak { enabled: true, height: 1e-9, spacing: f64::NAN, ..TableBreak::default() });
let p = t.parts();
assert!(p.len() <= MAX_TABLE_PARTS);
assert_eq!(p.iter().map(|x| x.rows.iter().filter(|r| **r >= 2).count()).sum::<usize>(), 2998);
t.breaks = Some(TableBreak { enabled: true, height: f64::INFINITY, ..TableBreak::default() });
assert_eq!(t.parts().len(), 1);
}
}
+2
View File
@@ -1,6 +1,7 @@
//! TABLE: create tables and edit cells, rows, columns and merges; cell styles, formulas
//! (`formula`), TABLEDIT and TABLEEXPORT (`cells`).
mod breaks;
mod cells;
pub(crate) mod formula;
@@ -36,6 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
]
.into_iter()
.chain(cells::specs())
.chain(breaks::specs())
.collect()
}
+372
View File
@@ -0,0 +1,372 @@
//! Table breaking (`table.break`) and data links (DATALINK, DATALINKUPDATE: cells filled from a
//! CSV file).
use cadcraft_doc::{BreakDirection, DataLink, EntityKind, Handle, Table, TableBreak, TableCell};
use serde_json::{Value, json};
use super::cells::cell_of;
use super::{MAX_COLS, MAX_ROWS, anchors, edit, set_anchors, usize_param};
use crate::cmd::machines::SelectRun;
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Largest linked file read (bytes).
#[cfg(not(target_arch = "wasm32"))]
const MAX_LINK_BYTES: u64 = 8 << 20;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("table.break", "Table Breaks", run_break)
.params(
"{handle, enabled?, direction?: right|left|down, repeatTop?, repeatBottom?, height? (largest part height), heights?: [first parts], \
spacing?, resetPositions?: bool} → {parts}",
)
.interactive(|_| Ok(Box::new(BreakM::default()))),
CommandSpec::new("datalink", "Data Link...", run_datalink)
.menu(&["Insert", "Data Link..."])
.params("{handle, path (.csv, .txt/.tsv tab-separated), startRow? (default: below the title), remove?: bool} → {rows, cols}"),
CommandSpec::new("datalinkupdate", "Update Data Links", run_update)
.params("{handles?} (default: every linked table) → {updated}")
.interactive(|_| Ok(Box::new(SelectRun::new("datalinkupdate", "DATALINKUPDATE")))),
]
}
// ---------------------------------------------------------------- table breaking
fn run_break(s: &mut Session, p: &Value) -> Result<Value> {
let id = "table.break";
let num = |k: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_f64().filter(|x| x.is_finite() && *x >= 0.0 && *x <= 1e12).map(Some).ok_or_else(|| bad(id, format!("invalid {k}: {v}"))),
}
};
let height = num("height")?;
let spacing = num("spacing")?;
let heights: Option<Vec<f64>> = match p.get("heights") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) => Some(
a.iter()
.take(cadcraft_doc::MAX_TABLE_PARTS)
.map(|v| v.as_f64().filter(|x| x.is_finite() && *x >= 0.0 && *x <= 1e12))
.collect::<Option<Vec<f64>>>()
.ok_or_else(|| bad(id, "`heights` must be non-negative numbers"))?,
),
Some(v) => return Err(bad(id, format!("invalid heights: {v}"))),
};
let direction = match str_param(p, "direction").map(|d| d.trim().to_ascii_lowercase()) {
None => None,
Some(d) => Some(match d.as_str() {
"right" | "r" => BreakDirection::Right,
"left" | "l" => BreakDirection::Left,
"down" | "d" => BreakDirection::Down,
_ => return Err(bad(id, format!("invalid direction {d} (right, left, down)"))),
}),
};
let r = edit(s, p, id, |t| {
let mut b = t.breaks.clone().unwrap_or_default();
// Giving a height switches breaking on unless `enabled` says otherwise.
b.enabled = p.get("enabled").and_then(Value::as_bool).unwrap_or(b.enabled || height.is_some_and(|h| h > 0.0));
if let Some(d) = direction {
b.direction = d;
}
if let Some(v) = p.get("repeatTop").and_then(Value::as_bool) {
b.repeat_top = v;
}
if let Some(v) = p.get("repeatBottom").and_then(Value::as_bool) {
b.repeat_bottom = v;
}
if let Some(h) = height {
b.height = h;
}
if let Some(h) = heights {
b.heights = h;
}
if let Some(sp) = spacing {
b.spacing = sp;
}
if bool_or(p, "resetPositions", false) {
b.offsets.clear();
}
t.breaks = if b == TableBreak::default() { None } else { Some(b) };
Ok(())
})?;
let h = r.get("handle").and_then(Value::as_str).and_then(Handle::parse_hex);
let parts = match h.and_then(|h| s.doc().ok()?.entity(h)).map(|e| &e.kind) {
Some(EntityKind::Table(t)) => t.parts().len(),
_ => 1,
};
Ok(json!({ "handle": r["handle"], "parts": parts }))
}
/// `table.break` at the command line: select a table, then the part height (or Off).
#[derive(Default)]
struct BreakM {
table: Option<Handle>,
}
impl Interactive for BreakM {
fn name(&self) -> &'static str {
"TABLEBREAK"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.table {
None => Prompt::new("Select a table", Accept::SELECT),
Some(_) => Prompt::new("Enter maximum part height or", Accept::POINT_OR_NUMBER).kw(&["Off"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let table_at = |s: &Session, p| {
let d = s.doc().ok()?;
d.space(&s.space())?.iter().find(|e| matches!(&e.kind, EntityKind::Table(t) if cell_of(t, p).is_some())).map(|e| e.handle)
};
match (self.table, i) {
(_, Input::Cancel) => Ok(Step::Cancel),
(None, Input::Pick(hs)) => {
let d = s.doc()?;
self.table = hs.into_iter().find(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Table(_))));
if self.table.is_none() {
s.echo("The object is not a table.");
}
Ok(Step::Continue)
}
(None, Input::Point(p)) => {
self.table = table_at(s, p);
if self.table.is_none() {
s.echo("No table there.");
}
Ok(Step::Continue)
}
(None, Input::Enter) => Ok(Step::Cancel),
(None, _) => Ok(Step::Continue),
(Some(_), Input::Enter) => Ok(Step::Done),
(Some(h), Input::Text(t) | Input::Keyword(t)) => {
let params = if t.trim().eq_ignore_ascii_case("off") || t.trim().eq_ignore_ascii_case("o") {
json!({ "handle": h.hex(), "enabled": false })
} else {
match super::super::machines::number(&t).filter(|v| v.is_finite() && *v > 0.0) {
Some(v) => json!({ "handle": h.hex(), "height": v, "enabled": true }),
None => {
s.echo("Requires a positive number or Off.");
return Ok(Step::Continue);
}
}
};
let r = run_break(s, &params)?;
s.echo(format!("Table in {} part(s).", r["parts"]));
Ok(Step::Done)
}
(Some(_), _) => Ok(Step::Continue),
}
}
}
// ---------------------------------------------------------------- data links
/// Parse CSV text (quotes with doubled quotes inside, CRLF or LF), bounded to the table limits.
pub(crate) fn parse_csv(text: &str, sep: char) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut row: Vec<String> = Vec::new();
let mut field = String::new();
let mut quoted = false;
let mut chars = text.chars().peekable();
let push_field = |row: &mut Vec<String>, field: &mut String| {
if row.len() < MAX_COLS {
row.push(std::mem::take(field));
} else {
field.clear();
}
};
while let Some(c) = chars.next() {
if rows.len() >= MAX_ROWS {
break;
}
if quoted {
if c == '"' {
if chars.peek() == Some(&'"') {
chars.next();
field.push('"');
} else {
quoted = false;
}
} else {
field.push(c);
}
} else if c == '"' && field.is_empty() {
quoted = true;
} else if c == sep {
push_field(&mut row, &mut field);
} else if c == '\n' || c == '\r' {
if c == '\r' && chars.peek() == Some(&'\n') {
chars.next();
}
push_field(&mut row, &mut field);
rows.push(std::mem::take(&mut row));
} else {
field.push(c);
}
}
if (!field.is_empty() || !row.is_empty()) && rows.len() < MAX_ROWS {
push_field(&mut row, &mut field);
rows.push(row);
}
rows
}
/// The separator of a linked file: tab for .txt/.tsv, else `;` when the first line has semicolons
/// but no commas, else `,`.
fn separator(path: &str, text: &str) -> char {
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if ext == "txt" || ext == "tsv" {
return '\t';
}
let first = text.lines().next().unwrap_or("");
if first.contains(';') && !first.contains(',') { ';' } else { ',' }
}
/// Read a linked file through the host (natively the file system; not on the web).
fn read_link(id: &str, path: &str) -> Result<String> {
#[cfg(not(target_arch = "wasm32"))]
{
let meta = std::fs::metadata(path).map_err(|e| bad(id, format!("{path}: {e}")))?;
if meta.len() > MAX_LINK_BYTES {
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
}
let bytes = std::fs::read(path).map_err(|e| bad(id, format!("{path}: {e}")))?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
Err(bad(id, "data links can't read files in the browser"))
}
}
/// Put `data` into `t` from row `start`: the table gets exactly the rows and columns needed.
fn fill(t: &mut Table, start: usize, data: &[Vec<String>]) {
let cols = data.iter().map(Vec::len).max().unwrap_or(1).clamp(1, MAX_COLS);
let start = start.min(t.cells.len());
let rows = (start + data.len().max(1)).min(MAX_ROWS);
let row_h = t.row_heights.last().copied().unwrap_or(t.text_height * 5.0 / 3.0);
let col_w = t.col_widths.last().copied().unwrap_or(2.5);
// Keep merges above the data, cut at the new size.
let a: Vec<_> = anchors(t).into_iter().filter(|(r, ..)| *r < start).collect();
t.row_heights.resize(rows, row_h);
t.col_widths.resize(cols, col_w);
t.cells.resize_with(rows, Vec::new);
t.row_styles.truncate(rows);
for row in &mut t.cells {
row.resize_with(cols, TableCell::default);
}
for (i, row) in t.cells.iter_mut().enumerate().skip(start) {
let src = data.get(i - start);
for (c, cell) in row.iter_mut().enumerate() {
*cell = TableCell {
text: src.and_then(|r| r.get(c)).cloned().unwrap_or_default(),
style: std::mem::take(&mut cell.style),
..TableCell::default()
};
}
}
set_anchors(t, &a);
// A title merged across the table stays across.
if t.title
&& start > 0
&& let Some(cell) = t.cells.first_mut().and_then(|r| r.first_mut())
{
cell.merged = (cols > 1).then_some((1, cols as u32));
}
}
/// Re-read the linked file of table `h`.
fn update(s: &mut Session, h: Handle) -> Result<(usize, usize)> {
let id = "datalinkupdate";
let link = match s.doc()?.entity(h).map(|e| &e.kind) {
Some(EntityKind::Table(t)) => t.data_link.clone().ok_or_else(|| bad(id, "the table has no data link"))?,
_ => return Err(bad(id, "object is not a table")),
};
let text = read_link(id, &link.path)?;
let data = parse_csv(&text, separator(&link.path, &text));
let mut size = (0, 0);
edit(s, &json!({ "handle": h.hex() }), id, |t| {
fill(t, link.start_row, &data);
size = (t.cells.len(), t.col_widths.len());
Ok(())
})?;
Ok(size)
}
fn run_datalink(s: &mut Session, p: &Value) -> Result<Value> {
let id = "datalink";
if bool_or(p, "remove", false) {
edit(s, p, id, |t| {
t.data_link = None;
Ok(())
})?;
return Ok(json!({ "removed": true }));
}
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(id, "`path` of a CSV file is required"))?.to_string();
let start = usize_param(p, "startRow");
let r = edit(s, p, id, |t| {
let start_row = start.unwrap_or(usize::from(t.title)).min(t.cells.len());
t.data_link = Some(DataLink { path: path.clone(), start_row });
Ok(())
})?;
let h = r.get("handle").and_then(Value::as_str).and_then(Handle::parse_hex).ok_or_else(|| bad(id, "no table"))?;
let (rows, cols) = update(s, h)?;
Ok(json!({ "handle": h.hex(), "rows": rows, "cols": cols }))
}
fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
let given: Option<Vec<Handle>> = p
.get("handles")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect())
.or_else(|| p.get("handle").and_then(Value::as_str).and_then(Handle::parse_hex).map(|h| vec![h]));
let hs: Vec<Handle> = {
let d = s.doc()?;
match given {
Some(hs) => {
hs.into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Table(t)) if t.data_link.is_some())).collect()
}
None => std::iter::once(&d.model)
.chain(d.layouts.iter().map(|l| &l.entities))
.flat_map(|st| st.iter())
.filter(|e| matches!(&e.kind, EntityKind::Table(t) if t.data_link.is_some()))
.map(|e| e.handle)
.collect(),
}
};
let mut updated = 0;
let mut errors = Vec::new();
for h in hs {
match update(s, h) {
Ok(_) => updated += 1,
Err(e) => errors.push(e.to_string()),
}
}
let mut msg = format!("{updated} data link(s) updated.");
for e in &errors {
msg.push('\n');
msg.push_str(e);
}
Ok(json!({ "updated": updated, "errors": errors, "message": msg }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn csv_parsing() {
assert_eq!(parse_csv("a,b\r\n\"c,d\",\"e\"\"f\"\n", ','), vec![vec!["a", "b"], vec!["c,d", "e\"f"]]);
assert_eq!(parse_csv("x;y", ';'), vec![vec!["x", "y"]]);
assert_eq!(parse_csv("", ','), Vec::<Vec<String>>::new());
let wide = ",".repeat(10_000);
assert_eq!(parse_csv(&wide, ',').first().map(Vec::len), Some(MAX_COLS));
let tall = "1\n".repeat(10_000);
assert_eq!(parse_csv(&tall, ',').len(), MAX_ROWS);
assert_eq!(separator("a.tsv", "x,y"), '\t');
assert_eq!(separator("a.csv", "x;y"), ';');
}
}
+1 -16
View File
@@ -412,22 +412,7 @@ fn cell_under(s: &Session, p: Vec2) -> Option<(Handle, usize, usize)> {
/// The cell of `t` containing `p`.
pub(crate) fn cell_of(t: &Table, p: Vec2) -> Option<(usize, usize)> {
let o = t.insert.xy();
let (x, y) = (p.x - o.x, o.y - p.y);
if !(x.is_finite() && y.is_finite()) || x < 0.0 || y < 0.0 {
return None;
}
let find = |sizes: &[f64], v: f64| {
let mut acc = 0.0;
for (i, s) in sizes.iter().enumerate() {
acc += s;
if v <= acc {
return Some(i);
}
}
None
};
Some((find(&t.row_heights, y)?, find(&t.col_widths, x)?))
t.cell_at_point(p)
}
fn run_tabledit(s: &mut Session, p: &Value) -> Result<Value> {
+14
View File
@@ -84,6 +84,20 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
let from = *grips.get(index)?;
let delta = to - from;
let mut k = kind.clone();
// The further parts of a broken table move on their own.
if index > 0
&& let EntityKind::Table(t) = &mut k
&& let Some(b) = &mut t.breaks
{
let i = index - 1;
if b.offsets.len() <= i {
b.offsets.resize(i + 1, Vec2::ZERO);
}
if let Some(o) = b.offsets.get_mut(i) {
*o += delta;
}
return Some(k);
}
match &mut k {
EntityKind::Line(l) => match index {
0 => set_xy(&mut l.a, to),
+127
View File
@@ -0,0 +1,127 @@
//! Table breaking (#534): parts with repeated labels, part grips, TABLEDIT in any part; data
//! links filling a table from a CSV file and DATALINKUPDATE.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{BreakDirection, EntityKind, Handle, Table};
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::render;
use serde_json::json;
fn table(s: &Session, h: &str) -> Table {
match &s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind {
EntityKind::Table(t) => t.clone(),
_ => panic!("not a table"),
}
}
/// Title, header and 8 data rows, each 1 high, 2 columns of 3.
fn session() -> (Session, String) {
let mut s = Session::new();
let cells: Vec<Vec<String>> = (1..=8).map(|i| vec![format!("r{i}"), i.to_string()]).collect();
let r = s
.execute("table", &json!({ "at": [0, 0], "title": "T", "header": ["Name", "N"], "cells": cells, "rowHeight": 1.0, "colWidth": 3.0 }))
.unwrap();
let h = r["handle"].as_str().unwrap().to_string();
// Same height for the title row.
let hh = Handle::parse_hex(&h).unwrap();
s.doc_mut()
.unwrap()
.modify_entity(hh, |e| {
if let EntityKind::Table(t) = &mut e.kind {
t.row_heights[0] = 1.0;
}
})
.unwrap();
(s, h)
}
#[test]
fn breaking_draws_parts_with_repeated_labels() {
let (mut s, h) = session();
let r = s.execute("table.break", &json!({ "handle": h, "height": 6.0, "spacing": 1.0 })).unwrap();
// 2 label rows + 4 data rows per part.
assert_eq!(r["parts"], 2);
let t = table(&s, &h);
let g = render::table_geometry(s.doc().unwrap(), &t);
// The header text appears in both parts.
assert_eq!(g.texts.iter().filter(|x| x.text == "Name").count(), 2);
let r5 = g.texts.iter().find(|x| x.text == "r5").unwrap();
assert_eq!(r5.row, 6);
assert!(r5.at.x > 7.0 && r5.at.y > -3.0, "r5 starts the second part at x 7: {:?}", r5.at);
// Extents cover both parts.
let b = cadcraft_engine::doc::entity_bounds(s.doc().unwrap(), s.doc().unwrap().entity(Handle::parse_hex(&h).unwrap()).unwrap(), 0);
assert!((b.max.x - 13.0).abs() < 1e-9 && (b.min.y + 6.0).abs() < 1e-9, "{b:?}");
// TABLEDIT finds cells in the second part.
s.execute("tabledit", &json!({ "at": [11.5, -2.5], "text": "five" })).unwrap();
assert_eq!(table(&s, &h).cells[6][1].text, "five");
// Down, with manual heights; Off.
s.execute("table.break", &json!({ "handle": h, "direction": "down", "heights": [4.0] })).unwrap();
let t = table(&s, &h);
assert_eq!(t.breaks.as_ref().unwrap().direction, BreakDirection::Down);
assert_eq!(t.parts().first().unwrap().rows, vec![0, 1, 2, 3]);
let r = s.execute("table.break", &json!({ "handle": h, "enabled": false })).unwrap();
assert_eq!(r["parts"], 1);
assert!(s.execute("table.break", &json!({ "handle": h, "direction": "up" })).is_err());
assert!(s.execute("table.break", &json!({ "handle": h, "height": -1 })).is_err());
}
#[test]
fn part_grips_move_parts() {
let (mut s, h) = session();
s.execute("table.break", &json!({ "handle": h, "height": 6.0, "spacing": 1.0 })).unwrap();
let k = EntityKind::Table(table(&s, &h));
let grips = k.grips();
assert_eq!(grips, vec![Vec2::new(0.0, 0.0), Vec2::new(7.0, 0.0)]);
// Drag the second part's grip; the first part stays.
let moved = cadcraft_engine::grips::stretch_grip(&k, 1, Vec2::new(9.0, -2.0)).unwrap();
let EntityKind::Table(t) = &moved else { panic!() };
let parts = t.parts();
assert_eq!((parts[0].origin, parts[1].origin), (Vec2::new(0.0, 0.0), Vec2::new(9.0, -2.0)));
// The insertion grip moves everything.
let all = cadcraft_engine::grips::stretch_grip(&moved, 0, Vec2::new(1.0, 1.0)).unwrap();
let EntityKind::Table(t) = &all else { panic!() };
assert_eq!(t.parts()[1].origin, Vec2::new(10.0, -1.0));
// Typed: select the table by a point, then the height.
s.execute("table.break", &json!({ "handle": h, "enabled": false })).unwrap();
s.cmdline("table.break").unwrap();
s.cmdline("1,-0.5").unwrap();
s.cmdline("4").unwrap();
assert_eq!(table(&s, &h).parts().len(), 4);
}
#[test]
fn data_links_fill_and_update() {
let dir = std::env::temp_dir().join(format!("cadcraft-datalink-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("parts.csv");
std::fs::write(&path, "Part,Qty,Note\r\nbolt,4,\"M6, zinc\"\nnut,8,\n").unwrap();
let mut s = Session::new();
let r = s.execute("table", &json!({ "at": [0, 0], "title": "Parts", "cols": 2, "rows": 1 })).unwrap();
let h = r["handle"].as_str().unwrap().to_string();
let r = s.execute("datalink", &json!({ "handle": h, "path": path.to_str().unwrap() })).unwrap();
assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(4), Some(3)));
let t = table(&s, &h);
assert_eq!(t.cells[0][0].merged, Some((1, 3)));
assert_eq!(t.cells[1][0].text, "Part");
assert_eq!(t.cells[2][2].text, "M6, zinc");
// An update re-reads the file and resizes the table to it.
std::fs::write(&path, "Part,Qty\nwasher,12\n").unwrap();
let r = s.execute("datalinkupdate", &json!({})).unwrap();
assert_eq!(r["updated"], 1);
let t = table(&s, &h);
assert_eq!((t.cells.len(), t.col_widths.len()), (3, 2));
assert_eq!(t.cells[2][1].text, "12");
// Typed DATALINKUPDATE with a selection.
s.cmdline("datalinkupdate").unwrap();
s.cmdline("1,-0.2").unwrap();
s.cmdline("").unwrap();
// Missing files and missing paths are errors, not panics.
assert!(s.execute("datalink", &json!({ "handle": h })).is_err());
assert!(s.execute("datalink", &json!({ "handle": h, "path": dir.join("missing.csv").to_str().unwrap() })).is_err());
std::fs::remove_file(&path).unwrap();
let r = s.execute("datalinkupdate", &json!({})).unwrap();
assert_eq!(r["updated"], 0);
s.execute("datalink", &json!({ "handle": h, "remove": true })).unwrap();
assert!(table(&s, &h).data_link.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
+15 -3
View File
@@ -8,7 +8,7 @@
//! row and cell styles and the formulas go into CADCraft xdata (`TABLEX`, JSON).
use cadcraft_color::Color;
use cadcraft_doc::{CellBorder, CellStyle, Lineweight, MAX_CELL_STYLES, Table, TableStyle};
use cadcraft_doc::{CellBorder, CellStyle, DataLink, Lineweight, MAX_CELL_STYLES, MAX_TABLE_PARTS, Table, TableBreak, TableStyle};
use cadcraft_dxf::Tag;
use serde::{Deserialize, Serialize};
@@ -178,11 +178,17 @@ struct TableExtra {
/// (row, col, cell style, formula).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
cells: Vec<(usize, usize, String, Option<String>)>,
/// Table breaking.
#[serde(default, skip_serializing_if = "Option::is_none")]
breaks: Option<TableBreak>,
/// The data link.
#[serde(default, skip_serializing_if = "Option::is_none")]
link: Option<DataLink>,
}
/// The xdata strings of a table's extra data (marker first), or nothing for a plain table.
pub(crate) fn table_xdata(t: &Table) -> Vec<String> {
let mut x = TableExtra { v: 1, rows: t.row_styles.clone(), cells: Vec::new() };
let mut x = TableExtra { v: 1, rows: t.row_styles.clone(), cells: Vec::new(), breaks: t.breaks.clone(), link: t.data_link.clone() };
for (r, row) in t.cells.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
if !cell.style.is_empty() || cell.formula.is_some() {
@@ -190,7 +196,7 @@ pub(crate) fn table_xdata(t: &Table) -> Vec<String> {
}
}
}
if x.rows.is_empty() && x.cells.is_empty() {
if x.rows.is_empty() && x.cells.is_empty() && x.breaks.is_none() && x.link.is_none() {
return Vec::new();
}
let Ok(json) = serde_json::to_string(&x) else { return Vec::new() };
@@ -208,6 +214,12 @@ pub(crate) fn read_table_xdata(tags: &[Tag], t: &mut Table) {
cell.formula = formula.filter(|f| f.chars().count() <= 1024);
}
}
t.breaks = x.breaks.map(|mut b| {
b.heights.truncate(MAX_TABLE_PARTS);
b.offsets.truncate(MAX_TABLE_PARTS);
b
});
t.data_link = x.link.filter(|l| !l.path.trim().is_empty() && l.path.len() <= 4096);
}
#[cfg(test)]
+2
View File
@@ -36,6 +36,8 @@ fn table() -> Table {
title: true,
header: true,
row_styles: Default::default(),
breaks: Default::default(),
data_link: Default::default(),
}
}
@@ -39,6 +39,7 @@ fn sample() -> (Drawing, Table) {
title: true,
header: true,
row_styles: vec![String::new(), String::new(), String::new(), "Totals".into()],
..Table::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Table(t.clone())).unwrap();
(d, t)
@@ -107,3 +108,19 @@ fn hostile_table_xdata_is_ignored() {
assert!(t.row_styles.is_empty());
assert_eq!(t.cells[3][1].text, "1.000");
}
#[test]
fn breaks_and_data_links_roundtrip_and_the_block_draws_the_parts() {
let (mut d, mut t) = sample();
t.breaks =
Some(TableBreak { enabled: true, height: 1.5, spacing: 0.5, offsets: vec![cadcraft_geom::Vec2::new(0.0, -1.0)], ..TableBreak::default() });
t.data_link = Some(DataLink { path: "costs.csv".into(), start_row: 1 });
let h = d.model.iter().next().unwrap().handle;
d.modify_entity(h, |e| e.kind = EntityKind::Table(t.clone())).unwrap();
let bytes = cadcraft_io::write(&d, "t.dxf").unwrap();
let back = cadcraft_io::read(&bytes, "t.dxf").unwrap();
assert_eq!(first_table(&back), t);
// Two parts: the block repeats the header row (Item / Cost).
let text = String::from_utf8(bytes).unwrap();
assert_eq!(text.lines().filter(|l| l.trim() == "Item").count(), 3, "two in the block, one in the table cells");
}
+54 -1
View File
@@ -110,8 +110,61 @@ struct Anchor {
style: usize,
}
/// Lay out table `t` of drawing `d`.
/// Lay out table `t` of drawing `d`, every part of a broken table.
pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom {
let parts = t.parts();
if parts.len() <= 1 {
return layout(d, t);
}
let mut g = TableGeom::default();
for p in parts {
let mut pg = layout(d, &part_table(t, &p.rows, p.origin));
for tx in &mut pg.texts {
tx.row = p.rows.get(tx.row).copied().unwrap_or(tx.row);
}
g.fills.extend(pg.fills);
g.lines.extend(pg.lines);
g.texts.extend(pg.texts);
}
g
}
/// One part of a broken table as a table of its own: its rows (with their cell styles) at
/// `origin`; merges are cut at the part's edge.
fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
let cols = t.col_widths.len();
let mut cells: Vec<Vec<cadcraft_doc::TableCell>> = rows.iter().map(|r| t.cells.get(*r).cloned().unwrap_or_default()).collect();
for (i, row) in cells.iter_mut().enumerate() {
row.resize_with(cols, Default::default);
for cell in row.iter_mut() {
if let Some((rs, cs)) = cell.merged {
// Rows of the merge that follow in this part.
let start = rows.get(i).copied().unwrap_or(0);
let mut n = 1u32;
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
n += 1;
}
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
}
}
}
Table {
insert: origin.to3(t.insert.z),
col_widths: t.col_widths.clone(),
row_heights: rows.iter().map(|r| t.row_heights.get(*r).copied().unwrap_or(0.0)).collect(),
row_styles: rows.iter().map(|r| t.row_style(*r).to_string()).collect(),
cells,
style: t.style.clone(),
text_height: t.text_height,
title: false,
header: false,
breaks: None,
data_link: None,
}
}
/// Lay out one part.
fn layout(d: &Drawing, t: &Table) -> TableGeom {
let st = table_style_of(d, t);
let rows = t.row_heights.len().min(10_000);
let cols = t.col_widths.len().min(10_000);
+3
View File
@@ -187,6 +187,7 @@
Dark Темна
Data Extraction... Вилучення даних...
Default Типово
Data Link... Зв’язок із даними...
Default Save Format Типовий формат збереження
Default Save Version Типова версія збереження
Define Attributes Визначити атрибути
@@ -1019,6 +1020,7 @@
Toggle PICKADD (on: picks add to the selection) Перемкнути PICKADD (увімкнено: вибране додається до набору вибраних об'єктів)
TOP ЗВЕРХУ
Table Таблиця
Table Breaks Розриви таблиці
Table Cell Style Стиль комірок таблиці
Table Style... Стиль таблиць...
Table style Стиль таблиць
@@ -1069,6 +1071,7 @@
Unmerge Cells Розділити комірки
Unsaved Layer State Незбережений стан шарів
Up Угору
Update Data Links Оновити зв’язки з даними
Update Оновити
Use System Setting Як у системі
User Користувач
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
187
188
189
190
191
192
193
1020
1021
1022
1023
1024
1025
1026
1071
1072
1073
1074
1075
1076
1077
+1 -1
View File
@@ -37,7 +37,7 @@ do them first.
| F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 |
| F9 | MTEXT editor is a dialog box, not in place; no columns, bullets, numbering, tabs, indents; no fields (FIELD, UPDATEFIELD); no spell check (SPELL) | `ui-egui/dialogs.rs:188-240` | Medium-high | 30–45 |
| F10 | MLEADER: no MLEADEREDIT/ALIGN/COLLECT; MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 |
| F11 | Tables: no cell styles, formulas, data links, table breaking | `cmd/table.rs` | Medium | 10–15 |
| F11 | Tables (cell styles, formulas, TABLEDIT, TABLEEXPORT, table breaking and CSV data links landed, #534): formulas aren't read from or written as AutoCAD FIELD objects (other programs see values); number formats apply to formula results only; cell text doesn't wrap; custom cell styles, breaks and data links travel as CADCraft xdata (not through DWG); data links read CSV only (no Excel ranges, no write-back, not on the web); no cell-style tab in the Table Style dialog | `cmd/table/*`, `io/dxf_table.rs` | Low-medium | 6–10 |
| F12 | Annotative scaling: the flag is stored, nothing scales; no scale list, no "Annotative Object Scale" commands | 4 uncovered menu items | Medium-high in layouts | 10–15 |
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); no hatch dialog (HATCH and HATCHEDIT options work at the command line and in JSON), no MPolygon; HATCHEDIT can't add or remove boundaries; a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 8–12 |
| F14 | **[alpha blocker]** No plot preview, plotter devices, PUBLISH/batch plot; Print opens no plot dialog (#160, PR #200). Plot styles (CTB and STB, [plot-styles.md](plot-styles.md)) apply since #410; their end/join/fill styles aren't applied and binary `.ctb`/`.stb` files can't be read | `palettes.rs:666` | High for sheet output | 15–25 |