From 9ca0dcf7d4ddc8f909fccccb0b255883acd19390 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 18:47:39 +0200 Subject: [PATCH] Tables: table breaking and CSV data links (fixes #534) (#553) Squash of PR #553 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/entity.rs | 16 + crates/doc/src/extents.rs | 1 + crates/doc/src/tables.rs | 2 + crates/doc/src/tables/cellstyle.rs | 2 + crates/doc/src/tables/tablebreak.rs | 268 +++++++++++++ crates/engine/src/cmd/table.rs | 2 + crates/engine/src/cmd/table/breaks.rs | 372 +++++++++++++++++++ crates/engine/src/cmd/table/cells.rs | 17 +- crates/engine/src/grips.rs | 14 + crates/engine/tests/table_breaks.rs | 127 +++++++ crates/io/src/dxf_table.rs | 18 +- crates/io/tests/dxf_versions.rs | 2 + crates/io/tests/table_cellstyle_roundtrip.rs | 17 + crates/render/src/table.rs | 55 ++- crates/ui-egui/src/i18n/uk.tsv | 3 + docs/gaps.md | 2 +- 16 files changed, 897 insertions(+), 21 deletions(-) create mode 100644 crates/doc/src/tables/tablebreak.rs create mode 100644 crates/engine/src/cmd/table/breaks.rs create mode 100644 crates/engine/tests/table_breaks.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index de9831e2..466a2726 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -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, + /// Table breaking (TABLEBREAK): the table drawn in several parts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub breaks: Option, + /// The CSV file the cells come from (DATALINK). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_link: Option, } 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 { + // 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()], diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index 9d3a785d..47e6ea1d 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -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) -> 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()), diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 3134d62c..17026b18 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -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)] diff --git a/crates/doc/src/tables/cellstyle.rs b/crates/doc/src/tables/cellstyle.rs index 15c9c531..b2907f69 100644 --- a/crates/doc/src/tables/cellstyle.rs +++ b/crates/doc/src/tables/cellstyle.rs @@ -233,6 +233,8 @@ impl Default for Table { title: false, header: false, row_styles: Vec::new(), + breaks: None, + data_link: None, } } } diff --git a/crates/doc/src/tables/tablebreak.rs b/crates/doc/src/tables/tablebreak.rs new file mode 100644 index 00000000..48137f11 --- /dev/null +++ b/crates/doc/src/tables/tablebreak.rs @@ -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, + /// Space between parts. + pub spacing: f64, + /// Moves of parts 2, 3, … from their automatic place (part grips). + pub offsets: Vec, +} + +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, + 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 { + 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 = (0..self.top_label_rows()).collect(); + let bottom: Option = (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::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 = if i == 0 || b.repeat_top { top.clone() } else { Vec::new() }; + let mut used: f64 = rows_here.iter().map(|r| h(*r)).sum::() + 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 { + 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> = 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> = 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::(), 2998); + t.breaks = Some(TableBreak { enabled: true, height: f64::INFINITY, ..TableBreak::default() }); + assert_eq!(t.parts().len(), 1); + } +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 502b6bfa..be156fb1 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -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 { ] .into_iter() .chain(cells::specs()) + .chain(breaks::specs()) .collect() } diff --git a/crates/engine/src/cmd/table/breaks.rs b/crates/engine/src/cmd/table/breaks.rs new file mode 100644 index 00000000..9bf98e56 --- /dev/null +++ b/crates/engine/src/cmd/table/breaks.rs @@ -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 { + 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 { + let id = "table.break"; + let num = |k: &str| -> Result> { + 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> = 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::>>() + .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, +} + +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 { + 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> { + let mut rows: Vec> = Vec::new(); + let mut row: Vec = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut chars = text.chars().peekable(); + let push_field = |row: &mut Vec, 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 { + #[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]) { + 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 { + 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 { + let given: Option> = 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 = { + 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::>::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"), ';'); + } +} diff --git a/crates/engine/src/cmd/table/cells.rs b/crates/engine/src/cmd/table/cells.rs index ec87d463..a2efca4d 100644 --- a/crates/engine/src/cmd/table/cells.rs +++ b/crates/engine/src/cmd/table/cells.rs @@ -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 { diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index f51cfec2..964bc1f6 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -84,6 +84,20 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option 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), diff --git a/crates/engine/tests/table_breaks.rs b/crates/engine/tests/table_breaks.rs new file mode 100644 index 00000000..90b05d7d --- /dev/null +++ b/crates/engine/tests/table_breaks.rs @@ -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> = (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); +} diff --git a/crates/io/src/dxf_table.rs b/crates/io/src/dxf_table.rs index dd0d90ab..c543db46 100644 --- a/crates/io/src/dxf_table.rs +++ b/crates/io/src/dxf_table.rs @@ -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)>, + /// Table breaking. + #[serde(default, skip_serializing_if = "Option::is_none")] + breaks: Option, + /// The data link. + #[serde(default, skip_serializing_if = "Option::is_none")] + link: Option, } /// 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 { - 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 { } } } - 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)] diff --git a/crates/io/tests/dxf_versions.rs b/crates/io/tests/dxf_versions.rs index a4b2b2ed..3fc2f1c7 100644 --- a/crates/io/tests/dxf_versions.rs +++ b/crates/io/tests/dxf_versions.rs @@ -36,6 +36,8 @@ fn table() -> Table { title: true, header: true, row_styles: Default::default(), + breaks: Default::default(), + data_link: Default::default(), } } diff --git a/crates/io/tests/table_cellstyle_roundtrip.rs b/crates/io/tests/table_cellstyle_roundtrip.rs index c24fdab3..54735b73 100644 --- a/crates/io/tests/table_cellstyle_roundtrip.rs +++ b/crates/io/tests/table_cellstyle_roundtrip.rs @@ -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"); +} diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs index 0a835c16..abeab7ce 100644 --- a/crates/render/src/table.rs +++ b/crates/render/src/table.rs @@ -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> = 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); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5f491ca3..f1b9d8c0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Користувач diff --git a/docs/gaps.md b/docs/gaps.md index 599254d2..3c15f726 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 |