mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
Squash of PR #553 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
784a7937ef
commit
9ca0dcf7d4
@@ -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()],
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -233,6 +233,8 @@ impl Default for Table {
|
||||
title: false,
|
||||
header: false,
|
||||
row_styles: Vec::new(),
|
||||
breaks: None,
|
||||
data_link: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¶ms)?;
|
||||
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"), ';');
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
-1
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user